Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 48 additions & 35 deletions integrations/gmail/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { badRequestError, ServiceError } from '@lowerdeck/error';
import { createAxios, SlateAuth } from 'slates';
import { z } from 'zod';
import { gmailOAuthError, gmailServiceError } from './lib/errors';
import { gmailScopes } from './scopes';

let googleAxios = createAxios({
Expand All @@ -11,8 +11,6 @@ let profileAxios = createAxios({
baseURL: 'https://www.googleapis.com'
});

let gmailServiceError = (message: string) => new ServiceError(badRequestError({ message }));

export let auth = SlateAuth.create()
.output(
z.object({
Expand Down Expand Up @@ -134,21 +132,26 @@ export let auth = SlateAuth.create()
},

handleCallback: async ctx => {
let response = await googleAxios.post(
'/token',
new URLSearchParams({
code: ctx.code,
client_id: ctx.clientId,
client_secret: ctx.clientSecret,
redirect_uri: ctx.redirectUri,
grant_type: 'authorization_code'
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
let response: any;
try {
response = await googleAxios.post(
'/token',
new URLSearchParams({
code: ctx.code,
client_id: ctx.clientId,
client_secret: ctx.clientSecret,
redirect_uri: ctx.redirectUri,
grant_type: 'authorization_code'
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
}
);
);
} catch (error) {
throw gmailOAuthError('callback', error);
}

let data = response.data;
let expiresAt = data.expires_in
Expand Down Expand Up @@ -176,20 +179,25 @@ export let auth = SlateAuth.create()
throw gmailServiceError('No refresh token available');
}

let response = await googleAxios.post(
'/token',
new URLSearchParams({
refresh_token: ctx.output.refreshToken,
client_id: ctx.clientId,
client_secret: ctx.clientSecret,
grant_type: 'refresh_token'
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
let response: any;
try {
response = await googleAxios.post(
'/token',
new URLSearchParams({
refresh_token: ctx.output.refreshToken,
client_id: ctx.clientId,
client_secret: ctx.clientSecret,
grant_type: 'refresh_token'
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
}
);
);
} catch (error) {
throw gmailOAuthError('refresh', error);
}

let data = response.data;
let expiresAt = data.expires_in
Expand All @@ -210,11 +218,16 @@ export let auth = SlateAuth.create()
input: {};
scopes: string[];
}) => {
let response = await profileAxios.get('/oauth2/v2/userinfo', {
headers: {
Authorization: `Bearer ${ctx.output.token}`
}
});
let response: any;
try {
response = await profileAxios.get('/oauth2/v2/userinfo', {
headers: {
Authorization: `Bearer ${ctx.output.token}`
}
});
} catch (error) {
throw gmailOAuthError('profile lookup', error);
}

let data = response.data;

Expand Down
122 changes: 122 additions & 0 deletions integrations/gmail/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { badRequestError, ServiceError } from '@lowerdeck/error';

type ErrorResponse = {
status?: number;
statusText?: string;
data?: unknown;
};

let isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;

let addUnique = (values: string[], value: unknown) => {
let text = typeof value === 'string' ? value.trim() : '';
if (text && !values.includes(text)) {
values.push(text);
}
};

let extractGoogleMessage = (error: unknown) => {
let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined;
let data = response?.data;
let details: string[] = [];

if (isRecord(data)) {
let googleError = data.error;

if (isRecord(googleError)) {
addUnique(details, googleError.message);
addUnique(details, googleError.status);

let errors = googleError.errors;
if (Array.isArray(errors)) {
for (let item of errors) {
if (isRecord(item)) {
addUnique(details, item.reason);
addUnique(details, item.message);
}
}
}
} else {
addUnique(details, googleError);
}

addUnique(details, data.error_description);
addUnique(details, data.message);
} else if (typeof data === 'string' && data.trim()) {
details.push(data.trim());
}

if (details.length > 0) {
return details.join(' - ');
}

if (error instanceof Error && error.message) {
return error.message;
}

return 'Unknown error';
};

let getErrorStatus = (error: unknown) => {
if (!isRecord(error)) {
return undefined;
}

let response = error.response as ErrorResponse | undefined;
return response?.status ?? error.status;
};

let getErrorStatusText = (error: unknown) => {
if (!isRecord(error)) {
return undefined;
}

let response = error.response as ErrorResponse | undefined;
return response?.statusText;
};

export let gmailServiceError = (message: string) =>
new ServiceError(badRequestError({ message }));

export let gmailApiError = (error: unknown, operation = 'request') => {
if (error instanceof ServiceError) {
return error;
}

let status = getErrorStatus(error);
let statusText = getErrorStatusText(error);
let statusLabel =
status !== undefined ? `HTTP ${status}${statusText ? ` ${statusText}` : ''}: ` : '';
let serviceError = gmailServiceError(
`Gmail API ${operation} failed: ${statusLabel}${extractGoogleMessage(error)}`
);

serviceError.data.reason = 'gmail_api_error';
serviceError.data.upstreamStatus = status;

if (error instanceof Error) {
serviceError.setParent(error);
}

return serviceError;
};

export let gmailOAuthError = (operation: string, error: unknown) => {
if (error instanceof ServiceError) {
return error;
}

let serviceError = gmailServiceError(
`Gmail OAuth ${operation} failed: ${extractGoogleMessage(error)}`
);
let status = getErrorStatus(error);
serviceError.data.reason = 'gmail_oauth_error';
serviceError.data.upstreamStatus = status;

if (error instanceof Error) {
serviceError.setParent(error);
}

return serviceError;
};
11 changes: 6 additions & 5 deletions integrations/gmail/src/tools/manage-draft.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SlateTool } from 'slates';
import { z } from 'zod';
import { Client, parseMessage } from '../lib/client';
import { gmailServiceError } from '../lib/errors';
import { gmailActionScopes } from '../scopes';
import { spec } from '../spec';

Expand Down Expand Up @@ -98,7 +99,7 @@ export let manageDraft = SlateTool.create(spec, {
}

if (action === 'update') {
if (!ctx.input.draftId) throw new Error('draftId is required for update action');
if (!ctx.input.draftId) throw gmailServiceError('draftId is required for update action');

let draft = await client.updateDraft(ctx.input.draftId, {
to: ctx.input.to || [],
Expand All @@ -121,7 +122,7 @@ export let manageDraft = SlateTool.create(spec, {
}

if (action === 'send') {
if (!ctx.input.draftId) throw new Error('draftId is required for send action');
if (!ctx.input.draftId) throw gmailServiceError('draftId is required for send action');

let message = await client.sendDraft(ctx.input.draftId);
let parsed = parseMessage(message);
Expand All @@ -139,7 +140,7 @@ export let manageDraft = SlateTool.create(spec, {
}

if (action === 'get') {
if (!ctx.input.draftId) throw new Error('draftId is required for get action');
if (!ctx.input.draftId) throw gmailServiceError('draftId is required for get action');

let draft = await client.getDraft(ctx.input.draftId);
let parsed = parseMessage(draft.message);
Expand Down Expand Up @@ -179,7 +180,7 @@ export let manageDraft = SlateTool.create(spec, {
}

if (action === 'delete') {
if (!ctx.input.draftId) throw new Error('draftId is required for delete action');
if (!ctx.input.draftId) throw gmailServiceError('draftId is required for delete action');

await client.deleteDraft(ctx.input.draftId);

Expand All @@ -191,5 +192,5 @@ export let manageDraft = SlateTool.create(spec, {
};
}

throw new Error(`Unknown action: ${action}`);
throw gmailServiceError(`Unknown action: ${action}`);
});
11 changes: 6 additions & 5 deletions integrations/gmail/src/tools/manage-labels.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SlateTool } from 'slates';
import { z } from 'zod';
import { Client } from '../lib/client';
import { gmailServiceError } from '../lib/errors';
import { gmailActionScopes } from '../scopes';
import { spec } from '../spec';

Expand Down Expand Up @@ -98,7 +99,7 @@ export let manageLabels = SlateTool.create(spec, {
}

if (action === 'create') {
if (!ctx.input.name) throw new Error('name is required for create action');
if (!ctx.input.name) throw gmailServiceError('name is required for create action');
let label = await client.createLabel({
name: ctx.input.name,
messageListVisibility: ctx.input.messageListVisibility,
Expand All @@ -113,7 +114,7 @@ export let manageLabels = SlateTool.create(spec, {
}

if (action === 'update') {
if (!ctx.input.labelId) throw new Error('labelId is required for update action');
if (!ctx.input.labelId) throw gmailServiceError('labelId is required for update action');
let label = await client.updateLabel(ctx.input.labelId, {
name: ctx.input.name,
messageListVisibility: ctx.input.messageListVisibility,
Expand All @@ -128,7 +129,7 @@ export let manageLabels = SlateTool.create(spec, {
}

if (action === 'get') {
if (!ctx.input.labelId) throw new Error('labelId is required for get action');
if (!ctx.input.labelId) throw gmailServiceError('labelId is required for get action');
let label = await client.getLabel(ctx.input.labelId);
return {
output: { label: mapLabel(label) },
Expand All @@ -137,13 +138,13 @@ export let manageLabels = SlateTool.create(spec, {
}

if (action === 'delete') {
if (!ctx.input.labelId) throw new Error('labelId is required for delete action');
if (!ctx.input.labelId) throw gmailServiceError('labelId is required for delete action');
await client.deleteLabel(ctx.input.labelId);
return {
output: { deleted: true },
message: `Label **${ctx.input.labelId}** deleted.`
};
}

throw new Error(`Unknown action: ${action}`);
throw gmailServiceError(`Unknown action: ${action}`);
});
12 changes: 8 additions & 4 deletions integrations/gmail/src/tools/manage-settings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SlateTool } from 'slates';
import { z } from 'zod';
import { Client } from '../lib/client';
import { gmailServiceError } from '../lib/errors';
import { gmailActionScopes } from '../scopes';
import { spec } from '../spec';

Expand Down Expand Up @@ -232,7 +233,9 @@ export let manageSettings = SlateTool.create(spec, {

if (action === 'create_filter') {
if (!ctx.input.filterCriteria || !ctx.input.filterAction) {
throw new Error('filterCriteria and filterAction are required for create_filter');
throw gmailServiceError(
'filterCriteria and filterAction are required for create_filter'
);
}
let filter = await client.createFilter({
criteria: ctx.input.filterCriteria,
Expand All @@ -245,7 +248,8 @@ export let manageSettings = SlateTool.create(spec, {
}

if (action === 'delete_filter') {
if (!ctx.input.filterId) throw new Error('filterId is required for delete_filter');
if (!ctx.input.filterId)
throw gmailServiceError('filterId is required for delete_filter');
await client.deleteFilter(ctx.input.filterId);
return {
output: { deleted: true },
Expand Down Expand Up @@ -280,7 +284,7 @@ export let manageSettings = SlateTool.create(spec, {

if (action === 'update_send_as') {
if (!ctx.input.sendAsEmail)
throw new Error('sendAsEmail is required for update_send_as');
throw gmailServiceError('sendAsEmail is required for update_send_as');
let result = await client.updateSendAs(ctx.input.sendAsEmail, {
displayName: ctx.input.displayName,
replyToAddress: ctx.input.replyToAddress,
Expand All @@ -297,5 +301,5 @@ export let manageSettings = SlateTool.create(spec, {
};
}

throw new Error(`Unknown action: ${action}`);
throw gmailServiceError(`Unknown action: ${action}`);
});
Loading