Skip to content

Commit e4182c6

Browse files
feat(connector): add http sms connector (#7510)
Co-authored-by: wangsijie <[email protected]>
1 parent c7ce50e commit e4182c6

File tree

11 files changed

+377
-124
lines changed

11 files changed

+377
-124
lines changed

.changeset/olive-maps-wave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@logto/connector-http-sms": minor
3+
---
4+
5+
add SMS http connector
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# HTTP SMS connector
2+
3+
The official Logto connector for HTTP SMS.
4+
5+
## Get started
6+
7+
The HTTP SMS connector allows you to send SMS messages via HTTP call. To use the HTTP SMS connector, you'll need to have your own SMS service that exposes an HTTP API for sending SMS messages. Logto will call this API when it needs to send an SMS. For example, when a user registers, Logto will call the HTTP API to send a verification SMS.
8+
9+
## Set up HTTP SMS connector
10+
11+
To use the HTTP SMS connector, you need to set up an HTTP endpoint that Logto can call, and an optional authorization token for the endpoint.
12+
13+
> 💡 **Tip**
14+
>
15+
> Note that to prevent errors in authentication flow, the configured `endpoint` must return a 2xx response after receiving the webhook to inform Logto that it has received the notification to send the SMS.
16+
>
17+
> Meanwhile, in this scenario, you need to monitor SMS service to ensure successful SMS delivery. Alternatively, you can add monitoring to your SMS sending API to promptly detect SMS delivery failures.
18+
19+
## Payload
20+
21+
The HTTP SMS connector will send the following payload to the endpoint when it needs to send an SMS:
22+
23+
```json
24+
{
25+
"to": "+1234567890",
26+
"type": "SignIn",
27+
"payload": {
28+
"code": "123456"
29+
}
30+
}
31+
```
32+
33+
You can find all of the types in https://docs.logto.io/docs/recipes/configure-connectors/sms-connector/configure-popular-sms-service/#sms-template, and the full type definition of `SendMessageData` in [connector-kit](https://github.com/logto-io/logto/tree/master/packages/toolkit/connector-kit/src/types/passwordless.ts).
Lines changed: 13 additions & 0 deletions
Loading
Lines changed: 13 additions & 0 deletions
Loading
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
{
2+
"name": "@logto/connector-http-sms",
3+
"version": "0.0.0",
4+
"description": "SMS connector to send SMS via HTTP call.",
5+
"author": "Michel Courtine <[email protected]>",
6+
"dependencies": {
7+
"@logto/connector-kit": "workspace:^4.3.0",
8+
"@silverhand/essentials": "^2.9.1",
9+
"got": "^14.0.0",
10+
"zod": "3.24.3"
11+
},
12+
"main": "./lib/index.js",
13+
"module": "./lib/index.js",
14+
"exports": "./lib/index.js",
15+
"license": "MPL-2.0",
16+
"type": "module",
17+
"files": [
18+
"lib",
19+
"docs",
20+
"logo.svg",
21+
"logo-dark.svg"
22+
],
23+
"scripts": {
24+
"precommit": "lint-staged",
25+
"check": "tsc --noEmit",
26+
"build": "tsup",
27+
"dev": "tsup --watch",
28+
"lint": "eslint --ext .ts src",
29+
"lint:report": "pnpm lint --format json --output-file report.json",
30+
"test": "vitest src",
31+
"test:ci": "pnpm run test --silent --coverage",
32+
"prepublishOnly": "pnpm build"
33+
},
34+
"engines": {
35+
"node": "^22.14.0"
36+
},
37+
"eslintConfig": {
38+
"extends": "@silverhand",
39+
"settings": {
40+
"import/core-modules": [
41+
"@silverhand/essentials",
42+
"got",
43+
"nock",
44+
"snakecase-keys",
45+
"zod"
46+
]
47+
}
48+
},
49+
"prettier": "@silverhand/eslint-config/.prettierrc",
50+
"publishConfig": {
51+
"access": "public"
52+
},
53+
"devDependencies": {
54+
"@silverhand/eslint-config": "6.0.1",
55+
"@silverhand/ts-config": "6.0.0",
56+
"@types/node": "^22.14.0",
57+
"@vitest/coverage-v8": "^3.1.1",
58+
"eslint": "^8.56.0",
59+
"lint-staged": "^15.0.2",
60+
"nock": "^14.0.3",
61+
"prettier": "^3.5.3",
62+
"tsup": "^8.5.0",
63+
"typescript": "^5.5.3",
64+
"vitest": "^3.1.1"
65+
}
66+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { ConnectorMetadata } from '@logto/connector-kit';
2+
import { ConnectorConfigFormItemType } from '@logto/connector-kit';
3+
4+
export const defaultMetadata: ConnectorMetadata = {
5+
id: 'http-sms',
6+
target: 'http-sms',
7+
platform: null,
8+
name: {
9+
en: 'HTTP SMS',
10+
},
11+
logo: './logo.svg',
12+
logoDark: './logo-dark.svg',
13+
description: {
14+
en: 'Send SMS via HTTP call.',
15+
},
16+
readme: './README.md',
17+
formItems: [
18+
{
19+
key: 'endpoint',
20+
label: 'Endpoint',
21+
type: ConnectorConfigFormItemType.Text,
22+
required: true,
23+
placeholder: '<https://example.com/your-http-sms-endpoint>',
24+
},
25+
{
26+
key: 'authorization',
27+
label: 'Authorization Header',
28+
type: ConnectorConfigFormItemType.Text,
29+
required: false,
30+
placeholder: '<Bearer your-token>',
31+
tooltip:
32+
'The authorization header to be sent with the request, you can verify the value in your server.',
33+
},
34+
],
35+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import nock from 'nock';
2+
3+
import { TemplateType } from '@logto/connector-kit';
4+
5+
import createConnector from './index.js';
6+
import { mockedConfig } from './mock.js';
7+
8+
const getConfig = vi.fn().mockResolvedValue(mockedConfig);
9+
10+
describe('HTTP SMS connector', () => {
11+
afterEach(() => {
12+
nock.cleanAll();
13+
});
14+
15+
it('should init without throwing errors', async () => {
16+
await expect(createConnector({ getConfig })).resolves.not.toThrow();
17+
});
18+
19+
it('should call endpoint with correct parameters', async () => {
20+
const url = new URL(mockedConfig.endpoint);
21+
const mockPost = nock(url.origin)
22+
.post(url.pathname, (body) => {
23+
expect(body).toMatchObject({
24+
to: '+1234567890',
25+
type: TemplateType.SignIn,
26+
payload: {
27+
code: '123456',
28+
},
29+
});
30+
return true;
31+
})
32+
.reply(200, {
33+
message: 'SMS sent successfully',
34+
});
35+
36+
const connector = await createConnector({ getConfig });
37+
await connector.sendMessage({
38+
to: '+1234567890',
39+
type: TemplateType.SignIn,
40+
payload: {
41+
code: '123456',
42+
},
43+
});
44+
45+
expect(mockPost.isDone()).toBe(true);
46+
});
47+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { assert } from '@silverhand/essentials';
2+
import { got, HTTPError } from 'got';
3+
4+
import type {
5+
GetConnectorConfig,
6+
SendMessageFunction,
7+
CreateConnector,
8+
SmsConnector,
9+
} from '@logto/connector-kit';
10+
import {
11+
ConnectorError,
12+
ConnectorErrorCodes,
13+
validateConfig,
14+
ConnectorType,
15+
} from '@logto/connector-kit';
16+
17+
import { defaultMetadata } from './constant.js';
18+
import { httpSmsConfigGuard } from './types.js';
19+
20+
const sendMessage =
21+
(getConfig: GetConnectorConfig): SendMessageFunction =>
22+
async (data, inputConfig) => {
23+
const { to, type, payload } = data;
24+
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
25+
validateConfig(config, httpSmsConfigGuard);
26+
const { endpoint, authorization } = config;
27+
28+
try {
29+
return await got.post(endpoint, {
30+
headers: {
31+
...(authorization && { Authorization: authorization }),
32+
'Content-Type': 'application/json',
33+
},
34+
json: {
35+
to,
36+
type,
37+
payload,
38+
},
39+
});
40+
} catch (error: unknown) {
41+
if (error instanceof HTTPError) {
42+
const {
43+
response: { body: rawBody },
44+
} = error;
45+
46+
assert(
47+
typeof rawBody === 'string',
48+
new ConnectorError(
49+
ConnectorErrorCodes.InvalidResponse,
50+
`Invalid response raw body type: ${typeof rawBody}`
51+
)
52+
);
53+
54+
throw new ConnectorError(ConnectorErrorCodes.General, rawBody);
55+
}
56+
57+
throw new ConnectorError(
58+
ConnectorErrorCodes.General,
59+
error instanceof Error ? error.message : String(error)
60+
);
61+
}
62+
};
63+
64+
const createHttpSmsConnector: CreateConnector<SmsConnector> = async ({ getConfig }) => {
65+
return {
66+
metadata: defaultMetadata,
67+
type: ConnectorType.Sms,
68+
configGuard: httpSmsConfigGuard,
69+
sendMessage: sendMessage(getConfig),
70+
};
71+
};
72+
73+
export default createHttpSmsConnector;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { HttpSmsConfig } from './types.js';
2+
3+
export const mockedConfig: HttpSmsConfig = {
4+
endpoint: 'https://example.com/your-http-sms-endpoint',
5+
authorization: 'SampleToken',
6+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { z } from 'zod';
2+
3+
export const httpSmsConfigGuard = z.object({
4+
endpoint: z.string(),
5+
authorization: z.string().optional(),
6+
});
7+
8+
export type HttpSmsConfig = z.infer<typeof httpSmsConfigGuard>;

0 commit comments

Comments
 (0)