mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat: add POST /connectors (#2477)
This commit is contained in:
parent
800a019689
commit
eac8b19245
17 changed files with 246 additions and 20 deletions
|
@ -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,
|
||||
|
|
|
@ -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<LogtoConnector[]> => {
|
||||
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<LogtoConnector>
|
|||
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);
|
||||
|
||||
|
|
|
@ -19,13 +19,13 @@ export type SocialUserInfo = z.infer<typeof socialUserInfoGuard>;
|
|||
/**
|
||||
* Dynamic loaded connector type.
|
||||
*/
|
||||
export type LoadConnector<T extends AllConnector = AllConnector> = T & {
|
||||
export type VirtualConnector<T extends AllConnector = AllConnector> = T & {
|
||||
validateConfig: (config: unknown) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The connector type with full context.
|
||||
*/
|
||||
export type LogtoConnector<T extends AllConnector = AllConnector> = LoadConnector<T> & {
|
||||
export type LogtoConnector<T extends AllConnector = AllConnector> = VirtualConnector<T> & {
|
||||
dbEntry: Connector;
|
||||
};
|
||||
|
|
|
@ -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<QueryType> = 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,
|
||||
|
|
|
@ -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<CreateConnector, Connector>(Connectors, {
|
||||
returning: true,
|
||||
});
|
||||
|
|
|
@ -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<VirtualConnector[]>
|
||||
>;
|
||||
const getLogtoConnectorsPlaceHolder = jest.fn() as jest.MockedFunction<
|
||||
() => Promise<LogtoConnector[]>
|
||||
>;
|
||||
|
||||
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();
|
||||
|
|
|
@ -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<T extends AuthedRouter>(router: T) {
|
||||
router.get(
|
||||
'/connectors',
|
||||
|
@ -73,6 +76,50 @@ export default function connectorRoutes<T extends AuthedRouter>(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({
|
||||
|
|
|
@ -8,6 +8,15 @@ export const listConnectors = async () =>
|
|||
export const getConnector = async (connectorId: string) =>
|
||||
authedAdminApi.get(`connectors/${connectorId}`).json<ConnectorResponse>();
|
||||
|
||||
// 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<string, unknown>) =>
|
||||
authedAdminApi
|
||||
.patch({
|
||||
|
|
|
@ -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<string, unknown>) => {
|
||||
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'));
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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: '휴대전화번호 그리고 이메일이 비어있어요.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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: '手机号与邮箱地址均为空',
|
||||
|
|
Loading…
Reference in a new issue