diff --git a/integrations/gmail/src/auth.ts b/integrations/gmail/src/auth.ts index 9557b4fa2e..71089d9cb7 100644 --- a/integrations/gmail/src/auth.ts +++ b/integrations/gmail/src/auth.ts @@ -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({ @@ -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({ @@ -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 @@ -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 @@ -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; diff --git a/integrations/gmail/src/lib/errors.ts b/integrations/gmail/src/lib/errors.ts new file mode 100644 index 0000000000..bd1be02d39 --- /dev/null +++ b/integrations/gmail/src/lib/errors.ts @@ -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 => + 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; +}; diff --git a/integrations/gmail/src/tools/manage-draft.ts b/integrations/gmail/src/tools/manage-draft.ts index 5fe7356acb..9c3e517114 100644 --- a/integrations/gmail/src/tools/manage-draft.ts +++ b/integrations/gmail/src/tools/manage-draft.ts @@ -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'; @@ -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 || [], @@ -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); @@ -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); @@ -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); @@ -191,5 +192,5 @@ export let manageDraft = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw gmailServiceError(`Unknown action: ${action}`); }); diff --git a/integrations/gmail/src/tools/manage-labels.ts b/integrations/gmail/src/tools/manage-labels.ts index 97026967c4..af687a36a1 100644 --- a/integrations/gmail/src/tools/manage-labels.ts +++ b/integrations/gmail/src/tools/manage-labels.ts @@ -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'; @@ -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, @@ -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, @@ -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) }, @@ -137,7 +138,7 @@ 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 }, @@ -145,5 +146,5 @@ export let manageLabels = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw gmailServiceError(`Unknown action: ${action}`); }); diff --git a/integrations/gmail/src/tools/manage-settings.ts b/integrations/gmail/src/tools/manage-settings.ts index f003204886..c180e8c44a 100644 --- a/integrations/gmail/src/tools/manage-settings.ts +++ b/integrations/gmail/src/tools/manage-settings.ts @@ -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'; @@ -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, @@ -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 }, @@ -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, @@ -297,5 +301,5 @@ export let manageSettings = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw gmailServiceError(`Unknown action: ${action}`); }); diff --git a/integrations/gmail/src/tools/manage-thread.ts b/integrations/gmail/src/tools/manage-thread.ts index 25760a14b8..c0fc0cc183 100644 --- a/integrations/gmail/src/tools/manage-thread.ts +++ b/integrations/gmail/src/tools/manage-thread.ts @@ -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'; @@ -111,7 +112,7 @@ export let manageThread = SlateTool.create(spec, { let { action } = ctx.input; if (action === 'get') { - if (!ctx.input.threadId) throw new Error('threadId is required for get action'); + if (!ctx.input.threadId) throw gmailServiceError('threadId is required for get action'); let thread = await client.getThread(ctx.input.threadId); let messages = (thread.messages || []).map(parseMessage); @@ -165,7 +166,7 @@ export let manageThread = SlateTool.create(spec, { if (action === 'modify_labels') { if (!ctx.input.threadId) - throw new Error('threadId is required for modify_labels action'); + throw gmailServiceError('threadId is required for modify_labels action'); let thread = await client.modifyThread( ctx.input.threadId, ctx.input.addLabelIds, @@ -183,7 +184,8 @@ export let manageThread = SlateTool.create(spec, { } if (action === 'trash') { - if (!ctx.input.threadId) throw new Error('threadId is required for trash action'); + if (!ctx.input.threadId) + throw gmailServiceError('threadId is required for trash action'); let thread = await client.trashThread(ctx.input.threadId); return { output: { @@ -196,7 +198,8 @@ export let manageThread = SlateTool.create(spec, { } if (action === 'untrash') { - if (!ctx.input.threadId) throw new Error('threadId is required for untrash action'); + if (!ctx.input.threadId) + throw gmailServiceError('threadId is required for untrash action'); let thread = await client.untrashThread(ctx.input.threadId); return { output: { @@ -209,7 +212,8 @@ export let manageThread = SlateTool.create(spec, { } if (action === 'delete') { - if (!ctx.input.threadId) throw new Error('threadId is required for delete action'); + if (!ctx.input.threadId) + throw gmailServiceError('threadId is required for delete action'); await client.deleteThread(ctx.input.threadId); return { output: { deleted: true }, @@ -217,5 +221,5 @@ export let manageThread = SlateTool.create(spec, { }; } - throw new Error(`Unknown action: ${action}`); + throw gmailServiceError(`Unknown action: ${action}`); });