mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(schemas): define data hook events (#5828)
* feat(schemas): define data hook events define data hook events * fix(schemas,core): fix the type error fix the type error * fix(core): fix unit test fix unit test * feat(test): add integration tests for DataHooks add integration tests for DataHooks * fix(test): fix ut of management api hook middleware fix ut of the management api hook middleware * refactor(test,core,schemas): refactor some DataHook definiations refactor some DataHook definitations * chore(test): remove upper scope describe wrap remove upper scope describe wrap * fix(test): fix tests fix tests * refactor(schemas): rename the info.update events rename the info.update events * refactor(schemas): rename rename * refactor(core,schemas): refactor DataHook code refactor DataHook code to address some code review comments * fix(test): fix ut fix ut * fix(schemas): update DataHookEventPayload type update DataHookEventPayload type * chore(schemas): update comments update comments
This commit is contained in:
parent
cad032a22a
commit
e7a642028d
11 changed files with 720 additions and 18 deletions
|
@ -65,15 +65,19 @@ describe('koaManagementApiHooks', () => {
|
|||
|
||||
it.each(events)('should append hook context for %s', async (key, event) => {
|
||||
const [method, route] = key.split(' ') as [string, string];
|
||||
const ctxParams = createContextWithRouteParameters();
|
||||
|
||||
const ctx: ParameterizedContext<unknown, WithHookContext> = {
|
||||
...createContextWithRouteParameters(),
|
||||
...ctxParams,
|
||||
header: {},
|
||||
appendDataHookContext: notToBeCalled,
|
||||
method,
|
||||
_matchedRoute: route,
|
||||
path: route,
|
||||
body: { key },
|
||||
response: {
|
||||
...ctxParams.response,
|
||||
body: { key },
|
||||
},
|
||||
status: 200,
|
||||
};
|
||||
|
||||
|
@ -85,7 +89,16 @@ describe('koaManagementApiHooks', () => {
|
|||
contextArray: [
|
||||
{
|
||||
event,
|
||||
data: { path: route, method, body: { key }, status: 200 },
|
||||
data: {
|
||||
path: route,
|
||||
method,
|
||||
response: {
|
||||
body: { key },
|
||||
},
|
||||
params: ctxParams.params,
|
||||
matchedRoute: route,
|
||||
status: 200,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -49,13 +49,24 @@ export const koaManagementApiHooks = <StateT, ContextT extends IRouterParamConte
|
|||
await next();
|
||||
|
||||
// Auto append pre-registered management API hooks if any
|
||||
const { path, method, body, status, _matchedRoute } = ctx;
|
||||
const hookRegistrationKey = buildManagementApiDataHookRegistrationKey(method, _matchedRoute);
|
||||
const {
|
||||
path,
|
||||
method,
|
||||
status,
|
||||
_matchedRoute: matchedRoute,
|
||||
params,
|
||||
response: { body },
|
||||
} = ctx;
|
||||
|
||||
const hookRegistrationKey = buildManagementApiDataHookRegistrationKey(method, matchedRoute);
|
||||
|
||||
// TODO: @simeng-li do we need to insert the request body to the hook context?
|
||||
if (hasRegisteredDataHookEvent(hookRegistrationKey)) {
|
||||
const event = managementApiHooksRegistration[hookRegistrationKey];
|
||||
dataHooks.appendContext({ event, data: { path, method, body, status } });
|
||||
|
||||
dataHooks.appendContext({
|
||||
event,
|
||||
data: { path, method, response: { body }, status, params, matchedRoute },
|
||||
});
|
||||
}
|
||||
|
||||
if (dataHooks.contextArray.length > 0) {
|
||||
|
|
|
@ -4,13 +4,13 @@ import type {
|
|||
PrimitiveValueExpression,
|
||||
TaggedTemplateLiteralInvocation,
|
||||
} from '@silverhand/slonik/dist/src/types.js';
|
||||
import type { MiddlewareType, Context, Middleware } from 'koa';
|
||||
import type { Context, Middleware, MiddlewareType } from 'koa';
|
||||
import Koa from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
import Router from 'koa-router';
|
||||
import request from 'supertest';
|
||||
|
||||
import type { ManagementApiRouter, AnonymousRouter } from '#src/routes/types.js';
|
||||
import type { AnonymousRouter, ManagementApiRouter } from '#src/routes/types.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
import type { Options } from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
|
||||
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
|
||||
|
|
|
@ -48,7 +48,8 @@
|
|||
"prettier": "^3.0.0",
|
||||
"puppeteer": "^22.6.5",
|
||||
"text-encoder": "^0.0.4",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.3.3",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import { createServer, type RequestListener, type Server } from 'node:http';
|
||||
|
||||
/**
|
||||
* A mock server that listens for incoming requests and responds with the request body.
|
||||
*
|
||||
* @example
|
||||
* const server = new WebhookMockServer(3000);
|
||||
* await server.listen();
|
||||
*/
|
||||
class WebhookMockServer {
|
||||
public readonly endpoint = `http://localhost:${this.port}`;
|
||||
private readonly server: Server;
|
||||
|
||||
constructor(
|
||||
/** The port number to listen on. */
|
||||
private readonly port: number,
|
||||
/** A callback that is called with the request body when a request is received. */
|
||||
requestCallback?: (body: string) => void
|
||||
) {
|
||||
const requestListener: RequestListener = (request, response) => {
|
||||
const data: Uint8Array[] = [];
|
||||
|
||||
request.on('data', (chunk: Uint8Array) => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
data.push(chunk);
|
||||
});
|
||||
|
||||
request.on('end', () => {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
|
||||
const payload: unknown = JSON.parse(Buffer.concat(data).toString());
|
||||
|
||||
const body = JSON.stringify({
|
||||
signature: request.headers['logto-signature-sha-256'],
|
||||
payload,
|
||||
});
|
||||
|
||||
requestCallback?.(body);
|
||||
|
||||
response.end(body);
|
||||
});
|
||||
};
|
||||
|
||||
this.server = createServer(requestListener);
|
||||
}
|
||||
|
||||
public async listen() {
|
||||
return new Promise((resolve) => {
|
||||
this.server.listen(this.port, () => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async close() {
|
||||
return new Promise((resolve) => {
|
||||
this.server.close(() => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default WebhookMockServer;
|
|
@ -0,0 +1,342 @@
|
|||
import {
|
||||
RoleType,
|
||||
hookEventGuard,
|
||||
hookEvents,
|
||||
jsonGuard,
|
||||
managementApiHooksRegistration,
|
||||
type Hook,
|
||||
type Role,
|
||||
} from '@logto/schemas';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authedAdminApi } from '#src/api/api.js';
|
||||
import { createResource } from '#src/api/resource.js';
|
||||
import { createScope } from '#src/api/scope.js';
|
||||
import {
|
||||
OrganizationApiTest,
|
||||
OrganizationRoleApiTest,
|
||||
OrganizationScopeApiTest,
|
||||
} from '#src/helpers/organization.js';
|
||||
import { UserApiTest, generateNewUser } from '#src/helpers/user.js';
|
||||
import { generateName, waitFor } from '#src/utils.js';
|
||||
|
||||
import WebhookMockServer from './WebhookMockServer.js';
|
||||
import {
|
||||
organizationDataHookTestCases,
|
||||
organizationRoleDataHookTestCases,
|
||||
organizationScopeDataHookTestCases,
|
||||
roleDataHookTestCases,
|
||||
scopesDataHookTestCases,
|
||||
userDataHookTestCases,
|
||||
} from './test-cases.js';
|
||||
|
||||
const mockHookResponseGuard = z.object({
|
||||
signature: z.string(),
|
||||
payload: z.object({
|
||||
event: hookEventGuard,
|
||||
createdAt: z.string(),
|
||||
hookId: z.string(),
|
||||
body: jsonGuard.optional(),
|
||||
method: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value?.toUpperCase()),
|
||||
matchedRoute: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
type MockHookResponse = z.infer<typeof mockHookResponseGuard>;
|
||||
|
||||
const hookName = 'management-api-hook';
|
||||
const webhooks = new Map<string, Hook>();
|
||||
const webhookResults = new Map<string, MockHookResponse>();
|
||||
|
||||
// Record the hook response to the webhookResults map.
|
||||
// Compare the webhookResults map with the managementApiHooksRegistration to verify all hook is triggered.
|
||||
const webhookResponseHandler = (response: string) => {
|
||||
const result = mockHookResponseGuard.parse(JSON.parse(response));
|
||||
const { payload } = result;
|
||||
|
||||
// Use matchedRoute as the key
|
||||
if (payload.matchedRoute) {
|
||||
webhookResults.set(`${payload.method} ${payload.matchedRoute}`, result);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the webhook result by the key.
|
||||
*
|
||||
* @remark Since the webhook request is async, we need to wait for a while
|
||||
* to ensure the webhook response is received.
|
||||
*/
|
||||
const getWebhookResult = async (key: string) => {
|
||||
await waitFor(100);
|
||||
|
||||
return webhookResults.get(key);
|
||||
};
|
||||
|
||||
const webhookServer = new WebhookMockServer(9999, webhookResponseHandler);
|
||||
|
||||
beforeAll(async () => {
|
||||
await webhookServer.listen();
|
||||
|
||||
const webhookInstance = await authedAdminApi
|
||||
.post('hooks', {
|
||||
json: {
|
||||
name: hookName,
|
||||
events: [...hookEvents],
|
||||
config: {
|
||||
url: webhookServer.endpoint,
|
||||
headers: { foo: 'bar' },
|
||||
},
|
||||
},
|
||||
})
|
||||
.json<Hook>();
|
||||
|
||||
webhooks.set(hookName, webhookInstance);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(
|
||||
Array.from(webhooks.values()).map(async (hook) => authedAdminApi.delete(`hooks/${hook.id}`))
|
||||
);
|
||||
|
||||
await webhookServer.close();
|
||||
});
|
||||
|
||||
describe('user data hook events', () => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-let
|
||||
let userId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a user to trigger the User.Created event.
|
||||
const { user } = await generateNewUser({ username: true, password: true });
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
userId = user.id;
|
||||
const userCreateHook = await getWebhookResult('POST /users');
|
||||
expect(userCreateHook?.payload.event).toBe('User.Created');
|
||||
});
|
||||
|
||||
it.each(userDataHookTestCases)(
|
||||
'test case %#: %p',
|
||||
async ({ route, event, method, endpoint, payload }) => {
|
||||
await authedAdminApi[method](endpoint.replace('{userId}', userId), { json: payload });
|
||||
const hook = await getWebhookResult(route);
|
||||
expect(hook?.payload.event).toBe(event);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('role data hook events', () => {
|
||||
/* eslint-disable @silverhand/fp/no-let */
|
||||
let roleId: string;
|
||||
let scopeId: string;
|
||||
let resourceId: string;
|
||||
/* eslint-enable @silverhand/fp/no-let */
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a role to trigger the Role.Created event.
|
||||
const role = await authedAdminApi
|
||||
.post('roles', {
|
||||
json: { name: generateName(), description: 'data-hook-role', type: RoleType.User },
|
||||
})
|
||||
.json<Role>();
|
||||
|
||||
const roleCreateHook = await getWebhookResult('POST /roles');
|
||||
expect(roleCreateHook?.payload.event).toBe('Role.Created');
|
||||
|
||||
// Prepare the role and scope id for the Role.Scopes.Updated event.
|
||||
/* eslint-disable @silverhand/fp/no-mutation */
|
||||
roleId = role.id;
|
||||
const resource = await createResource();
|
||||
const scope = await createScope(resource.id);
|
||||
|
||||
scopeId = scope.id;
|
||||
resourceId = resource.id;
|
||||
/* eslint-enable @silverhand/fp/no-mutation */
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await authedAdminApi.delete(`resources/${resourceId}`);
|
||||
});
|
||||
|
||||
it.each(roleDataHookTestCases)(
|
||||
'test case %#: %p',
|
||||
async ({ route, event, method, endpoint, payload }) => {
|
||||
await authedAdminApi[method](
|
||||
endpoint.replace('{roleId}', roleId).replace('{scopeId}', scopeId),
|
||||
// Replace all the scopeId placeholder in the payload
|
||||
{ json: JSON.parse(JSON.stringify(payload).replace('{scopeId}', scopeId)) }
|
||||
);
|
||||
const hook = await getWebhookResult(route);
|
||||
expect(hook?.payload.event).toBe(event);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('scope data hook events', () => {
|
||||
/* eslint-disable @silverhand/fp/no-let */
|
||||
let resourceId: string;
|
||||
let scopeId: string;
|
||||
/* eslint-enable @silverhand/fp/no-let */
|
||||
|
||||
beforeAll(async () => {
|
||||
const resource = await createResource();
|
||||
const scope = await createScope(resource.id);
|
||||
|
||||
/* eslint-disable @silverhand/fp/no-mutation */
|
||||
resourceId = resource.id;
|
||||
scopeId = scope.id;
|
||||
/* eslint-enable @silverhand/fp/no-mutation */
|
||||
|
||||
const scopesCreateHook = await getWebhookResult('POST /resources/:resourceId/scopes');
|
||||
expect(scopesCreateHook?.payload.event).toBe('Scope.Created');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await authedAdminApi.delete(`resources/${resourceId}`);
|
||||
});
|
||||
|
||||
it.each(scopesDataHookTestCases)(
|
||||
'test case %#: %p',
|
||||
async ({ route, event, method, endpoint, payload }) => {
|
||||
await authedAdminApi[method](
|
||||
endpoint.replace('{resourceId}', resourceId).replace('{scopeId}', scopeId),
|
||||
{ json: payload }
|
||||
);
|
||||
const hook = await getWebhookResult(route);
|
||||
expect(hook?.payload.event).toBe(event);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('organization data hook events', () => {
|
||||
/* eslint-disable @silverhand/fp/no-let */
|
||||
let organizationId: string;
|
||||
let userId: string;
|
||||
/* eslint-enable @silverhand/fp/no-let */
|
||||
|
||||
const organizationApi = new OrganizationApiTest();
|
||||
const userApi = new UserApiTest();
|
||||
|
||||
beforeAll(async () => {
|
||||
const organization = await organizationApi.create({
|
||||
name: generateName(),
|
||||
description: 'organization data hook test organization.',
|
||||
});
|
||||
|
||||
const user = await userApi.create({ name: generateName() });
|
||||
|
||||
/* eslint-disable @silverhand/fp/no-mutation */
|
||||
organizationId = organization.id;
|
||||
userId = user.id;
|
||||
/* eslint-enable @silverhand/fp/no-mutation */
|
||||
|
||||
const organizationCreateHook = await getWebhookResult('POST /organizations');
|
||||
expect(organizationCreateHook?.payload.event).toBe('Organization.Created');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userApi.cleanUp();
|
||||
});
|
||||
|
||||
it.each(organizationDataHookTestCases)(
|
||||
'test case %#: %p',
|
||||
async ({ route, event, method, endpoint, payload }) => {
|
||||
await authedAdminApi[method](
|
||||
endpoint.replace('{organizationId}', organizationId).replace('{userId}', userId),
|
||||
{ json: JSON.parse(JSON.stringify(payload).replace('{userId}', userId)) }
|
||||
);
|
||||
const hook = await getWebhookResult(route);
|
||||
expect(hook?.payload.event).toBe(event);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('organization scope data hook events', () => {
|
||||
/* eslint-disable @silverhand/fp/no-let */
|
||||
let scopeId: string;
|
||||
/* eslint-enable @silverhand/fp/no-let */
|
||||
|
||||
const organizationScopeApi = new OrganizationScopeApiTest();
|
||||
|
||||
beforeAll(async () => {
|
||||
const scope = await organizationScopeApi.create({
|
||||
name: generateName(),
|
||||
description: 'organization scope data hook test scope.',
|
||||
});
|
||||
|
||||
/* eslint-disable @silverhand/fp/no-mutation */
|
||||
scopeId = scope.id;
|
||||
/* eslint-enable @silverhand/fp/no-mutation */
|
||||
|
||||
const organizationScopeCreateHook = await getWebhookResult('POST /organization-scopes');
|
||||
expect(organizationScopeCreateHook?.payload.event).toBe('OrganizationScope.Created');
|
||||
});
|
||||
|
||||
it.each(organizationScopeDataHookTestCases)(
|
||||
'test case %#: %p',
|
||||
async ({ route, event, method, endpoint, payload }) => {
|
||||
await authedAdminApi[method](endpoint.replace('{organizationScopeId}', scopeId), {
|
||||
json: payload,
|
||||
});
|
||||
const hook = await getWebhookResult(route);
|
||||
expect(hook?.payload.event).toBe(event);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('organization role data hook events', () => {
|
||||
/* eslint-disable @silverhand/fp/no-let */
|
||||
let roleId: string;
|
||||
let scopeId: string;
|
||||
/* eslint-enable @silverhand/fp/no-let */
|
||||
|
||||
const organizationScopeApi = new OrganizationScopeApiTest();
|
||||
const roleApi = new OrganizationRoleApiTest();
|
||||
|
||||
beforeAll(async () => {
|
||||
const role = await roleApi.create({
|
||||
name: generateName(),
|
||||
description: 'organization role data hook test role.',
|
||||
});
|
||||
|
||||
const scope = await organizationScopeApi.create({
|
||||
name: generateName(),
|
||||
description: 'organization role data hook test scope.',
|
||||
});
|
||||
|
||||
/* eslint-disable @silverhand/fp/no-mutation */
|
||||
roleId = role.id;
|
||||
scopeId = scope.id;
|
||||
/* eslint-enable @silverhand/fp/no-mutation */
|
||||
|
||||
const organizationRoleCreateHook = await getWebhookResult('POST /organization-roles');
|
||||
expect(organizationRoleCreateHook?.payload.event).toBe('OrganizationRole.Created');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await organizationScopeApi.cleanUp();
|
||||
});
|
||||
|
||||
it.each(organizationRoleDataHookTestCases)(
|
||||
'test case %#: %p',
|
||||
async ({ route, event, method, endpoint, payload }) => {
|
||||
await authedAdminApi[method](
|
||||
endpoint.replace('{organizationRoleId}', roleId).replace('{scopeId}', scopeId),
|
||||
{ json: JSON.parse(JSON.stringify(payload).replace('{scopeId}', scopeId)) }
|
||||
);
|
||||
const hook = await getWebhookResult(route);
|
||||
expect(hook?.payload.event).toBe(event);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('data hook events coverage', () => {
|
||||
const keys = Object.keys(managementApiHooksRegistration);
|
||||
it.each(keys)('should have test case for %s', async (key) => {
|
||||
const webhookResult = await getWebhookResult(key);
|
||||
expect(webhookResult).toBeDefined();
|
||||
expect(webhookResult?.signature).toBeDefined();
|
||||
});
|
||||
});
|
195
packages/integration-tests/src/tests/api/hook/test-cases.ts
Normal file
195
packages/integration-tests/src/tests/api/hook/test-cases.ts
Normal file
|
@ -0,0 +1,195 @@
|
|||
import { generateName } from '#src/utils.js';
|
||||
|
||||
type TestCase = {
|
||||
route: string;
|
||||
event: string;
|
||||
method: 'patch' | 'post' | 'delete' | 'put';
|
||||
endpoint: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const userDataHookTestCases: TestCase[] = [
|
||||
{
|
||||
route: 'PATCH /users/:userId',
|
||||
event: 'User.Updated',
|
||||
method: 'patch',
|
||||
endpoint: `users/{userId}`,
|
||||
payload: { name: 'new name' },
|
||||
},
|
||||
{
|
||||
route: 'PATCH /users/:userId/custom-data',
|
||||
event: 'User.Updated',
|
||||
method: 'patch',
|
||||
endpoint: `users/{userId}/custom-data`,
|
||||
payload: { customData: { foo: 'bar' } },
|
||||
},
|
||||
{
|
||||
route: 'PATCH /users/:userId/profile',
|
||||
event: 'User.Updated',
|
||||
method: 'patch',
|
||||
endpoint: `users/{userId}/profile`,
|
||||
payload: { profile: { nickname: 'darcy' } },
|
||||
},
|
||||
{
|
||||
route: 'PATCH /users/:userId/password',
|
||||
event: 'User.Updated',
|
||||
method: 'patch',
|
||||
endpoint: `users/{userId}/password`,
|
||||
payload: { password: 'new-password' },
|
||||
},
|
||||
{
|
||||
route: 'PATCH /users/:userId/is-suspended',
|
||||
event: 'User.SuspensionStatus.Updated',
|
||||
method: 'patch',
|
||||
endpoint: `users/{userId}/is-suspended`,
|
||||
payload: { isSuspended: true },
|
||||
},
|
||||
{
|
||||
route: 'DELETE /users/:userId',
|
||||
event: 'User.Deleted',
|
||||
method: 'delete',
|
||||
endpoint: `users/{userId}`,
|
||||
payload: {},
|
||||
},
|
||||
];
|
||||
|
||||
export const roleDataHookTestCases: TestCase[] = [
|
||||
{
|
||||
route: 'PATCH /roles/:id',
|
||||
event: 'Role.Updated',
|
||||
method: 'patch',
|
||||
endpoint: `roles/{roleId}`,
|
||||
payload: { name: 'new name' },
|
||||
},
|
||||
{
|
||||
route: 'POST /roles/:id/scopes',
|
||||
event: 'Role.Scopes.Updated',
|
||||
method: 'post',
|
||||
endpoint: `roles/{roleId}/scopes`,
|
||||
payload: { scopeIds: ['{scopeId}'] },
|
||||
},
|
||||
{
|
||||
route: 'DELETE /roles/:id/scopes/:scopeId',
|
||||
event: 'Role.Scopes.Updated',
|
||||
method: 'delete',
|
||||
endpoint: `roles/{roleId}/scopes/{scopeId}`,
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
route: 'DELETE /roles/:id',
|
||||
event: 'Role.Deleted',
|
||||
method: 'delete',
|
||||
endpoint: `roles/{roleId}`,
|
||||
payload: {},
|
||||
},
|
||||
];
|
||||
|
||||
export const scopesDataHookTestCases: TestCase[] = [
|
||||
{
|
||||
route: 'PATCH /resources/:resourceId/scopes/:scopeId',
|
||||
event: 'Scope.Updated',
|
||||
method: 'patch',
|
||||
endpoint: `resources/{resourceId}/scopes/{scopeId}`,
|
||||
payload: { name: generateName() },
|
||||
},
|
||||
{
|
||||
route: 'DELETE /resources/:resourceId/scopes/:scopeId',
|
||||
event: 'Scope.Deleted',
|
||||
method: 'delete',
|
||||
endpoint: `resources/{resourceId}/scopes/{scopeId}`,
|
||||
payload: {},
|
||||
},
|
||||
];
|
||||
|
||||
export const organizationDataHookTestCases: TestCase[] = [
|
||||
{
|
||||
route: 'PATCH /organizations/:id',
|
||||
event: 'Organization.Updated',
|
||||
method: 'patch',
|
||||
endpoint: `organizations/{organizationId}`,
|
||||
payload: { description: 'new org description' },
|
||||
},
|
||||
{
|
||||
route: 'POST /organizations/:id/users',
|
||||
event: 'Organization.Membership.Updated',
|
||||
method: 'post',
|
||||
endpoint: `organizations/{organizationId}/users`,
|
||||
payload: { userIds: ['{userId}'] },
|
||||
},
|
||||
{
|
||||
route: 'PUT /organizations/:id/users',
|
||||
event: 'Organization.Membership.Updated',
|
||||
method: 'put',
|
||||
endpoint: `organizations/{organizationId}/users`,
|
||||
payload: { userIds: ['{userId}'] },
|
||||
},
|
||||
{
|
||||
route: 'DELETE /organizations/:id/users/:userId',
|
||||
event: 'Organization.Membership.Updated',
|
||||
method: 'delete',
|
||||
endpoint: `organizations/{organizationId}/users/{userId}`,
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
route: 'DELETE /organizations/:id',
|
||||
event: 'Organization.Deleted',
|
||||
method: 'delete',
|
||||
endpoint: `organizations/{organizationId}`,
|
||||
payload: {},
|
||||
},
|
||||
];
|
||||
|
||||
export const organizationScopeDataHookTestCases: TestCase[] = [
|
||||
{
|
||||
route: 'PATCH /organization-scopes/:id',
|
||||
event: 'OrganizationScope.Updated',
|
||||
method: 'patch',
|
||||
endpoint: `organization-scopes/{organizationScopeId}`,
|
||||
payload: { description: 'new org scope description' },
|
||||
},
|
||||
{
|
||||
route: 'DELETE /organization-scopes/:id',
|
||||
event: 'OrganizationScope.Deleted',
|
||||
method: 'delete',
|
||||
endpoint: `organization-scopes/{organizationScopeId}`,
|
||||
payload: {},
|
||||
},
|
||||
];
|
||||
|
||||
export const organizationRoleDataHookTestCases: TestCase[] = [
|
||||
{
|
||||
route: 'PATCH /organization-roles/:id',
|
||||
event: 'OrganizationRole.Updated',
|
||||
method: 'patch',
|
||||
endpoint: `organization-roles/{organizationRoleId}`,
|
||||
payload: { name: generateName() },
|
||||
},
|
||||
{
|
||||
route: 'POST /organization-roles/:id/scopes',
|
||||
event: 'OrganizationRole.Scopes.Updated',
|
||||
method: 'post',
|
||||
endpoint: `organization-roles/{organizationRoleId}/scopes`,
|
||||
payload: { organizationScopeIds: ['{scopeId}'] },
|
||||
},
|
||||
{
|
||||
route: 'PUT /organization-roles/:id/scopes',
|
||||
event: 'OrganizationRole.Scopes.Updated',
|
||||
method: 'put',
|
||||
endpoint: `organization-roles/{organizationRoleId}/scopes`,
|
||||
payload: { organizationScopeIds: ['{scopeId}'] },
|
||||
},
|
||||
{
|
||||
route: 'DELETE /organization-roles/:id/scopes/:organizationScopeId',
|
||||
event: 'OrganizationRole.Scopes.Updated',
|
||||
method: 'delete',
|
||||
endpoint: `organization-roles/{organizationRoleId}/scopes/{scopeId}`,
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
route: 'DELETE /organization-roles/:id',
|
||||
event: 'OrganizationRole.Deleted',
|
||||
method: 'delete',
|
||||
endpoint: `organization-roles/{organizationRoleId}`,
|
||||
payload: {},
|
||||
},
|
||||
];
|
|
@ -15,26 +15,62 @@ export enum InteractionHookEvent {
|
|||
}
|
||||
|
||||
// DataHookEvent
|
||||
// TODO: @simeng-li implement more data hook events
|
||||
enum DataHookMutableSchema {
|
||||
enum DataHookSchema {
|
||||
User = 'User',
|
||||
Role = 'Role',
|
||||
Scope = 'Scope',
|
||||
Organization = 'Organization',
|
||||
OrganizationRole = 'OrganizationRole',
|
||||
OrganizationScope = 'OrganizationScope',
|
||||
}
|
||||
|
||||
enum DataHookMutationType {
|
||||
enum DataHookBasicMutationType {
|
||||
Created = 'Created',
|
||||
Updated = 'Updated',
|
||||
Deleted = 'Deleted',
|
||||
Updated = 'Updated',
|
||||
}
|
||||
export type DataHookEvent = `${DataHookMutableSchema}.${DataHookMutationType}`;
|
||||
|
||||
type BasicDataHookEvent = `${DataHookSchema}.${DataHookBasicMutationType}`;
|
||||
|
||||
// Custom DataHook mutable schemas
|
||||
type CustomDataHookMutableSchema =
|
||||
| `${DataHookSchema.User}.SuspensionStatus`
|
||||
| `${DataHookSchema.Role}.Scopes`
|
||||
| `${DataHookSchema.Organization}.Membership`
|
||||
| `${DataHookSchema.OrganizationRole}.Scopes`;
|
||||
|
||||
type DataHookPropertyUpdateEvent =
|
||||
`${CustomDataHookMutableSchema}.${DataHookBasicMutationType.Updated}`;
|
||||
|
||||
export type DataHookEvent = BasicDataHookEvent | DataHookPropertyUpdateEvent;
|
||||
|
||||
/** The hook event values that can be registered. */
|
||||
export const hookEvents = Object.freeze([
|
||||
InteractionHookEvent.PostRegister,
|
||||
InteractionHookEvent.PostSignIn,
|
||||
InteractionHookEvent.PostResetPassword,
|
||||
'User.Created',
|
||||
'User.Deleted',
|
||||
'User.Updated',
|
||||
'User.SuspensionStatus.Updated',
|
||||
'Role.Created',
|
||||
'Role.Updated',
|
||||
'Role.Deleted',
|
||||
'Role.Updated',
|
||||
'Role.Scopes.Updated',
|
||||
'Scope.Created',
|
||||
'Scope.Deleted',
|
||||
'Scope.Updated',
|
||||
'Organization.Created',
|
||||
'Organization.Deleted',
|
||||
'Organization.Updated',
|
||||
'Organization.Membership.Updated',
|
||||
'OrganizationRole.Created',
|
||||
'OrganizationRole.Deleted',
|
||||
'OrganizationRole.Updated',
|
||||
'OrganizationRole.Scopes.Updated',
|
||||
'OrganizationScope.Created',
|
||||
'OrganizationScope.Deleted',
|
||||
'OrganizationScope.Updated',
|
||||
] as const satisfies Array<InteractionHookEvent | DataHookEvent>);
|
||||
|
||||
/** The type of hook event values that can be registered. */
|
||||
|
@ -74,7 +110,34 @@ type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|||
* Define the hook event that should be triggered when the management API is called.
|
||||
*/
|
||||
export const managementApiHooksRegistration = Object.freeze({
|
||||
'POST /users': 'User.Created',
|
||||
'DELETE /users/:userId': 'User.Deleted',
|
||||
'PATCH /users/:userId': 'User.Updated',
|
||||
'PATCH /users/:userId/custom-data': 'User.Updated',
|
||||
'PATCH /users/:userId/profile': 'User.Updated',
|
||||
'PATCH /users/:userId/password': 'User.Updated',
|
||||
'PATCH /users/:userId/is-suspended': 'User.SuspensionStatus.Updated',
|
||||
'POST /roles': 'Role.Created',
|
||||
'PATCH /roles/:id': 'Role.Updated',
|
||||
'DELETE /roles/:id': 'Role.Deleted',
|
||||
'PATCH /roles/:id': 'Role.Updated',
|
||||
'POST /roles/:id/scopes': 'Role.Scopes.Updated',
|
||||
'DELETE /roles/:id/scopes/:scopeId': 'Role.Scopes.Updated',
|
||||
'POST /resources/:resourceId/scopes': 'Scope.Created',
|
||||
'DELETE /resources/:resourceId/scopes/:scopeId': 'Scope.Deleted',
|
||||
'PATCH /resources/:resourceId/scopes/:scopeId': 'Scope.Updated',
|
||||
'POST /organizations': 'Organization.Created',
|
||||
'DELETE /organizations/:id': 'Organization.Deleted',
|
||||
'PATCH /organizations/:id': 'Organization.Updated',
|
||||
'PUT /organizations/:id/users': 'Organization.Membership.Updated',
|
||||
'POST /organizations/:id/users': 'Organization.Membership.Updated',
|
||||
'DELETE /organizations/:id/users/:userId': 'Organization.Membership.Updated',
|
||||
'POST /organization-roles': 'OrganizationRole.Created',
|
||||
'DELETE /organization-roles/:id': 'OrganizationRole.Deleted',
|
||||
'PATCH /organization-roles/:id': 'OrganizationRole.Updated',
|
||||
'POST /organization-scopes': 'OrganizationScope.Created',
|
||||
'DELETE /organization-scopes/:id': 'OrganizationScope.Deleted',
|
||||
'PATCH /organization-scopes/:id': 'OrganizationScope.Updated',
|
||||
'PUT /organization-roles/:id/scopes': 'OrganizationRole.Scopes.Updated',
|
||||
'POST /organization-roles/:id/scopes': 'OrganizationRole.Scopes.Updated',
|
||||
'DELETE /organization-roles/:id/scopes/:organizationScopeId': 'OrganizationRole.Scopes.Updated',
|
||||
} satisfies Record<`${ApiMethod} ${string}`, DataHookEvent>);
|
||||
|
|
|
@ -23,9 +23,19 @@ export type DataHookEventPayload = {
|
|||
hookId: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
body?: Record<string, unknown>;
|
||||
/** An object that contains response data. */
|
||||
response?: {
|
||||
body?: Record<string, unknown>;
|
||||
};
|
||||
/** Request route params. */
|
||||
params?: Record<string, string>;
|
||||
/** Request route path. */
|
||||
path?: string;
|
||||
/** Matched route used as the identifier to trigger the hook. */
|
||||
matchedRoute?: string;
|
||||
/** Response status code. */
|
||||
status?: number;
|
||||
/** Request method. */
|
||||
method?: string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
|
|
|
@ -3767,6 +3767,9 @@ importers:
|
|||
typescript:
|
||||
specifier: ^5.3.3
|
||||
version: 5.3.3
|
||||
zod:
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4
|
||||
|
||||
packages/phrases:
|
||||
dependencies:
|
||||
|
|
Loading…
Reference in a new issue