0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat: hooks schema and APIs

This commit is contained in:
Gao Sun 2022-12-20 12:14:15 +08:00
parent 0f2548e0c4
commit 92b18c7e3c
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
13 changed files with 245 additions and 18 deletions

View file

@ -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"
]
}

View file

@ -36,6 +36,7 @@
"slonik",
"stylelint",
"topbar",
"hasura"
"hasura",
"withtyped"
]
}

View file

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

View file

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

View file

@ -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<Awaited<ReturnType<typeof loadEnvValues>>>;
let pool: Optional<DatabasePool>;
// Use another pool for `withtyped` while adopting the new model,
// as we cannot extract the original PgPool from slonik
let queryClient: Optional<QueryClient<PostgreSql>>;
let oidc: Optional<Awaited<ReturnType<typeof loadOidcValues>>>;
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);

View file

@ -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<T extends AuthedRouter>(router: T) {
const modelRouter = createModelRouter(Hooks, envSet.queryClient).withCrud();
router.all('/hooks/(.*)?', koaBody(), errorHandler, koaAdapter(modelRouter.routes()));
}

View file

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

View file

@ -131,24 +131,27 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
) as OpenAPIV3.Document;
const routes = allRouters.flatMap<RouteObject>((router) =>
router.stack.flatMap<RouteObject>(({ 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<RouteObject>(({ 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<string, MethodMap>();

View file

@ -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<PostgreSql> {
async connect() {
console.debug('MockQueryClient connect');
}
async end() {
console.debug('MockQueryClient end');
}
async query() {
console.debug('MockQueryClient query');
return { rows: [], rowCount: 0 };
}
}

View file

@ -82,6 +82,7 @@
"@logto/language-kit": "workspace:*",
"@logto/phrases": "workspace:*",
"@logto/phrases-ui": "workspace:*",
"@withtyped/server": "^0.3.0",
"zod": "^3.20.2"
}
}

View file

@ -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<string, string>;
/**
* 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<HookConfig> = 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);

View file

@ -0,0 +1 @@
export * from './hooks.js';

View file

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