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:
parent
0f2548e0c4
commit
92b18c7e3c
13 changed files with 245 additions and 18 deletions
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -36,6 +36,7 @@
|
|||
"slonik",
|
||||
"stylelint",
|
||||
"topbar",
|
||||
"hasura"
|
||||
"hasura",
|
||||
"withtyped"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
43
packages/core/src/env-set/create-query-client-by-env.ts
Normal file
43
packages/core/src/env-set/create-query-client-by-env.ts
Normal 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;
|
|
@ -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);
|
||||
|
|
32
packages/core/src/routes/hook.ts
Normal file
32
packages/core/src/routes/hook.ts
Normal 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()));
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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>();
|
||||
|
|
19
packages/core/src/test-utils/query-client.ts
Normal file
19
packages/core/src/test-utils/query-client.ts
Normal 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 };
|
||||
}
|
||||
}
|
|
@ -82,6 +82,7 @@
|
|||
"@logto/language-kit": "workspace:*",
|
||||
"@logto/phrases": "workspace:*",
|
||||
"@logto/phrases-ui": "workspace:*",
|
||||
"@withtyped/server": "^0.3.0",
|
||||
"zod": "^3.20.2"
|
||||
}
|
||||
}
|
||||
|
|
45
packages/schemas/src/models/hooks.ts
Normal file
45
packages/schemas/src/models/hooks.ts
Normal 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);
|
1
packages/schemas/src/models/index.ts
Normal file
1
packages/schemas/src/models/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './hooks.js';
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue