From eac8b19245ea8f1264d2447d4f6ab9615cbd3a45 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Tue, 22 Nov 2022 11:04:08 +0800 Subject: [PATCH] feat: add POST /connectors (#2477) --- packages/core/src/__mocks__/connector.ts | 8 +- packages/core/src/connectors/index.ts | 22 ++-- packages/core/src/connectors/types.ts | 4 +- packages/core/src/queries/connector.test.ts | 26 ++++- packages/core/src/queries/connector.ts | 7 ++ packages/core/src/routes/connector.test.ts | 104 +++++++++++++++++- packages/core/src/routes/connector.ts | 51 ++++++++- .../integration-tests/src/api/connector.ts | 9 ++ packages/integration-tests/src/helpers.ts | 8 +- .../tests/api/connector.test.ts | 7 ++ packages/phrases/src/locales/de/errors.ts | 3 + packages/phrases/src/locales/en/errors.ts | 3 + packages/phrases/src/locales/fr/errors.ts | 3 + packages/phrases/src/locales/ko/errors.ts | 3 + packages/phrases/src/locales/pt-pt/errors.ts | 3 + packages/phrases/src/locales/tr-tr/errors.ts | 3 + packages/phrases/src/locales/zh-cn/errors.ts | 2 + 17 files changed, 246 insertions(+), 20 deletions(-) diff --git a/packages/core/src/__mocks__/connector.ts b/packages/core/src/__mocks__/connector.ts index 91634053d..f0dc2d5cd 100644 --- a/packages/core/src/__mocks__/connector.ts +++ b/packages/core/src/__mocks__/connector.ts @@ -3,7 +3,7 @@ import type { Connector } from '@logto/schemas'; import { ConnectorType } from '@logto/schemas'; import { any } from 'zod'; -import type { LogtoConnector } from '@/connectors/types'; +import type { VirtualConnector, LogtoConnector } from '@/connectors/types'; import { mockConnector0, @@ -43,6 +43,12 @@ export const mockLogtoConnector = { configGuard: any(), }; +export const mockVirtualConnector: VirtualConnector = { + metadata: mockMetadata, + type: ConnectorType.Social, + ...mockLogtoConnector, +}; + export const mockConnectorList: Connector[] = [ mockConnector0, mockConnector1, diff --git a/packages/core/src/connectors/index.ts b/packages/core/src/connectors/index.ts index 048b548a6..5845a7dc9 100644 --- a/packages/core/src/connectors/index.ts +++ b/packages/core/src/connectors/index.ts @@ -12,15 +12,15 @@ import RequestError from '@/errors/RequestError'; import { findAllConnectors, insertConnector } from '@/queries/connector'; import { defaultConnectorMethods } from './consts'; -import type { LoadConnector, LogtoConnector } from './types'; +import type { VirtualConnector, LogtoConnector } from './types'; import { getConnectorConfig, readUrl, validateConnectorModule } from './utilities'; // eslint-disable-next-line @silverhand/fp/no-let -let cachedConnectors: LoadConnector[] | undefined; +let cachedVirtualConnectors: VirtualConnector[] | undefined; -const loadConnectors = async () => { - if (cachedConnectors) { - return cachedConnectors; +export const loadVirtualConnectors = async () => { + if (cachedVirtualConnectors) { + return cachedVirtualConnectors; } // Until we migrate to ESM @@ -44,7 +44,7 @@ const loadConnectors = async () => { const rawConnector = await createConnector({ getConfig: getConnectorConfig }); validateConnectorModule(rawConnector); - const connector: LoadConnector = { + const connector: VirtualConnector = { ...defaultConnectorMethods, ...rawConnector, metadata: { @@ -83,17 +83,17 @@ const loadConnectors = async () => { ); // eslint-disable-next-line @silverhand/fp/no-mutation - cachedConnectors = connectors.filter( - (connector): connector is LoadConnector => connector !== undefined + cachedVirtualConnectors = connectors.filter( + (connector): connector is VirtualConnector => connector !== undefined ); - return cachedConnectors; + return cachedVirtualConnectors; }; export const getLogtoConnectors = async (): Promise => { const connectors = await findAllConnectors(); - const virtualConnectors = await loadConnectors(); + const virtualConnectors = await loadVirtualConnectors(); return connectors .map((connector) => { @@ -131,7 +131,7 @@ export const getLogtoConnectorById = async (id: string): Promise export const initConnectors = async () => { const connectors = await findAllConnectors(); const existingConnectors = new Map(connectors.map((connector) => [connector.id, connector])); - const allConnectors = await loadConnectors(); + const allConnectors = await loadVirtualConnectors(); const newConnectors = allConnectors.filter(({ metadata: { id } }) => { const connector = existingConnectors.get(id); diff --git a/packages/core/src/connectors/types.ts b/packages/core/src/connectors/types.ts index 95412a334..3e54831ad 100644 --- a/packages/core/src/connectors/types.ts +++ b/packages/core/src/connectors/types.ts @@ -19,13 +19,13 @@ export type SocialUserInfo = z.infer; /** * Dynamic loaded connector type. */ -export type LoadConnector = T & { +export type VirtualConnector = T & { validateConfig: (config: unknown) => void; }; /** * The connector type with full context. */ -export type LogtoConnector = LoadConnector & { +export type LogtoConnector = VirtualConnector & { dbEntry: Connector; }; diff --git a/packages/core/src/queries/connector.test.ts b/packages/core/src/queries/connector.test.ts index edb5cd169..191b7aa51 100644 --- a/packages/core/src/queries/connector.test.ts +++ b/packages/core/src/queries/connector.test.ts @@ -7,7 +7,12 @@ import envSet from '@/env-set'; import type { QueryType } from '@/utils/test-utils'; import { expectSqlAssert } from '@/utils/test-utils'; -import { findAllConnectors, insertConnector, updateConnector } from './connector'; +import { + findAllConnectors, + countConnectorByConnectorId, + insertConnector, + updateConnector, +} from './connector'; const mockQuery: jest.MockedFunction = jest.fn(); @@ -40,6 +45,25 @@ describe('connector queries', () => { await expect(findAllConnectors()).resolves.toEqual([rowData]); }); + it('countConnectorsByConnectorId', async () => { + const rowData = { id: 'foo', connectorId: 'bar' }; + + const expectSql = sql` + select count(*) + from ${table} + where ${fields.connectorId}=$1 + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual(['bar']); + + return createMockQueryResult([rowData]); + }); + + await expect(countConnectorByConnectorId(rowData.connectorId)).resolves.toEqual(rowData); + }); + it('insertConnector', async () => { const connector = { ...mockConnector, diff --git a/packages/core/src/queries/connector.ts b/packages/core/src/queries/connector.ts index 7cd81895b..22391dab3 100644 --- a/packages/core/src/queries/connector.ts +++ b/packages/core/src/queries/connector.ts @@ -18,6 +18,13 @@ export const findAllConnectors = async () => `) ); +export const countConnectorByConnectorId = async (connectorId: string) => + envSet.pool.one<{ count: number }>(sql` + select count(*) + from ${table} + where ${fields.connectorId}=${connectorId} + `); + export const insertConnector = buildInsertInto(Connectors, { returning: true, }); diff --git a/packages/core/src/routes/connector.test.ts b/packages/core/src/routes/connector.test.ts index 019e9b64f..c45f0771d 100644 --- a/packages/core/src/routes/connector.test.ts +++ b/packages/core/src/routes/connector.test.ts @@ -3,20 +3,35 @@ import { MessageTypes } from '@logto/connector-kit'; import { ConnectorType } from '@logto/schemas'; import { any } from 'zod'; -import { mockMetadata, mockConnector, mockLogtoConnectorList } from '@/__mocks__'; +import { + mockMetadata, + mockConnector, + mockVirtualConnector, + mockLogtoConnectorList, +} from '@/__mocks__'; import { defaultConnectorMethods } from '@/connectors/consts'; -import type { LogtoConnector } from '@/connectors/types'; +import type { VirtualConnector, LogtoConnector } from '@/connectors/types'; import RequestError from '@/errors/RequestError'; +import { countConnectorByConnectorId } from '@/queries/connector'; import assertThat from '@/utils/assert-that'; import { createRequester } from '@/utils/test-utils'; import connectorRoutes from './connector'; +const loadVirtualConnectorsPlaceHolder = jest.fn() as jest.MockedFunction< + () => Promise +>; const getLogtoConnectorsPlaceHolder = jest.fn() as jest.MockedFunction< () => Promise >; +jest.mock('@/queries/connector', () => ({ + countConnectorByConnectorId: jest.fn(), + insertConnector: jest.fn(async (body: unknown) => body), +})); + jest.mock('@/connectors', () => ({ + loadVirtualConnectors: async () => loadVirtualConnectorsPlaceHolder(), getLogtoConnectors: async () => getLogtoConnectorsPlaceHolder(), getLogtoConnectorById: async (connectorId: string) => { const connectors = await getLogtoConnectorsPlaceHolder(); @@ -89,6 +104,91 @@ describe('connector route', () => { }); }); + describe('POST /connectors', () => { + const mockedCountConnectorByConnectorId = countConnectorByConnectorId as jest.Mock; + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should post a new connector record', async () => { + loadVirtualConnectorsPlaceHolder.mockResolvedValueOnce([ + { + ...mockVirtualConnector, + metadata: { ...mockVirtualConnector.metadata, id: 'connectorId' }, + }, + ]); + mockedCountConnectorByConnectorId.mockResolvedValueOnce({ count: 0 }); + const response = await connectorRequest.post('/connectors').send({ + connectorId: 'connectorId', + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + }); + expect(response.body).toMatchObject( + expect.objectContaining({ + connectorId: 'connectorId', + config: { + cliend_id: 'client_id', + client_secret: 'client_secret', + }, + }) + ); + expect(response).toHaveProperty('statusCode', 200); + }); + + it('throws when virtual connector not found', async () => { + loadVirtualConnectorsPlaceHolder.mockResolvedValueOnce([ + { + ...mockVirtualConnector, + metadata: { ...mockVirtualConnector.metadata, id: 'connectorId' }, + }, + ]); + mockedCountConnectorByConnectorId.mockResolvedValueOnce({ count: 0 }); + const response = await connectorRequest.post('/connectors').send({ + connectorId: 'id0', + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + }); + expect(response).toHaveProperty('statusCode', 422); + }); + + it('should post a new record when add more than 1 instance with virtual connector', async () => { + loadVirtualConnectorsPlaceHolder.mockResolvedValueOnce([ + { + ...mockVirtualConnector, + metadata: { ...mockVirtualConnector.metadata, id: 'id0', isStandard: true }, + }, + ]); + mockedCountConnectorByConnectorId.mockResolvedValueOnce({ count: 1 }); + const response = await connectorRequest.post('/connectors').send({ + connectorId: 'id0', + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + }); + expect(response.body).toMatchObject( + expect.objectContaining({ + connectorId: 'id0', + config: { + cliend_id: 'client_id', + client_secret: 'client_secret', + }, + }) + ); + expect(response).toHaveProperty('statusCode', 200); + }); + + it('throws when add more than 1 instance with non-virtual connector', async () => { + loadVirtualConnectorsPlaceHolder.mockResolvedValueOnce([ + { + ...mockVirtualConnector, + metadata: { ...mockVirtualConnector.metadata, id: 'id0' }, + }, + ]); + mockedCountConnectorByConnectorId.mockResolvedValueOnce({ count: 1 }); + const response = await connectorRequest.post('/connectors').send({ + connectorId: 'id0', + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + }); + expect(response).toHaveProperty('statusCode', 422); + }); + }); + describe('POST /connectors/:id/test', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/packages/core/src/routes/connector.ts b/packages/core/src/routes/connector.ts index 734068a22..682b7a7e7 100644 --- a/packages/core/src/routes/connector.ts +++ b/packages/core/src/routes/connector.ts @@ -2,14 +2,15 @@ import { MessageTypes } from '@logto/connector-kit'; import { emailRegEx, phoneRegEx } from '@logto/core-kit'; import type { ConnectorResponse } from '@logto/schemas'; import { arbitraryObjectGuard, Connectors, ConnectorType } from '@logto/schemas'; +import { buildIdGenerator } from '@logto/shared'; import { object, string } from 'zod'; -import { getLogtoConnectorById, getLogtoConnectors } from '@/connectors'; +import { getLogtoConnectorById, getLogtoConnectors, loadVirtualConnectors } from '@/connectors'; import type { LogtoConnector } from '@/connectors/types'; import RequestError from '@/errors/RequestError'; import { removeUnavailableSocialConnectorTargets } from '@/lib/sign-in-experience'; import koaGuard from '@/middleware/koa-guard'; -import { updateConnector } from '@/queries/connector'; +import { countConnectorByConnectorId, insertConnector, updateConnector } from '@/queries/connector'; import assertThat from '@/utils/assert-that'; import type { AuthedRouter } from './types'; @@ -24,6 +25,8 @@ const transpileLogtoConnector = ({ ...dbEntry, }); +const generateConnectorId = buildIdGenerator(12); + export default function connectorRoutes(router: T) { router.get( '/connectors', @@ -73,6 +76,50 @@ export default function connectorRoutes(router: T) { } ); + router.post( + '/connectors', + koaGuard({ + body: Connectors.createGuard.pick({ + config: true, + connectorId: true, + metadata: true, + syncProfile: true, + }), + }), + async (ctx, next) => { + const { + body: { connectorId }, + body, + } = ctx.guard; + + const virtualConnectors = await loadVirtualConnectors(); + const virtualConnector = virtualConnectors.find(({ metadata: { id } }) => id === connectorId); + + if (!virtualConnector) { + throw new RequestError({ + code: 'connector.not_found_with_connector_id', + status: 422, + }); + } + + const { count } = await countConnectorByConnectorId(connectorId); + assertThat( + count === 0 || virtualConnector.metadata.isStandard === true, + new RequestError({ + code: 'connector.multiple_instances_not_supported', + status: 422, + }) + ); + + ctx.body = await insertConnector({ + id: generateConnectorId(), + ...body, + }); + + return next(); + } + ); + router.patch( '/connectors/:id/enabled', koaGuard({ diff --git a/packages/integration-tests/src/api/connector.ts b/packages/integration-tests/src/api/connector.ts index 939d9505d..ca6fb27dd 100644 --- a/packages/integration-tests/src/api/connector.ts +++ b/packages/integration-tests/src/api/connector.ts @@ -8,6 +8,15 @@ export const listConnectors = async () => export const getConnector = async (connectorId: string) => authedAdminApi.get(`connectors/${connectorId}`).json(); +// FIXME @Darcy: correct use of `id` and `connectorId`. +export const postConnector = async (connectorId: string) => + authedAdminApi + .post({ + url: `connectors`, + json: { connectorId }, + }) + .json(); + export const updateConnectorConfig = async (connectorId: string, config: Record) => authedAdminApi .patch({ diff --git a/packages/integration-tests/src/helpers.ts b/packages/integration-tests/src/helpers.ts index 4af50b09c..e31a66e29 100644 --- a/packages/integration-tests/src/helpers.ts +++ b/packages/integration-tests/src/helpers.ts @@ -13,6 +13,7 @@ import { enableConnector, bindWithSocial, getAuthWithSocial, + postConnector, signInWithSocial, updateSignInExperience, } from '@/api'; @@ -72,7 +73,12 @@ export const signIn = async ({ username, email, password }: SignInHelper) => { }; export const setUpConnector = async (connectorId: string, config: Record) => { - await updateConnectorConfig(connectorId, config); + try { + await updateConnectorConfig(connectorId, config); + } catch { + await postConnector(connectorId); + await updateConnectorConfig(connectorId, config); + } const connector = await enableConnector(connectorId); assert(connector.enabled, new Error('Connector Setup Failed')); }; diff --git a/packages/integration-tests/tests/api/connector.test.ts b/packages/integration-tests/tests/api/connector.test.ts index 47f0030fb..f805a769d 100644 --- a/packages/integration-tests/tests/api/connector.test.ts +++ b/packages/integration-tests/tests/api/connector.test.ts @@ -14,6 +14,7 @@ import { enableConnector, getConnector, listConnectors, + postConnector, sendEmailTestMessage, sendSmsTestMessage, updateConnectorConfig, @@ -66,6 +67,12 @@ test('connector set-up flow', async () => { { id: mockSmsConnectorId, config: mockSmsConnectorConfig, type: ConnectorType.Sms }, { id: mockEmailConnectorId, config: mockEmailConnectorConfig, type: ConnectorType.Email }, ].map(async ({ id, config, type }) => { + // FIXME @Darcy: fix use of `id` and `connectorId` + try { + await getConnector(id); + } catch { + await postConnector(id); + } const updatedConnector = await updateConnectorConfig(id, config); expect(updatedConnector.config).toEqual(config); const enabledConnector = await enableConnector(id); diff --git a/packages/phrases/src/locales/de/errors.ts b/packages/phrases/src/locales/de/errors.ts index 638adaab2..2924cdc81 100644 --- a/packages/phrases/src/locales/de/errors.ts +++ b/packages/phrases/src/locales/de/errors.ts @@ -99,6 +99,9 @@ const errors = { more_than_one_sms: 'The number of SMS connectors is larger then 1.', more_than_one_email: 'The number of Email connectors is larger then 1.', db_connector_type_mismatch: 'There is a connector in the DB that does not match the type.', + not_found_with_connector_id: 'Can not find connector with given standard connector id.', + multiple_instances_not_supported: + 'Can not create multiple instance with picked standard connector.', }, passcode: { phone_email_empty: 'Telefonnummer oder E-Mail darf nicht leer sein.', diff --git a/packages/phrases/src/locales/en/errors.ts b/packages/phrases/src/locales/en/errors.ts index c3dc74bbc..e0623a626 100644 --- a/packages/phrases/src/locales/en/errors.ts +++ b/packages/phrases/src/locales/en/errors.ts @@ -98,6 +98,9 @@ const errors = { more_than_one_sms: 'The number of SMS connectors is larger then 1.', more_than_one_email: 'The number of Email connectors is larger then 1.', db_connector_type_mismatch: 'There is a connector in the DB that does not match the type.', + not_found_with_connector_id: 'Can not find connector with given standard connector id.', + multiple_instances_not_supported: + 'Can not create multiple instance with picked standard connector.', }, passcode: { phone_email_empty: 'Both phone and email are empty.', diff --git a/packages/phrases/src/locales/fr/errors.ts b/packages/phrases/src/locales/fr/errors.ts index 260640487..b23cb9681 100644 --- a/packages/phrases/src/locales/fr/errors.ts +++ b/packages/phrases/src/locales/fr/errors.ts @@ -105,6 +105,9 @@ const errors = { more_than_one_email: 'Le nombre de connecteurs Email est supérieur à 1.', db_connector_type_mismatch: 'Il y a un connecteur dans la base de donnée qui ne correspond pas au type.', + not_found_with_connector_id: 'Can not find connector with given standard connector id.', // UNTRANSLATED + multiple_instances_not_supported: + 'Can not create multiple instance with picked standard connector.', // UNTRANSLATED }, passcode: { phone_email_empty: "Le téléphone et l'email sont vides.", diff --git a/packages/phrases/src/locales/ko/errors.ts b/packages/phrases/src/locales/ko/errors.ts index becc12735..0f0dee0a7 100644 --- a/packages/phrases/src/locales/ko/errors.ts +++ b/packages/phrases/src/locales/ko/errors.ts @@ -97,6 +97,9 @@ const errors = { more_than_one_sms: '연동된 SMS 서비스가 1개 이상이여야 해요.', more_than_one_email: '연동된 이메일 서비스가 1개 이상이여야 해요.', db_connector_type_mismatch: '종류가 일치하지 않은 연동 서비스가 DB에 존재해요.', + not_found_with_connector_id: 'Can not find connector with given standard connector id.', // UNTRANSLATED + multiple_instances_not_supported: + 'Can not create multiple instance with picked standard connector.', // UNTRANSLATED }, passcode: { phone_email_empty: '휴대전화번호 그리고 이메일이 비어있어요.', diff --git a/packages/phrases/src/locales/pt-pt/errors.ts b/packages/phrases/src/locales/pt-pt/errors.ts index 77f997f4e..3b35a9d5b 100644 --- a/packages/phrases/src/locales/pt-pt/errors.ts +++ b/packages/phrases/src/locales/pt-pt/errors.ts @@ -100,6 +100,9 @@ const errors = { more_than_one_sms: 'O número de conectores SMS é maior que 1.', more_than_one_email: 'O número de conectores de e-mail é maior que 1.', db_connector_type_mismatch: 'Há um conector no banco de dados que não corresponde ao tipo.', + not_found_with_connector_id: 'Can not find connector with given standard connector id.', // UNTRANSLATED + multiple_instances_not_supported: + 'Can not create multiple instance with picked standard connector.', // UNTRANSLATED }, passcode: { phone_email_empty: 'O campos telefone e email estão vazios.', diff --git a/packages/phrases/src/locales/tr-tr/errors.ts b/packages/phrases/src/locales/tr-tr/errors.ts index 0b5049746..8f2d0b225 100644 --- a/packages/phrases/src/locales/tr-tr/errors.ts +++ b/packages/phrases/src/locales/tr-tr/errors.ts @@ -99,6 +99,9 @@ const errors = { more_than_one_sms: 'SMS bağlayıcılarının sayısı 1den fazla.', more_than_one_email: 'E-posta adresi bağlayıcılarının sayısı 1den fazla.', db_connector_type_mismatch: 'Dbde türle eşleşmeyen bir bağlayıcı var.', + not_found_with_connector_id: 'Can not find connector with given standard connector id.', // UNTRANSLATED + multiple_instances_not_supported: + 'Can not create multiple instance with picked standard connector.', // UNTRANSLATED }, passcode: { phone_email_empty: 'Hem telefon hem de e-posta adresi yok.', diff --git a/packages/phrases/src/locales/zh-cn/errors.ts b/packages/phrases/src/locales/zh-cn/errors.ts index 6e2b66edc..a139fd8de 100644 --- a/packages/phrases/src/locales/zh-cn/errors.ts +++ b/packages/phrases/src/locales/zh-cn/errors.ts @@ -95,6 +95,8 @@ const errors = { more_than_one_sms: '同时存在超过 1 个短信连接器', more_than_one_email: '同时存在超过 1 个邮件连接器', db_connector_type_mismatch: '数据库中存在一个类型不匹配的连接。', + not_found_with_connector_id: '找不到所给 connector id 对应的连接器', + multiple_instances_not_supported: '你选择的连接器不支持创建多实例。', }, passcode: { phone_email_empty: '手机号与邮箱地址均为空',