From 92b18c7e3ccd7daf3c3d5a85605474bb0d277ead Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 20 Dec 2022 12:14:15 +0800 Subject: [PATCH] feat: hooks schema and APIs --- .vscode/extensions.json | 3 +- .vscode/settings.json | 3 +- packages/core/package.json | 2 + .../src/env-set/create-query-client-by-env.ts | 43 +++++++++++++ packages/core/src/env-set/index.ts | 14 +++++ packages/core/src/routes/hook.ts | 32 ++++++++++ packages/core/src/routes/init.ts | 2 + packages/core/src/routes/swagger.ts | 35 ++++++----- packages/core/src/test-utils/query-client.ts | 19 ++++++ packages/schemas/package.json | 1 + packages/schemas/src/models/hooks.ts | 45 +++++++++++++ packages/schemas/src/models/index.ts | 1 + pnpm-lock.yaml | 63 +++++++++++++++++++ 13 files changed, 245 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/env-set/create-query-client-by-env.ts create mode 100644 packages/core/src/routes/hook.ts create mode 100644 packages/core/src/test-utils/query-client.ts create mode 100644 packages/schemas/src/models/hooks.ts create mode 100644 packages/schemas/src/models/index.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index b8770f157..a25318eaf 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,6 +4,7 @@ "dbaeumer.vscode-eslint", "stylelint.vscode-stylelint", "clinyong.vscode-css-modules", - "vunguyentuan.vscode-css-variables" + "vunguyentuan.vscode-css-variables", + "frigus02.vscode-sql-tagged-template-literals-syntax-only" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 2d324d934..c1fcccb05 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,6 +36,7 @@ "slonik", "stylelint", "topbar", - "hasura" + "hasura", + "withtyped" ] } diff --git a/packages/core/package.json b/packages/core/package.json index f4dd523e6..7e63f9064 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,8 @@ "@logto/schemas": "workspace:*", "@logto/shared": "workspace:*", "@silverhand/essentials": "^1.3.0", + "@withtyped/postgres": "^0.3.1", + "@withtyped/server": "^0.3.0", "chalk": "^5.0.0", "clean-deep": "^3.4.0", "date-fns": "^2.29.3", diff --git a/packages/core/src/env-set/create-query-client-by-env.ts b/packages/core/src/env-set/create-query-client-by-env.ts new file mode 100644 index 000000000..cb950c5b1 --- /dev/null +++ b/packages/core/src/env-set/create-query-client-by-env.ts @@ -0,0 +1,43 @@ +import { assert, assertEnv } from '@silverhand/essentials'; +import { PostgresQueryClient } from '@withtyped/postgres'; +import chalk from 'chalk'; +import { parseDsn } from 'slonik'; + +import { MockQueryClient } from '#src/test-utils/query-client.js'; + +const createQueryClientByEnv = (isTest: boolean) => { + // Database connection is disabled in unit test environment + if (isTest) { + return new MockQueryClient(); + } + + const key = 'DB_URL'; + + try { + const databaseDsn = assertEnv(key); + assert(parseDsn(databaseDsn), new Error('Database name is required in `DB_URL`')); + + return new PostgresQueryClient({ connectionString: databaseDsn }); + } catch (error: unknown) { + if (error instanceof Error && error.message === `env variable ${key} not found`) { + console.error( + `${chalk.red('[error]')} No Postgres DSN (${chalk.green( + key + )}) found in env variables.\n\n` + + ` Either provide it in your env, or add it to the ${chalk.blue( + '.env' + )} file in the Logto project root.\n\n` + + ` If you want to set up a new Logto database, run ${chalk.green( + 'npm run cli db seed' + )} before setting env ${chalk.green(key)}.\n\n` + + ` Visit ${chalk.blue( + 'https://docs.logto.io/docs/references/core/configuration' + )} for more info about setting up env.\n` + ); + } + + throw error; + } +}; + +export default createQueryClientByEnv; diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index 4d668805a..e50b6f42a 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -1,5 +1,7 @@ import type { Optional } from '@silverhand/essentials'; import { getEnv, getEnvAsStringArray } from '@silverhand/essentials'; +import type { PostgreSql } from '@withtyped/postgres'; +import type { QueryClient } from '@withtyped/server'; import type { DatabasePool } from 'slonik'; import { getOidcConfigs } from '#src/libraries/logto-config.js'; @@ -7,6 +9,7 @@ import { appendPath } from '#src/utils/url.js'; import { checkAlterationState } from './check-alteration-state.js'; import createPoolByEnv from './create-pool-by-env.js'; +import createQueryClientByEnv from './create-query-client-by-env.js'; import loadOidcValues from './oidc.js'; import { isTrue } from './parameters.js'; @@ -54,6 +57,9 @@ const throwNotLoadedError = () => { function createEnvSet() { let values: Optional>>; let pool: Optional; + // Use another pool for `withtyped` while adopting the new model, + // as we cannot extract the original PgPool from slonik + let queryClient: Optional>; let oidc: Optional>>; return { @@ -74,6 +80,13 @@ function createEnvSet() { get poolSafe() { return pool; }, + get queryClient() { + if (!queryClient) { + return throwNotLoadedError(); + } + + return queryClient; + }, get oidc() { if (!oidc) { return throwNotLoadedError(); @@ -84,6 +97,7 @@ function createEnvSet() { load: async () => { values = await loadEnvValues(); pool = await createPoolByEnv(values.isTest); + queryClient = createQueryClientByEnv(values.isTest); const [, oidcConfigs] = await Promise.all([checkAlterationState(pool), getOidcConfigs(pool)]); oidc = await loadOidcValues(appendPath(values.endpoint, '/oidc').toString(), oidcConfigs); diff --git a/packages/core/src/routes/hook.ts b/packages/core/src/routes/hook.ts new file mode 100644 index 000000000..e7bbb8ff2 --- /dev/null +++ b/packages/core/src/routes/hook.ts @@ -0,0 +1,32 @@ +import { Hooks } from '@logto/schemas/models'; +import { createModelRouter } from '@withtyped/postgres'; +import { koaAdapter, RequestError } from '@withtyped/server'; +import type { MiddlewareType } from 'koa'; +import koaBody from 'koa-body'; + +import envSet from '#src/env-set/index.js'; +import LogtoRequestError from '#src/errors/RequestError/index.js'; + +import type { AuthedRouter } from './types.js'; + +// Organize this function if we decide to adopt withtyped eventually +const errorHandler: MiddlewareType = async (_, next) => { + try { + await next(); + } catch (error: unknown) { + if (error instanceof RequestError) { + throw new LogtoRequestError( + { code: 'request.general', status: error.status }, + error.original + ); + } + + throw error; + } +}; + +export default function hookRoutes(router: T) { + const modelRouter = createModelRouter(Hooks, envSet.queryClient).withCrud(); + + router.all('/hooks/(.*)?', koaBody(), errorHandler, koaAdapter(modelRouter.routes())); +} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index b8a70d671..34c3bdb76 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -14,6 +14,7 @@ import authnRoutes from './authn.js'; import connectorRoutes from './connector.js'; import customPhraseRoutes from './custom-phrase.js'; import dashboardRoutes from './dashboard.js'; +import hookRoutes from './hook.js'; import interactionRoutes from './interaction/index.js'; import logRoutes from './log.js'; import phraseRoutes from './phrase.js'; @@ -48,6 +49,7 @@ const createRouters = (provider: Provider) => { roleRoutes(managementRouter); dashboardRoutes(managementRouter); customPhraseRoutes(managementRouter); + hookRoutes(managementRouter); const profileRouter: AnonymousRouter = new Router(); profileRoutes(profileRouter, provider); diff --git a/packages/core/src/routes/swagger.ts b/packages/core/src/routes/swagger.ts index e72ab66a8..b4e959594 100644 --- a/packages/core/src/routes/swagger.ts +++ b/packages/core/src/routes/swagger.ts @@ -131,24 +131,27 @@ export default function swaggerRoutes((router) => - router.stack.flatMap(({ path: routerPath, stack, methods }) => - methods - .map((method) => method.toLowerCase()) - // There is no need to show the HEAD method. - .filter((method): method is OpenAPIV3.HttpMethods => method !== 'head') - .map((httpMethod) => { - const path = `/api${routerPath}`; + router.stack + // Filter out universal routes (mostly like a proxy route to withtyped) + .filter(({ path }) => !path.includes('.*')) + .flatMap(({ path: routerPath, stack, methods }) => + methods + .map((method) => method.toLowerCase()) + // There is no need to show the HEAD method. + .filter((method): method is OpenAPIV3.HttpMethods => method !== 'head') + .map((httpMethod) => { + const path = `/api${routerPath}`; - const additionalPathItem = additionalSwagger.paths[path] ?? {}; - const additionalResponses = additionalPathItem[httpMethod]?.responses; + const additionalPathItem = additionalSwagger.paths[path] ?? {}; + const additionalResponses = additionalPathItem[httpMethod]?.responses; - return { - path, - method: httpMethod, - operation: buildOperation(stack, routerPath, additionalResponses), - }; - }) - ) + return { + path, + method: httpMethod, + operation: buildOperation(stack, routerPath, additionalResponses), + }; + }) + ) ); const pathMap = new Map(); diff --git a/packages/core/src/test-utils/query-client.ts b/packages/core/src/test-utils/query-client.ts new file mode 100644 index 000000000..8c0c1c726 --- /dev/null +++ b/packages/core/src/test-utils/query-client.ts @@ -0,0 +1,19 @@ +import type { PostgreSql } from '@withtyped/postgres'; +import { QueryClient } from '@withtyped/server'; + +// Consider move to withtyped if everything goes well +export class MockQueryClient extends QueryClient { + async connect() { + console.debug('MockQueryClient connect'); + } + + async end() { + console.debug('MockQueryClient end'); + } + + async query() { + console.debug('MockQueryClient query'); + + return { rows: [], rowCount: 0 }; + } +} diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 5f158ba0b..374cf7a3f 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -82,6 +82,7 @@ "@logto/language-kit": "workspace:*", "@logto/phrases": "workspace:*", "@logto/phrases-ui": "workspace:*", + "@withtyped/server": "^0.3.0", "zod": "^3.20.2" } } diff --git a/packages/schemas/src/models/hooks.ts b/packages/schemas/src/models/hooks.ts new file mode 100644 index 000000000..620c96504 --- /dev/null +++ b/packages/schemas/src/models/hooks.ts @@ -0,0 +1,45 @@ +import { generateStandardId } from '@logto/core-kit'; +import { createModel } from '@withtyped/server'; +import { z } from 'zod'; + +export enum HookEvent { + PostRegister = 'PostRegister', + PostSignIn = 'PostSignIn', + PostForgotPassword = 'PostForgotPassword', +} + +export type HookConfig = { + /** We don't need `type` since v1 only has web hook */ + // type: 'web'; + /** Method fixed to `POST` */ + url: string; + /** Additional headers that attach to the request */ + headers?: Record; + /** + * Retry times when hook response status >= 500. + * + * Must be less than or equal to `3`. Use `0` to disable retry. + **/ + retries: number; +}; + +export const hookConfigGuard: z.ZodType = z.object({ + url: z.string(), + headers: z.record(z.string()).optional(), + retries: z.number().gte(0).lte(3), +}); + +export const Hooks = createModel(/* sql */ ` + create table hooks ( + id varchar(32) not null, + event varchar(128) not null, + config jsonb /* @use HookConfig */ not null, + created_at timestamptz not null default(now()), + primary key (id) + ); + + create index hooks__event on hooks (event); +`) + .extend('id', { default: () => generateStandardId(), readonly: true }) + .extend('event', z.nativeEnum(HookEvent)) // Tried to use `.refine()` to show the correct error path, but not working. + .extend('config', hookConfigGuard); diff --git a/packages/schemas/src/models/index.ts b/packages/schemas/src/models/index.ts new file mode 100644 index 000000000..a06cc95d3 --- /dev/null +++ b/packages/schemas/src/models/index.ts @@ -0,0 +1 @@ +export * from './hooks.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9ddc5d6e..480150928 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,6 +279,8 @@ importers: '@types/oidc-provider': ^7.12.0 '@types/sinon': ^10.0.13 '@types/supertest': ^2.0.11 + '@withtyped/postgres': ^0.3.1 + '@withtyped/server': ^0.3.0 chalk: ^5.0.0 clean-deep: ^3.4.0 copyfiles: ^2.4.1 @@ -335,6 +337,8 @@ importers: '@logto/schemas': link:../schemas '@logto/shared': link:../shared '@silverhand/essentials': 1.3.0 + '@withtyped/postgres': 0.3.1_@withtyped+server@0.3.0 + '@withtyped/server': 0.3.0 chalk: 5.1.2 clean-deep: 3.4.0 date-fns: 2.29.3 @@ -579,6 +583,7 @@ importers: '@types/lodash.uniq': ^4.5.6 '@types/node': ^16.0.0 '@types/pluralize': ^0.0.29 + '@withtyped/server': ^0.3.0 camelcase: ^7.0.0 eslint: ^8.21.0 jest: ^29.1.2 @@ -595,6 +600,7 @@ importers: '@logto/language-kit': link:../toolkit/language-kit '@logto/phrases': link:../phrases '@logto/phrases-ui': link:../phrases-ui + '@withtyped/server': 0.3.0 zod: 3.20.2 devDependencies: '@silverhand/eslint-config': 1.3.0_eu7dlo3qq5moigliolva3udaxa @@ -4336,6 +4342,14 @@ packages: resolution: {integrity: sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==} dev: true + /@types/pg/8.6.5: + resolution: {integrity: sha512-tOkGtAqRVkHa/PVZicq67zuujI4Oorfglsr2IbKofDwBSysnaqSx7W1mDqFqdkGE6Fbgh+PZAl0r/BWON/mozw==} + dependencies: + '@types/node': 17.0.23 + pg-protocol: 1.5.0 + pg-types: 2.2.0 + dev: false + /@types/pluralize/0.0.29: resolution: {integrity: sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==} dev: true @@ -4643,6 +4657,29 @@ packages: eslint-visitor-keys: 3.3.0 dev: true + /@withtyped/postgres/0.3.1_@withtyped+server@0.3.0: + resolution: {integrity: sha512-+XP+kbmTKKpv/5Nf4KDVKfWp6kYGIyty3aUUnSrBY0KLdOUfesuPjFK6S7sNgbh+7pvk/iU48/3UDsjuy4m+SQ==} + peerDependencies: + '@withtyped/server': ^0.3.0 + dependencies: + '@types/pg': 8.6.5 + '@withtyped/server': 0.3.0 + '@withtyped/shared': 0.2.0 + pg: 8.8.0 + transitivePeerDependencies: + - pg-native + dev: false + + /@withtyped/server/0.3.0: + resolution: {integrity: sha512-fvKf3JryFKIOgGp2z2YBbjiJrSKznuUUlH7Kv9v1gcQxkJXYjSbb0tDY4ObJjKGIrhqIJLWV4Gx40ANJMBDqww==} + dependencies: + '@withtyped/shared': 0.2.0 + dev: false + + /@withtyped/shared/0.2.0: + resolution: {integrity: sha512-SADIVEospfIWAVK0LxX7F1T04hsWMZ0NkfR3lNfvJqOktJ52GglI3FOTVYOM1NJYReDT6pR0XFlCfaF8TVPt8w==} + dev: false + /JSONStream/1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -11952,6 +11989,14 @@ packages: dependencies: pg: 8.7.3 + /pg-pool/3.5.2_pg@8.8.0: + resolution: {integrity: sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==} + peerDependencies: + pg: '>=8.0' + dependencies: + pg: 8.8.0 + dev: false + /pg-protocol/1.5.0: resolution: {integrity: sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==} @@ -12006,6 +12051,24 @@ packages: pg-types: 2.2.0 pgpass: 1.0.4 + /pg/8.8.0: + resolution: {integrity: sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==} + engines: {node: '>= 8.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + dependencies: + buffer-writer: 2.0.0 + packet-reader: 1.0.0 + pg-connection-string: 2.5.0 + pg-pool: 3.5.2_pg@8.8.0 + pg-protocol: 1.5.0 + pg-types: 2.2.0 + pgpass: 1.0.4 + dev: false + /pgpass/1.0.4: resolution: {integrity: sha512-YmuA56alyBq7M59vxVBfPJrGSozru8QAdoNlWuW3cz8l+UX3cWge0vTvjKhsSHSJpo3Bom8/Mm6hf0TR5GY0+w==} dependencies: