From c42df1c268ca28310b69175361faa61d179ee4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 13 May 2025 15:04:58 +0200 Subject: [PATCH] refactor(core): Restructure decorators and add tests (#15348) --- packages/@n8n/decorators/jest.config.js | 1 + .../src/__tests__/redactable.test.ts | 195 ++++++++++++++++++ .../src/controller/__tests__/args.test.ts | 133 ++++++++++++ .../controller-registry-metadata.test.ts | 134 ++++++++++++ .../src/controller/__tests__/license.test.ts | 93 +++++++++ .../__tests__/rest-controller.test.ts | 53 +++++ .../src/controller/__tests__/route.test.ts | 158 ++++++++++++++ .../src/controller/__tests__/scoped.test.ts | 188 +++++++++++++++++ .../decorators/src/{ => controller}/args.ts | 0 .../controller-registry-metadata.ts | 0 .../@n8n/decorators/src/controller/index.ts | 8 + .../src/{ => controller}/licensed.ts | 0 .../src/{ => controller}/middleware.ts | 0 .../src/{ => controller}/rest-controller.ts | 0 .../decorators/src/{ => controller}/route.ts | 0 .../decorators/src/{ => controller}/scoped.ts | 0 .../@n8n/decorators/src/controller/types.ts | 49 +++++ .../__tests__/on-lifecycle-event.test.ts | 2 +- .../src/execution-lifecycle/index.ts | 9 + .../lifecycle-metadata.ts | 2 +- .../on-lifecycle-event.ts | 4 +- packages/@n8n/decorators/src/index.ts | 36 +--- .../src/module/__tests__/module.test.ts | 103 +++++++++ packages/@n8n/decorators/src/module/index.ts | 2 + .../src/{ => module}/module-metadata.ts | 0 .../decorators/src/{ => module}/module.ts | 2 +- .../decorators/src/multi-main-metadata.ts | 36 ---- .../__tests__/on-multi-main-event.test.ts | 2 +- .../@n8n/decorators/src/multi-main/index.ts | 2 + .../src/multi-main/multi-main-metadata.ts | 23 +++ .../{ => multi-main}/on-multi-main-event.ts | 5 +- .../__tests__/on-shutdown.test.ts | 0 .../@n8n/decorators/src/shutdown/index.ts | 8 + .../src/{ => shutdown}/on-shutdown.ts | 6 +- .../shutdown-registry-metadata.ts | 2 +- .../@n8n/decorators/src/shutdown/types.ts | 9 + packages/@n8n/decorators/src/types.ts | 66 +----- packages/cli/src/commands/base-command.ts | 2 +- packages/cli/src/modules/module-registry.ts | 4 +- .../__tests__/shutdown.service.test.ts | 20 +- 40 files changed, 1210 insertions(+), 147 deletions(-) create mode 100644 packages/@n8n/decorators/src/__tests__/redactable.test.ts create mode 100644 packages/@n8n/decorators/src/controller/__tests__/args.test.ts create mode 100644 packages/@n8n/decorators/src/controller/__tests__/controller-registry-metadata.test.ts create mode 100644 packages/@n8n/decorators/src/controller/__tests__/license.test.ts create mode 100644 packages/@n8n/decorators/src/controller/__tests__/rest-controller.test.ts create mode 100644 packages/@n8n/decorators/src/controller/__tests__/route.test.ts create mode 100644 packages/@n8n/decorators/src/controller/__tests__/scoped.test.ts rename packages/@n8n/decorators/src/{ => controller}/args.ts (100%) rename packages/@n8n/decorators/src/{ => controller}/controller-registry-metadata.ts (100%) create mode 100644 packages/@n8n/decorators/src/controller/index.ts rename packages/@n8n/decorators/src/{ => controller}/licensed.ts (100%) rename packages/@n8n/decorators/src/{ => controller}/middleware.ts (100%) rename packages/@n8n/decorators/src/{ => controller}/rest-controller.ts (100%) rename packages/@n8n/decorators/src/{ => controller}/route.ts (100%) rename packages/@n8n/decorators/src/{ => controller}/scoped.ts (100%) create mode 100644 packages/@n8n/decorators/src/controller/types.ts rename packages/@n8n/decorators/src/{ => execution-lifecycle}/__tests__/on-lifecycle-event.test.ts (98%) create mode 100644 packages/@n8n/decorators/src/execution-lifecycle/index.ts rename packages/@n8n/decorators/src/{ => execution-lifecycle}/lifecycle-metadata.ts (97%) rename packages/@n8n/decorators/src/{ => execution-lifecycle}/on-lifecycle-event.ts (88%) create mode 100644 packages/@n8n/decorators/src/module/__tests__/module.test.ts create mode 100644 packages/@n8n/decorators/src/module/index.ts rename packages/@n8n/decorators/src/{ => module}/module-metadata.ts (100%) rename packages/@n8n/decorators/src/{ => module}/module.ts (91%) delete mode 100644 packages/@n8n/decorators/src/multi-main-metadata.ts rename packages/@n8n/decorators/src/{ => multi-main}/__tests__/on-multi-main-event.test.ts (99%) create mode 100644 packages/@n8n/decorators/src/multi-main/index.ts create mode 100644 packages/@n8n/decorators/src/multi-main/multi-main-metadata.ts rename packages/@n8n/decorators/src/{ => multi-main}/on-multi-main-event.ts (89%) rename packages/@n8n/decorators/src/{ => shutdown}/__tests__/on-shutdown.test.ts (100%) create mode 100644 packages/@n8n/decorators/src/shutdown/index.ts rename packages/@n8n/decorators/src/{ => shutdown}/on-shutdown.ts (86%) rename packages/@n8n/decorators/src/{ => shutdown}/shutdown-registry-metadata.ts (96%) create mode 100644 packages/@n8n/decorators/src/shutdown/types.ts diff --git a/packages/@n8n/decorators/jest.config.js b/packages/@n8n/decorators/jest.config.js index d14f2d60c6..dd71dd6ae3 100644 --- a/packages/@n8n/decorators/jest.config.js +++ b/packages/@n8n/decorators/jest.config.js @@ -4,4 +4,5 @@ module.exports = { transform: { '^.+\\.ts$': ['ts-jest', { isolatedModules: false }], }, + coveragePathIgnorePatterns: ['index.ts'], }; diff --git a/packages/@n8n/decorators/src/__tests__/redactable.test.ts b/packages/@n8n/decorators/src/__tests__/redactable.test.ts new file mode 100644 index 0000000000..78331c6297 --- /dev/null +++ b/packages/@n8n/decorators/src/__tests__/redactable.test.ts @@ -0,0 +1,195 @@ +import { UnexpectedError } from 'n8n-workflow'; + +import { Redactable, RedactableError } from '../redactable'; + +describe('Redactable Decorator', () => { + class TestClass { + @Redactable() + methodWithUser(arg: { + user: { id: string; email?: string; firstName?: string; lastName?: string; role: string }; + }) { + return arg; + } + + @Redactable('inviter') + methodWithInviter(arg: { + inviter: { id: string; email?: string; firstName?: string; lastName?: string; role: string }; + }) { + return arg; + } + + @Redactable('invitee') + methodWithInvitee(arg: { + invitee: { id: string; email?: string; firstName?: string; lastName?: string; role: string }; + }) { + return arg; + } + + @Redactable() + methodWithMultipleArgs( + firstArg: { something: string }, + secondArg: { + user: { id: string; email?: string; firstName?: string; lastName?: string; role: string }; + }, + ) { + return { firstArg, secondArg }; + } + + @Redactable() + methodWithoutUser(arg: { something: string }) { + return arg; + } + } + + let instance: TestClass; + + beforeEach(() => { + instance = new TestClass(); + }); + + describe('RedactableError', () => { + it('should extend UnexpectedError', () => { + const error = new RedactableError('user', 'testArg'); + expect(error).toBeInstanceOf(UnexpectedError); + }); + + it('should have correct error message', () => { + const error = new RedactableError('user', 'testArg'); + expect(error.message).toBe( + 'Failed to find "user" property in argument "testArg". Please set the decorator `@Redactable()` only on `LogStreamingEventRelay` methods where the argument contains a "user" property.', + ); + }); + }); + + describe('@Redactable() decorator', () => { + it('should transform user properties in a method with a user argument', () => { + const input = { + user: { + id: '123', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'admin', + }, + }; + + const result = instance.methodWithUser(input); + + expect(result.user).toEqual({ + userId: '123', + _email: 'test@example.com', + _firstName: 'John', + _lastName: 'Doe', + globalRole: 'admin', + }); + }); + + it('should transform inviter properties when fieldName is set to "inviter"', () => { + const input = { + inviter: { + id: '123', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'admin', + }, + }; + + const result = instance.methodWithInviter(input); + + expect(result.inviter).toEqual({ + userId: '123', + _email: 'test@example.com', + _firstName: 'John', + _lastName: 'Doe', + globalRole: 'admin', + }); + }); + + it('should transform invitee properties when fieldName is set to "invitee"', () => { + const input = { + invitee: { + id: '123', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'admin', + }, + }; + + const result = instance.methodWithInvitee(input); + + expect(result.invitee).toEqual({ + userId: '123', + _email: 'test@example.com', + _firstName: 'John', + _lastName: 'Doe', + globalRole: 'admin', + }); + }); + + it('should handle user object with missing optional properties', () => { + const input = { + user: { + id: '123', + role: 'admin', + }, + }; + + const result = instance.methodWithUser(input); + + expect(result.user).toEqual({ + userId: '123', + _email: undefined, + _firstName: undefined, + _lastName: undefined, + globalRole: 'admin', + }); + }); + + it('should find user property in any argument', () => { + const firstArg = { something: 'test' }; + const secondArg = { + user: { + id: '123', + email: 'test@example.com', + role: 'admin', + }, + }; + + const result = instance.methodWithMultipleArgs(firstArg, secondArg); + + expect(result.secondArg.user).toEqual({ + userId: '123', + _email: 'test@example.com', + _firstName: undefined, + _lastName: undefined, + globalRole: 'admin', + }); + expect(result.firstArg).toEqual(firstArg); + }); + + it('should throw RedactableError when no user property is found', () => { + expect(() => { + instance.methodWithoutUser({ something: 'test' }); + }).toThrow(RedactableError); + }); + + it('should correctly apply the original method', () => { + const spy = jest.spyOn(instance, 'methodWithUser'); + + const input = { + user: { + id: '123', + email: 'test@example.com', + role: 'admin', + }, + }; + + instance.methodWithUser(input); + + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + }); +}); diff --git a/packages/@n8n/decorators/src/controller/__tests__/args.test.ts b/packages/@n8n/decorators/src/controller/__tests__/args.test.ts new file mode 100644 index 0000000000..b823b17265 --- /dev/null +++ b/packages/@n8n/decorators/src/controller/__tests__/args.test.ts @@ -0,0 +1,133 @@ +import { Container } from '@n8n/di'; + +import { Body, Query, Param } from '../args'; +import { ControllerRegistryMetadata } from '../controller-registry-metadata'; +import type { Controller } from '../types'; + +describe('Args Decorators', () => { + let controllerRegistryMetadata: ControllerRegistryMetadata; + + beforeEach(() => { + jest.resetAllMocks(); + + controllerRegistryMetadata = new ControllerRegistryMetadata(); + Container.set(ControllerRegistryMetadata, controllerRegistryMetadata); + }); + + describe.each([ + { decorator: Body, type: 'Body', expectedArg: { type: 'body' } }, + { decorator: Query, type: 'Query', expectedArg: { type: 'query' } }, + ])('@$type decorator', ({ decorator, type, expectedArg }) => { + it(`should set ${type} arg at correct parameter index`, () => { + class TestController { + testMethod(@decorator _parameter: unknown) {} + } + + const parameterIndex = 0; + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'testMethod', + ); + + expect(routeMetadata.args[parameterIndex]).toEqual(expectedArg); + }); + + it(`should handle multiple parameters with ${type}`, () => { + class TestController { + testMethod(_first: string, @decorator _second: unknown, _third: number) {} + } + + const parameterIndex = 1; + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'testMethod', + ); + + expect(routeMetadata.args[parameterIndex]).toEqual(expectedArg); + }); + }); + + describe('@Param decorator', () => { + it('should set param arg with key at correct parameter index', () => { + class TestController { + testMethod(@Param('id') _id: string) {} + } + + const parameterIndex = 0; + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'testMethod', + ); + + expect(routeMetadata.args[parameterIndex]).toEqual({ type: 'param', key: 'id' }); + }); + + it('should handle multiple Param decorators with different keys', () => { + class TestController { + testMethod(@Param('id') _id: string, @Param('userId') _userId: string) {} + } + + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'testMethod', + ); + + expect(routeMetadata.args[0]).toEqual({ type: 'param', key: 'id' }); + expect(routeMetadata.args[1]).toEqual({ type: 'param', key: 'userId' }); + }); + }); + + it('should work with all decorators combined', () => { + class TestController { + testMethod(@Body _body: unknown, @Query _query: unknown, @Param('id') _id: string) {} + } + + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'testMethod', + ); + + expect(routeMetadata.args[0]).toEqual({ type: 'body' }); + expect(routeMetadata.args[1]).toEqual({ type: 'query' }); + expect(routeMetadata.args[2]).toEqual({ type: 'param', key: 'id' }); + }); + + it('should work with complex parameter combinations', () => { + class TestController { + simpleMethod(@Body _body: unknown) {} + + queryMethod(@Query _query: unknown) {} + + mixedMethod( + @Param('id') _id: string, + _undecorated: number, + @Body _body: unknown, + @Query _query: unknown, + ) {} + } + + const simpleRouteMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'simpleMethod', + ); + + const queryRouteMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'queryMethod', + ); + + const mixedRouteMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'mixedMethod', + ); + + expect(simpleRouteMetadata.args[0]).toEqual({ type: 'body' }); + + expect(queryRouteMetadata.args[0]).toEqual({ type: 'query' }); + + expect(mixedRouteMetadata.args[0]).toEqual({ type: 'param', key: 'id' }); + expect(mixedRouteMetadata.args[1]).toBeUndefined(); // undecorated parameter + expect(mixedRouteMetadata.args[2]).toEqual({ type: 'body' }); + expect(mixedRouteMetadata.args[3]).toEqual({ type: 'query' }); + }); +}); diff --git a/packages/@n8n/decorators/src/controller/__tests__/controller-registry-metadata.test.ts b/packages/@n8n/decorators/src/controller/__tests__/controller-registry-metadata.test.ts new file mode 100644 index 0000000000..d3904c0b9e --- /dev/null +++ b/packages/@n8n/decorators/src/controller/__tests__/controller-registry-metadata.test.ts @@ -0,0 +1,134 @@ +import { ControllerRegistryMetadata } from '../controller-registry-metadata'; +import type { Controller, HandlerName } from '../types'; + +describe('ControllerRegistryMetadata', () => { + let registry: ControllerRegistryMetadata; + + const TestController = class TestController { + async testHandler() { + return 'test'; + } + + anotherHandler() {} + } as Controller; + + const AnotherController = class AnotherController { + async handler() {} + } as Controller; + + beforeEach(() => { + registry = new ControllerRegistryMetadata(); + }); + + describe('getControllerMetadata', () => { + it('should create and return default metadata for a new controller', () => { + const metadata = registry.getControllerMetadata(TestController); + + expect(metadata).toEqual({ + basePath: '/', + middlewares: [], + routes: expect.any(Map), + }); + }); + + it('should return existing metadata for a registered controller', () => { + // Get metadata first time to register + const initialMetadata = registry.getControllerMetadata(TestController); + // Update metadata + initialMetadata.basePath = '/api'; + initialMetadata.middlewares.push('auth'); + + // Get metadata second time + const metadata = registry.getControllerMetadata(TestController); + + expect(metadata).toBe(initialMetadata); + expect(metadata.basePath).toBe('/api'); + expect(metadata.middlewares).toEqual(['auth']); + }); + }); + + describe('getRouteMetadata', () => { + it('should create and return default route metadata for a new handler', () => { + const handlerName: HandlerName = 'testHandler'; + const routeMetadata = registry.getRouteMetadata(TestController, handlerName); + + expect(routeMetadata).toEqual({ + args: [], + }); + }); + + it('should return existing route metadata for a registered handler', () => { + const handlerName: HandlerName = 'testHandler'; + + const initialRouteMetadata = registry.getRouteMetadata(TestController, handlerName); + + initialRouteMetadata.method = 'get'; + initialRouteMetadata.path = '/test'; + initialRouteMetadata.args.push({ type: 'query' }); + + const routeMetadata = registry.getRouteMetadata(TestController, handlerName); + + expect(routeMetadata).toBe(initialRouteMetadata); + expect(routeMetadata.method).toBe('get'); + expect(routeMetadata.path).toBe('/test'); + expect(routeMetadata.args).toEqual([{ type: 'query' }]); + }); + }); + + describe('controllerClasses', () => { + it('should return an iterator of registered controller classes', () => { + registry.getControllerMetadata(TestController); + registry.getControllerMetadata(AnotherController); + + const iteratorClasses = registry.controllerClasses; + const controllers = Array.from(iteratorClasses); + + expect(controllers).toHaveLength(2); + expect(controllers).toContain(TestController); + expect(controllers).toContain(AnotherController); + }); + + it('should return an empty iterator when no controllers are registered', () => { + const iteratorClasses = registry.controllerClasses; + const controllers = Array.from(iteratorClasses); + + expect(controllers).toHaveLength(0); + }); + }); + + it('should handle complete controller and routes registration correctly', () => { + const controllerMetadata = registry.getControllerMetadata(TestController); + controllerMetadata.basePath = '/test-api'; + controllerMetadata.middlewares = ['global']; + + const route1 = registry.getRouteMetadata(TestController, 'testHandler'); + route1.method = 'get'; + route1.path = '/items'; + route1.args = [{ type: 'query' }]; + route1.middlewares = [() => {}]; + route1.skipAuth = true; + + const route2 = registry.getRouteMetadata(TestController, 'anotherHandler'); + route2.method = 'post'; + route2.path = '/items/:id'; + route2.args = [{ type: 'param', key: 'id' }, { type: 'body' }]; + + const retrievedMetadata = registry.getControllerMetadata(TestController); + expect(retrievedMetadata.basePath).toBe('/test-api'); + expect(retrievedMetadata.middlewares).toEqual(['global']); + + expect(retrievedMetadata.routes.size).toBe(2); + + const retrievedRoute1 = retrievedMetadata.routes.get('testHandler'); + expect(retrievedRoute1?.method).toBe('get'); + expect(retrievedRoute1?.path).toBe('/items'); + expect(retrievedRoute1?.args).toEqual([{ type: 'query' }]); + expect(retrievedRoute1?.skipAuth).toBe(true); + expect(retrievedRoute1?.middlewares).toHaveLength(1); + + const retrievedRoute2 = retrievedMetadata.routes.get('anotherHandler'); + expect(retrievedRoute2?.method).toBe('post'); + expect(retrievedRoute2?.path).toBe('/items/:id'); + expect(retrievedRoute2?.args).toEqual([{ type: 'param', key: 'id' }, { type: 'body' }]); + }); +}); diff --git a/packages/@n8n/decorators/src/controller/__tests__/license.test.ts b/packages/@n8n/decorators/src/controller/__tests__/license.test.ts new file mode 100644 index 0000000000..31a350f79a --- /dev/null +++ b/packages/@n8n/decorators/src/controller/__tests__/license.test.ts @@ -0,0 +1,93 @@ +import type { BooleanLicenseFeature } from '@n8n/constants'; +import { Container } from '@n8n/di'; + +import { ControllerRegistryMetadata } from '../controller-registry-metadata'; +import { Licensed } from '../licensed'; +import type { Controller } from '../types'; + +describe('@Licensed Decorator', () => { + let controllerRegistryMetadata: ControllerRegistryMetadata; + + beforeEach(() => { + jest.resetAllMocks(); + + controllerRegistryMetadata = new ControllerRegistryMetadata(); + Container.set(ControllerRegistryMetadata, controllerRegistryMetadata); + }); + + it('should set license feature on route metadata', () => { + const licenseFeature: BooleanLicenseFeature = 'feat:variables'; + + class TestController { + @Licensed(licenseFeature) + testMethod() {} + } + + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'testMethod', + ); + + expect(routeMetadata.licenseFeature).toBe(licenseFeature); + }); + + it('should work with different license features', () => { + class TestController { + @Licensed('feat:ldap') + ldapMethod() {} + + @Licensed('feat:saml') + samlMethod() {} + + @Licensed('feat:sharing') + sharingMethod() {} + } + + const ldapMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'ldapMethod', + ); + expect(ldapMetadata.licenseFeature).toBe('feat:ldap'); + + const samlMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'samlMethod', + ); + expect(samlMetadata.licenseFeature).toBe('feat:saml'); + + const sharingMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'sharingMethod', + ); + expect(sharingMetadata.licenseFeature).toBe('feat:sharing'); + }); + + it('should work alongside other decorators', () => { + // Assuming we have a Get decorator imported + const Get = (path: string) => { + return (target: object, handlerName: string | symbol) => { + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + target.constructor as Controller, + String(handlerName), + ); + routeMetadata.method = 'get'; + routeMetadata.path = path; + }; + }; + + class TestController { + @Get('/test') + @Licensed('feat:variables') + testMethod() {} + } + + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'testMethod', + ); + + expect(routeMetadata.licenseFeature).toBe('feat:variables'); + expect(routeMetadata.method).toBe('get'); + expect(routeMetadata.path).toBe('/test'); + }); +}); diff --git a/packages/@n8n/decorators/src/controller/__tests__/rest-controller.test.ts b/packages/@n8n/decorators/src/controller/__tests__/rest-controller.test.ts new file mode 100644 index 0000000000..e8435a4a47 --- /dev/null +++ b/packages/@n8n/decorators/src/controller/__tests__/rest-controller.test.ts @@ -0,0 +1,53 @@ +import { Container } from '@n8n/di'; + +import { ControllerRegistryMetadata } from '../controller-registry-metadata'; +import { RestController } from '../rest-controller'; +import type { Controller } from '../types'; + +describe('@RestController Decorator', () => { + let controllerRegistryMetadata: ControllerRegistryMetadata; + + beforeEach(() => { + jest.resetAllMocks(); + + controllerRegistryMetadata = new ControllerRegistryMetadata(); + Container.set(ControllerRegistryMetadata, controllerRegistryMetadata); + }); + + it('should set default base path when no path provided', () => { + @RestController() + class TestController {} + + const metadata = controllerRegistryMetadata.getControllerMetadata(TestController as Controller); + expect(metadata.basePath).toBe('/'); + expect(Container.has(TestController)).toBe(true); + }); + + it('should set custom base path when provided', () => { + @RestController('/test') + class TestController {} + + const metadata = controllerRegistryMetadata.getControllerMetadata(TestController as Controller); + expect(metadata.basePath).toBe('/test'); + expect(Container.has(TestController)).toBe(true); + }); + + it('should register the controller in the registry', () => { + @RestController('/users') + class UsersController {} + + @RestController('/projects') + class ProjectsController {} + + const controllers = Array.from(controllerRegistryMetadata.controllerClasses); + expect(controllers).toEqual([UsersController, ProjectsController]); + expect(Container.has(UsersController)).toBe(true); + expect(Container.has(ProjectsController)).toBe(true); + expect( + controllerRegistryMetadata.getControllerMetadata(UsersController as Controller).basePath, + ).toBe('/users'); + expect( + controllerRegistryMetadata.getControllerMetadata(ProjectsController as Controller).basePath, + ).toBe('/projects'); + }); +}); diff --git a/packages/@n8n/decorators/src/controller/__tests__/route.test.ts b/packages/@n8n/decorators/src/controller/__tests__/route.test.ts new file mode 100644 index 0000000000..2d3cae2783 --- /dev/null +++ b/packages/@n8n/decorators/src/controller/__tests__/route.test.ts @@ -0,0 +1,158 @@ +import { Container } from '@n8n/di'; + +import { ControllerRegistryMetadata } from '../controller-registry-metadata'; +import { Get, Post, Put, Patch, Delete } from '../route'; +import type { Controller } from '../types'; + +describe('Route Decorators', () => { + let controllerRegistryMetadata: ControllerRegistryMetadata; + + beforeEach(() => { + jest.resetAllMocks(); + + controllerRegistryMetadata = new ControllerRegistryMetadata(); + Container.set(ControllerRegistryMetadata, controllerRegistryMetadata); + }); + + describe.each([ + { decorator: Get, method: 'Get' }, + { decorator: Post, method: 'Post' }, + { decorator: Put, method: 'Put' }, + { decorator: Patch, method: 'Patch' }, + { decorator: Delete, method: 'Delete' }, + ])('@$method decorator', ({ decorator, method }) => { + it('should set correct metadata with default options', () => { + class TestController { + @decorator('/test') + testMethod() {} + } + + const handlerName = 'testMethod'; + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + handlerName, + ); + + expect(routeMetadata.method).toBe(method.toLowerCase()); + expect(routeMetadata.path).toBe('/test'); + expect(routeMetadata.middlewares).toEqual([]); + expect(routeMetadata.usesTemplates).toBe(false); + expect(routeMetadata.skipAuth).toBe(false); + expect(routeMetadata.rateLimit).toBeUndefined(); + }); + + it('should accept and apply route options', () => { + const middleware = () => {}; + + class TestController { + @decorator('/test', { + middlewares: [middleware], + usesTemplates: true, + skipAuth: true, + rateLimit: { limit: 10, windowMs: 60000 }, + }) + testMethod() {} + } + + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'testMethod', + ); + + expect(routeMetadata.middlewares).toEqual([middleware]); + expect(routeMetadata.usesTemplates).toBe(true); + expect(routeMetadata.skipAuth).toBe(true); + expect(routeMetadata.rateLimit).toEqual({ limit: 10, windowMs: 60000 }); + }); + + it('should work with boolean rateLimit option', () => { + class TestController { + @decorator('/test', { rateLimit: true }) + testMethod() {} + } + + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'testMethod', + ); + + expect(routeMetadata.rateLimit).toBe(true); + }); + + it('should work with multiple routes on the same controller', () => { + class TestController { + @decorator('/first') + firstMethod() {} + + @decorator('/second', { skipAuth: true }) + secondMethod() {} + } + + const firstRouteMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'firstMethod', + ); + + const secondRouteMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'secondMethod', + ); + + expect(firstRouteMetadata.method).toBe(method.toLowerCase()); + expect(firstRouteMetadata.path).toBe('/first'); + expect(firstRouteMetadata.skipAuth).toBe(false); + + expect(secondRouteMetadata.method).toBe(method.toLowerCase()); + expect(secondRouteMetadata.path).toBe('/second'); + expect(secondRouteMetadata.skipAuth).toBe(true); + }); + }); + + it('should allow different HTTP methods on the same controller', () => { + class TestController { + @Get('/users') + getUsers() {} + + @Post('/users') + createUser() {} + + @Put('/users/:id') + updateUser() {} + + @Delete('/users/:id') + deleteUser() {} + } + + const getMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'getUsers', + ); + + const postMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'createUser', + ); + + const putMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'updateUser', + ); + + const deleteMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'deleteUser', + ); + + expect(getMetadata.method).toBe('get'); + expect(getMetadata.path).toBe('/users'); + + expect(postMetadata.method).toBe('post'); + expect(postMetadata.path).toBe('/users'); + + expect(putMetadata.method).toBe('put'); + expect(putMetadata.path).toBe('/users/:id'); + + expect(deleteMetadata.method).toBe('delete'); + expect(deleteMetadata.path).toBe('/users/:id'); + }); +}); diff --git a/packages/@n8n/decorators/src/controller/__tests__/scoped.test.ts b/packages/@n8n/decorators/src/controller/__tests__/scoped.test.ts new file mode 100644 index 0000000000..aaf952cdf2 --- /dev/null +++ b/packages/@n8n/decorators/src/controller/__tests__/scoped.test.ts @@ -0,0 +1,188 @@ +import { Container } from '@n8n/di'; +import type { Scope } from '@n8n/permissions'; + +import { ControllerRegistryMetadata } from '../controller-registry-metadata'; +import { GlobalScope, ProjectScope } from '../scoped'; +import type { Controller } from '../types'; + +describe('Scope Decorators', () => { + let controllerRegistryMetadata: ControllerRegistryMetadata; + + beforeEach(() => { + jest.resetAllMocks(); + + controllerRegistryMetadata = new ControllerRegistryMetadata(); + Container.set(ControllerRegistryMetadata, controllerRegistryMetadata); + }); + + describe('@GlobalScope', () => { + it('should set global scope on route metadata', () => { + const scope: Scope = 'user:read'; + + class TestController { + @GlobalScope(scope) + testMethod() {} + } + + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'testMethod', + ); + + expect(routeMetadata.accessScope).toEqual({ + scope, + globalOnly: true, + }); + }); + + it('should work with different scopes', () => { + class TestController { + @GlobalScope('user:read') + readUserMethod() {} + + @GlobalScope('user:create') + createUserMethod() {} + + @GlobalScope('user:delete') + deleteUserMethod() {} + } + + const readMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'readUserMethod', + ); + + const createMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'createUserMethod', + ); + + const deleteMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'deleteUserMethod', + ); + + expect(readMetadata.accessScope).toEqual({ scope: 'user:read', globalOnly: true }); + expect(createMetadata.accessScope).toEqual({ scope: 'user:create', globalOnly: true }); + expect(deleteMetadata.accessScope).toEqual({ scope: 'user:delete', globalOnly: true }); + }); + }); + + describe('@ProjectScope', () => { + it('should set project scope on route metadata', () => { + const scope: Scope = 'workflow:read'; + + class TestController { + @ProjectScope(scope) + testMethod() {} + } + + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'testMethod', + ); + + expect(routeMetadata.accessScope).toEqual({ + scope, + globalOnly: false, + }); + }); + + it('should work with different scopes', () => { + class TestController { + @ProjectScope('workflow:read') + readWorkflowMethod() {} + + @ProjectScope('workflow:create') + createWorkflowMethod() {} + + @ProjectScope('workflow:delete') + deleteWorkflowMethod() {} + } + + const readMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'readWorkflowMethod', + ); + + const createMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'createWorkflowMethod', + ); + + const deleteMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'deleteWorkflowMethod', + ); + + expect(readMetadata.accessScope).toEqual({ scope: 'workflow:read', globalOnly: false }); + expect(createMetadata.accessScope).toEqual({ scope: 'workflow:create', globalOnly: false }); + expect(deleteMetadata.accessScope).toEqual({ scope: 'workflow:delete', globalOnly: false }); + }); + }); + + it('should work with both scope types on the same controller', () => { + class TestController { + @GlobalScope('user:read') + readUserMethod() {} + + @ProjectScope('workflow:read') + readWorkflowMethod() {} + } + + const userMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'readUserMethod', + ); + + const workflowMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'readWorkflowMethod', + ); + + expect(userMetadata.accessScope).toEqual({ scope: 'user:read', globalOnly: true }); + expect(workflowMetadata.accessScope).toEqual({ scope: 'workflow:read', globalOnly: false }); + }); + + it('should work alongside other decorators', () => { + // Assuming we have a Get decorator imported + const Get = (path: string) => { + return (target: object, handlerName: string | symbol) => { + const routeMetadata = controllerRegistryMetadata.getRouteMetadata( + target.constructor as Controller, + String(handlerName), + ); + routeMetadata.method = 'get'; + routeMetadata.path = path; + }; + }; + + class TestController { + @Get('/users') + @GlobalScope('user:read') + getUsers() {} + + @Get('/workflows') + @ProjectScope('workflow:read') + getWorkflows() {} + } + + const usersMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'getUsers', + ); + + const workflowsMetadata = controllerRegistryMetadata.getRouteMetadata( + TestController as Controller, + 'getWorkflows', + ); + + expect(usersMetadata.method).toBe('get'); + expect(usersMetadata.path).toBe('/users'); + expect(usersMetadata.accessScope).toEqual({ scope: 'user:read', globalOnly: true }); + + expect(workflowsMetadata.method).toBe('get'); + expect(workflowsMetadata.path).toBe('/workflows'); + expect(workflowsMetadata.accessScope).toEqual({ scope: 'workflow:read', globalOnly: false }); + }); +}); diff --git a/packages/@n8n/decorators/src/args.ts b/packages/@n8n/decorators/src/controller/args.ts similarity index 100% rename from packages/@n8n/decorators/src/args.ts rename to packages/@n8n/decorators/src/controller/args.ts diff --git a/packages/@n8n/decorators/src/controller-registry-metadata.ts b/packages/@n8n/decorators/src/controller/controller-registry-metadata.ts similarity index 100% rename from packages/@n8n/decorators/src/controller-registry-metadata.ts rename to packages/@n8n/decorators/src/controller/controller-registry-metadata.ts diff --git a/packages/@n8n/decorators/src/controller/index.ts b/packages/@n8n/decorators/src/controller/index.ts new file mode 100644 index 0000000000..66cbd2da1e --- /dev/null +++ b/packages/@n8n/decorators/src/controller/index.ts @@ -0,0 +1,8 @@ +export { Body, Query, Param } from './args'; +export { RestController } from './rest-controller'; +export { Get, Post, Put, Patch, Delete } from './route'; +export { Middleware } from './middleware'; +export { ControllerRegistryMetadata } from './controller-registry-metadata'; +export { Licensed } from './licensed'; +export { GlobalScope, ProjectScope } from './scoped'; +export type { AccessScope, Controller, RateLimit } from './types'; diff --git a/packages/@n8n/decorators/src/licensed.ts b/packages/@n8n/decorators/src/controller/licensed.ts similarity index 100% rename from packages/@n8n/decorators/src/licensed.ts rename to packages/@n8n/decorators/src/controller/licensed.ts diff --git a/packages/@n8n/decorators/src/middleware.ts b/packages/@n8n/decorators/src/controller/middleware.ts similarity index 100% rename from packages/@n8n/decorators/src/middleware.ts rename to packages/@n8n/decorators/src/controller/middleware.ts diff --git a/packages/@n8n/decorators/src/rest-controller.ts b/packages/@n8n/decorators/src/controller/rest-controller.ts similarity index 100% rename from packages/@n8n/decorators/src/rest-controller.ts rename to packages/@n8n/decorators/src/controller/rest-controller.ts diff --git a/packages/@n8n/decorators/src/route.ts b/packages/@n8n/decorators/src/controller/route.ts similarity index 100% rename from packages/@n8n/decorators/src/route.ts rename to packages/@n8n/decorators/src/controller/route.ts diff --git a/packages/@n8n/decorators/src/scoped.ts b/packages/@n8n/decorators/src/controller/scoped.ts similarity index 100% rename from packages/@n8n/decorators/src/scoped.ts rename to packages/@n8n/decorators/src/controller/scoped.ts diff --git a/packages/@n8n/decorators/src/controller/types.ts b/packages/@n8n/decorators/src/controller/types.ts new file mode 100644 index 0000000000..fa752cd061 --- /dev/null +++ b/packages/@n8n/decorators/src/controller/types.ts @@ -0,0 +1,49 @@ +import type { BooleanLicenseFeature } from '@n8n/constants'; +import type { Constructable } from '@n8n/di'; +import type { Scope } from '@n8n/permissions'; +import type { RequestHandler } from 'express'; + +export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete'; + +export type Arg = { type: 'body' | 'query' } | { type: 'param'; key: string }; + +export interface RateLimit { + /** + * The maximum number of requests to allow during the `window` before rate limiting the client. + * @default 5 + */ + limit?: number; + /** + * How long we should remember the requests. + * @default 300_000 (5 minutes) + */ + windowMs?: number; +} + +export type HandlerName = string; + +export interface AccessScope { + scope: Scope; + globalOnly: boolean; +} + +export interface RouteMetadata { + method: Method; + path: string; + middlewares: RequestHandler[]; + usesTemplates: boolean; + skipAuth: boolean; + rateLimit?: boolean | RateLimit; + licenseFeature?: BooleanLicenseFeature; + accessScope?: AccessScope; + args: Arg[]; +} + +export interface ControllerMetadata { + basePath: `/${string}`; + middlewares: HandlerName[]; + routes: Map; +} + +export type Controller = Constructable & + Record Promise>; diff --git a/packages/@n8n/decorators/src/__tests__/on-lifecycle-event.test.ts b/packages/@n8n/decorators/src/execution-lifecycle/__tests__/on-lifecycle-event.test.ts similarity index 98% rename from packages/@n8n/decorators/src/__tests__/on-lifecycle-event.test.ts rename to packages/@n8n/decorators/src/execution-lifecycle/__tests__/on-lifecycle-event.test.ts index be7258f5b3..0d7bf1bf0d 100644 --- a/packages/@n8n/decorators/src/__tests__/on-lifecycle-event.test.ts +++ b/packages/@n8n/decorators/src/execution-lifecycle/__tests__/on-lifecycle-event.test.ts @@ -1,6 +1,6 @@ import { Container, Service } from '@n8n/di'; -import { NonMethodError } from '../errors'; +import { NonMethodError } from '../../errors'; import { LifecycleMetadata } from '../lifecycle-metadata'; import { OnLifecycleEvent } from '../on-lifecycle-event'; diff --git a/packages/@n8n/decorators/src/execution-lifecycle/index.ts b/packages/@n8n/decorators/src/execution-lifecycle/index.ts new file mode 100644 index 0000000000..f33d8597d3 --- /dev/null +++ b/packages/@n8n/decorators/src/execution-lifecycle/index.ts @@ -0,0 +1,9 @@ +export { OnLifecycleEvent } from './on-lifecycle-event'; +export type { + LifecycleContext, + NodeExecuteBeforeContext, + NodeExecuteAfterContext, + WorkflowExecuteBeforeContext, + WorkflowExecuteAfterContext, +} from './lifecycle-metadata'; +export { LifecycleMetadata } from './lifecycle-metadata'; diff --git a/packages/@n8n/decorators/src/lifecycle-metadata.ts b/packages/@n8n/decorators/src/execution-lifecycle/lifecycle-metadata.ts similarity index 97% rename from packages/@n8n/decorators/src/lifecycle-metadata.ts rename to packages/@n8n/decorators/src/execution-lifecycle/lifecycle-metadata.ts index 90aaa5d3ad..b199575ae8 100644 --- a/packages/@n8n/decorators/src/lifecycle-metadata.ts +++ b/packages/@n8n/decorators/src/execution-lifecycle/lifecycle-metadata.ts @@ -9,7 +9,7 @@ import type { Workflow, } from 'n8n-workflow'; -import type { Class } from './types'; +import type { Class } from '../types'; export type LifecycleHandlerClass = Class< Record Promise | void> diff --git a/packages/@n8n/decorators/src/on-lifecycle-event.ts b/packages/@n8n/decorators/src/execution-lifecycle/on-lifecycle-event.ts similarity index 88% rename from packages/@n8n/decorators/src/on-lifecycle-event.ts rename to packages/@n8n/decorators/src/execution-lifecycle/on-lifecycle-event.ts index 8d31a5c16e..35e172390c 100644 --- a/packages/@n8n/decorators/src/on-lifecycle-event.ts +++ b/packages/@n8n/decorators/src/execution-lifecycle/on-lifecycle-event.ts @@ -1,12 +1,12 @@ import { Container } from '@n8n/di'; -import { NonMethodError } from './errors'; import type { LifecycleEvent, LifecycleHandlerClass } from './lifecycle-metadata'; import { LifecycleMetadata } from './lifecycle-metadata'; +import { NonMethodError } from '../errors'; /** * Decorator that registers a method to be called when a specific lifecycle event occurs. - * For more information, see `execution-lifecyle-hooks.ts` in `cli` and `core`. + * For more information, see `execution-lifecycle-hooks.ts` in `cli` and `core`. * * @example * diff --git a/packages/@n8n/decorators/src/index.ts b/packages/@n8n/decorators/src/index.ts index 1a3f08a6ce..c331f7b30b 100644 --- a/packages/@n8n/decorators/src/index.ts +++ b/packages/@n8n/decorators/src/index.ts @@ -1,32 +1,8 @@ -export { Body, Query, Param } from './args'; -export { RestController } from './rest-controller'; -export { Get, Post, Put, Patch, Delete } from './route'; -export { Middleware } from './middleware'; -export { ControllerRegistryMetadata } from './controller-registry-metadata'; -export { Licensed } from './licensed'; -export { GlobalScope, ProjectScope } from './scoped'; -export { - HIGHEST_SHUTDOWN_PRIORITY, - DEFAULT_SHUTDOWN_PRIORITY, - LOWEST_SHUTDOWN_PRIORITY, -} from './shutdown/constants'; -export { ShutdownRegistryMetadata } from './shutdown-registry-metadata'; -export { OnShutdown } from './on-shutdown'; -export { Redactable } from './redactable'; -export { BaseN8nModule, N8nModule } from './module'; -export { ModuleMetadata } from './module-metadata'; +export * from './controller'; export { Debounce } from './debounce'; -export type { AccessScope, Controller, RateLimit } from './types'; -export type { ShutdownHandler } from './types'; -export { MultiMainMetadata } from './multi-main-metadata'; -export { OnLeaderTakeover, OnLeaderStepdown } from './on-multi-main-event'; +export * from './execution-lifecycle'; export { Memoized } from './memoized'; -export { OnLifecycleEvent } from './on-lifecycle-event'; -export type { - LifecycleContext, - NodeExecuteBeforeContext, - NodeExecuteAfterContext, - WorkflowExecuteBeforeContext, - WorkflowExecuteAfterContext, -} from './lifecycle-metadata'; -export { LifecycleMetadata } from './lifecycle-metadata'; +export * from './module'; +export * from './multi-main'; +export { Redactable } from './redactable'; +export * from './shutdown'; diff --git a/packages/@n8n/decorators/src/module/__tests__/module.test.ts b/packages/@n8n/decorators/src/module/__tests__/module.test.ts new file mode 100644 index 0000000000..d54fc1a834 --- /dev/null +++ b/packages/@n8n/decorators/src/module/__tests__/module.test.ts @@ -0,0 +1,103 @@ +import { Container } from '@n8n/di'; + +import { N8nModule } from '../module'; +import { ModuleMetadata } from '../module-metadata'; + +describe('@N8nModule Decorator', () => { + let moduleMetadata: ModuleMetadata; + + beforeEach(() => { + jest.resetAllMocks(); + + moduleMetadata = new ModuleMetadata(); + Container.set(ModuleMetadata, moduleMetadata); + }); + + it('should register module in ModuleMetadata', () => { + @N8nModule() + class TestModule { + initialize() {} + } + + const registeredModules = Array.from(moduleMetadata.getModules()); + + expect(registeredModules).toContain(TestModule); + expect(registeredModules).toHaveLength(1); + }); + + it('should register multiple modules', () => { + @N8nModule() + class FirstModule { + initialize() {} + } + + @N8nModule() + class SecondModule { + initialize() {} + } + + @N8nModule() + class ThirdModule { + initialize() {} + } + + const registeredModules = Array.from(moduleMetadata.getModules()); + + expect(registeredModules).toContain(FirstModule); + expect(registeredModules).toContain(SecondModule); + expect(registeredModules).toContain(ThirdModule); + expect(registeredModules).toHaveLength(3); + }); + + it('should work with modules without initialize method', () => { + @N8nModule() + class TestModule {} + + const registeredModules = Array.from(moduleMetadata.getModules()); + + expect(registeredModules).toContain(TestModule); + expect(registeredModules).toHaveLength(1); + }); + + it('should support async initialize method', async () => { + const mockInitialize = jest.fn(); + + @N8nModule() + class TestModule { + async initialize() { + mockInitialize(); + } + } + + const registeredModules = Array.from(moduleMetadata.getModules()); + + expect(registeredModules).toContain(TestModule); + + const moduleInstance = new TestModule(); + await moduleInstance.initialize(); + + expect(mockInitialize).toHaveBeenCalled(); + }); + + describe('ModuleMetadata', () => { + it('should allow retrieving and checking registered modules', () => { + @N8nModule() + class FirstModule {} + + @N8nModule() + class SecondModule {} + + const registeredModules = Array.from(moduleMetadata.getModules()); + + expect(registeredModules).toContain(FirstModule); + expect(registeredModules).toContain(SecondModule); + }); + }); + + it('should apply Service decorator', () => { + @N8nModule() + class TestModule {} + + expect(Container.has(TestModule)).toBe(true); + }); +}); diff --git a/packages/@n8n/decorators/src/module/index.ts b/packages/@n8n/decorators/src/module/index.ts new file mode 100644 index 0000000000..5e1a8aad26 --- /dev/null +++ b/packages/@n8n/decorators/src/module/index.ts @@ -0,0 +1,2 @@ +export { BaseN8nModule, N8nModule } from './module'; +export { ModuleMetadata } from './module-metadata'; diff --git a/packages/@n8n/decorators/src/module-metadata.ts b/packages/@n8n/decorators/src/module/module-metadata.ts similarity index 100% rename from packages/@n8n/decorators/src/module-metadata.ts rename to packages/@n8n/decorators/src/module/module-metadata.ts diff --git a/packages/@n8n/decorators/src/module.ts b/packages/@n8n/decorators/src/module/module.ts similarity index 91% rename from packages/@n8n/decorators/src/module.ts rename to packages/@n8n/decorators/src/module/module.ts index a5a30b6e39..096d8825ee 100644 --- a/packages/@n8n/decorators/src/module.ts +++ b/packages/@n8n/decorators/src/module/module.ts @@ -3,7 +3,7 @@ import { Container, Service, type Constructable } from '@n8n/di'; import { ModuleMetadata } from './module-metadata'; export interface BaseN8nModule { - initialize?(): void; + initialize?(): void | Promise; } export type Module = Constructable; diff --git a/packages/@n8n/decorators/src/multi-main-metadata.ts b/packages/@n8n/decorators/src/multi-main-metadata.ts deleted file mode 100644 index ce4bde8544..0000000000 --- a/packages/@n8n/decorators/src/multi-main-metadata.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Service } from '@n8n/di'; - -import type { Class } from './types'; - -export const LEADER_TAKEOVER_EVENT_NAME = 'leader-takeover'; -export const LEADER_STEPDOWN_EVENT_NAME = 'leader-stepdown'; - -export type MultiMainEvent = typeof LEADER_TAKEOVER_EVENT_NAME | typeof LEADER_STEPDOWN_EVENT_NAME; - -type EventHandlerFn = () => Promise | void; - -export type EventHandlerClass = Class>; - -type EventHandler = { - /** Class holding the method to call on a multi-main event. */ - eventHandlerClass: EventHandlerClass; - - /** Name of the method to call on a multi-main event. */ - methodName: string; - - /** Name of the multi-main event to listen to. */ - eventName: MultiMainEvent; -}; - -@Service() -export class MultiMainMetadata { - private readonly handlers: EventHandler[] = []; - - register(handler: EventHandler) { - this.handlers.push(handler); - } - - getHandlers(): EventHandler[] { - return this.handlers; - } -} diff --git a/packages/@n8n/decorators/src/__tests__/on-multi-main-event.test.ts b/packages/@n8n/decorators/src/multi-main/__tests__/on-multi-main-event.test.ts similarity index 99% rename from packages/@n8n/decorators/src/__tests__/on-multi-main-event.test.ts rename to packages/@n8n/decorators/src/multi-main/__tests__/on-multi-main-event.test.ts index efa5a65abf..20d691e1b5 100644 --- a/packages/@n8n/decorators/src/__tests__/on-multi-main-event.test.ts +++ b/packages/@n8n/decorators/src/multi-main/__tests__/on-multi-main-event.test.ts @@ -2,7 +2,7 @@ import { Container } from '@n8n/di'; import { Service } from '@n8n/di'; import { EventEmitter } from 'node:events'; -import { NonMethodError } from '../errors'; +import { NonMethodError } from '../../errors'; import { MultiMainMetadata } from '../multi-main-metadata'; import { LEADER_TAKEOVER_EVENT_NAME, LEADER_STEPDOWN_EVENT_NAME } from '../multi-main-metadata'; import { OnLeaderStepdown, OnLeaderTakeover } from '../on-multi-main-event'; diff --git a/packages/@n8n/decorators/src/multi-main/index.ts b/packages/@n8n/decorators/src/multi-main/index.ts new file mode 100644 index 0000000000..651d4e7bb8 --- /dev/null +++ b/packages/@n8n/decorators/src/multi-main/index.ts @@ -0,0 +1,2 @@ +export { MultiMainMetadata } from './multi-main-metadata'; +export { OnLeaderTakeover, OnLeaderStepdown } from './on-multi-main-event'; diff --git a/packages/@n8n/decorators/src/multi-main/multi-main-metadata.ts b/packages/@n8n/decorators/src/multi-main/multi-main-metadata.ts new file mode 100644 index 0000000000..e45c7e9141 --- /dev/null +++ b/packages/@n8n/decorators/src/multi-main/multi-main-metadata.ts @@ -0,0 +1,23 @@ +import { Service } from '@n8n/di'; + +import type { EventHandler } from '../types'; + +export const LEADER_TAKEOVER_EVENT_NAME = 'leader-takeover'; +export const LEADER_STEPDOWN_EVENT_NAME = 'leader-stepdown'; + +export type MultiMainEvent = typeof LEADER_TAKEOVER_EVENT_NAME | typeof LEADER_STEPDOWN_EVENT_NAME; + +type MultiMainEventHandler = EventHandler; + +@Service() +export class MultiMainMetadata { + private readonly handlers: MultiMainEventHandler[] = []; + + register(handler: MultiMainEventHandler) { + this.handlers.push(handler); + } + + getHandlers(): MultiMainEventHandler[] { + return this.handlers; + } +} diff --git a/packages/@n8n/decorators/src/on-multi-main-event.ts b/packages/@n8n/decorators/src/multi-main/on-multi-main-event.ts similarity index 89% rename from packages/@n8n/decorators/src/on-multi-main-event.ts rename to packages/@n8n/decorators/src/multi-main/on-multi-main-event.ts index 7891380563..a9b4b1e71a 100644 --- a/packages/@n8n/decorators/src/on-multi-main-event.ts +++ b/packages/@n8n/decorators/src/multi-main/on-multi-main-event.ts @@ -1,12 +1,13 @@ import { Container } from '@n8n/di'; -import { NonMethodError } from './errors'; -import type { EventHandlerClass, MultiMainEvent } from './multi-main-metadata'; +import type { MultiMainEvent } from './multi-main-metadata'; import { LEADER_TAKEOVER_EVENT_NAME, LEADER_STEPDOWN_EVENT_NAME, MultiMainMetadata, } from './multi-main-metadata'; +import { NonMethodError } from '../errors'; +import type { EventHandlerClass } from '../types'; const OnMultiMainEvent = (eventName: MultiMainEvent): MethodDecorator => diff --git a/packages/@n8n/decorators/src/__tests__/on-shutdown.test.ts b/packages/@n8n/decorators/src/shutdown/__tests__/on-shutdown.test.ts similarity index 100% rename from packages/@n8n/decorators/src/__tests__/on-shutdown.test.ts rename to packages/@n8n/decorators/src/shutdown/__tests__/on-shutdown.test.ts diff --git a/packages/@n8n/decorators/src/shutdown/index.ts b/packages/@n8n/decorators/src/shutdown/index.ts new file mode 100644 index 0000000000..224d077bd9 --- /dev/null +++ b/packages/@n8n/decorators/src/shutdown/index.ts @@ -0,0 +1,8 @@ +export { + HIGHEST_SHUTDOWN_PRIORITY, + DEFAULT_SHUTDOWN_PRIORITY, + LOWEST_SHUTDOWN_PRIORITY, +} from './constants'; +export { ShutdownRegistryMetadata } from './shutdown-registry-metadata'; +export { OnShutdown } from './on-shutdown'; +export type { ShutdownHandler, ShutdownServiceClass } from './types'; diff --git a/packages/@n8n/decorators/src/on-shutdown.ts b/packages/@n8n/decorators/src/shutdown/on-shutdown.ts similarity index 86% rename from packages/@n8n/decorators/src/on-shutdown.ts rename to packages/@n8n/decorators/src/shutdown/on-shutdown.ts index 9fbc39a83d..7a9896ba74 100644 --- a/packages/@n8n/decorators/src/on-shutdown.ts +++ b/packages/@n8n/decorators/src/shutdown/on-shutdown.ts @@ -1,9 +1,9 @@ import { Container } from '@n8n/di'; import { UnexpectedError } from 'n8n-workflow'; -import { DEFAULT_SHUTDOWN_PRIORITY } from './shutdown/constants'; +import { DEFAULT_SHUTDOWN_PRIORITY } from './constants'; import { ShutdownRegistryMetadata } from './shutdown-registry-metadata'; -import type { ServiceClass } from './types'; +import type { ShutdownServiceClass } from './types'; /** * Decorator that registers a method as a shutdown hook. The method will @@ -27,7 +27,7 @@ import type { ServiceClass } from './types'; export const OnShutdown = (priority = DEFAULT_SHUTDOWN_PRIORITY): MethodDecorator => (prototype, propertyKey, descriptor) => { - const serviceClass = prototype.constructor as ServiceClass; + const serviceClass = prototype.constructor as ShutdownServiceClass; const methodName = String(propertyKey); // TODO: assert that serviceClass is decorated with @Service if (typeof descriptor?.value === 'function') { diff --git a/packages/@n8n/decorators/src/shutdown-registry-metadata.ts b/packages/@n8n/decorators/src/shutdown/shutdown-registry-metadata.ts similarity index 96% rename from packages/@n8n/decorators/src/shutdown-registry-metadata.ts rename to packages/@n8n/decorators/src/shutdown/shutdown-registry-metadata.ts index 6107eb07a3..f72d555e38 100644 --- a/packages/@n8n/decorators/src/shutdown-registry-metadata.ts +++ b/packages/@n8n/decorators/src/shutdown/shutdown-registry-metadata.ts @@ -1,7 +1,7 @@ import { Service } from '@n8n/di'; import { UserError } from 'n8n-workflow'; -import { HIGHEST_SHUTDOWN_PRIORITY, LOWEST_SHUTDOWN_PRIORITY } from './shutdown/constants'; +import { HIGHEST_SHUTDOWN_PRIORITY, LOWEST_SHUTDOWN_PRIORITY } from './constants'; import type { ShutdownHandler } from './types'; @Service() diff --git a/packages/@n8n/decorators/src/shutdown/types.ts b/packages/@n8n/decorators/src/shutdown/types.ts new file mode 100644 index 0000000000..b13545b98a --- /dev/null +++ b/packages/@n8n/decorators/src/shutdown/types.ts @@ -0,0 +1,9 @@ +import type { Class } from '../types'; + +type ShutdownHandlerFn = () => Promise | void; +export type ShutdownServiceClass = Class>; + +export interface ShutdownHandler { + serviceClass: ShutdownServiceClass; + methodName: string; +} diff --git a/packages/@n8n/decorators/src/types.ts b/packages/@n8n/decorators/src/types.ts index dcab87ecd6..c87decd248 100644 --- a/packages/@n8n/decorators/src/types.ts +++ b/packages/@n8n/decorators/src/types.ts @@ -1,60 +1,14 @@ -import type { BooleanLicenseFeature } from '@n8n/constants'; -import type { Constructable } from '@n8n/di'; -import type { Scope } from '@n8n/permissions'; -import type { RequestHandler } from 'express'; - -export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete'; - -export type Arg = { type: 'body' | 'query' } | { type: 'param'; key: string }; - -export interface RateLimit { - /** - * The maximum number of requests to allow during the `window` before rate limiting the client. - * @default 5 - */ - limit?: number; - /** - * How long we should remember the requests. - * @default 300_000 (5 minutes) - */ - windowMs?: number; -} - -export type HandlerName = string; - -export interface AccessScope { - scope: Scope; - globalOnly: boolean; -} - -export interface RouteMetadata { - method: Method; - path: string; - middlewares: RequestHandler[]; - usesTemplates: boolean; - skipAuth: boolean; - rateLimit?: boolean | RateLimit; - licenseFeature?: BooleanLicenseFeature; - accessScope?: AccessScope; - args: Arg[]; -} - -export interface ControllerMetadata { - basePath: `/${string}`; - middlewares: HandlerName[]; - routes: Map; -} - -export type Controller = Constructable & - Record Promise>; - -type RouteHandlerFn = () => Promise | void; - export type Class = new (...args: A) => T; -export type ServiceClass = Class>; +type EventHandlerFn = () => Promise | void; +export type EventHandlerClass = Class>; +export type EventHandler = { + /** Class holding the method to call on an event. */ + eventHandlerClass: EventHandlerClass; -export interface ShutdownHandler { - serviceClass: ServiceClass; + /** Name of the method to call on an event. */ methodName: string; -} + + /** Name of the event to listen to. */ + eventName: T; +}; diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index 33de468bcf..a6b34bd8e1 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -97,7 +97,7 @@ export abstract class BaseCommand extends Command { } } - Container.get(ModuleRegistry).initializeModules(); + await Container.get(ModuleRegistry).initializeModules(); if (this.instanceSettings.isMultiMain) { Container.get(MultiMainSetup).registerEventHandlers(); diff --git a/packages/cli/src/modules/module-registry.ts b/packages/cli/src/modules/module-registry.ts index 691a323588..bd41158c59 100644 --- a/packages/cli/src/modules/module-registry.ts +++ b/packages/cli/src/modules/module-registry.ts @@ -19,9 +19,9 @@ export class ModuleRegistry { private readonly lifecycleMetadata: LifecycleMetadata, ) {} - initializeModules() { + async initializeModules() { for (const ModuleClass of this.moduleMetadata.getModules()) { - Container.get(ModuleClass).initialize?.(); + await Container.get(ModuleClass).initialize?.(); } } diff --git a/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts b/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts index adbf9841b3..f1fe514f0c 100644 --- a/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts +++ b/packages/cli/src/shutdown/__tests__/shutdown.service.test.ts @@ -1,5 +1,5 @@ import { ShutdownRegistryMetadata } from '@n8n/decorators'; -import type { ServiceClass } from '@n8n/decorators/src/types'; +import type { ShutdownServiceClass } from '@n8n/decorators'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { ErrorReporter } from 'n8n-core'; @@ -29,7 +29,7 @@ describe('ShutdownService', () => { describe('shutdown', () => { it('should signal shutdown', () => { shutdownService.register(10, { - serviceClass: MockComponent as unknown as ServiceClass, + serviceClass: MockComponent as unknown as ShutdownServiceClass, methodName: 'onShutdown', }); shutdownService.shutdown(); @@ -51,12 +51,12 @@ describe('ShutdownService', () => { jest.spyOn(mockService, 'onShutdownLowPrio').mockImplementation(() => order.push('low')); shutdownService.register(100, { - serviceClass: MockService as unknown as ServiceClass, + serviceClass: MockService as unknown as ShutdownServiceClass, methodName: 'onShutdownHighPrio', }); shutdownService.register(10, { - serviceClass: MockService as unknown as ServiceClass, + serviceClass: MockService as unknown as ShutdownServiceClass, methodName: 'onShutdownLowPrio', }); @@ -68,7 +68,7 @@ describe('ShutdownService', () => { it('should throw error if shutdown is already in progress', () => { shutdownService.register(10, { methodName: 'onShutdown', - serviceClass: MockComponent as unknown as ServiceClass, + serviceClass: MockComponent as unknown as ShutdownServiceClass, }); shutdownService.shutdown(); expect(() => shutdownService.shutdown()).toThrow('App is already shutting down'); @@ -80,7 +80,7 @@ describe('ShutdownService', () => { throw componentError; }); shutdownService.register(10, { - serviceClass: MockComponent as unknown as ServiceClass, + serviceClass: MockComponent as unknown as ShutdownServiceClass, methodName: 'onShutdown', }); shutdownService.shutdown(); @@ -100,7 +100,7 @@ describe('ShutdownService', () => { describe('waitForShutdown', () => { it('should wait for shutdown', async () => { shutdownService.register(10, { - serviceClass: MockComponent as unknown as ServiceClass, + serviceClass: MockComponent as unknown as ShutdownServiceClass, methodName: 'onShutdown', }); shutdownService.shutdown(); @@ -117,7 +117,7 @@ describe('ShutdownService', () => { describe('isShuttingDown', () => { it('should return true if app is shutting down', () => { shutdownService.register(10, { - serviceClass: MockComponent as unknown as ServiceClass, + serviceClass: MockComponent as unknown as ShutdownServiceClass, methodName: 'onShutdown', }); shutdownService.shutdown(); @@ -136,7 +136,7 @@ describe('ShutdownService', () => { } shutdownService.register(10, { - serviceClass: UnregisteredComponent as unknown as ServiceClass, + serviceClass: UnregisteredComponent as unknown as ShutdownServiceClass, methodName: 'onShutdown', }); @@ -149,7 +149,7 @@ describe('ShutdownService', () => { class TestComponent {} shutdownService.register(10, { - serviceClass: TestComponent as unknown as ServiceClass, + serviceClass: TestComponent as unknown as ShutdownServiceClass, methodName: 'onShutdown', });