refactor(core): Restructure decorators and add tests (#15348)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2025-05-13 15:04:58 +02:00
committed by GitHub
parent cd1d6c9dfc
commit c42df1c268
40 changed files with 1210 additions and 147 deletions

View File

@@ -4,4 +4,5 @@ module.exports = {
transform: { transform: {
'^.+\\.ts$': ['ts-jest', { isolatedModules: false }], '^.+\\.ts$': ['ts-jest', { isolatedModules: false }],
}, },
coveragePathIgnorePatterns: ['index.ts'],
}; };

View File

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

View File

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

View File

@@ -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' }]);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<HandlerName, RouteMetadata>;
}
export type Controller = Constructable<object> &
Record<HandlerName, (...args: unknown[]) => Promise<unknown>>;

View File

@@ -1,6 +1,6 @@
import { Container, Service } from '@n8n/di'; import { Container, Service } from '@n8n/di';
import { NonMethodError } from '../errors'; import { NonMethodError } from '../../errors';
import { LifecycleMetadata } from '../lifecycle-metadata'; import { LifecycleMetadata } from '../lifecycle-metadata';
import { OnLifecycleEvent } from '../on-lifecycle-event'; import { OnLifecycleEvent } from '../on-lifecycle-event';

View File

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

View File

@@ -9,7 +9,7 @@ import type {
Workflow, Workflow,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { Class } from './types'; import type { Class } from '../types';
export type LifecycleHandlerClass = Class< export type LifecycleHandlerClass = Class<
Record<string, (ctx: LifecycleContext) => Promise<void> | void> Record<string, (ctx: LifecycleContext) => Promise<void> | void>

View File

@@ -1,12 +1,12 @@
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { NonMethodError } from './errors';
import type { LifecycleEvent, LifecycleHandlerClass } from './lifecycle-metadata'; import type { LifecycleEvent, LifecycleHandlerClass } from './lifecycle-metadata';
import { LifecycleMetadata } 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. * 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 * @example
* *

View File

@@ -1,32 +1,8 @@
export { Body, Query, Param } from './args'; export * from './controller';
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 { Debounce } from './debounce'; export { Debounce } from './debounce';
export type { AccessScope, Controller, RateLimit } from './types'; export * from './execution-lifecycle';
export type { ShutdownHandler } from './types';
export { MultiMainMetadata } from './multi-main-metadata';
export { OnLeaderTakeover, OnLeaderStepdown } from './on-multi-main-event';
export { Memoized } from './memoized'; export { Memoized } from './memoized';
export { OnLifecycleEvent } from './on-lifecycle-event'; export * from './module';
export type { export * from './multi-main';
LifecycleContext, export { Redactable } from './redactable';
NodeExecuteBeforeContext, export * from './shutdown';
NodeExecuteAfterContext,
WorkflowExecuteBeforeContext,
WorkflowExecuteAfterContext,
} from './lifecycle-metadata';
export { LifecycleMetadata } from './lifecycle-metadata';

View File

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

View File

@@ -0,0 +1,2 @@
export { BaseN8nModule, N8nModule } from './module';
export { ModuleMetadata } from './module-metadata';

View File

@@ -3,7 +3,7 @@ import { Container, Service, type Constructable } from '@n8n/di';
import { ModuleMetadata } from './module-metadata'; import { ModuleMetadata } from './module-metadata';
export interface BaseN8nModule { export interface BaseN8nModule {
initialize?(): void; initialize?(): void | Promise<void>;
} }
export type Module = Constructable<BaseN8nModule>; export type Module = Constructable<BaseN8nModule>;

View File

@@ -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> | void;
export type EventHandlerClass = Class<Record<string, EventHandlerFn>>;
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;
}
}

View File

@@ -2,7 +2,7 @@ import { Container } from '@n8n/di';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { NonMethodError } from '../errors'; import { NonMethodError } from '../../errors';
import { MultiMainMetadata } from '../multi-main-metadata'; import { MultiMainMetadata } from '../multi-main-metadata';
import { LEADER_TAKEOVER_EVENT_NAME, LEADER_STEPDOWN_EVENT_NAME } 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'; import { OnLeaderStepdown, OnLeaderTakeover } from '../on-multi-main-event';

View File

@@ -0,0 +1,2 @@
export { MultiMainMetadata } from './multi-main-metadata';
export { OnLeaderTakeover, OnLeaderStepdown } from './on-multi-main-event';

View File

