Skip to content

Commit

Permalink
feat: init management api hook middleware function
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun committed Apr 24, 2024
1 parent 9af9086 commit 8d0a7ea
Show file tree
Hide file tree
Showing 15 changed files with 252 additions and 63 deletions.
5 changes: 5 additions & 0 deletions .changeset/fluffy-steaks-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@logto/schemas": minor
---

add Management API hook types
10 changes: 5 additions & 5 deletions packages/core/src/__mocks__/hook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Hook, HookEvent } from '@logto/schemas';
import { type Hook, InteractionHookEvent } from '@logto/schemas';

export const mockNanoIdForHook = 'random_string';

Expand All @@ -11,7 +11,7 @@ export const mockHook: Hook = {
id: mockNanoIdForHook,
name: 'foo',
event: null,
events: [HookEvent.PostRegister],
events: [InteractionHookEvent.PostRegister],
config: {
url: 'https://example.com',
},
Expand All @@ -25,7 +25,7 @@ const mockHookData1: Hook = {
id: 'hook_id_1',
name: 'foo',
event: null,
events: [HookEvent.PostRegister],
events: [InteractionHookEvent.PostRegister],
config: {
url: 'https://example1.com',
},
Expand All @@ -39,7 +39,7 @@ const mockHookData2: Hook = {
id: 'hook_id_2',
name: 'bar',
event: null,
events: [HookEvent.PostResetPassword],
events: [InteractionHookEvent.PostResetPassword],
config: {
url: 'https://example2.com',
},
Expand All @@ -53,7 +53,7 @@ const mockHookData3: Hook = {
id: 'hook_id_3',
name: 'baz',
event: null,
events: [HookEvent.PostSignIn],
events: [InteractionHookEvent.PostSignIn],
config: {
url: 'https://example3.com',
},
Expand Down
27 changes: 18 additions & 9 deletions packages/core/src/libraries/hook/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Hook } from '@logto/schemas';
import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas';
import { InteractionHookEvent, InteractionEvent, LogResult } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';

import RequestError from '#src/errors/RequestError/index.js';
Expand Down Expand Up @@ -32,8 +32,8 @@ const hook: Hook = {
tenantId: 'bar',
id: 'foo',
name: 'hook_name',
event: HookEvent.PostSignIn,
events: [HookEvent.PostSignIn],
event: InteractionHookEvent.PostSignIn,
events: [InteractionHookEvent.PostSignIn],
signingKey: 'signing_key',
enabled: true,
config: { headers: { bar: 'baz' }, url, retries: 3 },
Expand Down Expand Up @@ -95,10 +95,13 @@ describe('triggerInteractionHooks()', () => {

const calledPayload: unknown = insertLog.mock.calls[0][0];
expect(calledPayload).toHaveProperty('id', mockId);
expect(calledPayload).toHaveProperty('key', 'TriggerHook.' + HookEvent.PostSignIn);
expect(calledPayload).toHaveProperty('key', 'TriggerHook.' + InteractionHookEvent.PostSignIn);
expect(calledPayload).toHaveProperty('payload.result', LogResult.Success);
expect(calledPayload).toHaveProperty('payload.hookId', 'foo');
expect(calledPayload).toHaveProperty('payload.hookRequest.body.event', HookEvent.PostSignIn);
expect(calledPayload).toHaveProperty(
'payload.hookRequest.body.event',
InteractionHookEvent.PostSignIn
);
expect(calledPayload).toHaveProperty(
'payload.hookRequest.body.interactionEvent',
InteractionEvent.SignIn
Expand All @@ -119,8 +122,8 @@ describe('testHook', () => {
it('should call sendWebhookRequest with correct values', async () => {
jest.useFakeTimers().setSystemTime(100_000);

await testHook(hook.id, [HookEvent.PostSignIn], hook.config);
const testHookPayload = generateHookTestPayload(hook.id, HookEvent.PostSignIn);
await testHook(hook.id, [InteractionHookEvent.PostSignIn], hook.config);
const testHookPayload = generateHookTestPayload(hook.id, InteractionHookEvent.PostSignIn);
expect(sendWebhookRequest).toHaveBeenCalledWith({
hookConfig: hook.config,
payload: testHookPayload,
Expand All @@ -131,13 +134,19 @@ describe('testHook', () => {
});

it('should call sendWebhookRequest with correct times if multiple events are provided', async () => {
await testHook(hook.id, [HookEvent.PostSignIn, HookEvent.PostResetPassword], hook.config);
await testHook(
hook.id,
[InteractionHookEvent.PostSignIn, InteractionHookEvent.PostResetPassword],
hook.config
);
expect(sendWebhookRequest).toBeCalledTimes(2);
});

it('should throw send test payload failed error if sendWebhookRequest fails', async () => {
sendWebhookRequest.mockRejectedValueOnce(new Error('test error'));
await expect(testHook(hook.id, [HookEvent.PostSignIn], hook.config)).rejects.toThrowError(
await expect(
testHook(hook.id, [InteractionHookEvent.PostSignIn], hook.config)
).rejects.toThrowError(
new RequestError({
code: 'hook.send_test_payload_failed',
message: 'Error: test error',
Expand Down
46 changes: 17 additions & 29 deletions packages/core/src/libraries/hook/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
HookEvent,
type HookEvent,
type HookEventPayload,
InteractionEvent,
LogResult,
userInfoSelectFields,
type HookConfig,
Expand All @@ -16,35 +15,14 @@ import { LogEntry } from '#src/middleware/koa-audit-log.js';
import type Queries from '#src/tenants/Queries.js';
import { consoleLog } from '#src/utils/console.js';

import {
type InteractionHookContext,
type InteractionHookResult,
eventToHook,
type ManagementHookContextManager,
} from './types.js';
import { generateHookTestPayload, parseResponse, sendWebhookRequest } from './utils.js';

/**
* The context for triggering interaction hooks by `triggerInteractionHooks`.
* In the `koaInteractionHooks` middleware,
* we will store the context before processing the interaction and consume it after the interaction is processed if needed.
*/
export type InteractionHookContext = {
event: InteractionEvent;
sessionId?: string;
applicationId?: string;
userIp?: string;
};

/**
* The interaction hook result for triggering interaction hooks by `triggerInteractionHooks`.
* In the `koaInteractionHooks` middleware,
* if we get an interaction hook result after the interaction is processed, related hooks will be triggered.
*/
export type InteractionHookResult = {
userId: string;
};

const eventToHook: Record<InteractionEvent, HookEvent> = {
[InteractionEvent.Register]: HookEvent.PostRegister,
[InteractionEvent.SignIn]: HookEvent.PostSignIn,
[InteractionEvent.ForgotPassword]: HookEvent.PostResetPassword,
};

export const createHookLibrary = (queries: Queries) => {
const {
applications: { findApplicationById },
Expand Down Expand Up @@ -132,6 +110,15 @@ export const createHookLibrary = (queries: Queries) => {
);
};

/**
* Trigger management hooks with the given context. All context objects will be used to trigger
* hooks.
*/
// eslint-disable-next-line unicorn/consistent-function-scoping
const triggerManagementHooks = async (hooks: ManagementHookContextManager) => {
// TODO: To be implemented
};

const testHook = async (hookId: string, events: HookEvent[], config: HookConfig) => {
const { signingKey } = await findHookById(hookId);
try {
Expand Down Expand Up @@ -169,6 +156,7 @@ export const createHookLibrary = (queries: Queries) => {

return {
triggerInteractionHooks,
triggerManagementHooks,
testHook,
};
};
52 changes: 52 additions & 0 deletions packages/core/src/libraries/hook/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { InteractionEvent, InteractionHookEvent, type ManagementHookEvent } from '@logto/schemas';

type ManagementHookContext = {
event: ManagementHookEvent;
data: unknown;
};

type ManagementHookMetadata = {
userAgent?: string;
ip: string;
};

/**
* The class for managing Management API hook contexts.
*/
export class ManagementHookContextManager {
contextArray: ManagementHookContext[] = [];

constructor(public metadata: ManagementHookMetadata) {}

appendContext(context: ManagementHookContext) {
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
this.contextArray.push(context);
}
}

/**
* The context for triggering interaction hooks by `triggerInteractionHooks`.
* In the `koaInteractionHooks` middleware,
* we will store the context before processing the interaction and consume it after the interaction is processed if needed.
*/
export type InteractionHookContext = {
event: InteractionEvent;
sessionId?: string;
applicationId?: string;
userIp?: string;
};

/**
* The interaction hook result for triggering interaction hooks by `triggerInteractionHooks`.
* In the `koaInteractionHooks` middleware,
* if we get an interaction hook result after the interaction is processed, related hooks will be triggered.
*/
export type InteractionHookResult = {
userId: string;
};

export const eventToHook: Record<InteractionEvent, InteractionHookEvent> = {
[InteractionEvent.Register]: InteractionHookEvent.PostRegister,
[InteractionEvent.SignIn]: InteractionHookEvent.PostSignIn,
[InteractionEvent.ForgotPassword]: InteractionHookEvent.PostResetPassword,
};
4 changes: 2 additions & 2 deletions packages/core/src/libraries/hook/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HookEvent } from '@logto/schemas';
import { type HookEvent, InteractionHookEvent } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import ky from 'ky';

Expand All @@ -21,7 +21,7 @@ const { generateHookTestPayload, sendWebhookRequest } = await import('./utils.js
describe('sendWebhookRequest', () => {
it('should call got.post with correct values', async () => {
const mockHookId = 'mockHookId';
const mockEvent: HookEvent = HookEvent.PostSignIn;
const mockEvent: HookEvent = InteractionHookEvent.PostSignIn;
const testPayload = generateHookTestPayload(mockHookId, mockEvent);

const mockUrl = 'https://logto.gg';
Expand Down
55 changes: 55 additions & 0 deletions packages/core/src/middleware/koa-management-api-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { type ParameterizedContext } from 'koa';

import type Libraries from '#src/tenants/Libraries.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';

import { type WithHookContext, koaManagementApiHooks } from './koa-management-api-hooks.js';

const { jest } = import.meta;

const notToBeCalled = () => {
throw new Error('Should not be called');
};

describe('koaManagementApiHooks', () => {
const next = jest.fn();
const triggerManagementHooks = jest.fn();
// @ts-expect-error mock
const mockHooksLibrary: Libraries['hooks'] = {
triggerManagementHooks,
};

it("should do nothing if there's no hook context", async () => {
const ctx = {
...createContextWithRouteParameters(),
header: {},
appendHookContext: notToBeCalled,
};
await koaManagementApiHooks(mockHooksLibrary)(ctx, next);
expect(triggerManagementHooks).not.toBeCalled();
});

it('should trigger management hooks', async () => {
const ctx: ParameterizedContext<unknown, WithHookContext> = {
...createContextWithRouteParameters(),
header: {},
appendHookContext: notToBeCalled,
};
next.mockImplementation(() => {
ctx.appendHookContext({ event: 'Role.Created', data: { id: '123' } });
});

await koaManagementApiHooks(mockHooksLibrary)(ctx, next);
expect(triggerManagementHooks).toBeCalledTimes(1);
expect(triggerManagementHooks).toBeCalledWith(
expect.objectContaining({
contextArray: [
{
event: 'Role.Created',
data: { id: '123' },
},
],
})
);
});
});
42 changes: 42 additions & 0 deletions packages/core/src/middleware/koa-management-api-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { trySafe } from '@silverhand/essentials';
import { type MiddlewareType } from 'koa';
import { type IRouterParamContext } from 'koa-router';

import { ManagementHookContextManager } from '#src/libraries/hook/types.js';
import type Libraries from '#src/tenants/Libraries.js';

export type WithHookContext<ContextT extends IRouterParamContext = IRouterParamContext> =
ContextT & { appendHookContext: ManagementHookContextManager['appendContext'] };

/**
* The factory to create a new management hook middleware function.
*
* To trigger management hooks, use `appendHookContext` to append the context.
*
* @param hooks The hooks library.
* @returns The middleware function.
*/
export const koaManagementApiHooks = <StateT, ContextT extends IRouterParamContext, ResponseT>(
hooks: Libraries['hooks']
): MiddlewareType<StateT, WithHookContext<ContextT>, ResponseT> => {
return async (ctx, next) => {
const {
header: { 'user-agent': userAgent },
ip,
} = ctx;
const managementHooks = new ManagementHookContextManager({ userAgent, ip });

/**
* Append a hook context to trigger management hooks. If multiple contexts are appended, all of
* them will be triggered.
*/
ctx.appendHookContext = managementHooks.appendContext.bind(managementHooks);

await next();

if (managementHooks.contextArray.length > 0) {
// Hooks should not crash the app
void trySafe(hooks.triggerManagementHooks(managementHooks));
}
};
};
6 changes: 4 additions & 2 deletions packages/core/src/routes/admin-user/basics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import assertThat from '#src/utils/assert-that.js';

import type { ManagementApiRouter, RouterInitArgs } from '../types.js';

export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(...args: RouterInitArgs<T>) {
export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
...args: RouterInitArgs<T>
) {
const [router, { queries, libraries }] = args;
const {
oidcModelInstances: { revokeInstanceByUserId },
Expand Down Expand Up @@ -346,9 +348,9 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(...

ctx.body = pick(user, ...userInfoSelectFields);

// eslint-disable-next-line max-lines
return next();
}
// eslint-disable-next-line max-lines
);

router.delete(
Expand Down
Loading

0 comments on commit 8d0a7ea

Please sign in to comment.