mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
refactor(core): Restructure decorators and add tests (#15348)
This commit is contained in:
committed by
GitHub
parent
cd1d6c9dfc
commit
c42df1c268
@@ -4,4 +4,5 @@ module.exports = {
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', { isolatedModules: false }],
|
||||
},
|
||||
coveragePathIgnorePatterns: ['index.ts'],
|
||||
};
|
||||
|
||||
195
packages/@n8n/decorators/src/__tests__/redactable.test.ts
Normal file
195
packages/@n8n/decorators/src/__tests__/redactable.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
133
packages/@n8n/decorators/src/controller/__tests__/args.test.ts
Normal file
133
packages/@n8n/decorators/src/controller/__tests__/args.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
158
packages/@n8n/decorators/src/controller/__tests__/route.test.ts
Normal file
158
packages/@n8n/decorators/src/controller/__tests__/route.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
188
packages/@n8n/decorators/src/controller/__tests__/scoped.test.ts
Normal file
188
packages/@n8n/decorators/src/controller/__tests__/scoped.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
8
packages/@n8n/decorators/src/controller/index.ts
Normal file
8
packages/@n8n/decorators/src/controller/index.ts
Normal 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';
|
||||
49
packages/@n8n/decorators/src/controller/types.ts
Normal file
49
packages/@n8n/decorators/src/controller/types.ts
Normal 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>>;
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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<string, (ctx: LifecycleContext) => Promise<void> | void>
|
||||
@@ -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
|
||||
*
|
||||
@@ -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';
|
||||
|
||||
103
packages/@n8n/decorators/src/module/__tests__/module.test.ts
Normal file
103
packages/@n8n/decorators/src/module/__tests__/module.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
2
packages/@n8n/decorators/src/module/index.ts
Normal file
2
packages/@n8n/decorators/src/module/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BaseN8nModule, N8nModule } from './module';
|
||||
export { ModuleMetadata } from './module-metadata';
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
export type Module = Constructable<BaseN8nModule>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
2
packages/@n8n/decorators/src/multi-main/index.ts
Normal file
2
packages/@n8n/decorators/src/multi-main/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MultiMainMetadata } from './multi-main-metadata';
|
||||
export { OnLeaderTakeover, OnLeaderStepdown } from './on-multi-main-event';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
8
packages/@n8n/decorators/src/shutdown/index.ts
Normal file
8
packages/@n8n/decorators/src/shutdown/index.ts
Normal 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';
|
||||
@@ -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') {
|
||||
@@ -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()
|
||||
9
packages/@n8n/decorators/src/shutdown/types.ts
Normal file
9
packages/@n8n/decorators/src/shutdown/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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 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 {
|
||||
serviceClass: ServiceClass;
|
||||
/** Name of the method to call on an event. */
|
||||
methodName: string;
|
||||
}
|
||||
|
||||
/** Name of the event to listen to. */
|
||||
eventName: T;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user