0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

feat(sms/email-connectors): expose third-party API request error message (#1059)

This commit is contained in:
Darcy Ye 2022-06-07 14:14:06 +08:00 committed by GitHub
parent 93916bfa54
commit 4cfd5788d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 148 additions and 79 deletions

View file

@ -8,11 +8,11 @@ import {
GetConnectorConfig,
} from '@logto/connector-types';
import { assert } from '@silverhand/essentials';
import { Response } from 'got';
import { HTTPError } from 'got';
import { defaultMetadata } from './constant';
import { singleSendMail } from './single-send-mail';
import { SendEmailResponse, AliyunDmConfig, aliyunDmConfigGuard } from './types';
import { AliyunDmConfig, aliyunDmConfigGuard } from './types';
export default class AliyunDmConnector implements EmailConnector {
public metadata: ConnectorMetadata = defaultMetadata;
@ -27,11 +27,7 @@ export default class AliyunDmConnector implements EmailConnector {
}
};
public sendMessage: EmailSendMessageFunction<Response<SendEmailResponse>> = async (
address,
type,
data
) => {
public sendMessage: EmailSendMessageFunction = async (address, type, data) => {
const config = await this.getConfig(this.metadata.id);
await this.validateConfig(config);
const { accessKeyId, accessKeySecret, accountName, fromAlias, templates } = config;
@ -45,21 +41,37 @@ export default class AliyunDmConnector implements EmailConnector {
)
);
return singleSendMail(
{
AccessKeyId: accessKeyId,
AccountName: accountName,
ReplyToAddress: 'false',
AddressType: '1',
ToAddress: address,
FromAlias: fromAlias,
Subject: template.subject,
HtmlBody:
typeof data.code === 'string'
? template.content.replace(/{{code}}/g, data.code)
: template.content,
},
accessKeySecret
);
try {
return await singleSendMail(
{
AccessKeyId: accessKeyId,
AccountName: accountName,
ReplyToAddress: 'false',
AddressType: '1',
ToAddress: address,
FromAlias: fromAlias,
Subject: template.subject,
HtmlBody:
typeof data.code === 'string'
? template.content.replace(/{{code}}/g, data.code)
: template.content,
},
accessKeySecret
);
} catch (error: unknown) {
if (error instanceof HTTPError) {
const {
response: { body: rawBody },
} = error;
assert(
typeof rawBody === 'string',
new ConnectorError(ConnectorErrorCodes.InvalidResponse)
);
throw new ConnectorError(ConnectorErrorCodes.General, rawBody);
}
throw error;
}
};
}

View file

@ -1,7 +1,5 @@
import { z } from 'zod';
export type { Response } from 'got';
export type SendEmailResponse = { EnvId: string; RequestId: string };
/**
@ -51,3 +49,15 @@ export type PublicParameters = {
Timestamp?: string;
Version?: string;
};
/**
* @doc https://next.api.aliyun.com/troubleshoot
*/
export const singleSendMailErrorResponseGuard = z.object({
Code: z.string(),
Message: z.string(),
RequestId: z.string(),
HostId: z.string(),
});
export type SingleSendMailErrorResponse = z.infer<typeof singleSendMailErrorResponseGuard>;

View file

