0
Fork 0
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:
Darcy Ye 2022-11-22 11:04:08 +08:00 committed by GitHub
parent 800a019689
commit eac8b19245
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 246 additions and 20 deletions

View file

@ -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,

View file

@ -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);

View file

@ -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;
};

View file

@ -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,

View file

@ -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,
});

View file

@ -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();

View file

@ -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({

View file

@ -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({

View file

@ -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'));
};

View file

@ -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);

View file

@ -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.',

View file

@ -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.',

View file

@ -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.",

View file

@ -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: '휴대전화번호 그리고 이메일이 비어있어요.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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: '手机号与邮箱地址均为空',