diff --git a/packages/cli/src/connector/utils.ts b/packages/cli/src/connector/utils.ts index 0a48d2513..ebc9a58ff 100644 --- a/packages/cli/src/connector/utils.ts +++ b/packages/cli/src/connector/utils.ts @@ -7,6 +7,7 @@ import type { BaseConnector, ConnectorMetadata, GetConnectorConfig, + GetCloudServiceClient, } from '@logto/connector-kit'; import { ConnectorError, ConnectorErrorCodes, ConnectorType } from '@logto/connector-kit'; @@ -91,11 +92,13 @@ export const parseMetadata = async ( export const buildRawConnector = async ( connectorFactory: ConnectorFactory, - getConnectorConfig?: GetConnectorConfig + getConnectorConfig?: GetConnectorConfig, + getCloudServiceClient?: GetCloudServiceClient ): Promise<{ rawConnector: T; rawMetadata: ConnectorMetadata }> => { const { createConnector, path: packagePath } = connectorFactory; const rawConnector = await createConnector({ getConfig: getConnectorConfig ?? notImplemented, + getCloudServiceClient, }); validateConnectorModule(rawConnector); const rawMetadata = await parseMetadata(rawConnector.metadata, packagePath); diff --git a/packages/connectors/connector-logto-email/src/grant-access-token.test.ts b/packages/connectors/connector-logto-email/src/grant-access-token.test.ts deleted file mode 100644 index 7319cfa27..000000000 --- a/packages/connectors/connector-logto-email/src/grant-access-token.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import nock from 'nock'; - -import { grantAccessToken } from './grant-access-token.js'; -import { mockedAccessTokenResponse, mockedConfig } from './mock.js'; - -describe('grantAccessToken()', () => { - it('should call got.post() and return access token', async () => { - nock(mockedConfig.tokenEndpoint).post('').reply(200, JSON.stringify(mockedAccessTokenResponse)); - - const response = await grantAccessToken(mockedConfig); - - expect(response).toMatchObject(mockedAccessTokenResponse); - }); -}); diff --git a/packages/connectors/connector-logto-email/src/grant-access-token.ts b/packages/connectors/connector-logto-email/src/grant-access-token.ts deleted file mode 100644 index 8ea338b47..000000000 --- a/packages/connectors/connector-logto-email/src/grant-access-token.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { got } from 'got'; - -import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; - -import { defaultTimeout, scope } from './constant.js'; -import { accessTokenResponseGuard } from './types.js'; - -export type GrantAccessTokenParameters = { - tokenEndpoint: string; - resource: string; - appId: string; - appSecret: string; -}; - -export const grantAccessToken = async ({ - tokenEndpoint, - resource, - appId, - appSecret, -}: GrantAccessTokenParameters) => { - const httpResponse = await got.post({ - url: tokenEndpoint, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: `Basic ${Buffer.from(`${appId}:${appSecret}`).toString('base64')}`, - }, - timeout: { request: defaultTimeout }, - form: { - grant_type: 'client_credentials', - resource, - scope, - }, - }); - - const result = accessTokenResponseGuard.safeParse(JSON.parse(httpResponse.body)); - - if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); - } - - return result.data; -}; diff --git a/packages/connectors/connector-logto-email/src/index.test.ts b/packages/connectors/connector-logto-email/src/index.test.ts index d25fc86ed..e230360d4 100644 --- a/packages/connectors/connector-logto-email/src/index.test.ts +++ b/packages/connectors/connector-logto-email/src/index.test.ts @@ -1,30 +1,56 @@ +import { got } from 'got'; import nock from 'nock'; import { VerificationCodeType } from '@logto/connector-kit'; -import { emailEndpoint } from './constant.js'; -import { mockedAccessTokenResponse, mockedConfig } from './mock.js'; +import { emailEndpoint, usageEndpoint } from './constant.js'; +import createConnector from './index.js'; const { jest } = import.meta; -const getConfig = jest.fn().mockResolvedValue(mockedConfig); +const endpoint = 'http://localhost:3003'; -const { default: createConnector } = await import('./index.js'); +const api = got.extend({ prefixUrl: endpoint }); +const dropLeadingSlash = (path: string) => path.replace(/^\//, ''); +const buildUrl = (path: string, endpoint: string) => new URL(`${endpoint}/api${path}`); + +const getConfig = jest.fn().mockResolvedValue({}); +const getCloudServiceClient = jest.fn().mockResolvedValue({ + post: async (path: string, payload: { body: unknown }) => { + return api(dropLeadingSlash(path), { + method: 'POST', + json: payload.body, + }); + }, + get: async (path: string, payload: { search: Record }) => { + return api(dropLeadingSlash(path), { + method: 'GET', + searchParams: payload.search, + }).json<{ count: number }>(); + }, +}); describe('sendMessage()', () => { - beforeAll(() => { - nock(mockedConfig.tokenEndpoint).post('').reply(200, JSON.stringify(mockedAccessTokenResponse)); - }); - it('should send message successfully', async () => { - nock(mockedConfig.endpoint).post(emailEndpoint).reply(200); - const connector = await createConnector({ getConfig }); + const url = buildUrl(emailEndpoint, endpoint); + nock(url.origin).post(url.pathname).reply(204); + const { sendMessage } = await createConnector({ getConfig, getCloudServiceClient }); await expect( - connector.sendMessage({ + sendMessage({ to: 'wangsijie94@gmail.com', type: VerificationCodeType.SignIn, payload: { code: '1234' }, }) ).resolves.not.toThrow(); }); + + it('should get usage successfully', async () => { + const date = new Date(); + const url = buildUrl(usageEndpoint, endpoint); + nock(url.origin).get(url.pathname).query({ from: date.toISOString() }).reply(200, { count: 1 }); + const connector = await createConnector({ getConfig, getCloudServiceClient }); + expect(connector.getUsage).toBeDefined(); + const usage = await connector.getUsage!(date); + expect(usage).toEqual(1); + }); }); diff --git a/packages/connectors/connector-logto-email/src/index.ts b/packages/connectors/connector-logto-email/src/index.ts index 31ac686f4..b045d345f 100644 --- a/packages/connectors/connector-logto-email/src/index.ts +++ b/packages/connectors/connector-logto-email/src/index.ts @@ -1,10 +1,10 @@ -import { assert } from '@silverhand/essentials'; -import { HTTPError, got } from 'got'; -import { z } from 'zod'; +import { assert, conditional } from '@silverhand/essentials'; +import { HTTPError } from 'got'; import type { CreateConnector, EmailConnector, + GetCloudServiceClient, GetConnectorConfig, GetUsageFunction, SendMessageFunction, @@ -14,53 +14,28 @@ import { validateConfig, ConnectorError, ConnectorErrorCodes, - parseJson, } from '@logto/connector-kit'; -import { defaultMetadata, defaultTimeout, emailEndpoint, usageEndpoint } from './constant.js'; -import { grantAccessToken } from './grant-access-token.js'; +import { defaultMetadata, emailEndpoint, usageEndpoint } from './constant.js'; import { logtoEmailConfigGuard } from './types.js'; const sendMessage = - (getConfig: GetConnectorConfig): SendMessageFunction => + (getConfig: GetConnectorConfig, getClient?: GetCloudServiceClient): SendMessageFunction => async (data, inputConfig) => { const config = inputConfig ?? (await getConfig(defaultMetadata.id)); validateConfig(config, logtoEmailConfigGuard); - const { - endpoint, - tokenEndpoint, - appId, - appSecret, - resource, - companyInformation, - senderName, - appLogo, - } = config; + const { companyInformation, senderName, appLogo } = config; const { to, type, payload } = data; - assert( - endpoint && tokenEndpoint && resource && appId && appSecret, - new ConnectorError(ConnectorErrorCodes.InvalidConfig) - ); - - const accessTokenResponse = await grantAccessToken({ - tokenEndpoint, - resource, - appId, - appSecret, - }); + assert(getClient, new ConnectorError(ConnectorErrorCodes.NotImplemented)); + const client = await getClient(); try { - await got.post({ - url: `${endpoint}${emailEndpoint}`, - headers: { - Authorization: `${accessTokenResponse.token_type} ${accessTokenResponse.access_token}`, - }, - json: { + await client.post(`/api${emailEndpoint}`, { + body: { data: { to, type, payload: { ...payload, senderName, companyInformation, appLogo } }, }, - timeout: { request: defaultTimeout }, }); } catch (error: unknown) { if (error instanceof HTTPError) { @@ -72,46 +47,30 @@ const sendMessage = }; const getUsage = - (getConfig: GetConnectorConfig): GetUsageFunction => + (getConfig: GetConnectorConfig, getClient?: GetCloudServiceClient): GetUsageFunction => async (startFrom?: Date) => { const config = await getConfig(defaultMetadata.id); validateConfig(config, logtoEmailConfigGuard); - const { endpoint, tokenEndpoint, appId, appSecret, resource } = config; + assert(getClient, new ConnectorError(ConnectorErrorCodes.NotImplemented)); + const client = await getClient(); - assert( - endpoint && tokenEndpoint && resource && appId && appSecret, - new ConnectorError(ConnectorErrorCodes.InvalidConfig) - ); - - const accessTokenResponse = await grantAccessToken({ - tokenEndpoint, - resource, - appId, - appSecret, + const { count } = await client.get(`/api${usageEndpoint}`, { + search: conditional(startFrom && { from: startFrom.toISOString() }) ?? {}, }); - - const httpResponse = await got.get({ - url: `${endpoint}${usageEndpoint}`, - headers: { - Authorization: `${accessTokenResponse.token_type} ${accessTokenResponse.access_token}`, - }, - timeout: { request: defaultTimeout }, - searchParams: { - from: startFrom?.toISOString(), - }, - }); - - return z.object({ count: z.number() }).parse(parseJson(httpResponse.body)).count; + return count; }; -const createLogtoEmailConnector: CreateConnector = async ({ getConfig }) => { +const createLogtoEmailConnector: CreateConnector = async ({ + getConfig, + getCloudServiceClient: getClient, +}) => { return { metadata: defaultMetadata, type: ConnectorType.Email, configGuard: logtoEmailConfigGuard, - sendMessage: sendMessage(getConfig), - getUsage: getUsage(getConfig), + sendMessage: sendMessage(getConfig, getClient), + getUsage: getUsage(getConfig, getClient), }; }; diff --git a/packages/connectors/connector-logto-email/src/mock.ts b/packages/connectors/connector-logto-email/src/mock.ts deleted file mode 100644 index 1a1033656..000000000 --- a/packages/connectors/connector-logto-email/src/mock.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { AccessTokenResponse } from './types.js'; - -export const mockedConfig = { - appId: 'mfvnO3josReyBf9zhDnlr', - appSecret: 'lXNWW4wPj0Bq6msjIl6H3', - tokenEndpoint: 'http://localhost:3002/oidc/token', - endpoint: 'http://localhost:3003/api', - resource: 'https://cloud.logto.io/api', -}; - -export const mockedAccessTokenResponse: AccessTokenResponse = { - access_token: 'access_token', - scope: 'scope', - token_type: 'token_type', - expires_in: 3600, -}; diff --git a/packages/core/src/libraries/cloud-connection.ts b/packages/core/src/libraries/cloud-connection.ts index 966ef0d39..d09343d06 100644 --- a/packages/core/src/libraries/cloud-connection.ts +++ b/packages/core/src/libraries/cloud-connection.ts @@ -1,5 +1,5 @@ import type router from '@logto/cloud/routes'; -import { cloudConnectionDataGuard } from '@logto/schemas'; +import { cloudConnectionDataGuard, CloudScope } from '@logto/schemas'; import { appendPath } from '@silverhand/essentials'; import Client from '@withtyped/client'; import { got } from 'got'; @@ -24,7 +24,11 @@ const accessTokenResponseGuard = z.object({ scope: z.string().optional(), }); -const scopes: string[] = []; +/** + * The scope here can be empty and still work, because the cloud API requests made using this client do not rely on scope verification. + * The `CloudScope.SendEmail` is added for now because it needs to call the cloud email service API. + */ +const scopes: string[] = [CloudScope.SendEmail]; const accessTokenExpirationMargin = 60; /** The library for connecting to Logto Cloud service. */ diff --git a/packages/core/src/libraries/connector.test.ts b/packages/core/src/libraries/connector.test.ts index ece7305e3..f173fad62 100644 --- a/packages/core/src/libraries/connector.test.ts +++ b/packages/core/src/libraries/connector.test.ts @@ -1,9 +1,10 @@ import type { Connector } from '@logto/schemas'; -import { mockGetCloudConnectionData } from '#src/__mocks__/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { MockQueries } from '#src/test-utils/tenant.js'; +const { jest } = import.meta; + const connectors: Connector[] = [ { tenantId: 'fake_tenant', @@ -19,7 +20,7 @@ const connectors: Connector[] = [ const { createConnectorLibrary } = await import('./connector.js'); const { getConnectorConfig } = createConnectorLibrary( new MockQueries({ connectors: { findAllConnectors: async () => connectors } }), - { getCloudConnectionData: mockGetCloudConnectionData } + { getClient: jest.fn() } ); it('getConnectorConfig() should return right config', async () => { diff --git a/packages/core/src/libraries/connector.ts b/packages/core/src/libraries/connector.ts index c5303a2f4..9ec4aee01 100644 --- a/packages/core/src/libraries/connector.ts +++ b/packages/core/src/libraries/connector.ts @@ -1,7 +1,7 @@ import { buildRawConnector, defaultConnectorMethods } from '@logto/cli/lib/connector/index.js'; import type { AllConnector } from '@logto/connector-kit'; import { validateConfig, ServiceConnector } from '@logto/connector-kit'; -import { pick, trySafe } from '@silverhand/essentials'; +import { conditional, pick, trySafe } from '@silverhand/essentials'; import RequestError from '#src/errors/RequestError/index.js'; import type Queries from '#src/tenants/Queries.js'; @@ -15,10 +15,10 @@ export type ConnectorLibrary = ReturnType; export const createConnectorLibrary = ( queries: Queries, - cloudConnection: Pick + cloudConnection: Pick ) => { const { findAllConnectors, findAllConnectorsWellKnown } = queries.connectors; - const { getCloudConnectionData } = cloudConnection; + const { getClient } = cloudConnection; const getConnectorConfig = async (id: string): Promise => { const connectors = await findAllConnectors(); @@ -26,10 +26,6 @@ export const createConnectorLibrary = ( assertThat(connector, new RequestError({ code: 'entity.not_found', id, status: 404 })); - if (ServiceConnector.Email === connector.connectorId) { - return { ...connector.config, ...(await getCloudConnectionData()) }; - } - return connector.config; }; @@ -81,7 +77,8 @@ export const createConnectorLibrary = ( try { const { rawConnector, rawMetadata } = await buildRawConnector( connectorFactory, - async () => getConnectorConfig(id) + async () => getConnectorConfig(id), + conditional(connectorFactory.metadata.id === ServiceConnector.Email && getClient) ); const connector: AllConnector = { @@ -123,7 +120,6 @@ export const createConnectorLibrary = ( }; return { - getCloudConnectionData, getConnectorConfig, getLogtoConnectors, getLogtoConnectorsWellKnown, diff --git a/packages/core/src/libraries/sign-in-experience/index.test.ts b/packages/core/src/libraries/sign-in-experience/index.test.ts index a2819ef73..dad05a3ec 100644 --- a/packages/core/src/libraries/sign-in-experience/index.test.ts +++ b/packages/core/src/libraries/sign-in-experience/index.test.ts @@ -7,7 +7,6 @@ import { socialTarget02, mockSignInExperience, mockSocialConnectors, - mockGetCloudConnectionData, } from '#src/__mocks__/index.js'; import RequestError from '#src/errors/RequestError/index.js'; @@ -39,7 +38,7 @@ const queries = new MockQueries({ signInExperiences, }); const connectorLibrary = createConnectorLibrary(queries, { - getCloudConnectionData: mockGetCloudConnectionData, + getClient: jest.fn(), }); const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors'); diff --git a/packages/core/src/routes/connector/config-testing.ts b/packages/core/src/routes/connector/config-testing.ts index 6868aa237..622adf8ae 100644 --- a/packages/core/src/routes/connector/config-testing.ts +++ b/packages/core/src/routes/connector/config-testing.ts @@ -1,4 +1,4 @@ -import { buildRawConnector } from '@logto/cli/lib/connector/index.js'; +import { buildRawConnector, notImplemented } from '@logto/cli/lib/connector/index.js'; import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js'; import { type SmsConnector, @@ -9,6 +9,7 @@ import { import { ServiceConnector } from '@logto/connector-kit'; import { phoneRegEx, emailRegEx } from '@logto/core-kit'; import { jsonObjectGuard, ConnectorType } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; import { string, object } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; @@ -21,7 +22,7 @@ import type { AuthedRouter, RouterInitArgs } from '../types.js'; export default function connectorConfigTestingRoutes( ...[router, { cloudConnection }]: RouterInitArgs ) { - const { getCloudConnectionData } = cloudConnection; + const { getClient } = cloudConnection; router.post( '/connectors/:factoryId/test', @@ -38,7 +39,7 @@ export default function connectorConfigTestingRoutes( params: { factoryId }, body, } = ctx.guard; - const { phone, email, config: originalConfig } = body; + const { phone, email, config } = body; const subject = phone ?? email; assertThat(subject, new RequestError({ code: 'guard.invalid_input' })); @@ -65,17 +66,12 @@ export default function connectorConfigTestingRoutes( const { rawConnector: { sendMessage }, - } = await buildRawConnector(connectorFactory); + } = await buildRawConnector( + connectorFactory, + notImplemented, + conditional(ServiceConnector.Email === connectorFactory.metadata.id && getClient) + ); - /** - * Should manually attach cloud connection data to logto email connector since we directly use - * this `config` to test the `sendMessage` method. - * Logto email connector will no longer save cloud connection data to its `config` after this change. - */ - const config = - ServiceConnector.Email === connectorFactory.metadata.id - ? { ...(await getCloudConnectionData()), ...originalConfig } - : originalConfig; await sendMessage( { to: subject, diff --git a/packages/toolkit/connector-kit/package.json b/packages/toolkit/connector-kit/package.json index 7a29f9474..09ca492f2 100644 --- a/packages/toolkit/connector-kit/package.json +++ b/packages/toolkit/connector-kit/package.json @@ -37,12 +37,14 @@ }, "dependencies": { "@logto/language-kit": "workspace:^1.0.0", - "@silverhand/essentials": "^2.5.0" + "@silverhand/essentials": "^2.5.0", + "@withtyped/client": "^0.7.21" }, "optionalDependencies": { "zod": "^3.20.2" }, "devDependencies": { + "@logto/cloud": "0.2.5-33a6965", "@jest/types": "^29.0.3", "@silverhand/eslint-config": "4.0.1", "@silverhand/ts-config": "4.0.0", diff --git a/packages/toolkit/connector-kit/src/types.ts b/packages/toolkit/connector-kit/src/types.ts index 615af6cd5..b1a9ce0c5 100644 --- a/packages/toolkit/connector-kit/src/types.ts +++ b/packages/toolkit/connector-kit/src/types.ts @@ -1,5 +1,7 @@ +import type router from '@logto/cloud/routes'; import type { LanguageTag } from '@logto/language-kit'; import { isLanguageTag } from '@logto/language-kit'; +import type Client from '@withtyped/client'; import type { ZodType } from 'zod'; import { z } from 'zod'; @@ -199,10 +201,13 @@ export type BaseConnector = { export type CreateConnector = (options: { getConfig: GetConnectorConfig; + getCloudServiceClient?: GetCloudServiceClient; }) => Promise; export type GetConnectorConfig = (id: string) => Promise; +export type GetCloudServiceClient = () => Promise>; + export type AllConnector = SmsConnector | EmailConnector | SocialConnector; // MARK: SMS + Email connector diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c184788a..ecb4712b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3747,6 +3747,9 @@ importers: '@silverhand/essentials': specifier: ^2.5.0 version: 2.5.0 + '@withtyped/client': + specifier: ^0.7.21 + version: 0.7.21(zod@3.20.2) optionalDependencies: zod: specifier: ^3.20.2 @@ -3755,6 +3758,9 @@ importers: '@jest/types': specifier: ^29.0.3 version: 29.1.2 + '@logto/cloud': + specifier: 0.2.5-33a6965 + version: 0.2.5-33a6965(zod@3.20.2) '@silverhand/eslint-config': specifier: 4.0.1 version: 4.0.1(eslint@8.44.0)(prettier@3.0.0)(typescript@5.0.2)