@ -9,7 +9,16 @@ const getConnectorConfig = jest.fn() as GetConnectorConfig<AliyunSmsConfig>;
const aliyunSmsMethods = new AliyunSmsConnector(getConnectorConfig);
jest.mock('./single-send-text');
jest.mock('./single-send-text', () => {
return {
sendSms: jest.fn(() => {
return {
body: JSON.stringify({ Code: 'OK', RequestId: 'request-id', Message: 'OK' }),
statusCode: 200,
};
}),
};
});
describe('validateConfig()', () => {
afterEach(() => {
@ -28,28 +37,29 @@ describe('validateConfig()', () => {
});
describe('sendMessage()', () => {
beforeEach(() => {
jest.spyOn(aliyunSmsMethods, 'getConfig').mockResolvedValueOnce(mockedConnectorConfig);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should call singleSendMail() and replace code in content', async () => {
jest.spyOn(aliyunSmsMethods, 'getConfig').mockResolvedValueOnce(mockedConnectorConfig);
await aliyunSmsMethods.sendMessage(phoneTest, 'SignIn', { code: codeTest });
const { templates, ...credentials } = mockedConnectorConfig;
expect(sendSms).toHaveBeenCalledWith(
expect.objectContaining({
AccessKeyId: credentials.accessKeyId,
AccessKeyId: mockedConnectorConfig.accessKeyId,
PhoneNumbers: phoneTest,
SignName: credentials.signName,
SignName: mockedConnectorConfig.signName,
TemplateCode: 'code',
TemplateParam: `{"code":"${codeTest}"}`,
}),
'accessKeySecret'
mockedConnectorConfig.accessKeySecret
);
});
it('throws if template is missing', async () => {
jest.spyOn(aliyunSmsMethods, 'getConfig').mockResolvedValueOnce(mockedConnectorConfig);
await expect(
aliyunSmsMethods.sendMessage(phoneTest, 'Register', { code: codeTest })
).rejects.toThrow();

View file

@ -8,11 +8,10 @@ import {
GetConnectorConfig,
} from '@logto/connector-types';
import { assert } from '@silverhand/essentials';
import { Response } from 'got';
import { defaultMetadata } from './constant';
import { sendSms } from './single-send-text';
import { aliyunSmsConfigGuard, AliyunSmsConfig, SendSmsResponse } from './types';
import { aliyunSmsConfigGuard, AliyunSmsConfig, sendSmsResponseGuard } from './types';
export default class AliyunSmsConnector implements SmsConnector {
public metadata: ConnectorMetadata = defaultMetadata;
@ -27,11 +26,7 @@ export default class AliyunSmsConnector implements SmsConnector {
}
};
public sendMessage: SmsSendMessageFunction<Response<SendSmsResponse>> = async (
phone,
type,
{ code }
) => {
public sendMessage: SmsSendMessageFunction = async (phone, type, { code }) => {
const config = await this.getConfig(this.metadata.id);
await this.validateConfig(config);
const { accessKeyId, accessKeySecret, signName, templates } = config;
@ -42,7 +37,7 @@ export default class AliyunSmsConnector implements SmsConnector {
new ConnectorError(ConnectorErrorCodes.TemplateNotFound, `Cannot find template!`)
);
return sendSms(
const httpResponse = await sendSms(
{
AccessKeyId: accessKeyId,
PhoneNumbers: phone,
@ -52,5 +47,11 @@ export default class AliyunSmsConnector implements SmsConnector {
},
accessKeySecret
);
const { body: rawBody } = httpResponse;
const { Code } = sendSmsResponseGuard.parse(JSON.parse(rawBody));
assert(Code === 'OK', new ConnectorError(ConnectorErrorCodes.General, rawBody));
return httpResponse;
};
}

View file

@ -1,19 +1,10 @@
import { Response } from 'got';
import { endpoint, staticConfigs } from './constant';
import { PublicParameters, SendSms, SendSmsResponse } from './types';
import { PublicParameters, SendSms } from './types';
import { request } from './utils';
/**
* @doc https://help.aliyun.com/document_detail/101414.html
*/
export const sendSms = async (
parameters: PublicParameters & SendSms,
accessKeySecret: string
): Promise<Response<SendSmsResponse>> => {
return request<SendSmsResponse>(
endpoint,
{ Action: 'SendSms', ...staticConfigs, ...parameters },
accessKeySecret
);
export const sendSms = async (parameters: PublicParameters & SendSms, accessKeySecret: string) => {
return request(endpoint, { Action: 'SendSms', ...staticConfigs, ...parameters }, accessKeySecret);
};

View file

@ -2,9 +2,14 @@ import { z } from 'zod';
import { SmsTemplateType } from './constant';
export type { Response } from 'got';
export const sendSmsResponseGuard = z.object({
BizId: z.string().optional(),
Code: z.string(),
Message: z.string(),
RequestId: z.string(),
});
export type SendSmsResponse = { BizId: string; Code: string; Message: string; RequestId: string };
export type SendSmsResponse = z.infer<typeof sendSmsResponseGuard>;
/**
* @doc https://help.aliyun.com/document_detail/101414.html

View file

@ -37,7 +37,7 @@ export const getSignature = (
return createHmac('sha1', `${secret}&`).update(stringToSign).digest('base64');
};
export const request = async <T>(
export const request = async (
url: string,
parameters: PublicParameters & Record<string, string>,
accessKeySecret: string
@ -56,7 +56,7 @@ export const request = async <T>(
}
payload.append('Signature', signature);
return got.post<T>({
return got.post({
url,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',

View file

@ -7,13 +7,12 @@ import {
EmailConnector,
GetConnectorConfig,
} from '@logto/connector-types';
import { assert, Nullable } from '@silverhand/essentials';
import got from 'got';
import { assert } from '@silverhand/essentials';
import got, { HTTPError } from 'got';
import { defaultMetadata, endpoint } from './constant';
import {
sendGridMailConfigGuard,
SendEmailResponse,
SendGridMailConfig,
EmailData,
Personalization,
@ -34,11 +33,7 @@ export default class SendGridMailConnector implements EmailConnector {
}
};
public sendMessage: EmailSendMessageFunction<Nullable<SendEmailResponse>> = async (
address,
type,
data
) => {
public sendMessage: EmailSendMessageFunction = async (address, type, data) => {
const config = await this.getConfig(this.metadata.id);
await this.validateConfig(config);
const { apiKey, fromEmail, fromName, templates } = config;
@ -73,14 +68,28 @@ export default class SendGridMailConnector implements EmailConnector {
content: [content],
};
return got
.post(endpoint, {
try {
return await got.post(endpoint, {
headers: {
Authorization: 'Bearer ' + apiKey,
'Content-Type': 'application/json',
},
json: parameters,
})
.json<Nullable<SendEmailResponse>>();
});
} catch (error: unknown) {
if (error instanceof HTTPError) {
const {
response: { body: rawBody },
} = error;
assert(
typeof rawBody === 'string',
new ConnectorError(ConnectorErrorCodes.InvalidResponse)
);
throw new ConnectorError(ConnectorErrorCodes.General, rawBody);
}
throw error;
}
};
}

View file

@ -1,4 +1,3 @@
import { Nullable } from '@silverhand/essentials';
import { z } from 'zod';
/**
@ -112,8 +111,17 @@ export type SendGridMailConfig = z.infer<typeof sendGridMailConfigGuard>;
/**
* @doc https://docs.sendgrid.com/api-reference/mail-send/mail-send#responses
*/
type HelpObject = Record<string, unknown>; // Helper text or docs for troubleshooting
const helpObjectGuard = z.record(z.string(), z.unknown()); // Helper text or docs for troubleshooting
type ErrorObject = { message: string; field?: Nullable<string>; help?: HelpObject };
const errorObjectGuard = z.object({
message: z.string(),
field: z.string().nullable().optional(),
help: helpObjectGuard.optional(),
});
export type SendEmailResponse = { errors: ErrorObject; id?: string };
export const sendEmailErrorResponseGuard = z.object({
errors: z.array(errorObjectGuard),
id: z.string().optional(),
});
export type SendEmailErrorResponse = z.infer<typeof sendEmailErrorResponseGuard>;

View file

@ -8,10 +8,10 @@ import {
GetConnectorConfig,
} from '@logto/connector-types';
import { assert } from '@silverhand/essentials';
import got from 'got';
import got, { HTTPError } from 'got';
import { defaultMetadata, endpoint } from './constant';
import { twilioSmsConfigGuard, SendSmsResponse, TwilioSmsConfig, PublicParameters } from './types';
import { twilioSmsConfigGuard, TwilioSmsConfig, PublicParameters } from './types';
export default class TwilioSmsConnector implements SmsConnector {
public metadata: ConnectorMetadata = defaultMetadata;
@ -26,7 +26,7 @@ export default class TwilioSmsConnector implements SmsConnector {
}
};
public sendMessage: EmailSendMessageFunction<SendSmsResponse> = async (address, type, data) => {
public sendMessage: EmailSendMessageFunction = async (address, type, data) => {
const config = await this.getConfig(this.metadata.id);
await this.validateConfig(config);
const { accountSID, authToken, fromMessagingServiceSID, templates } = config;
@ -49,15 +49,29 @@ export default class TwilioSmsConnector implements SmsConnector {
: template.content,
};
return got
.post(endpoint.replace(/{{accountSID}}/g, accountSID), {
try {
return await got.post(endpoint.replace(/{{accountSID}}/g, accountSID), {
headers: {
Authorization:
'Basic ' + Buffer.from([accountSID, authToken].join(':')).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(parameters).toString(),
})
.json<SendSmsResponse>();
});
} catch (error: unknown) {
if (error instanceof HTTPError) {
const {
response: { body: rawBody },
} = error;
assert(
typeof rawBody === 'string',
new ConnectorError(ConnectorErrorCodes.InvalidResponse)
);
throw new ConnectorError(ConnectorErrorCodes.General, rawBody);
}
throw error;
}
};
}

View file

@ -61,3 +61,12 @@ export type SendSmsResponse = {
to: string;
uri: string;
};
export const sendSmsErrorResponseGuard = z.object({
code: z.number(),
message: z.string(),
more_info: z.string(),
status: z.number(),
});
export type SendSmsErrorResponse = z.infer<typeof sendSmsErrorResponseGuard>;