From 9dc0ea32c025374c1f4b823c7141a9330b7cf3b3 Mon Sep 17 00:00:00 2001
From: Darcy Ye <darcyye@silverhand.io>
Date: Mon, 24 Jan 2022 14:40:15 +0800
Subject: [PATCH] feat(connector): connector queries and APIs (#178)

* feat(connector): connector queries and APIs

* chore(connectors): remove type from DB schema design and fix code accordingly

* chore(connectors): put connector as ConnectorInstance's property

* chore(connector): put connector as optional property of ConnectorInstance
---
 .../core/src/connectors/aliyun-dm/index.ts    |  7 +-
 packages/core/src/connectors/github/index.ts  |  6 +-
 packages/core/src/connectors/index.ts         | 33 ++++++--
 packages/core/src/connectors/types.ts         |  8 +-
 .../core/src/connectors/utilities/index.ts    | 14 ++--
 packages/core/src/queries/connector.ts        | 14 +++-
 packages/core/src/routes/connector.ts         | 84 +++++++++++++++++++
 packages/core/src/routes/init.ts              |  2 +
 packages/schemas/src/db-entries/connector.ts  |  7 +-
 .../schemas/src/db-entries/custom-types.ts    |  5 --
 .../schemas/src/foundations/jsonb-types.ts    |  2 +-
 packages/schemas/tables/connectors.sql        |  5 +-
 12 files changed, 142 insertions(+), 45 deletions(-)
 create mode 100644 packages/core/src/routes/connector.ts

diff --git a/packages/core/src/connectors/aliyun-dm/index.ts b/packages/core/src/connectors/aliyun-dm/index.ts
index 31793a181..b74e16822 100644
--- a/packages/core/src/connectors/aliyun-dm/index.ts
+++ b/packages/core/src/connectors/aliyun-dm/index.ts
@@ -1,4 +1,3 @@
-import { ConnectorType } from '@logto/schemas';
 import { z } from 'zod';
 
 import {
@@ -7,6 +6,7 @@ import {
   ConnectorMetadata,
   EmailSendMessageFunction,
   ValidateConfig,
+  ConnectorType,
 } from '../types';
 import { getConnectorConfig } from '../utilities';
 import { singleSendMail } from './single-send-mail';
@@ -54,10 +54,7 @@ const configGuard = z.object({
 export type AliyunDmConfig = z.infer<typeof configGuard>;
 
 export const sendMessage: EmailSendMessageFunction = async (address, type, data) => {
-  const config: AliyunDmConfig = await getConnectorConfig<AliyunDmConfig>(
-    metadata.id,
-    metadata.type
-  );
+  const config = await getConnectorConfig<AliyunDmConfig>(metadata.id);
   const template = config.templates.find((template) => template.type === type);
 
   if (!template) {
diff --git a/packages/core/src/connectors/github/index.ts b/packages/core/src/connectors/github/index.ts
index fbd5a83d7..ab67fb50c 100644
--- a/packages/core/src/connectors/github/index.ts
+++ b/packages/core/src/connectors/github/index.ts
@@ -1,4 +1,3 @@
-import { ConnectorType } from '@logto/schemas';
 import got from 'got';
 import { stringify } from 'query-string';
 import { z } from 'zod';
@@ -10,6 +9,7 @@ import {
   GetAuthorizationUri,
   ValidateConfig,
   GetUserInfo,
+  ConnectorType,
 } from '../types';
 import { getConnectorConfig } from '../utilities';
 import { authorizationEndpoint, accessTokenEndpoint, scope, userInfoEndpoint } from './constant';
@@ -47,7 +47,7 @@ export const validateConfig: ValidateConfig = async (config: unknown) => {
 };
 
 export const getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => {
-  const config = await getConnectorConfig<GithubConfig>(metadata.id, metadata.type);
+  const config = await getConnectorConfig<GithubConfig>(metadata.id);
   return `${authorizationEndpoint}?${stringify({
     client_id: config.clientId,
     redirect_uri: redirectUri,
@@ -64,7 +64,7 @@ export const getAccessToken: GetAccessToken = async (code) => {
   };
 
   const { clientId: client_id, clientSecret: client_secret } =
-    await getConnectorConfig<GithubConfig>(metadata.id, metadata.type);
+    await getConnectorConfig<GithubConfig>(metadata.id);
   const { access_token: accessToken } = await got
     .post({
       url: accessTokenEndpoint,
diff --git a/packages/core/src/connectors/index.ts b/packages/core/src/connectors/index.ts
index 06340d80c..85ce1170a 100644
--- a/packages/core/src/connectors/index.ts
+++ b/packages/core/src/connectors/index.ts
@@ -1,25 +1,44 @@
-import { findConnectorByIdAndType, insertConnector } from '@/queries/connector';
+import RequestError from '@/errors/RequestError';
+import { findConnectorById, insertConnector } from '@/queries/connector';
 
 import * as AliyunDM from './aliyun-dm';
 import { ConnectorInstance } from './types';
 
-const connectors: ConnectorInstance[] = [AliyunDM];
+const allConnectors: ConnectorInstance[] = [AliyunDM];
 
-export const getConnectorById = (id: string): ConnectorInstance | null => {
-  return connectors.find((connector) => connector.metadata.id === id) ?? null;
+export const getConnectorInstances = async (): Promise<ConnectorInstance[]> => {
+  return Promise.all(
+    allConnectors.map(async (element) => {
+      const connector = await findConnectorById(element.metadata.id);
+      return { connector, ...element };
+    })
+  );
+};
+
+export const getConnectorInstanceById = async (id: string): Promise<ConnectorInstance> => {
+  const found = allConnectors.find((element) => element.metadata.id === id);
+  if (!found) {
+    throw new RequestError({
+      code: 'entity.not_found',
+      id,
+      status: 404,
+    });
+  }
+
+  const connector = await findConnectorById(id);
+  return { connector, ...found };
 };
 
 export const initConnectors = async () => {
   await Promise.all(
-    connectors.map(async ({ metadata: { id, type } }) => {
-      const record = await findConnectorByIdAndType(id, type);
+    allConnectors.map(async ({ metadata: { id } }) => {
+      const record = await findConnectorById(id);
       if (record) {
         return;
       }
 
       await insertConnector({
         id,
-        type,
       });
     })
   );
diff --git a/packages/core/src/connectors/types.ts b/packages/core/src/connectors/types.ts
index 8dd3e42d0..ee65308d3 100644
--- a/packages/core/src/connectors/types.ts
+++ b/packages/core/src/connectors/types.ts
@@ -1,6 +1,11 @@
 import { Languages } from '@logto/phrases';
-import { ConnectorConfig, ConnectorType } from '@logto/schemas';
+import { ConnectorConfig, Connector } from '@logto/schemas';
 
+export enum ConnectorType {
+  SMS = 'SMS',
+  Email = 'Email',
+  Social = 'Social',
+}
 export interface ConnectorMetadata {
   id: string;
   type: ConnectorType;
@@ -13,6 +18,7 @@ export interface ConnectorMetadata {
 export type ConnectorInstance = EmailConector | SocialConector;
 
 export interface BaseConnector {
+  connector?: Connector;
   metadata: ConnectorMetadata;
   validateConfig: ValidateConfig;
 }
diff --git a/packages/core/src/connectors/utilities/index.ts b/packages/core/src/connectors/utilities/index.ts
index 270b5550a..65d068412 100644
--- a/packages/core/src/connectors/utilities/index.ts
+++ b/packages/core/src/connectors/utilities/index.ts
@@ -1,13 +1,10 @@
-import { ConnectorConfig, ConnectorType } from '@logto/schemas';
+import { ConnectorConfig } from '@logto/schemas';
 
 import RequestError from '@/errors/RequestError';
-import { findConnectorByIdAndType, updateConnector } from '@/queries/connector';
+import { findConnectorById, updateConnector } from '@/queries/connector';
 
-export const getConnectorConfig = async <T extends ConnectorConfig>(
-  id: string,
-  type: ConnectorType
-): Promise<T> => {
-  const connector = await findConnectorByIdAndType(id, type);
+export const getConnectorConfig = async <T extends ConnectorConfig>(id: string): Promise<T> => {
+  const connector = await findConnectorById(id);
   if (!connector) {
     throw new RequestError({
       code: 'entity.not_exists_with_id',
@@ -22,11 +19,10 @@ export const getConnectorConfig = async <T extends ConnectorConfig>(
 
 export const updateConnectorConfig = async <T extends ConnectorConfig>(
   id: string,
-  type: ConnectorType,
   config: T
 ): Promise<void> => {
   await updateConnector({
-    where: { id, type },
+    where: { id },
     set: { config },
   });
 };
diff --git a/packages/core/src/queries/connector.ts b/packages/core/src/queries/connector.ts
index 211554beb..2ee8bfc5d 100644
--- a/packages/core/src/queries/connector.ts
+++ b/packages/core/src/queries/connector.ts
@@ -1,4 +1,4 @@
-import { Connector, CreateConnector, Connectors, ConnectorType } from '@logto/schemas';
+import { Connector, CreateConnector, Connectors } from '@logto/schemas';
 import { sql } from 'slonik';
 
 import { buildInsertInto } from '@/database/insert-into';
@@ -8,11 +8,17 @@ import { convertToIdentifiers } from '@/database/utils';
 
 const { table, fields } = convertToIdentifiers(Connectors);
 
-export const findConnectorByIdAndType = async (id: string, type: ConnectorType) =>
-  pool.maybeOne<Connector>(sql`
+export const findAllConnectors = async () =>
+  pool.many<Connector>(sql`
     select ${sql.join(Object.values(fields), sql`, `)}
     from ${table}
-    where ${fields.id}=${id} and ${fields.type}=${type}
+  `);
+
+export const findConnectorById = async (id: string) =>
+  pool.one<Connector>(sql`
+    select ${sql.join(Object.values(fields), sql`, `)}
+    from ${table}
+    where ${fields.id}=${id}
   `);
 
 export const insertConnector = buildInsertInto<CreateConnector, Connector>(pool, Connectors, {
diff --git a/packages/core/src/routes/connector.ts b/packages/core/src/routes/connector.ts
new file mode 100644
index 000000000..127865730
--- /dev/null
+++ b/packages/core/src/routes/connector.ts
@@ -0,0 +1,84 @@
+import { Connectors } from '@logto/schemas';
+import { object, string } from 'zod';
+
+import { getConnectorInstances, getConnectorInstanceById } from '@/connectors';
+import { ConnectorInstance } from '@/connectors/types';
+import koaGuard from '@/middleware/koa-guard';
+import { findConnectorById, updateConnector } from '@/queries/connector';
+
+import { AuthedRouter } from './types';
+
+const transpileConnectorInstance = ({ connector, metadata }: ConnectorInstance) => ({
+  ...connector,
+  metadata,
+});
+
+export default function connectorRoutes<T extends AuthedRouter>(router: T) {
+  router.get('/connectors', async (ctx, next) => {
+    const connectorInstances = await getConnectorInstances();
+    ctx.body = connectorInstances.map((connectorInstance) => {
+      return transpileConnectorInstance(connectorInstance);
+    });
+
+    return next();
+  });
+
+  router.get(
+    '/connectors/:id',
+    koaGuard({ params: object({ id: string().min(1) }) }),
+    async (ctx, next) => {
+      const {
+        params: { id },
+      } = ctx.guard;
+      const connectorInstance = await getConnectorInstanceById(id);
+      ctx.body = transpileConnectorInstance(connectorInstance);
+
+      return next();
+    }
+  );
+
+  router.patch(
+    '/connectors/:id/enabled',
+    koaGuard({
+      params: object({ id: string().min(1) }),
+      body: Connectors.createGuard.pick({ enabled: true }),
+    }),
+    async (ctx, next) => {
+      const {
+        params: { id },
+        body: { enabled },
+      } = ctx.guard;
+      await findConnectorById(id);
+      await updateConnector({ set: { enabled }, where: { id } });
+      ctx.body = { enabled };
+
+      return next();
+    }
+  );
+
+  router.patch(
+    '/connectors/:id',
+    koaGuard({
+      params: object({ id: string().min(1) }),
+      body: Connectors.createGuard
+        .omit({ id: true, type: true, enabled: true, createdAt: true })
+        .partial(),
+    }),
+    async (ctx, next) => {
+      const {
+        params: { id },
+        body,
+      } = ctx.guard;
+      const connectorInstance = await getConnectorInstanceById(id);
+
+      if (body.config) {
+        await connectorInstance.validateConfig(body.config);
+      }
+
+      await updateConnector({ set: body, where: { id } });
+      ctx.body = transpileConnectorInstance(await getConnectorInstanceById(id));
+
+      return next();
+    }
+  );
+}
diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts
index 918d0c77f..f3b22cc5a 100644
--- a/packages/core/src/routes/init.ts
+++ b/packages/core/src/routes/init.ts
@@ -5,6 +5,7 @@ import { Provider } from 'oidc-provider';
 
 import koaAuth from '@/middleware/koa-auth';
 import applicationRoutes from '@/routes/application';
+import connectorRoutes from '@/routes/connector';
 import resourceRoutes from '@/routes/resource';
 import sessionRoutes from '@/routes/session';
 import settingRoutes from '@/routes/setting';
@@ -26,6 +27,7 @@ const createRouters = (provider: Provider) => {
   router.use(koaAuth());
   applicationRoutes(router);
   settingRoutes(router);
+  connectorRoutes(router);
   resourceRoutes(router);
 
   return [anonymousRouter, router];
diff --git a/packages/schemas/src/db-entries/connector.ts b/packages/schemas/src/db-entries/connector.ts
index cadf6e525..8f12de9f7 100644
--- a/packages/schemas/src/db-entries/connector.ts
+++ b/packages/schemas/src/db-entries/connector.ts
@@ -3,12 +3,10 @@
 import { z } from 'zod';
 
 import { ConnectorConfig, connectorConfigGuard, GeneratedSchema, Guard } from '../foundations';
-import { ConnectorType } from './custom-types';
 
 export type CreateConnector = {
   id: string;
   enabled?: boolean;
-  type: ConnectorType;
   config?: ConnectorConfig;
   createdAt?: number;
 };
@@ -16,7 +14,6 @@ export type CreateConnector = {
 export type Connector = {
   id: string;
   enabled: boolean;
-  type: ConnectorType;
   config: ConnectorConfig;
   createdAt: number;
 };
@@ -24,7 +21,6 @@ export type Connector = {
 const createGuard: Guard<CreateConnector> = z.object({
   id: z.string(),
   enabled: z.boolean().optional(),
-  type: z.nativeEnum(ConnectorType),
   config: connectorConfigGuard.optional(),
   createdAt: z.number().optional(),
 });
@@ -35,10 +31,9 @@ export const Connectors: GeneratedSchema<CreateConnector> = Object.freeze({
   fields: {
     id: 'id',
     enabled: 'enabled',
-    type: 'type',
     config: 'config',
     createdAt: 'created_at',
   },
-  fieldKeys: ['id', 'enabled', 'type', 'config', 'createdAt'],
+  fieldKeys: ['id', 'enabled', 'config', 'createdAt'],
   createGuard,
 });
diff --git a/packages/schemas/src/db-entries/custom-types.ts b/packages/schemas/src/db-entries/custom-types.ts
index 95edfc3bb..6cba93da5 100644
--- a/packages/schemas/src/db-entries/custom-types.ts
+++ b/packages/schemas/src/db-entries/custom-types.ts
@@ -5,11 +5,6 @@ export enum ApplicationType {
   SPA = 'SPA',
   Traditional = 'Traditional',
 }
-export enum ConnectorType {
-  SMS = 'SMS',
-  Email = 'Email',
-  Social = 'Social',
-}
 export enum UserLogType {
   SignInUsernameAndPassword = 'SignInUsernameAndPassword',
   ExchangeAccessToken = 'ExchangeAccessToken',
diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts
index 7f84b184f..c6e26c938 100644
--- a/packages/schemas/src/foundations/jsonb-types.ts
+++ b/packages/schemas/src/foundations/jsonb-types.ts
@@ -45,7 +45,7 @@ export const userLogPayloadGuard = z.object({
 export type UserLogPayload = z.infer<typeof userLogPayloadGuard>;
 
 // TODO: support empty shape of object
-export const connectorConfigGuard = z.object({});
+export const connectorConfigGuard = z.object({}).catchall(z.unknown());
 
 export type ConnectorConfig = z.infer<typeof connectorConfigGuard>;
 
diff --git a/packages/schemas/tables/connectors.sql b/packages/schemas/tables/connectors.sql
index 8b76cee46..80d0374a6 100644
--- a/packages/schemas/tables/connectors.sql
+++ b/packages/schemas/tables/connectors.sql
@@ -1,10 +1,7 @@
-create type connector_type as enum ('SMS', 'Email', 'Social');
-
 create table connectors (
   id varchar(128) not null,
   enabled boolean not null default TRUE,
-  type connector_type not null,
   config jsonb /* @use ConnectorConfig */ not null default '{}'::jsonb,
   created_at timestamptz not null default(now()),
-  primary key (id, type)
+  primary key (id)
 );