@@ -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<MultiMainEvent>;
@Service()
export class MultiMainMetadata {
private readonly handlers: MultiMainEventHandler[] = [];
register(handler: MultiMainEventHandler) {
this.handlers.push(handler);
}
getHandlers(): MultiMainEventHandler[] {
return this.handlers;
}
}

View File

@@ -1,12 +1,13 @@
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { NonMethodError } from './errors'; import type { MultiMainEvent } from './multi-main-metadata';
import type { EventHandlerClass, MultiMainEvent } from './multi-main-metadata';
import { import {
LEADER_TAKEOVER_EVENT_NAME, LEADER_TAKEOVER_EVENT_NAME,
LEADER_STEPDOWN_EVENT_NAME, LEADER_STEPDOWN_EVENT_NAME,
MultiMainMetadata, MultiMainMetadata,
} from './multi-main-metadata'; } from './multi-main-metadata';
import { NonMethodError } from '../errors';
import type { EventHandlerClass } from '../types';
const OnMultiMainEvent = const OnMultiMainEvent =
(eventName: MultiMainEvent): MethodDecorator => (eventName: MultiMainEvent): MethodDecorator =>

View File

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

View File

@@ -1,9 +1,9 @@
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { UnexpectedError } from 'n8n-workflow'; 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 { 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 * Decorator that registers a method as a shutdown hook. The method will
@@ -27,7 +27,7 @@ import type { ServiceClass } from './types';
export const OnShutdown = export const OnShutdown =
(priority = DEFAULT_SHUTDOWN_PRIORITY): MethodDecorator => (priority = DEFAULT_SHUTDOWN_PRIORITY): MethodDecorator =>
(prototype, propertyKey, descriptor) => { (prototype, propertyKey, descriptor) => {
const serviceClass = prototype.constructor as ServiceClass; const serviceClass = prototype.constructor as ShutdownServiceClass;
const methodName = String(propertyKey); const methodName = String(propertyKey);
// TODO: assert that serviceClass is decorated with @Service // TODO: assert that serviceClass is decorated with @Service
if (typeof descriptor?.value === 'function') { if (typeof descriptor?.value === 'function') {

View File

@@ -1,7 +1,7 @@
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow'; 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'; import type { ShutdownHandler } from './types';
@Service() @Service()

View File

@@ -0,0 +1,9 @@
import type { Class } from '../types';
type ShutdownHandlerFn = () => Promise<void> | void;
export type ShutdownServiceClass = Class<Record<string, ShutdownHandlerFn>>;
export interface ShutdownHandler {
serviceClass: ShutdownServiceClass;
methodName: string;
}

View File

@@ -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<HandlerName, RouteMetadata>;
}
export type Controller = Constructable<object> &
Record<HandlerName, (...args: unknown[]) => Promise<unknown>>;
type RouteHandlerFn = () => Promise<void> | void;
export type Class<T = object, A extends unknown[] = unknown[]> = new (...args: A) => T; export type Class<T = object, A extends unknown[] = unknown[]> = new (...args: A) => T;
export type ServiceClass = Class<Record<string, RouteHandlerFn>>; type EventHandlerFn = () => Promise<void> | void;
export type EventHandlerClass = Class<Record<string, EventHandlerFn>>;
export type EventHandler<T extends string> = {
/** Class holding the method to call on an event. */
eventHandlerClass: EventHandlerClass;
export interface ShutdownHandler { /** Name of the method to call on an event. */
serviceClass: ServiceClass;
methodName: string; methodName: string;
}
/** Name of the event to listen to. */
eventName: T;
};

View File

@@ -97,7 +97,7 @@ export abstract class BaseCommand extends Command {
} }
} }
Container.get(ModuleRegistry).initializeModules(); await Container.get(ModuleRegistry).initializeModules();
if (this.instanceSettings.isMultiMain) { if (this.instanceSettings.isMultiMain) {
Container.get(MultiMainSetup).registerEventHandlers(); Container.get(MultiMainSetup).registerEventHandlers();

View File

@@ -19,9 +19,9 @@ export class ModuleRegistry {
private readonly lifecycleMetadata: LifecycleMetadata, private readonly lifecycleMetadata: LifecycleMetadata,
) {} ) {}
initializeModules() { async initializeModules() {
for (const ModuleClass of this.moduleMetadata.getModules()) { for (const ModuleClass of this.moduleMetadata.getModules()) {
Container.get(ModuleClass).initialize?.(); await Container.get(ModuleClass).initialize?.();
} }
} }

View File

@@ -1,5 +1,5 @@
import { ShutdownRegistryMetadata } from '@n8n/decorators'; 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 { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { ErrorReporter } from 'n8n-core'; import type { ErrorReporter } from 'n8n-core';
@@ -29,7 +29,7 @@ describe('ShutdownService', () => {
describe('shutdown', () => { describe('shutdown', () => {
it('should signal shutdown', () => { it('should signal shutdown', () => {
shutdownService.register(10, { shutdownService.register(10, {
serviceClass: MockComponent as unknown as ServiceClass, serviceClass: MockComponent as unknown as ShutdownServiceClass,
methodName: 'onShutdown', methodName: 'onShutdown',
}); });
shutdownService.shutdown(); shutdownService.shutdown();
@@ -51,12 +51,12 @@ describe('ShutdownService', () => {
jest.spyOn(mockService, 'onShutdownLowPrio').mockImplementation(() => order.push('low')); jest.spyOn(mockService, 'onShutdownLowPrio').mockImplementation(() => order.push('low'));
shutdownService.register(100, { shutdownService.register(100, {
serviceClass: MockService as unknown as ServiceClass, serviceClass: MockService as unknown as ShutdownServiceClass,
methodName: 'onShutdownHighPrio', methodName: 'onShutdownHighPrio',
}); });
shutdownService.register(10, { shutdownService.register(10, {
serviceClass: MockService as unknown as ServiceClass, serviceClass: MockService as unknown as ShutdownServiceClass,
methodName: 'onShutdownLowPrio', methodName: 'onShutdownLowPrio',
}); });
@@ -68,7 +68,7 @@ describe('ShutdownService', () => {
it('should throw error if shutdown is already in progress', () => { it('should throw error if shutdown is already in progress', () => {
shutdownService.register(10, { shutdownService.register(10, {
methodName: 'onShutdown', methodName: 'onShutdown',
serviceClass: MockComponent as unknown as ServiceClass, serviceClass: MockComponent as unknown as ShutdownServiceClass,
}); });
shutdownService.shutdown(); shutdownService.shutdown();
expect(() => shutdownService.shutdown()).toThrow('App is already shutting down'); expect(() => shutdownService.shutdown()).toThrow('App is already shutting down');
@@ -80,7 +80,7 @@ describe('ShutdownService', () => {
throw componentError; throw componentError;
}); });
shutdownService.register(10, { shutdownService.register(10, {
serviceClass: MockComponent as unknown as ServiceClass, serviceClass: MockComponent as unknown as ShutdownServiceClass,
methodName: 'onShutdown', methodName: 'onShutdown',
}); });
shutdownService.shutdown(); shutdownService.shutdown();
@@ -100,7 +100,7 @@ describe('ShutdownService', () => {
describe('waitForShutdown', () => { describe('waitForShutdown', () => {
it('should wait for shutdown', async () => { it('should wait for shutdown', async () => {
shutdownService.register(10, { shutdownService.register(10, {
serviceClass: MockComponent as unknown as ServiceClass, serviceClass: MockComponent as unknown as ShutdownServiceClass,
methodName: 'onShutdown', methodName: 'onShutdown',
}); });
shutdownService.shutdown(); shutdownService.shutdown();
@@ -117,7 +117,7 @@ describe('ShutdownService', () => {
describe('isShuttingDown', () => { describe('isShuttingDown', () => {
it('should return true if app is shutting down', () => { it('should return true if app is shutting down', () => {
shutdownService.register(10, { shutdownService.register(10, {
serviceClass: MockComponent as unknown as ServiceClass, serviceClass: MockComponent as unknown as ShutdownServiceClass,
methodName: 'onShutdown', methodName: 'onShutdown',
}); });
shutdownService.shutdown(); shutdownService.shutdown();
@@ -136,7 +136,7 @@ describe('ShutdownService', () => {
} }
shutdownService.register(10, { shutdownService.register(10, {
serviceClass: UnregisteredComponent as unknown as ServiceClass, serviceClass: UnregisteredComponent as unknown as ShutdownServiceClass,
methodName: 'onShutdown', methodName: 'onShutdown',
}); });
@@ -149,7 +149,7 @@ describe('ShutdownService', () => {
class TestComponent {} class TestComponent {}
shutdownService.register(10, { shutdownService.register(10, {
serviceClass: TestComponent as unknown as ServiceClass, serviceClass: TestComponent as unknown as ShutdownServiceClass,
methodName: 'onShutdown', methodName: 'onShutdown',
}); });