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:
parent
93916bfa54
commit
4cfd5788d2
11 changed files with 148 additions and 79 deletions
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
Loading…
Reference in a new issue