first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { baseConfig } from '@n8n/eslint-config/base';
|
||||
|
||||
export default defineConfig(
|
||||
baseConfig,
|
||||
{
|
||||
rules: {
|
||||
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
|
||||
|
||||
// TODO: Remove this
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
'@typescript-eslint/naming-convention': 'warn',
|
||||
'@typescript-eslint/no-base-to-string': 'warn',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'warn',
|
||||
'import-x/export': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-expressions': 'warn',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'warn',
|
||||
'@typescript-eslint/unbound-method': 'warn',
|
||||
'import-x/no-duplicates': 'warn',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "1.11.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"watch": "tsc -p tsconfig.build.json --watch",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run",
|
||||
"test:dev": "vitest"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "src/index.ts",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@types/express": "catalog:",
|
||||
"@types/lodash": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@n8n/constants": "workspace:*",
|
||||
"@n8n/di": "workspace:*",
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"lodash": "catalog:",
|
||||
"n8n-workflow": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { AssertionError, ok } from 'node:assert';
|
||||
import { setFlagsFromString } from 'node:v8';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
|
||||
import { Memoized } from '../memoized';
|
||||
|
||||
describe('Memoized Decorator', () => {
|
||||
class TestClass {
|
||||
private computeCount = 0;
|
||||
|
||||
constructor(private readonly value: number = 42) {}
|
||||
|
||||
@Memoized
|
||||
get expensiveComputation() {
|
||||
this.computeCount++;
|
||||
return this.value * 2;
|
||||
}
|
||||
|
||||
getComputeCount() {
|
||||
return this.computeCount;
|
||||
}
|
||||
}
|
||||
|
||||
it('should only compute the value once', () => {
|
||||
const instance = new TestClass();
|
||||
|
||||
// First access should compute
|
||||
expect(instance.expensiveComputation).toBe(84);
|
||||
expect(instance.getComputeCount()).toBe(1);
|
||||
|
||||
// Second access should use cached value
|
||||
expect(instance.expensiveComputation).toBe(84);
|
||||
expect(instance.getComputeCount()).toBe(1);
|
||||
|
||||
// Third access should still use cached value
|
||||
expect(instance.expensiveComputation).toBe(84);
|
||||
expect(instance.getComputeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should cache values independently for different instances', () => {
|
||||
const instance1 = new TestClass(10);
|
||||
const instance2 = new TestClass(20);
|
||||
|
||||
expect(instance1.expensiveComputation).toBe(20);
|
||||
expect(instance2.expensiveComputation).toBe(40);
|
||||
|
||||
expect(instance1.getComputeCount()).toBe(1);
|
||||
expect(instance2.getComputeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw error when used on non-getter', () => {
|
||||
expect(() => {
|
||||
class InvalidClass {
|
||||
// @ts-expect-error this code will fail at compile time and at runtime
|
||||
@Memoized
|
||||
normalProperty = 42;
|
||||
}
|
||||
new InvalidClass();
|
||||
}).toThrow(AssertionError);
|
||||
});
|
||||
|
||||
it('should make cached value non-enumerable', () => {
|
||||
const instance = new TestClass();
|
||||
instance.expensiveComputation; // Access to trigger caching
|
||||
|
||||
const propertyNames = Object.keys(instance);
|
||||
expect(propertyNames).not.toContain('expensiveComputation');
|
||||
});
|
||||
|
||||
it('should not allow reconfiguring the cached value', () => {
|
||||
const instance = new TestClass();
|
||||
instance.expensiveComputation; // Access to trigger caching
|
||||
|
||||
expect(() => {
|
||||
Object.defineProperty(instance, 'expensiveComputation', {
|
||||
value: 999,
|
||||
configurable: true,
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should work when child class references memoized getter in parent class', () => {
|
||||
class ParentClass {
|
||||
protected computeCount = 0;
|
||||
|
||||
@Memoized
|
||||
get parentValue() {
|
||||
this.computeCount++;
|
||||
return 42;
|
||||
}
|
||||
|
||||
getComputeCount() {
|
||||
return this.computeCount;
|
||||
}
|
||||
}
|
||||
|
||||
class ChildClass extends ParentClass {
|
||||
get childValue() {
|
||||
return this.parentValue * 2;
|
||||
}
|
||||
}
|
||||
|
||||
const child = new ChildClass();
|
||||
|
||||
expect(child.childValue).toBe(84);
|
||||
expect(child.getComputeCount()).toBe(1);
|
||||
|
||||
expect(child.childValue).toBe(84);
|
||||
expect(child.getComputeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should have correct property descriptor after memoization', () => {
|
||||
const instance = new TestClass();
|
||||
|
||||
// Before accessing (original getter descriptor)
|
||||
const beforeDescriptor = Object.getOwnPropertyDescriptor(
|
||||
TestClass.prototype,
|
||||
'expensiveComputation',
|
||||
);
|
||||
expect(beforeDescriptor?.configurable).toBe(true);
|
||||
expect(beforeDescriptor?.enumerable).toBe(false);
|
||||
expect(typeof beforeDescriptor?.get).toBe('function');
|
||||
expect(beforeDescriptor?.set).toBeUndefined();
|
||||
|
||||
// After accessing (memoized value descriptor)
|
||||
instance.expensiveComputation; // Trigger memoization
|
||||
const afterDescriptor = Object.getOwnPropertyDescriptor(instance, 'expensiveComputation');
|
||||
expect(afterDescriptor?.configurable).toBe(false);
|
||||
expect(afterDescriptor?.enumerable).toBe(false);
|
||||
expect(afterDescriptor?.writable).toBe(false);
|
||||
expect(afterDescriptor?.value).toBe(84);
|
||||
expect(afterDescriptor?.get).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not prevent garbage collection of instances', async () => {
|
||||
setFlagsFromString('--expose_gc');
|
||||
const gc = runInNewContext('gc') as unknown as () => void;
|
||||
|
||||
let instance: TestClass | undefined = new TestClass();
|
||||
const weakRef = new WeakRef(instance);
|
||||
instance.expensiveComputation;
|
||||
|
||||
// Remove the strong reference
|
||||
instance = undefined;
|
||||
|
||||
// Wait for garbage collection, forcing it if needed
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
gc();
|
||||
|
||||
const ref = weakRef.deref();
|
||||
ok(!ref, 'GC did not collect the instance ref');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
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: { slug: string };
|
||||
};
|
||||
}) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
@Redactable('inviter')
|
||||
methodWithInviter(arg: {
|
||||
inviter: {
|
||||
id: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role: { slug: string };
|
||||
};
|
||||
}) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
@Redactable('invitee')
|
||||
methodWithInvitee(arg: {
|
||||
invitee: {
|
||||
id: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role: { slug: string };
|
||||
};
|
||||
}) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
@Redactable()
|
||||
methodWithMultipleArgs(
|
||||
firstArg: { something: string },
|
||||
secondArg: {
|
||||
user: {
|
||||
id: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role: { slug: 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: { slug: '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: { slug: '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: { slug: '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: { slug: '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: { slug: '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 = vi.spyOn(instance, 'methodWithUser');
|
||||
|
||||
const input = {
|
||||
user: {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
role: { slug: 'admin' },
|
||||
},
|
||||
};
|
||||
|
||||
instance.methodWithUser(input);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
import { Container, Service } from '@n8n/di';
|
||||
|
||||
import type { AuthHandlerClass, IPasswordAuthHandler } from '../auth-handler';
|
||||
import { AuthHandler, AuthHandlerEntryMetadata } from '../auth-handler-metadata';
|
||||
|
||||
describe('AuthHandlerEntryMetadata', () => {
|
||||
let metadata: AuthHandlerEntryMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
metadata = new AuthHandlerEntryMetadata();
|
||||
});
|
||||
|
||||
it('should register handler classes', () => {
|
||||
class TestHandler implements IPasswordAuthHandler<object> {
|
||||
metadata = { name: 'test', type: 'password' as const };
|
||||
readonly userClass = Object;
|
||||
async handleLogin() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
metadata.register({ class: TestHandler as AuthHandlerClass });
|
||||
|
||||
expect(metadata.getClasses()).toContain(TestHandler);
|
||||
});
|
||||
|
||||
it('should return all registered entries', () => {
|
||||
class Handler1 implements IPasswordAuthHandler<object> {
|
||||
metadata = { name: 'test1', type: 'password' as const };
|
||||
readonly userClass = Object;
|
||||
async handleLogin() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
class Handler2 implements IPasswordAuthHandler<object> {
|
||||
metadata = { name: 'test2', type: 'password' as const };
|
||||
readonly userClass = Object;
|
||||
async handleLogin() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
metadata.register({ class: Handler1 as AuthHandlerClass });
|
||||
metadata.register({ class: Handler2 as AuthHandlerClass });
|
||||
|
||||
const entries = metadata.getEntries();
|
||||
expect(entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return all registered classes', () => {
|
||||
class Handler1 implements IPasswordAuthHandler<object> {
|
||||
metadata = { name: 'test1', type: 'password' as const };
|
||||
readonly userClass = Object;
|
||||
async handleLogin() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
class Handler2 implements IPasswordAuthHandler<object> {
|
||||
metadata = { name: 'test2', type: 'password' as const };
|
||||
readonly userClass = Object;
|
||||
async handleLogin() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
metadata.register({ class: Handler1 as AuthHandlerClass });
|
||||
metadata.register({ class: Handler2 as AuthHandlerClass });
|
||||
|
||||
const classes = metadata.getClasses();
|
||||
expect(classes).toEqual([Handler1, Handler2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('@AuthHandler decorator', () => {
|
||||
beforeEach(() => {
|
||||
Container.reset();
|
||||
});
|
||||
|
||||
it('should register handler in metadata', () => {
|
||||
@AuthHandler()
|
||||
class TestAuthHandler implements IPasswordAuthHandler<object> {
|
||||
metadata = { name: 'test', type: 'password' as const };
|
||||
readonly userClass = Object;
|
||||
async handleLogin() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = Container.get(AuthHandlerEntryMetadata);
|
||||
const registeredClasses = metadata.getClasses();
|
||||
|
||||
expect(registeredClasses).toContain(TestAuthHandler);
|
||||
});
|
||||
|
||||
it('should enable dependency injection', () => {
|
||||
@AuthHandler()
|
||||
class TestAuthHandler implements IPasswordAuthHandler<object> {
|
||||
metadata = { name: 'test', type: 'password' as const };
|
||||
readonly userClass = Object;
|
||||
async handleLogin() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const instance = Container.get(TestAuthHandler);
|
||||
expect(instance).toBeInstanceOf(TestAuthHandler);
|
||||
expect(instance.metadata.name).toBe('test');
|
||||
expect(instance.metadata.type).toBe('password');
|
||||
});
|
||||
|
||||
it('should support handlers with dependencies', () => {
|
||||
@Service()
|
||||
class Logger {
|
||||
log(message: string) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
@AuthHandler()
|
||||
class TestAuthHandler implements IPasswordAuthHandler<object> {
|
||||
metadata = { name: 'test', type: 'password' as const };
|
||||
constructor(private logger: Logger) {}
|
||||
readonly userClass = Object;
|
||||
|
||||
async handleLogin() {
|
||||
this.logger.log('login');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const instance = Container.get(TestAuthHandler);
|
||||
expect(instance).toBeInstanceOf(TestAuthHandler);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Container, Service } from '@n8n/di';
|
||||
|
||||
import { AuthHandlerClass } from './auth-handler';
|
||||
|
||||
type AuthHandlerEntry = {
|
||||
class: AuthHandlerClass;
|
||||
};
|
||||
|
||||
/**
|
||||
* Registry service for auth handler type discovery and instantiation.
|
||||
* Handler classes decorated with @AuthHandler() are automatically registered.
|
||||
*/
|
||||
@Service()
|
||||
export class AuthHandlerEntryMetadata {
|
||||
private readonly authHandlerEntries: Set<AuthHandlerEntry> = new Set();
|
||||
|
||||
/** Registers an auth handler class. Called automatically by @AuthHandler() decorator. */
|
||||
register(authHandlerEntry: AuthHandlerEntry) {
|
||||
this.authHandlerEntries.add(authHandlerEntry);
|
||||
}
|
||||
|
||||
/** Returns all registered handler entries as [index, entry] tuples. */
|
||||
getEntries() {
|
||||
return [...this.authHandlerEntries.entries()];
|
||||
}
|
||||
|
||||
/** Returns all registered handler classes. */
|
||||
getClasses() {
|
||||
return [...this.authHandlerEntries.values()].map((entry) => entry.class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator to mark a class as an authentication handler.
|
||||
* Automatically registers the handler for discovery and enables dependency injection.
|
||||
*
|
||||
* @example
|
||||
* @AuthHandler()
|
||||
* class LdapAuthHandler implements IPasswordAuthHandler {
|
||||
* metadata = { name: 'ldap', type: 'password' };
|
||||
* async handleLogin(loginId: string, password: string) { ... }
|
||||
* }
|
||||
*/
|
||||
export const AuthHandler =
|
||||
<T extends AuthHandlerClass>() =>
|
||||
(target: T) => {
|
||||
// Register handler class for discovery by registry
|
||||
Container.get(AuthHandlerEntryMetadata).register({
|
||||
class: target,
|
||||
});
|
||||
|
||||
// Enable dependency injection for the handler class
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Service()(target);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Constructable } from '@n8n/di';
|
||||
|
||||
/**
|
||||
* Authentication type discriminator.
|
||||
* Extend this union as new authentication methods are added (e.g., 'password' | 'oauth' | 'saml').
|
||||
*/
|
||||
export type AuthType = 'password';
|
||||
|
||||
/**
|
||||
* Metadata describing an auth handler.
|
||||
*/
|
||||
export interface AuthHandlerMetadata {
|
||||
/** Unique name of the auth handler (e.g., 'ldap', 'saml') */
|
||||
name: string;
|
||||
/** Type of authentication this handler provides */
|
||||
type: AuthType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for all authentication handlers.
|
||||
* Contains common fields shared across all auth types.
|
||||
*/
|
||||
interface IAuthHandlerBase<TUser> {
|
||||
/**
|
||||
* Metadata identifying this auth handler.
|
||||
*/
|
||||
readonly metadata: AuthHandlerMetadata;
|
||||
|
||||
/** The user type returned by the auth handler */
|
||||
readonly userClass: Constructable<TUser>;
|
||||
|
||||
/**
|
||||
* Optional lifecycle hook called during handler initialization.
|
||||
* Use this to set up connections, load config, etc.
|
||||
*/
|
||||
init?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for password-based authentication handlers.
|
||||
* Implementations can provide custom authentication logic for different auth methods (LDAP, SAML, etc.).
|
||||
*
|
||||
* @template TUser - The user type returned by the auth handler (typically from @n8n/db)
|
||||
*/
|
||||
export interface IPasswordAuthHandler<TUser> extends IAuthHandlerBase<TUser> {
|
||||
readonly metadata: AuthHandlerMetadata & { type: 'password' };
|
||||
|
||||
/**
|
||||
* Handles login attempt with provided credentials.
|
||||
*
|
||||
* @param loginId - User identifier (email, username, LDAP ID, etc.)
|
||||
* @param password - User password
|
||||
* @returns User object if authentication succeeds, undefined otherwise
|
||||
*/
|
||||
handleLogin(loginId: string, password: string): Promise<TUser | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type of all authentication handler interfaces.
|
||||
* TypeScript uses the `metadata.type` field to discriminate between handler types.
|
||||
*/
|
||||
export type IAuthHandler<TUser = unknown> = IPasswordAuthHandler<TUser>;
|
||||
|
||||
/**
|
||||
* Type helper for auth handler class constructors.
|
||||
*/
|
||||
export type AuthHandlerClass = Constructable<IAuthHandler>;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Authentication Handler Module
|
||||
*
|
||||
* Provides interfaces and infrastructure for authentication handlers.
|
||||
* Handlers can implement custom authentication logic for different auth methods (LDAP, SAML, OAuth, etc.).
|
||||
*
|
||||
* The system uses a discriminated union pattern based on the `type` field in metadata:
|
||||
* - 'password': Username/password authentication (IPasswordAuthHandler)
|
||||
* - Future: 'oauth', 'saml', etc.
|
||||
*/
|
||||
|
||||
export { AuthHandlerEntryMetadata, AuthHandler } from './auth-handler-metadata';
|
||||
export type * from './auth-handler';
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Command } from '../command';
|
||||
import { CommandMetadata } from '../command-metadata';
|
||||
|
||||
describe('@Command decorator', () => {
|
||||
let commandMetadata: CommandMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
Container.reset();
|
||||
|
||||
commandMetadata = new CommandMetadata();
|
||||
Container.set(CommandMetadata, commandMetadata);
|
||||
});
|
||||
|
||||
it('should register command in CommandMetadata', () => {
|
||||
@Command({
|
||||
name: 'test',
|
||||
description: 'Test command',
|
||||
examples: ['example usage'],
|
||||
flagsSchema: z.object({}),
|
||||
})
|
||||
class TestCommand {
|
||||
async run() {}
|
||||
}
|
||||
|
||||
const registeredCommands = commandMetadata.getEntries();
|
||||
|
||||
expect(registeredCommands).toHaveLength(1);
|
||||
const [commandName, entry] = registeredCommands[0];
|
||||
expect(commandName).toBe('test');
|
||||
expect(entry.class).toBe(TestCommand);
|
||||
});
|
||||
|
||||
it('should register multiple commands', () => {
|
||||
@Command({
|
||||
name: 'first-command',
|
||||
description: 'First test command',
|
||||
examples: ['example 1'],
|
||||
flagsSchema: z.object({}),
|
||||
})
|
||||
class FirstCommand {
|
||||
async run() {}
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'second-command',
|
||||
description: 'Second test command',
|
||||
examples: ['example 2'],
|
||||
flagsSchema: z.object({}),
|
||||
})
|
||||
class SecondCommand {
|
||||
async run() {}
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'third-command',
|
||||
description: 'Third test command',
|
||||
examples: ['example 3'],
|
||||
flagsSchema: z.object({}),
|
||||
})
|
||||
class ThirdCommand {
|
||||
async run() {}
|
||||
}
|
||||
|
||||
const registeredCommands = commandMetadata.getEntries();
|
||||
|
||||
expect(registeredCommands).toHaveLength(3);
|
||||
expect(commandMetadata.get('first-command')?.class).toBe(FirstCommand);
|
||||
expect(commandMetadata.get('second-command')?.class).toBe(SecondCommand);
|
||||
expect(commandMetadata.get('third-command')?.class).toBe(ThirdCommand);
|
||||
});
|
||||
|
||||
it('should apply Service decorator', () => {
|
||||
@Command({
|
||||
name: 'test',
|
||||
description: 'Test command',
|
||||
examples: ['example usage'],
|
||||
flagsSchema: z.object({}),
|
||||
})
|
||||
class TestCommand {
|
||||
async run() {}
|
||||
}
|
||||
|
||||
expect(Container.has(TestCommand)).toBe(true);
|
||||
});
|
||||
|
||||
it('stores the command metadata correctly', () => {
|
||||
const name = 'test-cmd';
|
||||
const description = 'Test command description';
|
||||
const examples = ['example 1', 'example 2'];
|
||||
const flagsSchema = z.object({
|
||||
flag1: z.string(),
|
||||
flag2: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@Command({
|
||||
name,
|
||||
description,
|
||||
examples,
|
||||
flagsSchema,
|
||||
})
|
||||
class TestCommand {
|
||||
async run() {}
|
||||
}
|
||||
|
||||
const entry = commandMetadata.get(name);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.description).toBe(description);
|
||||
expect(entry?.examples).toEqual(examples);
|
||||
expect(entry?.flagsSchema).toBe(flagsSchema);
|
||||
expect(entry?.class).toBe(TestCommand);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type { CommandEntry } from './types';
|
||||
|
||||
@Service()
|
||||
export class CommandMetadata {
|
||||
private readonly commands: Map<string, CommandEntry> = new Map();
|
||||
|
||||
register(name: string, entry: CommandEntry) {
|
||||
this.commands.set(name, entry);
|
||||
}
|
||||
|
||||
get(name: string) {
|
||||
return this.commands.get(name);
|
||||
}
|
||||
|
||||
getEntries() {
|
||||
return [...this.commands.entries()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Container, Service } from '@n8n/di';
|
||||
|
||||
import { CommandMetadata } from './command-metadata';
|
||||
import type { CommandClass, CommandOptions } from './types';
|
||||
|
||||
export const Command =
|
||||
({ name, description, examples, flagsSchema }: CommandOptions): ClassDecorator =>
|
||||
(target) => {
|
||||
const commandClass = target as unknown as CommandClass;
|
||||
Container.get(CommandMetadata).register(name, {
|
||||
description,
|
||||
flagsSchema,
|
||||
class: commandClass,
|
||||
examples,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Service()(target);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Command } from './command';
|
||||
export { CommandMetadata } from './command-metadata';
|
||||
export type { ICommand, CommandClass, CommandEntry } from './types';
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Constructable } from '@n8n/di';
|
||||
import type { ZodObject, ZodTypeAny } from 'zod';
|
||||
|
||||
type FlagsSchema = ZodObject<Record<string, ZodTypeAny>>;
|
||||
|
||||
export type CommandOptions = {
|
||||
name: string;
|
||||
description: string;
|
||||
examples?: string[];
|
||||
flagsSchema?: FlagsSchema;
|
||||
};
|
||||
|
||||
export type ICommand = {
|
||||
flags?: object;
|
||||
init?: () => Promise<void>;
|
||||
run: () => Promise<void>;
|
||||
catch?: (e: Error) => Promise<void>;
|
||||
finally?: (e?: Error) => Promise<void>;
|
||||
};
|
||||
|
||||
export type CommandClass = Constructable<ICommand>;
|
||||
|
||||
export type CommandEntry = {
|
||||
class: CommandClass;
|
||||
description: string;
|
||||
examples?: string[];
|
||||
flagsSchema?: FlagsSchema;
|
||||
};
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import type {
|
||||
ContextEstablishmentOptions,
|
||||
ContextEstablishmentResult,
|
||||
IContextEstablishmentHook,
|
||||
} from '../context-establishment-hook';
|
||||
import {
|
||||
ContextEstablishmentHookMetadata,
|
||||
ContextEstablishmentHook,
|
||||
} from '../context-establishment-hook-metadata';
|
||||
|
||||
describe('@ContextEstablishmentHook decorator', () => {
|
||||
let hookMetadata: ContextEstablishmentHookMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
hookMetadata = new ContextEstablishmentHookMetadata();
|
||||
Container.set(ContextEstablishmentHookMetadata, hookMetadata);
|
||||
});
|
||||
|
||||
it('should register hook in ContextEstablishmentHookMetadata', () => {
|
||||
@ContextEstablishmentHook()
|
||||
class TestHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'test.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const registeredHooks = hookMetadata.getClasses();
|
||||
|
||||
expect(registeredHooks).toContain(TestHook);
|
||||
expect(registeredHooks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should register multiple hooks', () => {
|
||||
@ContextEstablishmentHook()
|
||||
class FirstHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'first.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
class SecondHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'second.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
class ThirdHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'third.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const registeredHooks = hookMetadata.getClasses();
|
||||
|
||||
expect(registeredHooks).toContain(FirstHook);
|
||||
expect(registeredHooks).toContain(SecondHook);
|
||||
expect(registeredHooks).toContain(ThirdHook);
|
||||
expect(registeredHooks).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should apply Service decorator', () => {
|
||||
@ContextEstablishmentHook()
|
||||
class TestHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'test.hook' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(Container.has(TestHook)).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow instantiation of registered hooks with accessible hookDescription', () => {
|
||||
@ContextEstablishmentHook()
|
||||
class TestHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'credentials.bearerToken' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const hookInstance = Container.get(TestHook);
|
||||
|
||||
expect(hookInstance).toBeInstanceOf(TestHook);
|
||||
expect(hookInstance.hookDescription).toEqual({ name: 'credentials.bearerToken' });
|
||||
expect(hookInstance.hookDescription.name).toBe('credentials.bearerToken');
|
||||
});
|
||||
|
||||
it('should register hooks with different description names', () => {
|
||||
@ContextEstablishmentHook()
|
||||
class BearerTokenHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'credentials.bearerToken' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ContextEstablishmentHook()
|
||||
class ApiKeyHook implements IContextEstablishmentHook {
|
||||
hookDescription = { name: 'credentials.apiKey' };
|
||||
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
return {};
|
||||
}
|
||||
isApplicableToTriggerNode(_nodeType: string): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const registeredHooks = hookMetadata.getClasses();
|
||||
const bearerTokenHook = Container.get(BearerTokenHook);
|
||||
const apiKeyHook = Container.get(ApiKeyHook);
|
||||
|
||||
expect(registeredHooks).toHaveLength(2);
|
||||
expect(bearerTokenHook.hookDescription.name).toBe('credentials.bearerToken');
|
||||
expect(apiKeyHook.hookDescription.name).toBe('credentials.apiKey');
|
||||
});
|
||||
});
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import { Container, Service } from '@n8n/di';
|
||||
|
||||
import { ContextEstablishmentHookClass } from './context-establishment-hook';
|
||||
|
||||
/**
|
||||
* Registry entry for a context establishment hook.
|
||||
*
|
||||
* This is a lightweight wrapper around the hook class constructor that can be
|
||||
* extended in the future to include additional metadata if needed (e.g., module
|
||||
* source, registration timestamp, feature flags, license flags).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
type ContextEstablishmentHookEntry = {
|
||||
/** The hook class constructor for DI container instantiation */
|
||||
class: ContextEstablishmentHookClass;
|
||||
};
|
||||
|
||||
/**
|
||||
* Low-level metadata registry for context establishment hooks.
|
||||
*
|
||||
* This service acts as a simple collection of registered hook classes that gets
|
||||
* populated automatically by the @ContextEstablishmentHook decorator at module
|
||||
* load time. It serves as the foundation for the higher-level Hook Registry.
|
||||
*
|
||||
* **Architecture:**
|
||||
* ```
|
||||
* Decorator → ContextEstablishmentHookMetadata → Hook Registry → Execution Engine
|
||||
* (registration) (collection) (discovery) (execution)
|
||||
* ```
|
||||
*
|
||||
* @see ContextEstablishmentHook decorator for automatic registration
|
||||
* @see IContextEstablishmentHook for hook interface
|
||||
*/
|
||||
@Service()
|
||||
export class ContextEstablishmentHookMetadata {
|
||||
/**
|
||||
* Internal collection of registered hook classes.
|
||||
*
|
||||
* Uses Set for efficient deduplication (though duplicate registration
|
||||
* should not occur with proper decorator usage).
|
||||
*/
|
||||
private readonly contextEstablishmentHooks: Set<ContextEstablishmentHookEntry> = new Set();
|
||||
|
||||
/**
|
||||
* Registers a hook class in the metadata collection.
|
||||
*
|
||||
* Called automatically by the @ContextEstablishmentHook decorator during
|
||||
* module loading. Should not be called directly by application code.
|
||||
*
|
||||
* **Note:** This method does not validate uniqueness or check for naming
|
||||
* conflicts. Validation happens later in the Hook Registry.
|
||||
*
|
||||
* @param hookEntry - The hook class entry to register
|
||||
*
|
||||
* @internal Called by decorator only
|
||||
*/
|
||||
register(hookEntry: ContextEstablishmentHookEntry) {
|
||||
this.contextEstablishmentHooks.add(hookEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all registered hook entries.
|
||||
*
|
||||
* Returns an array of [index, entry] tuples compatible with Set.entries().
|
||||
* Primarily used for debugging or low-level iteration.
|
||||
*
|
||||
* **Prefer getClasses()** for most use cases as it returns just the classes.
|
||||
*
|
||||
* @returns Array of [index, entry] tuples from the internal Set
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const entries = metadata.getEntries();
|
||||
* for (const [index, entry] of entries) {
|
||||
* console.log(`Hook ${index}:`, entry.class.name);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getEntries() {
|
||||
return [...this.contextEstablishmentHooks.entries()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all registered hook classes.
|
||||
*
|
||||
* This is the primary method used by the Hook Registry to obtain hook classes
|
||||
* for instantiation and indexing. Returns just the class constructors without
|
||||
* the wrapper entry objects.
|
||||
*
|
||||
* **Usage pattern:**
|
||||
* ```typescript
|
||||
* const classes = metadata.getClasses();
|
||||
* const hooks = classes.map(HookClass => Container.get(HookClass));
|
||||
* const hooksByName = new Map(hooks.map(h => [h.hookDescription.name, h]));
|
||||
* ```
|
||||
*
|
||||
* @returns Array of hook class constructors ready for DI instantiation
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Service()
|
||||
* export class HookRegistry {
|
||||
* constructor(
|
||||
* private metadata: ContextEstablishmentHookMetadata,
|
||||
* private container: Container
|
||||
* ) {
|
||||
* const hookClasses = metadata.getClasses();
|
||||
* this.hooks = hookClasses.map(cls => container.get(cls));
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getClasses() {
|
||||
return [...this.contextEstablishmentHooks.values()].map((entry) => entry.class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class decorator for context establishment hooks.
|
||||
*
|
||||
* This decorator performs two critical functions:
|
||||
* 1. **Registers** the hook class in ContextEstablishmentHookMetadata for discovery
|
||||
* 2. **Enables DI** by applying @Service() to make the hook injectable
|
||||
*
|
||||
* The decorator executes at module load time (when the class is defined), ensuring
|
||||
* all hooks are registered before the application starts. This enables automatic
|
||||
* discovery without manual registration code.
|
||||
*
|
||||
* **Registration flow:**
|
||||
* ```
|
||||
* @ContextEstablishmentHook() // 1. Decorator executes
|
||||
* export class BearerTokenHook // 2. Class is defined
|
||||
* ↓
|
||||
* ContextEstablishmentHookMetadata // 3. Hook class registered in metadata
|
||||
* ↓
|
||||
* @Service() // 4. DI container registration
|
||||
* ↓
|
||||
* Hook is discoverable & injectable // 5. Ready for use
|
||||
* ```
|
||||
*
|
||||
* **Design pattern:**
|
||||
* This follows the declarative registration pattern used throughout n8n for
|
||||
* extensibility (similar to node registration). Hooks self-register without
|
||||
* requiring central registration files or manual imports.
|
||||
*
|
||||
* **Requirements:**
|
||||
* - Decorated class MUST implement IContextEstablishmentHook
|
||||
* - Decorated class MUST have a hookDescription property with unique name
|
||||
*
|
||||
* **Important notes:**
|
||||
* - No decorator parameters needed (hook metadata lives on hook instance)
|
||||
* - Hooks are registered as singletons via @Service()
|
||||
* - Registration happens eagerly at module load, not lazily
|
||||
* - Duplicate decoration of the same class is safe (Set deduplicates)
|
||||
*
|
||||
* @see IContextEstablishmentHook for interface requirements
|
||||
* @see ContextEstablishmentHookMetadata for underlying registry
|
||||
* @see HookDescription for hook metadata structure
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic hook registration:
|
||||
* @ContextEstablishmentHook()
|
||||
* export class BearerTokenHook implements IContextEstablishmentHook {
|
||||
* hookDescription = {
|
||||
* name: 'credentials.bearerToken'
|
||||
* };
|
||||
*
|
||||
* async execute(options: ContextEstablishmentOptions) {
|
||||
* // Extract bearer token from Authorization header
|
||||
* const token = this.extractToken(options.triggerItem);
|
||||
* return {
|
||||
* triggerItem: this.removeAuthHeader(options.triggerItem),
|
||||
* contextUpdate: {
|
||||
* credentials: { version: 1, identity: token }
|
||||
* }
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* isApplicableToTriggerNode(nodeType: string) {
|
||||
* return nodeType === 'n8n-nodes-base.webhook';
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns A class decorator function that registers and enables DI for the hook
|
||||
*/
|
||||
export const ContextEstablishmentHook =
|
||||
<T extends ContextEstablishmentHookClass>() =>
|
||||
(target: T) => {
|
||||
// Register hook class in metadata for discovery by Hook Registry
|
||||
Container.get(ContextEstablishmentHookMetadata).register({
|
||||
class: target,
|
||||
});
|
||||
|
||||
// Enable dependency injection for the hook class
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Service()(target);
|
||||
};
|
||||
@@ -0,0 +1,431 @@
|
||||
import type { Constructable } from '@n8n/di';
|
||||
import type {
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
PlaintextExecutionContext,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Input parameters passed to a context establishment hook during execution.
|
||||
*
|
||||
* Hooks receive the current workflow state and extract information from
|
||||
* trigger items to build the execution context (e.g., credentials, environment).
|
||||
* All hooks work with plaintext (decrypted) context for runtime operations.
|
||||
*
|
||||
* @see IContextEstablishmentHook
|
||||
* @see PlaintextExecutionContext
|
||||
*/
|
||||
export type ContextEstablishmentOptions = {
|
||||
/** The trigger node that initiated the workflow execution */
|
||||
triggerNode: INode;
|
||||
|
||||
/** The complete workflow definition */
|
||||
workflow: Workflow;
|
||||
|
||||
/**
|
||||
* Trigger items from the workflow execution start.
|
||||
* This array represents items as modified by previous hooks in the chain.
|
||||
* Hooks can extract data from these items and optionally modify them
|
||||
* (e.g., removing sensitive headers before storage).
|
||||
*/
|
||||
triggerItems: INodeExecutionData[] | null;
|
||||
|
||||
/**
|
||||
* The plaintext execution context built so far.
|
||||
* Includes base context plus results from any previously executed hooks.
|
||||
* Contains decrypted credential data for runtime operations.
|
||||
*
|
||||
* @see PlaintextExecutionContext for security considerations
|
||||
*/
|
||||
context: PlaintextExecutionContext;
|
||||
|
||||
/**
|
||||
* Hook-specific configuration provided by the trigger node.
|
||||
* Structure varies per hook type (e.g., { removeFromItem: true } for bearer token hook).
|
||||
*/
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result returned by a context establishment hook after execution.
|
||||
*
|
||||
* Hooks can modify trigger items (e.g., remove sensitive headers) and
|
||||
* contribute partial context updates that get merged into the execution context.
|
||||
* All context data is in plaintext form during hook execution.
|
||||
*
|
||||
* @see IContextEstablishmentHook
|
||||
* @see PlaintextExecutionContext
|
||||
*/
|
||||
export type ContextEstablishmentResult = {
|
||||
/**
|
||||
* The potentially modified trigger items.
|
||||
* If undefined, the original trigger items are preserved unchanged.
|
||||
*
|
||||
* Common use case: Removing sensitive data (e.g., Authorization headers)
|
||||
* before storing items in execution history.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Remove Authorization header from trigger items
|
||||
* const modifiedItems = options.triggerItems.map(item => ({
|
||||
* ...item,
|
||||
* json: {
|
||||
* ...item.json,
|
||||
* headers: {
|
||||
* ...item.json.headers,
|
||||
* authorization: undefined
|
||||
* }
|
||||
* }
|
||||
* }));
|
||||
* return { triggerItems: modifiedItems, contextUpdate: { ... } };
|
||||
* ```
|
||||
*/
|
||||
triggerItems?: INodeExecutionData[];
|
||||
|
||||
/**
|
||||
* Partial context update to merge into the execution context.
|
||||
* If undefined, no context updates are applied.
|
||||
*
|
||||
* Contains only this hook's contributions (e.g., credentials data).
|
||||
* Multiple hooks' updates are merged sequentially during execution.
|
||||
* Context data is in plaintext form and will be encrypted before persistence.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Add credential context from bearer token
|
||||
* return {
|
||||
* triggerItems: modifiedItems,
|
||||
* contextUpdate: {
|
||||
* credentials: {
|
||||
* version: 1,
|
||||
* identity: extractedToken,
|
||||
* metadata: { source: 'bearer-token' }
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
contextUpdate?: Partial<PlaintextExecutionContext>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata describing a context establishment hook.
|
||||
*
|
||||
* This object carries self-describing information about the hook that enables
|
||||
* runtime discovery, lookup, and instantiation. Each hook instance serves as
|
||||
* the single source of truth for its own metadata.
|
||||
*
|
||||
* **Design rationale:**
|
||||
* - Hook instances are self-describing (no external configuration files)
|
||||
* - Name lookup happens at runtime via Registry, not during registration
|
||||
* - Description can be extended without changing decorator or registry internals
|
||||
* - Supports future features like versioning, schema validation, and categorization
|
||||
*
|
||||
* **Future extensions** may include:
|
||||
* - `version?: string` - Semantic version of hook implementation for compatibility checks
|
||||
* - `configSchema?: ZodSchema` - Validation schema for hook-specific options
|
||||
* - `tags?: string[]` - Categorization tags for grouping and filtering
|
||||
* - `applicableTriggers?: string[]` - Cached list of compatible trigger node types
|
||||
* - `deprecated?: boolean | string` - Deprecation status and migration guidance
|
||||
*
|
||||
* @see IContextEstablishmentHook.hookDescription
|
||||
* @see ContextEstablishmentHookMetadata for registration mechanism
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @ContextEstablishmentHook()
|
||||
* export class BearerTokenHook implements IContextEstablishmentHook {
|
||||
* hookDescription = {
|
||||
* name: 'credentials.bearerToken',
|
||||
* displayName: 'Bearer Token',
|
||||
* options: [
|
||||
* {
|
||||
* displayName: 'Remove from Item',
|
||||
* name: 'removeFromItem',
|
||||
* type: 'boolean',
|
||||
* default: true,
|
||||
* description: 'Whether to remove the Authorization header from the trigger item'
|
||||
* }
|
||||
* ]
|
||||
* };
|
||||
*
|
||||
* // ... hook implementation
|
||||
* }
|
||||
*
|
||||
* ```
|
||||
*/
|
||||
export type HookDescription = {
|
||||
/**
|
||||
* Unique identifier for this hook type.
|
||||
*
|
||||
* Used by the Hook Registry (to be implemented) to index and retrieve
|
||||
* hook instances at runtime. Must be unique across all registered hooks.
|
||||
*
|
||||
* **Naming convention**: Use namespaced names like 'credentials.bearerToken'
|
||||
* or 'envVars.tenantConfig' to organize hooks by domain and avoid collisions.
|
||||
*
|
||||
* **Usage contexts:**
|
||||
* - Trigger node configuration specifies hooks by name
|
||||
* - Hook Registry uses name as lookup key
|
||||
* - UI displays localized names via i18n (e.g., `hooks.${name}.displayName`)
|
||||
* - Logging and debugging references hooks by name
|
||||
* - Error messages include hook name for troubleshooting
|
||||
*
|
||||
* **Versioning**: Future hook versions can use naming like 'credentials.bearerToken.v2'
|
||||
* if breaking changes are needed, though this is not required initially.
|
||||
*
|
||||
* @example 'credentials.bearerToken'
|
||||
* @example 'credentials.apiKey'
|
||||
* @example 'envVars.tenantConfig'
|
||||
* @example 'audit.requestMetadata'
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Human-readable display name for the hook.
|
||||
* Used in the UI when presenting the hook selection to users.
|
||||
* If not provided, the name will be used as the display name.
|
||||
*
|
||||
* @example 'Bearer Token Authentication'
|
||||
* @example 'API Key from Header'
|
||||
*/
|
||||
displayName?: string;
|
||||
|
||||
/**
|
||||
* Hook-specific configuration options that will be exposed in the trigger node UI.
|
||||
* These options are passed to the hook's execute() method via the options parameter.
|
||||
*
|
||||
* Each option should be a valid node property object with at minimum:
|
||||
* displayName, name, type, and default fields.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* options: [
|
||||
* {
|
||||
* displayName: 'Remove from Item',
|
||||
* name: 'removeFromItem',
|
||||
* type: 'boolean',
|
||||
* default: true,
|
||||
* description: 'Whether to remove the Authorization header from trigger items'
|
||||
* },
|
||||
* {
|
||||
* displayName: 'Header Name',
|
||||
* name: 'headerName',
|
||||
* type: 'string',
|
||||
* default: 'Authorization',
|
||||
* description: 'The name of the header containing the bearer token'
|
||||
* }
|
||||
* ]
|
||||
* ```
|
||||
*/
|
||||
options?: INodeProperties[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for context establishment hooks that extract data from trigger
|
||||
* items and extend the execution context during workflow initialization.
|
||||
*
|
||||
* @see ContextEstablishmentOptions - Input parameters
|
||||
* @see ContextEstablishmentResult - Output structure
|
||||
* @see PlaintextExecutionContext - Runtime context type with decrypted data
|
||||
*/
|
||||
export interface IContextEstablishmentHook {
|
||||
/**
|
||||
* Self-describing metadata for this hook instance.
|
||||
*
|
||||
* Provides the unique name and future metadata used by the Hook Registry
|
||||
* for discovery, lookup, and validation. This property makes each hook
|
||||
* instance self-contained and discoverable without external configuration.
|
||||
*
|
||||
* @see HookDescription for detailed metadata structure and future extensions
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @ContextEstablishmentHook()
|
||||
* export class BearerTokenHook implements IContextEstablishmentHook {
|
||||
* hookDescription = {
|
||||
* name: 'credentials.bearerToken'
|
||||
* };
|
||||
*
|
||||
* async execute(options: ContextEstablishmentOptions) {
|
||||
* // Hook implementation
|
||||
* }
|
||||
*
|
||||
* isApplicableToTriggerNode(nodeType: string) {
|
||||
* return nodeType === 'n8n-nodes-base.webhook';
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
hookDescription: HookDescription;
|
||||
/**
|
||||
* Executes the hook to extract context data from trigger information.
|
||||
*
|
||||
* **Implementation requirements:**
|
||||
* 1. Extract relevant data from trigger items (headers, body, query params, etc.)
|
||||
* 2. Optionally modify trigger items to remove sensitive data, if these are not provided in the response they are not modified
|
||||
* 3. Return partial context updates to merge into execution context
|
||||
* 4. Throw errors for unrecoverable failures (stops workflow execution)
|
||||
*
|
||||
* **Execution order:**
|
||||
* Hooks execute sequentially in the order configured by the trigger node.
|
||||
* Each hook receives:
|
||||
* - Trigger items as modified by all previous hooks
|
||||
* - Context with updates from all previous hooks
|
||||
*
|
||||
* **Context handling:**
|
||||
* - Input context is plaintext (PlaintextExecutionContext) for runtime operations
|
||||
* - Output updates are plaintext and will be encrypted before persistence
|
||||
* - Never log or expose plaintext context outside hook execution
|
||||
*
|
||||
* **Error handling:**
|
||||
* - Throw errors if required data is missing (e.g., expected header not found)
|
||||
* - Use descriptive error messages for debugging
|
||||
* - Errors stop workflow execution (fail-fast approach)
|
||||
*
|
||||
* @param options - Input parameters including trigger node, workflow, items, and current context
|
||||
* @returns Promise resolving to modified trigger items and context updates
|
||||
* @throws Error if hook execution fails (stops workflow execution)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* async execute(options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
|
||||
* const removeHeader = options.options?.removeFromItem ?? true;
|
||||
*
|
||||
* // Extract data
|
||||
* const token = this.extractToken(options.triggerItems);
|
||||
* if (!token) {
|
||||
* throw new Error('Bearer token not found in Authorization header');
|
||||
* }
|
||||
*
|
||||
* // Optionally modify items
|
||||
* const modifiedItems = removeHeader
|
||||
* ? this.removeAuthHeader(options.triggerItems)
|
||||
* : undefined;
|
||||
*
|
||||
* // Return context update
|
||||
* return {
|
||||
* triggerItems: modifiedItems,
|
||||
* contextUpdate: {
|
||||
* credentials: { version: 1, identity: token }
|
||||
* }
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
execute(options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult>;
|
||||
|
||||
/**
|
||||
* Method to determine if this hook is applicable to a specific trigger node type.
|
||||
*
|
||||
* **Use cases:**
|
||||
* - **UI filtering**: Show only relevant hooks for a trigger type in node configuration
|
||||
* - **Validation**: Prevent incompatible hook configurations at save time
|
||||
* - **Auto-suggestion**: Suggest applicable hooks based on trigger node selection
|
||||
* - **Documentation**: Generate trigger-specific hook documentation
|
||||
*
|
||||
* **Implementation notes:**
|
||||
* - Return true if the hook can extract meaningful data from this trigger type
|
||||
* - Consider transport layer (HTTP, AMQP, manual, etc.)
|
||||
* - Multiple triggers can share the same hook (e.g., webhook and form trigger both support bearer tokens)
|
||||
*
|
||||
* @param nodeType - The node type identifier (e.g., 'n8n-nodes-base.webhook')
|
||||
* @returns true if this hook can be used with the given trigger node type
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Hook only works with HTTP-based triggers
|
||||
* isApplicableToTriggerNode(nodeType: string): boolean {
|
||||
* return [
|
||||
* 'n8n-nodes-base.webhook',
|
||||
* 'n8n-nodes-base.formTrigger',
|
||||
* 'n8n-nodes-base.httpRequest'
|
||||
* ].includes(nodeType);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Hook works with any trigger that has HTTP headers
|
||||
* isApplicableToTriggerNode(nodeType: string): boolean {
|
||||
* return nodeType.includes('webhook') || nodeType.includes('http');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
isApplicableToTriggerNode(nodeType: string): boolean;
|
||||
|
||||
/**
|
||||
* Optional hook initialization method called during registry setup.
|
||||
*
|
||||
* Use this to perform one-time setup operations before the hook starts
|
||||
* processing workflow executions (e.g., loading configuration, establishing
|
||||
* connections, validating dependencies).
|
||||
*
|
||||
* **Lifecycle:**
|
||||
* - Called once during application startup when ExecutionContextHookRegistry.init() runs
|
||||
* - Can be called multiple times if the registry is reinitialized
|
||||
* - Called AFTER DI container instantiates the hook but BEFORE registration in the registry
|
||||
*
|
||||
* **Error handling:**
|
||||
* - If init() throws an error, the hook is NOT registered and will be unavailable
|
||||
* - The error is logged but does not stop other hooks from initializing
|
||||
* - Other hooks continue to load normally even if one fails
|
||||
* - Only throw errors for failures that make the hook unusable
|
||||
*
|
||||
* **Best practices:**
|
||||
* - Keep initialization fast (avoid blocking operations)
|
||||
* - Don't allocate resources in constructor - do it here instead
|
||||
* - Make init() idempotent (safe to call multiple times)
|
||||
* - Throw errors only when the hook cannot function without successful initialization
|
||||
* - Add logging for initialization steps to aid debugging
|
||||
*
|
||||
* @returns Promise that resolves when initialization is complete
|
||||
* @throws Error if initialization fails and hook should not be registered
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @ContextEstablishmentHook()
|
||||
* export class CustomHook implements IContextEstablishmentHook {
|
||||
* hookDescription = { name: 'custom.hook' };
|
||||
*
|
||||
* private config!: ConfigData;
|
||||
*
|
||||
* async init() {
|
||||
* // Load required configuration
|
||||
* this.config = await this.loadConfig();
|
||||
* if (!this.config) {
|
||||
* throw new Error('Failed to load required configuration');
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* async execute(options: ContextEstablishmentOptions) {
|
||||
* // Config is guaranteed to be loaded if init() succeeded
|
||||
* return this.processWithConfig(options, this.config);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
init?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representing the constructor/class of a context establishment hook.
|
||||
*
|
||||
* Used by the dependency injection container to register and instantiate
|
||||
* hook classes at runtime. Works with the @ContextEstablishmentHook decorator.
|
||||
*
|
||||
* @see IContextEstablishmentHook
|
||||
* @see ContextEstablishmentHook decorator in './index.ts'
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Container } from '@n8n/di';
|
||||
* import type { ContextEstablishmentHookClass } from './context-establishment-hook';
|
||||
*
|
||||
* const HookClass: ContextEstablishmentHookClass = BearerTokenHook;
|
||||
* const hookInstance = Container.get(HookClass);
|
||||
* ```
|
||||
*/
|
||||
export type ContextEstablishmentHookClass = Constructable<IContextEstablishmentHook>;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
ContextEstablishmentHookMetadata,
|
||||
ContextEstablishmentHook,
|
||||
} from './context-establishment-hook-metadata';
|
||||
export type * from './context-establishment-hook';
|
||||
@@ -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(() => {
|
||||
vi.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' });
|
||||
});
|
||||
});
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
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: '/',
|
||||
registerOnRootPath: false,
|
||||
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(() => {
|
||||
vi.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,56 @@
|
||||
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(() => {
|
||||
vi.resetAllMocks();
|
||||
Container.reset();
|
||||
|
||||
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(metadata.registerOnRootPath).toBe(false);
|
||||
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(metadata.registerOnRootPath).toBe(false);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { ControllerRegistryMetadata } from '../controller-registry-metadata';
|
||||
import { RootLevelController } from '../root-level-controller';
|
||||
import type { Controller } from '../types';
|
||||
|
||||
describe('@RootLevelController Decorator', () => {
|
||||
let controllerRegistryMetadata: ControllerRegistryMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
Container.reset();
|
||||
|
||||
controllerRegistryMetadata = new ControllerRegistryMetadata();
|
||||
Container.set(ControllerRegistryMetadata, controllerRegistryMetadata);
|
||||
});
|
||||
|
||||
it('should default to root path and register on root', () => {
|
||||
@RootLevelController()
|
||||
class TestController {}
|
||||
|
||||
const metadata = controllerRegistryMetadata.getControllerMetadata(TestController as Controller);
|
||||
expect(metadata.basePath).toBe('/');
|
||||
expect(metadata.registerOnRootPath).toBe(true);
|
||||
expect(Container.has(TestController)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept custom base path', () => {
|
||||
@RootLevelController('/foo')
|
||||
class FooController {}
|
||||
|
||||
const metadata = controllerRegistryMetadata.getControllerMetadata(FooController as Controller);
|
||||
expect(metadata.basePath).toBe('/foo');
|
||||
expect(metadata.registerOnRootPath).toBe(true);
|
||||
expect(Container.has(FooController)).toBe(true);
|
||||
});
|
||||
|
||||
it('should register multiple controllers with their metadata', () => {
|
||||
@RootLevelController('/users')
|
||||
class UsersController {}
|
||||
|
||||
@RootLevelController('/projects')
|
||||
class ProjectsController {}
|
||||
|
||||
const controllers = Array.from(controllerRegistryMetadata.controllerClasses);
|
||||
expect(controllers).toEqual([UsersController, ProjectsController]);
|
||||
expect(
|
||||
controllerRegistryMetadata.getControllerMetadata(UsersController as Controller)
|
||||
.registerOnRootPath,
|
||||
).toBe(true);
|
||||
expect(
|
||||
controllerRegistryMetadata.getControllerMetadata(ProjectsController as Controller).basePath,
|
||||
).toBe('/projects');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { ControllerRegistryMetadata } from '../controller-registry-metadata';
|
||||
import { createBodyKeyedRateLimiter } from '../rate-limit';
|
||||
import { Get, Post, Put, Patch, Delete } from '../route';
|
||||
import type { Controller } from '../types';
|
||||
|
||||
describe('Route Decorators', () => {
|
||||
let controllerRegistryMetadata: ControllerRegistryMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.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.ipRateLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept and apply route options', () => {
|
||||
const middleware = () => {};
|
||||
|
||||
class TestController {
|
||||
@decorator('/test', {
|
||||
middlewares: [middleware],
|
||||
usesTemplates: true,
|
||||
skipAuth: true,
|
||||
ipRateLimit: { limit: 10, windowMs: 60000 },
|
||||
keyedRateLimit: createBodyKeyedRateLimiter<{ email: string }>({
|
||||
limit: 10,
|
||||
windowMs: 60000,
|
||||
field: 'email',
|
||||
}),
|
||||
})
|
||||
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.ipRateLimit).toEqual({ limit: 10, windowMs: 60000 });
|
||||
expect(routeMetadata.keyedRateLimit).toMatchObject({
|
||||
limit: 10,
|
||||
windowMs: 60000,
|
||||
source: 'body',
|
||||
field: 'email',
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with boolean ipRateLimit option', () => {
|
||||
class TestController {
|
||||
@decorator('/test', { ipRateLimit: true })
|
||||
testMethod() {}
|
||||
}
|
||||
|
||||
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
|
||||
TestController as Controller,
|
||||
'testMethod',
|
||||
);
|
||||
|
||||
expect(routeMetadata.ipRateLimit).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');
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
vi.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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { ControllerRegistryMetadata } from './controller-registry-metadata';
|
||||
import type { Arg, Controller } from './types';
|
||||
|
||||
const ArgDecorator =
|
||||
(arg: Arg): ParameterDecorator =>
|
||||
(target, handlerName, parameterIndex) => {
|
||||
const routeMetadata = Container.get(ControllerRegistryMetadata).getRouteMetadata(
|
||||
target.constructor as Controller,
|
||||
String(handlerName),
|
||||
);
|
||||
routeMetadata.args[parameterIndex] = arg;
|
||||
};
|
||||
|
||||
/** Injects the request body into the handler */
|
||||
export const Body = ArgDecorator({ type: 'body' });
|
||||
|
||||
/** Injects the request query into the handler */
|
||||
export const Query = ArgDecorator({ type: 'query' });
|
||||
|
||||
/** Injects a request parameter into the handler */
|
||||
export const Param = (key: string) => ArgDecorator({ type: 'param', key });
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type { Controller, ControllerMetadata, HandlerName, RouteMetadata } from './types';
|
||||
|
||||
@Service()
|
||||
export class ControllerRegistryMetadata {
|
||||
private registry = new Map<Controller, ControllerMetadata>();
|
||||
|
||||
getControllerMetadata(controllerClass: Controller) {
|
||||
let metadata = this.registry.get(controllerClass);
|
||||
if (!metadata) {
|
||||
metadata = {
|
||||
basePath: '/',
|
||||
registerOnRootPath: false,
|
||||
middlewares: [],
|
||||
routes: new Map(),
|
||||
};
|
||||
this.registry.set(controllerClass, metadata);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
getRouteMetadata(controllerClass: Controller, handlerName: HandlerName) {
|
||||
const metadata = this.getControllerMetadata(controllerClass);
|
||||
let route = metadata.routes.get(handlerName);
|
||||
if (!route) {
|
||||
route = {} as RouteMetadata;
|
||||
route.args = [];
|
||||
metadata.routes.set(handlerName, route);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
get controllerClasses() {
|
||||
return this.registry.keys();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export { Body, Query, Param } from './args';
|
||||
export { RestController } from './rest-controller';
|
||||
export { RootLevelController } from './root-level-controller';
|
||||
export { Get, Post, Put, Patch, Delete, Head, Options } 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,
|
||||
CorsOptions,
|
||||
Method,
|
||||
StaticRouterMetadata,
|
||||
} from './types';
|
||||
export {
|
||||
type RateLimiterLimits,
|
||||
type BodyKeyedRateLimiterConfig,
|
||||
type UserKeyedRateLimiterConfig,
|
||||
type KeyedRateLimiterConfig,
|
||||
createBodyKeyedRateLimiter,
|
||||
createUserKeyedRateLimiter,
|
||||
} from './rate-limit';
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { BooleanLicenseFeature } from '@n8n/constants';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { ControllerRegistryMetadata } from './controller-registry-metadata';
|
||||
import type { Controller } from './types';
|
||||
|
||||
export const Licensed =
|
||||
(licenseFeature: BooleanLicenseFeature): MethodDecorator =>
|
||||
(target, handlerName) => {
|
||||
const routeMetadata = Container.get(ControllerRegistryMetadata).getRouteMetadata(
|
||||
target.constructor as Controller,
|
||||
String(handlerName),
|
||||
);
|
||||
routeMetadata.licenseFeature = licenseFeature;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { ControllerRegistryMetadata } from './controller-registry-metadata';
|
||||
import type { Controller } from './types';
|
||||
|
||||
export const Middleware = (): MethodDecorator => (target, handlerName) => {
|
||||
const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(
|
||||
target.constructor as Controller,
|
||||
);
|
||||
metadata.middlewares.push(String(handlerName));
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
export interface RateLimiterLimits {
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for extracting a key from the request body.
|
||||
*/
|
||||
export interface BodyKeyedRateLimiterConfig extends RateLimiterLimits {
|
||||
/** How to extract key from request */
|
||||
source: 'body';
|
||||
/** The field name in the request body to use as the key */
|
||||
field: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for extracting a key from the authenticated user.
|
||||
*/
|
||||
export interface UserKeyedRateLimiterConfig extends RateLimiterLimits {
|
||||
/** How to extract key from request */
|
||||
source: 'user';
|
||||
}
|
||||
|
||||
export type KeyedRateLimiterConfig = BodyKeyedRateLimiterConfig | UserKeyedRateLimiterConfig;
|
||||
|
||||
/**
|
||||
* Create a body keyed rate limiter configuration. This ends up creating
|
||||
* a rate limiter that is keyed by the value of the specified field in the
|
||||
* request body.
|
||||
*
|
||||
* @example
|
||||
* createBodyKeyedRateLimiter<LoginRequestDto>({
|
||||
* field: 'email',
|
||||
* limit: 10,
|
||||
* windowMs: 60000,
|
||||
* });
|
||||
*/
|
||||
export const createBodyKeyedRateLimiter = <T extends object>({
|
||||
limit,
|
||||
windowMs,
|
||||
field,
|
||||
}: RateLimiterLimits & {
|
||||
field: keyof T & string;
|
||||
}): BodyKeyedRateLimiterConfig => ({
|
||||
source: 'body',
|
||||
limit,
|
||||
windowMs,
|
||||
field,
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a user keyed rate limiter configuration. This ends up creating
|
||||
* a rate limiter that is keyed by the authenticated user's ID.
|
||||
*
|
||||
* @example
|
||||
* createUserKeyedRateLimiter({
|
||||
* limit: 10,
|
||||
* windowMs: 60000,
|
||||
* });
|
||||
*/
|
||||
export const createUserKeyedRateLimiter = ({
|
||||
limit,
|
||||
windowMs,
|
||||
}: RateLimiterLimits): UserKeyedRateLimiterConfig => ({
|
||||
source: 'user',
|
||||
limit,
|
||||
windowMs,
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Container, Service } from '@n8n/di';
|
||||
|
||||
import { ControllerRegistryMetadata } from './controller-registry-metadata';
|
||||
import type { Controller } from './types';
|
||||
|
||||
export const RestController =
|
||||
(basePath: `/${string}` = '/'): ClassDecorator =>
|
||||
(target) => {
|
||||
const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(
|
||||
target as unknown as Controller,
|
||||
);
|
||||
metadata.basePath = basePath;
|
||||
metadata.registerOnRootPath = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Service()(target);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Container, Service } from '@n8n/di';
|
||||
|
||||
import { ControllerRegistryMetadata } from './controller-registry-metadata';
|
||||
import type { Controller } from './types';
|
||||
|
||||
/**
|
||||
* Defines a controller that should be registered on the root path, without any prefix
|
||||
* @param basePath defaults to `/`
|
||||
* @returns ClassDecorator
|
||||
*/
|
||||
export const RootLevelController =
|
||||
(basePath: `/${string}` = '/'): ClassDecorator =>
|
||||
(target) => {
|
||||
const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(
|
||||
target as unknown as Controller,
|
||||
);
|
||||
metadata.basePath = basePath;
|
||||
metadata.registerOnRootPath = true;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Service()(target);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
import { ControllerRegistryMetadata } from './controller-registry-metadata';
|
||||
import type { KeyedRateLimiterConfig, RateLimiterLimits } from './rate-limit';
|
||||
import type { Controller, CorsOptions, Method } from './types';
|
||||
|
||||
interface RouteOptions {
|
||||
middlewares?: RequestHandler[];
|
||||
usesTemplates?: boolean;
|
||||
/** When this flag is set to true, auth cookie isn't validated, and req.user will not be set */
|
||||
skipAuth?: boolean;
|
||||
allowSkipPreviewAuth?: boolean;
|
||||
/** When this flag is set to true, the endpoint can be accessed without authentication */
|
||||
allowUnauthenticated?: boolean;
|
||||
/** When this flag is set to true, the auth cookie does not enforce MFA to be used in the token */
|
||||
allowSkipMFA?: boolean;
|
||||
/** When these options are set, calls to this endpoint are rate limited based on IP address */
|
||||
ipRateLimit?: boolean | RateLimiterLimits;
|
||||
/** When these options are set, calls to this endpoint are rate limited based on a key extracted from the request */
|
||||
keyedRateLimit?: KeyedRateLimiterConfig;
|
||||
/** When this flag is set to true, the endpoint is protected by API key */
|
||||
apiKeyAuth?: boolean;
|
||||
/** CORS options for the route, this does not handle preflight requests */
|
||||
cors?: Partial<CorsOptions> | true;
|
||||
}
|
||||
|
||||
const RouteFactory =
|
||||
(method: Method) =>
|
||||
(path: `/${string}`, options: RouteOptions = {}): MethodDecorator =>
|
||||
(target, handlerName) => {
|
||||
const routeMetadata = Container.get(ControllerRegistryMetadata).getRouteMetadata(
|
||||
target.constructor as Controller,
|
||||
String(handlerName),
|
||||
);
|
||||
routeMetadata.method = method;
|
||||
routeMetadata.path = path;
|
||||
routeMetadata.middlewares = options.middlewares ?? [];
|
||||
routeMetadata.usesTemplates = options.usesTemplates ?? false;
|
||||
routeMetadata.skipAuth = options.skipAuth ?? false;
|
||||
routeMetadata.allowSkipPreviewAuth = options.allowSkipPreviewAuth ?? false;
|
||||
routeMetadata.allowSkipMFA = options.allowSkipMFA ?? false;
|
||||
routeMetadata.allowUnauthenticated = options.allowUnauthenticated ?? false;
|
||||
routeMetadata.apiKeyAuth = options.apiKeyAuth ?? false;
|
||||
routeMetadata.ipRateLimit = options.ipRateLimit;
|
||||
routeMetadata.keyedRateLimit = options.keyedRateLimit;
|
||||
routeMetadata.cors = options.cors;
|
||||
};
|
||||
|
||||
export const Get = RouteFactory('get');
|
||||
export const Post = RouteFactory('post');
|
||||
export const Put = RouteFactory('put');
|
||||
export const Patch = RouteFactory('patch');
|
||||
export const Delete = RouteFactory('delete');
|
||||
export const Head = RouteFactory('head');
|
||||
export const Options = RouteFactory('options');
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
|
||||
import { ControllerRegistryMetadata } from './controller-registry-metadata';
|
||||
import type { Controller } from './types';
|
||||
|
||||
const Scoped =
|
||||
(scope: Scope, { globalOnly } = { globalOnly: false }): MethodDecorator =>
|
||||
(target, handlerName) => {
|
||||
const routeMetadata = Container.get(ControllerRegistryMetadata).getRouteMetadata(
|
||||
target.constructor as Controller,
|
||||
String(handlerName),
|
||||
);
|
||||
routeMetadata.accessScope = { scope, globalOnly };
|
||||
};
|
||||
|
||||
/**
|
||||
* Decorator for a controller method to ensure the user has a scope,
|
||||
* checking only at the global level.
|
||||
*
|
||||
* To check only at project level as well, use the `@ProjectScope` decorator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* @RestController()
|
||||
* export class UsersController {
|
||||
* @Delete('/:id')
|
||||
* @GlobalScope('user:delete')
|
||||
* async deleteUser(req, res) { ... }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const GlobalScope = (scope: Scope) => Scoped(scope, { globalOnly: true });
|
||||
|
||||
/**
|
||||
* Decorator for a controller method to ensure the user has a scope,
|
||||
* checking first at project level and then at global level.
|
||||
*
|
||||
* To check only at global level, use the `@GlobalScope` decorator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* @RestController()
|
||||
* export class WorkflowController {
|
||||
* @Get('/:workflowId')
|
||||
* @GlobalScope('workflow:read')
|
||||
* async getWorkflow(req, res) { ... }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export const ProjectScope = (scope: Scope) => Scoped(scope);
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { BooleanLicenseFeature } from '@n8n/constants';
|
||||
import type { Constructable } from '@n8n/di';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import type { RequestHandler, Router } from 'express';
|
||||
|
||||
import type { KeyedRateLimiterConfig, RateLimiterLimits } from './rate-limit';
|
||||
|
||||
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options';
|
||||
|
||||
export type Arg = { type: 'body' | 'query' } | { type: 'param'; key: string };
|
||||
|
||||
export interface CorsOptions {
|
||||
allowedOrigins: string[];
|
||||
allowedMethods: Method[];
|
||||
allowedHeaders: string[];
|
||||
allowCredentials?: boolean;
|
||||
maxAge?: 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;
|
||||
allowSkipPreviewAuth: boolean;
|
||||
allowSkipMFA: boolean;
|
||||
allowUnauthenticated: boolean;
|
||||
apiKeyAuth: boolean;
|
||||
cors?: Partial<CorsOptions> | true;
|
||||
/** Whether to apply IP-based rate limiting to the route */
|
||||
ipRateLimit?: boolean | RateLimiterLimits;
|
||||
/** Whether to apply keyed rate limiting to the route */
|
||||
keyedRateLimit?: KeyedRateLimiterConfig;
|
||||
licenseFeature?: BooleanLicenseFeature;
|
||||
accessScope?: AccessScope;
|
||||
args: Arg[];
|
||||
router?: Router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for static routers mounted on a controller.
|
||||
* Picks relevant fields from RouteMetadata and makes router required.
|
||||
*/
|
||||
export type StaticRouterMetadata = {
|
||||
path: string;
|
||||
router: Router;
|
||||
} & Partial<
|
||||
Pick<
|
||||
RouteMetadata,
|
||||
| 'skipAuth'
|
||||
| 'allowSkipPreviewAuth'
|
||||
| 'allowSkipMFA'
|
||||
| 'middlewares'
|
||||
| 'ipRateLimit'
|
||||
| 'keyedRateLimit'
|
||||
| 'licenseFeature'
|
||||
| 'accessScope'
|
||||
>
|
||||
>;
|
||||
|
||||
export interface ControllerMetadata {
|
||||
basePath: `/${string}`;
|
||||
// If true, the controller will be registered on the root path without the any prefix
|
||||
registerOnRootPath?: boolean;
|
||||
middlewares: HandlerName[];
|
||||
routes: Map<HandlerName, RouteMetadata>;
|
||||
}
|
||||
|
||||
export type Controller = Constructable<object> &
|
||||
Record<HandlerName, (...args: unknown[]) => Promise<unknown>>;
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import type { ICredentialContext, ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
|
||||
import type { CredentialResolverConfiguration, ICredentialResolver } from '../credential-resolver';
|
||||
import {
|
||||
CredentialResolver,
|
||||
CredentialResolverEntryMetadata,
|
||||
} from '../credential-resolver-metadata';
|
||||
|
||||
describe('@CredentialResolver decorator', () => {
|
||||
let resolverMetadata: CredentialResolverEntryMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
resolverMetadata = new CredentialResolverEntryMetadata();
|
||||
Container.set(CredentialResolverEntryMetadata, resolverMetadata);
|
||||
});
|
||||
|
||||
it('should register resolver in CredentialResolverEntryMetadata', () => {
|
||||
@CredentialResolver()
|
||||
class TestResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'test.resolver',
|
||||
description: 'Test resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
const registeredResolvers = resolverMetadata.getClasses();
|
||||
|
||||
expect(registeredResolvers).toContain(TestResolver);
|
||||
expect(registeredResolvers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should register multiple resolvers', () => {
|
||||
@CredentialResolver()
|
||||
class FirstResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'first.resolver',
|
||||
description: 'First resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
@CredentialResolver()
|
||||
class SecondResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'second.resolver',
|
||||
description: 'Second resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
@CredentialResolver()
|
||||
class ThirdResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'third.resolver',
|
||||
description: 'Third resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
const registeredResolvers = resolverMetadata.getClasses();
|
||||
|
||||
expect(registeredResolvers).toContain(FirstResolver);
|
||||
expect(registeredResolvers).toContain(SecondResolver);
|
||||
expect(registeredResolvers).toContain(ThirdResolver);
|
||||
expect(registeredResolvers).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should apply Service decorator', () => {
|
||||
@CredentialResolver()
|
||||
class TestResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'test.resolver',
|
||||
description: 'Test resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
expect(Container.has(TestResolver)).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow instantiation of registered resolvers with accessible metadata', () => {
|
||||
@CredentialResolver()
|
||||
class TestResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'oauth.introspection',
|
||||
description: 'OAuth introspection resolver',
|
||||
displayName: 'OAuth Introspection',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
const resolverInstance = Container.get(TestResolver);
|
||||
|
||||
expect(resolverInstance).toBeInstanceOf(TestResolver);
|
||||
expect(resolverInstance.metadata).toEqual({
|
||||
name: 'oauth.introspection',
|
||||
description: 'OAuth introspection resolver',
|
||||
displayName: 'OAuth Introspection',
|
||||
});
|
||||
expect(resolverInstance.metadata.name).toBe('oauth.introspection');
|
||||
});
|
||||
|
||||
it('should register resolvers with different metadata', () => {
|
||||
@CredentialResolver()
|
||||
class OAuthResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'oauth.resolver',
|
||||
description: 'OAuth-based credential resolver',
|
||||
displayName: 'OAuth Resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
@CredentialResolver()
|
||||
class TestResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'test.resolver',
|
||||
description: 'Test resolver for testing',
|
||||
displayName: 'Test Resolver',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
const registeredResolvers = resolverMetadata.getClasses();
|
||||
const oauthResolver = Container.get(OAuthResolver);
|
||||
const testResolver = Container.get(TestResolver);
|
||||
|
||||
expect(registeredResolvers).toHaveLength(2);
|
||||
expect(oauthResolver.metadata.name).toBe('oauth.resolver');
|
||||
expect(oauthResolver.metadata.displayName).toBe('OAuth Resolver');
|
||||
expect(testResolver.metadata.name).toBe('test.resolver');
|
||||
expect(testResolver.metadata.displayName).toBe('Test Resolver');
|
||||
});
|
||||
|
||||
it('should support resolvers with configuration options', () => {
|
||||
@CredentialResolver()
|
||||
class ConfigurableResolver implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'configurable.resolver',
|
||||
description: 'Resolver with configuration options',
|
||||
options: [
|
||||
{
|
||||
displayName: 'API Endpoint',
|
||||
name: 'apiEndpoint',
|
||||
type: 'string' as const,
|
||||
default: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
const resolverInstance = Container.get(ConfigurableResolver);
|
||||
|
||||
expect(resolverInstance.metadata.options).toBeDefined();
|
||||
expect(resolverInstance.metadata.options).toHaveLength(1);
|
||||
expect(resolverInstance.metadata.options[0].name).toBe('apiEndpoint');
|
||||
});
|
||||
|
||||
it('should support optional deleteSecret method', () => {
|
||||
@CredentialResolver()
|
||||
class ResolverWithDelete implements ICredentialResolver {
|
||||
metadata = {
|
||||
name: 'resolver.with.delete',
|
||||
description: 'Resolver with delete support',
|
||||
};
|
||||
|
||||
async getSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<ICredentialDataDecryptedObject> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_data: ICredentialDataDecryptedObject,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async deleteSecret(
|
||||
_credentialId: string,
|
||||
_context: ICredentialContext,
|
||||
_options: CredentialResolverConfiguration,
|
||||
): Promise<void> {}
|
||||
|
||||
async validateOptions(_options: CredentialResolverConfiguration): Promise<void> {}
|
||||
}
|
||||
|
||||
const withDelete = Container.get(ResolverWithDelete);
|
||||
|
||||
expect(withDelete.deleteSecret).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Container, Service } from '@n8n/di';
|
||||
|
||||
import { CredentialResolverClass } from './credential-resolver';
|
||||
|
||||
type CredentialResolverEntry = {
|
||||
class: CredentialResolverClass;
|
||||
};
|
||||
|
||||
/**
|
||||
* Registry service for credential resolver type discovery and instantiation.
|
||||
* Resolver classes decorated with @CredentialResolver() are automatically registered.
|
||||
*/
|
||||
@Service()
|
||||
export class CredentialResolverEntryMetadata {
|
||||
private readonly credentialResolverEntries: Set<CredentialResolverEntry> = new Set();
|
||||
|
||||
/** Registers a credential resolver class. Called automatically by @CredentialResolver() decorator. */
|
||||
register(credentialResolverEntry: CredentialResolverEntry) {
|
||||
this.credentialResolverEntries.add(credentialResolverEntry);
|
||||
}
|
||||
|
||||
/** Returns all registered resolver entries as [index, entry] tuples. */
|
||||
getEntries() {
|
||||
return [...this.credentialResolverEntries.entries()];
|
||||
}
|
||||
|
||||
/** Returns all registered resolver classes. */
|
||||
getClasses() {
|
||||
return [...this.credentialResolverEntries.values()].map((entry) => entry.class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator to mark a class as a credential resolver.
|
||||
* Automatically registers the resolver for discovery and enables dependency injection.
|
||||
*
|
||||
* @example
|
||||
* @CredentialResolver()
|
||||
* class MyResolver implements ICredentialResolver { ... }
|
||||
*/
|
||||
export const CredentialResolver =
|
||||
<T extends CredentialResolverClass>() =>
|
||||
(target: T) => {
|
||||
// Register resolver class for discovery by registry
|
||||
Container.get(CredentialResolverEntryMetadata).register({
|
||||
class: target,
|
||||
});
|
||||
|
||||
// Enable dependency injection for the resolver class
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Service()(target);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Constructable } from '@n8n/di';
|
||||
import type {
|
||||
ICredentialContext,
|
||||
ICredentialDataDecryptedObject,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Configuration object passed to resolver methods. Structure is defined by resolver type's metadata.options.
|
||||
*/
|
||||
export type CredentialResolverConfiguration = Record<string, unknown>;
|
||||
|
||||
export type CredentialResolverHandle = {
|
||||
configuration: CredentialResolverConfiguration;
|
||||
resolverName: string;
|
||||
resolverId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata describing a credential resolver type for UI integration and discovery.
|
||||
*/
|
||||
export interface CredentialResolverMetadata {
|
||||
/** Unique identifier for the resolver type */
|
||||
name: string;
|
||||
|
||||
/** Human-readable description of what this resolver does */
|
||||
description: string;
|
||||
|
||||
/** Optional display name shown in UI. Falls back to name if not provided. */
|
||||
displayName?: string;
|
||||
|
||||
/** Configuration schema using n8n's INodeProperties format for dynamic form rendering */
|
||||
options?: INodeProperties[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Core interface for credential resolver implementations.
|
||||
* Resolvers fetch credential data dynamically based on execution context and configuration.
|
||||
*/
|
||||
export interface ICredentialResolver {
|
||||
/** Metadata for UI integration and resolver discovery */
|
||||
metadata: CredentialResolverMetadata;
|
||||
|
||||
/**
|
||||
* Retrieves credential data for a specific entity from the resolver's storage.
|
||||
* @throws {CredentialResolverDataNotFoundError} When no data exists for the given context
|
||||
* @throws {CredentialResolverError} For other resolver-specific errors
|
||||
*/
|
||||
getSecret(
|
||||
credentialId: string,
|
||||
context: ICredentialContext,
|
||||
handle: CredentialResolverHandle,
|
||||
): Promise<ICredentialDataDecryptedObject>;
|
||||
|
||||
/**
|
||||
* Stores credential data for a specific entity in the resolver's storage.
|
||||
* @throws {CredentialResolverError} When storage operation fails
|
||||
*/
|
||||
setSecret(
|
||||
credentialId: string,
|
||||
context: ICredentialContext,
|
||||
data: ICredentialDataDecryptedObject,
|
||||
handle: CredentialResolverHandle,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes credential data for a specific entity from the resolver's storage.
|
||||
* Optional - not all resolvers support deletion.
|
||||
* @throws {CredentialResolverError} When deletion operation fails
|
||||
*/
|
||||
deleteSecret?(
|
||||
credentialId: string,
|
||||
context: ICredentialContext,
|
||||
handle: CredentialResolverHandle,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes all credential data for the resolver.
|
||||
* Optional - not all resolvers support deletion.
|
||||
* @throws {CredentialResolverError} When deletion operation fails
|
||||
*/
|
||||
deleteAllSecrets?(handle: CredentialResolverHandle): Promise<void>;
|
||||
|
||||
/**
|
||||
* Validates resolver configuration before saving.
|
||||
* Should verify connectivity, authentication, and configuration structure.
|
||||
* @throws {CredentialResolverValidationError} When configuration is invalid
|
||||
*/
|
||||
validateOptions(options: CredentialResolverConfiguration): Promise<void>;
|
||||
|
||||
/**
|
||||
* Validates if the userIdentity provided has access to the resolver capable credential
|
||||
*
|
||||
* @param context - The identity of the entity to validate access for
|
||||
* @throws {CredentialResolverAccessValidationError} When access is invalid
|
||||
*/
|
||||
validateIdentity?(context: ICredentialContext, handle: CredentialResolverHandle): Promise<void>;
|
||||
|
||||
/**
|
||||
* Runs initialization logic for the resolver. This might be called multiple times!
|
||||
* Optional - not all resolvers require initialization.
|
||||
*/
|
||||
init?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type helper for credential resolver class constructors.
|
||||
*/
|
||||
export type CredentialResolverClass = Constructable<ICredentialResolver>;
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Base error class for all credential resolver errors.
|
||||
*/
|
||||
export class CredentialResolverError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CredentialResolverError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when no credential data exists for the requested credential and context combination.
|
||||
* Indicates the entity has not stored credentials for this credential type.
|
||||
*/
|
||||
export class CredentialResolverDataNotFoundError extends CredentialResolverError {
|
||||
constructor() {
|
||||
super('No data found available for the requested credential and context combination.');
|
||||
this.name = 'CredentialResolverDataNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when resolver configuration validation fails.
|
||||
* Indicates invalid configuration values or unreachable external services.
|
||||
*/
|
||||
export class CredentialResolverValidationError extends CredentialResolverError {
|
||||
constructor(message: string) {
|
||||
super(`Credential resolver options validation failed: ${message}`);
|
||||
this.name = 'CredentialResolverValidationError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Credential Resolver Module
|
||||
*
|
||||
* Provides interfaces and infrastructure for dynamic credential resolution based on execution context.
|
||||
* Resolvers fetch credential data at runtime from external storage based on entity identity.
|
||||
*/
|
||||
|
||||
export {
|
||||
CredentialResolverEntryMetadata,
|
||||
CredentialResolver,
|
||||
} from './credential-resolver-metadata';
|
||||
export * from './errors';
|
||||
export type * from './credential-resolver';
|
||||
@@ -0,0 +1,37 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
/**
|
||||
* Debounce a class method using `lodash/debounce`.
|
||||
*
|
||||
* @param waitMs - Number of milliseconds to debounce method by.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* class MyClass {
|
||||
* @Debounce(1000)
|
||||
* async myMethod() {
|
||||
* // debounced
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Debounce =
|
||||
(waitMs: number): MethodDecorator =>
|
||||
<T>(
|
||||
_: object,
|
||||
methodName: string | symbol,
|
||||
originalDescriptor: PropertyDescriptor,
|
||||
): TypedPropertyDescriptor<T> => ({
|
||||
configurable: true,
|
||||
|
||||
get() {
|
||||
const debouncedFn = debounce(originalDescriptor.value, waitMs);
|
||||
|
||||
Object.defineProperty(this, methodName, {
|
||||
configurable: false,
|
||||
value: debouncedFn,
|
||||
});
|
||||
|
||||
return debouncedFn as T;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
export class NonMethodError extends UnexpectedError {
|
||||
constructor(name: string) {
|
||||
super(`${name} must be a method on a class to use this decorator`);
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { Container, Service } from '@n8n/di';
|
||||
|
||||
import { NonMethodError } from '../../errors';
|
||||
import { LifecycleMetadata } from '../lifecycle-metadata';
|
||||
import { OnLifecycleEvent } from '../on-lifecycle-event';
|
||||
|
||||
describe('OnLifecycleEvent', () => {
|
||||
let lifecycleMetadata: LifecycleMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
lifecycleMetadata = new LifecycleMetadata();
|
||||
Container.set(LifecycleMetadata, lifecycleMetadata);
|
||||
vi.spyOn(lifecycleMetadata, 'register');
|
||||
});
|
||||
|
||||
it('should register a method decorated with OnLifecycleEvent', () => {
|
||||
@Service()
|
||||
class TestService {
|
||||
@OnLifecycleEvent('nodeExecuteBefore')
|
||||
async handleNodeExecuteBefore() {}
|
||||
}
|
||||
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledTimes(1);
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledWith({
|
||||
handlerClass: TestService,
|
||||
methodName: 'handleNodeExecuteBefore',
|
||||
eventName: 'nodeExecuteBefore',
|
||||
});
|
||||
});
|
||||
|
||||
it('should register methods for all lifecycle event types', () => {
|
||||
@Service()
|
||||
// @ts-expect-error Testing
|
||||
class TestService {
|
||||
@OnLifecycleEvent('nodeExecuteBefore')
|
||||
async handleNodeExecuteBefore() {}
|
||||
|
||||
@OnLifecycleEvent('nodeExecuteAfter')
|
||||
async handleNodeExecuteAfter() {}
|
||||
|
||||
@OnLifecycleEvent('workflowExecuteBefore')
|
||||
async handleWorkflowExecuteBefore() {}
|
||||
|
||||
@OnLifecycleEvent('workflowExecuteAfter')
|
||||
async handleWorkflowExecuteAfter() {}
|
||||
|
||||
@OnLifecycleEvent('workflowExecuteResume')
|
||||
async handleWorkflowExecuteResume() {}
|
||||
}
|
||||
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledTimes(5);
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventName: 'nodeExecuteBefore' }),
|
||||
);
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventName: 'nodeExecuteAfter' }),
|
||||
);
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventName: 'workflowExecuteBefore' }),
|
||||
);
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventName: 'workflowExecuteAfter' }),
|
||||
);
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventName: 'workflowExecuteResume' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should register multiple handlers in the same class', () => {
|
||||
@Service()
|
||||
class TestService {
|
||||
@OnLifecycleEvent('nodeExecuteBefore')
|
||||
async handleNodeExecuteBefore1() {}
|
||||
|
||||
@OnLifecycleEvent('nodeExecuteBefore')
|
||||
async handleNodeExecuteBefore2() {}
|
||||
}
|
||||
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledTimes(2);
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledWith({
|
||||
handlerClass: TestService,
|
||||
methodName: 'handleNodeExecuteBefore1',
|
||||
eventName: 'nodeExecuteBefore',
|
||||
});
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledWith({
|
||||
handlerClass: TestService,
|
||||
methodName: 'handleNodeExecuteBefore2',
|
||||
eventName: 'nodeExecuteBefore',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if the decorated target is not a method', () => {
|
||||
expect(() => {
|
||||
@Service()
|
||||
class TestService {
|
||||
// @ts-expect-error Testing invalid code
|
||||
@OnLifecycleEvent('nodeExecuteBefore')
|
||||
notAFunction = 'string';
|
||||
}
|
||||
|
||||
new TestService();
|
||||
}).toThrow(NonMethodError);
|
||||
});
|
||||
|
||||
it('should register handlers from multiple service classes', () => {
|
||||
@Service()
|
||||
class FirstService {
|
||||
@OnLifecycleEvent('nodeExecuteBefore')
|
||||
async handleNodeExecuteBefore() {}
|
||||
}
|
||||
|
||||
@Service()
|
||||
class SecondService {
|
||||
@OnLifecycleEvent('workflowExecuteAfter')
|
||||
async handleWorkflowExecuteAfter() {}
|
||||
}
|
||||
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledTimes(2);
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
handlerClass: FirstService,
|
||||
eventName: 'nodeExecuteBefore',
|
||||
}),
|
||||
);
|
||||
expect(lifecycleMetadata.register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
handlerClass: SecondService,
|
||||
eventName: 'workflowExecuteAfter',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export { OnLifecycleEvent } from './on-lifecycle-event';
|
||||
export type {
|
||||
LifecycleContext,
|
||||
NodeExecuteBeforeContext,
|
||||
NodeExecuteAfterContext,
|
||||
WorkflowExecuteBeforeContext,
|
||||
WorkflowExecuteAfterContext,
|
||||
WorkflowExecuteResumeContext,
|
||||
} from './lifecycle-metadata';
|
||||
export { LifecycleMetadata } from './lifecycle-metadata';
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Service } from '@n8n/di';
|
||||
import type {
|
||||
IDataObject,
|
||||
IRun,
|
||||
IRunExecutionData,
|
||||
ITaskData,
|
||||
ITaskStartedData,
|
||||
IWorkflowBase,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { Class } from '../types';
|
||||
|
||||
export type LifecycleHandlerClass = Class<
|
||||
Record<string, (ctx: LifecycleContext) => Promise<void> | void>
|
||||
>;
|
||||
|
||||
export type NodeExecuteBeforeContext = {
|
||||
type: 'nodeExecuteBefore';
|
||||
workflow: IWorkflowBase;
|
||||
nodeName: string;
|
||||
taskData: ITaskStartedData;
|
||||
};
|
||||
|
||||
export type NodeExecuteAfterContext = {
|
||||
type: 'nodeExecuteAfter';
|
||||
workflow: IWorkflowBase;
|
||||
nodeName: string;
|
||||
taskData: ITaskData;
|
||||
executionData: IRunExecutionData;
|
||||
};
|
||||
|
||||
export type WorkflowExecuteBeforeContext = {
|
||||
type: 'workflowExecuteBefore';
|
||||
workflow: IWorkflowBase;
|
||||
workflowInstance: Workflow;
|
||||
executionData?: IRunExecutionData;
|
||||
executionId: string;
|
||||
};
|
||||
|
||||
export type WorkflowExecuteAfterContext = {
|
||||
type: 'workflowExecuteAfter';
|
||||
workflow: IWorkflowBase;
|
||||
runData: IRun;
|
||||
newStaticData: IDataObject;
|
||||
executionId: string;
|
||||
};
|
||||
|
||||
export type WorkflowExecuteResumeContext = {
|
||||
type: 'workflowExecuteResume';
|
||||
workflow: IWorkflowBase;
|
||||
workflowInstance: Workflow;
|
||||
executionData: IRunExecutionData;
|
||||
executionId: string;
|
||||
};
|
||||
|
||||
/** Context arg passed to a lifecycle event handler method. */
|
||||
export type LifecycleContext =
|
||||
| NodeExecuteBeforeContext
|
||||
| NodeExecuteAfterContext
|
||||
| WorkflowExecuteBeforeContext
|
||||
| WorkflowExecuteAfterContext
|
||||
| WorkflowExecuteResumeContext;
|
||||
|
||||
type LifecycleHandler = {
|
||||
/** Class holding the method to call on a lifecycle event. */
|
||||
handlerClass: LifecycleHandlerClass;
|
||||
|
||||
/** Name of the method to call on a lifecycle event. */
|
||||
methodName: string;
|
||||
|
||||
/** Name of the lifecycle event to listen to. */
|
||||
eventName: LifecycleEvent;
|
||||
};
|
||||
|
||||
export type LifecycleEvent = LifecycleContext['type'];
|
||||
|
||||
@Service()
|
||||
export class LifecycleMetadata {
|
||||
private readonly handlers: LifecycleHandler[] = [];
|
||||
|
||||
register(handler: LifecycleHandler) {
|
||||
this.handlers.push(handler);
|
||||
}
|
||||
|
||||
getHandlers(): LifecycleHandler[] {
|
||||
return this.handlers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
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-lifecycle-hooks.ts` in `cli` and `core`.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* @Service()
|
||||
* class MyService {
|
||||
* @OnLifecycleEvent('workflowExecuteAfter')
|
||||
* async handleEvent(ctx: WorkflowExecuteAfterContext) {
|
||||
* // ...
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const OnLifecycleEvent =
|
||||
(eventName: LifecycleEvent): MethodDecorator =>
|
||||
(prototype, propertyKey, descriptor) => {
|
||||
const handlerClass = prototype.constructor as LifecycleHandlerClass;
|
||||
const methodName = String(propertyKey);
|
||||
|
||||
if (typeof descriptor?.value !== 'function') {
|
||||
throw new NonMethodError(`${handlerClass.name}.${methodName}()`);
|
||||
}
|
||||
|
||||
Container.get(LifecycleMetadata).register({
|
||||
handlerClass,
|
||||
methodName,
|
||||
eventName,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export * from './controller';
|
||||
export * from './command';
|
||||
export { Debounce } from './debounce';
|
||||
export * from './execution-lifecycle';
|
||||
export { Memoized } from './memoized';
|
||||
export * from './auth-handler';
|
||||
export * from './context-establishment';
|
||||
export * from './credential-resolver';
|
||||
export * from './module';
|
||||
export * from './multi-main';
|
||||
export * from './pubsub';
|
||||
export { Redactable } from './redactable';
|
||||
export * from './shutdown';
|
||||
export * from './module/module-metadata';
|
||||
export type { TimedOptions } from './timed';
|
||||
export { Timed } from './timed';
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert';
|
||||
|
||||
/**
|
||||
* A decorator that implements memoization for class property getters.
|
||||
*
|
||||
* The decorated getter will only be executed once and its value cached for subsequent access
|
||||
*
|
||||
* @example
|
||||
* class Example {
|
||||
* @Memoized
|
||||
* get computedValue() {
|
||||
* // This will only run once and the result will be cached
|
||||
* return heavyComputation();
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @throws If decorator is used on something other than a getter
|
||||
*/
|
||||
export function Memoized<T = unknown>(
|
||||
target: object,
|
||||
propertyKey: string | symbol,
|
||||
descriptor?: TypedPropertyDescriptor<T>,
|
||||
): TypedPropertyDescriptor<T> {
|
||||
const originalGetter = descriptor?.get;
|
||||
assert(originalGetter, '@Memoized can only be used on getters');
|
||||
|
||||
// Replace the original getter for the first call
|
||||
descriptor.get = function (this: typeof target.constructor): T {
|
||||
const value = originalGetter.call(this);
|
||||
// Add a property on the class instance to stop reading from the getter on class prototype
|
||||
Object.defineProperty(this, propertyKey, {
|
||||
value,
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
});
|
||||
return value;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import type { ModuleInterface } from '../module';
|
||||
import { BackendModule } from '../module';
|
||||
import { ModuleMetadata } from '../module-metadata';
|
||||
|
||||
describe('@BackendModule decorator', () => {
|
||||
let moduleMetadata: ModuleMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
moduleMetadata = new ModuleMetadata();
|
||||
Container.set(ModuleMetadata, moduleMetadata);
|
||||
});
|
||||
|
||||
it('should register module in ModuleMetadata', () => {
|
||||
@BackendModule({ name: 'test' })
|
||||
class TestModule implements ModuleInterface {}
|
||||
|
||||
const registeredModules = moduleMetadata.getClasses();
|
||||
|
||||
expect(registeredModules).toContain(TestModule);
|
||||
expect(registeredModules).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should register multiple modules', () => {
|
||||
@BackendModule({ name: 'test-1' })
|
||||
class FirstModule implements ModuleInterface {}
|
||||
|
||||
@BackendModule({ name: 'test-2' })
|
||||
class SecondModule implements ModuleInterface {}
|
||||
|
||||
@BackendModule({ name: 'test-3' })
|
||||
class ThirdModule implements ModuleInterface {}
|
||||
|
||||
const registeredModules = moduleMetadata.getClasses();
|
||||
|
||||
expect(registeredModules).toContain(FirstModule);
|
||||
expect(registeredModules).toContain(SecondModule);
|
||||
expect(registeredModules).toContain(ThirdModule);
|
||||
expect(registeredModules).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should apply Service decorator', () => {
|
||||
@BackendModule({ name: 'test' })
|
||||
class TestModule implements ModuleInterface {}
|
||||
|
||||
expect(Container.has(TestModule)).toBe(true);
|
||||
});
|
||||
|
||||
it('stores the test name and licenseFlag flag in the metadata', () => {
|
||||
const name = 'test';
|
||||
const licenseFlag = 'feat:ldap';
|
||||
|
||||
@BackendModule({ name, licenseFlag })
|
||||
class TestModule implements ModuleInterface {}
|
||||
|
||||
const registeredModules = moduleMetadata.getEntries();
|
||||
|
||||
expect(registeredModules).toHaveLength(1);
|
||||
const [moduleName, options] = registeredModules[0];
|
||||
expect(moduleName).toBe(name);
|
||||
expect(options.licenseFlag).toBe(licenseFlag);
|
||||
expect(options.class).toBe(TestModule);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { ModuleInterface, EntityClass, ModuleSettings, ModuleContext } from './module';
|
||||
export { BackendModule } from './module';
|
||||
export { ModuleMetadata } from './module-metadata';
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { InstanceType } from '@n8n/constants';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type { LicenseFlag, ModuleClass } from './module';
|
||||
|
||||
/**
|
||||
* Internal representation of a registered module.
|
||||
* For field descriptions, see {@link BackendModuleOptions}.
|
||||
*/
|
||||
type ModuleEntry = {
|
||||
class: ModuleClass;
|
||||
licenseFlag?: LicenseFlag | LicenseFlag[];
|
||||
instanceTypes?: InstanceType[];
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class ModuleMetadata {
|
||||
private readonly modules: Map<string, ModuleEntry> = new Map();
|
||||
|
||||
register(moduleName: string, moduleEntry: ModuleEntry) {
|
||||
this.modules.set(moduleName, moduleEntry);
|
||||
}
|
||||
|
||||
get(moduleName: string) {
|
||||
return this.modules.get(moduleName);
|
||||
}
|
||||
|
||||
getEntries() {
|
||||
return [...this.modules.entries()];
|
||||
}
|
||||
|
||||
getClasses() {
|
||||
return [...this.modules.values()].map((entry) => entry.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { LICENSE_FEATURES, InstanceType } from '@n8n/constants';
|
||||
import { Container, Service, type Constructable } from '@n8n/di';
|
||||
|
||||
import { ModuleMetadata } from './module-metadata';
|
||||
|
||||
/**
|
||||
* Structurally similar (not identical) interface to typeorm's `BaseEntity`
|
||||
* to prevent importing `@n8n/typeorm` into `@n8n/decorators`.
|
||||
*/
|
||||
export interface BaseEntity {
|
||||
hasId(): boolean;
|
||||
save(options?: unknown): Promise<this>;
|
||||
remove(options?: unknown): Promise<this>;
|
||||
softRemove(options?: unknown): Promise<this>;
|
||||
recover(options?: unknown): Promise<this>;
|
||||
reload(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TimestampedIdEntity {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface TimestampedEntity {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type EntityClass = new () => BaseEntity | TimestampedIdEntity | TimestampedEntity;
|
||||
|
||||
export type ModuleSettings = Record<string, unknown>;
|
||||
export type ModuleContext = Record<string, unknown>;
|
||||
|
||||
export interface ModuleInterface {
|
||||
init?(): Promise<void>;
|
||||
shutdown?(): Promise<void>;
|
||||
|
||||
commands?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Return a list of entities to register with the typeorm database connection.
|
||||
*
|
||||
* @example [ InsightsByPeriod, InsightsMetadata, InsightsRaw ]
|
||||
*/
|
||||
entities?(): Promise<EntityClass[]>;
|
||||
|
||||
/**
|
||||
* Return an object with settings to send to the client via `/module-settings`.
|
||||
*
|
||||
* @example { summary: true, dashboard: false }
|
||||
*/
|
||||
settings?(): Promise<ModuleSettings>;
|
||||
|
||||
/**
|
||||
* Return an object to merge into workflow context, a.k.a. `WorkflowExecuteAdditionalData`.
|
||||
* This object will be namespaced under the module name set by `@BackendModule('name')`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // at Module.context()
|
||||
* { proxy: Container.get(InsightsProxyService) }
|
||||
*
|
||||
* // at callsite
|
||||
* additionalData.insights.proxy.method()
|
||||
* ```
|
||||
*
|
||||
* For type safety, add the module context to `IWorkflowExecuteAdditionalData`.
|
||||
*
|
||||
* ```ts
|
||||
* export interface IWorkflowExecuteAdditionalData {
|
||||
* insights?: {
|
||||
* proxy: { method: () => void };
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
context?(): Promise<ModuleContext>;
|
||||
|
||||
/**
|
||||
* Return a path to a dir to load nodes and credentials from.
|
||||
*
|
||||
* @returns Path to a dir to load nodes and credentials from. `null` to skip.
|
||||
* @example '/Users/nathan/.n8n/nodes/node_modules'
|
||||
*/
|
||||
loadDir?(): Promise<string | null>;
|
||||
}
|
||||
|
||||
export type ModuleClass = Constructable<ModuleInterface>;
|
||||
|
||||
export type LicenseFlag = (typeof LICENSE_FEATURES)[keyof typeof LICENSE_FEATURES];
|
||||
|
||||
export type BackendModuleOptions = {
|
||||
/** Canonical name of the backend module. Use kebab-case.*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* If present, initialize the module only if the instance has access to a licensed feature.
|
||||
* Multiple license flags use `OR` logic, i.e. at least one must be licensed.
|
||||
*/
|
||||
licenseFlag?: LicenseFlag | LicenseFlag[];
|
||||
|
||||
/** If present, initialize the module only if the instance type is one of the specified types. */
|
||||
instanceTypes?: InstanceType[];
|
||||
};
|
||||
|
||||
export const BackendModule =
|
||||
(opts: BackendModuleOptions): ClassDecorator =>
|
||||
(target) => {
|
||||
Container.get(ModuleMetadata).register(opts.name, {
|
||||
class: target as unknown as ModuleClass,
|
||||
licenseFlag: opts?.licenseFlag,
|
||||
instanceTypes: opts?.instanceTypes,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Service()(target);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
import { NonMethodError } from '../../errors';
|
||||
import {
|
||||
MultiMainMetadata,
|
||||
LEADER_TAKEOVER_EVENT_NAME,
|
||||
LEADER_STEPDOWN_EVENT_NAME,
|
||||
} from '../multi-main-metadata';
|
||||
import { OnLeaderStepdown, OnLeaderTakeover } from '../on-multi-main-event';
|
||||
|
||||
class MockMultiMainSetup extends EventEmitter {
|
||||
registerEventHandlers() {
|
||||
const handlers = Container.get(MultiMainMetadata).getHandlers();
|
||||
|
||||
for (const { eventHandlerClass, methodName, eventName } of handlers) {
|
||||
const instance = Container.get(eventHandlerClass);
|
||||
this.on(eventName, async () => {
|
||||
return await instance[methodName].call(instance);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let multiMainSetup: MockMultiMainSetup;
|
||||
let metadata: MultiMainMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
Container.reset();
|
||||
|
||||
metadata = new MultiMainMetadata();
|
||||
Container.set(MultiMainMetadata, metadata);
|
||||
|
||||
multiMainSetup = new MockMultiMainSetup();
|
||||
});
|
||||
|
||||
it('should register methods decorated with @OnLeaderTakeover', () => {
|
||||
vi.spyOn(metadata, 'register');
|
||||
|
||||
@Service()
|
||||
class TestService {
|
||||
@OnLeaderTakeover()
|
||||
async handleLeaderTakeover() {}
|
||||
}
|
||||
|
||||
expect(metadata.register).toHaveBeenCalledWith({
|
||||
eventName: LEADER_TAKEOVER_EVENT_NAME,
|
||||
methodName: 'handleLeaderTakeover',
|
||||
eventHandlerClass: TestService,
|
||||
});
|
||||
});
|
||||
|
||||
it('should register methods decorated with @OnLeaderStepdown', () => {
|
||||
vi.spyOn(metadata, 'register');
|
||||
|
||||
@Service()
|
||||
class TestService {
|
||||
@OnLeaderStepdown()
|
||||
async handleLeaderStepdown() {}
|
||||
}
|
||||
|
||||
expect(metadata.register).toHaveBeenCalledTimes(1);
|
||||
expect(metadata.register).toHaveBeenCalledWith({
|
||||
eventName: LEADER_STEPDOWN_EVENT_NAME,
|
||||
methodName: 'handleLeaderStepdown',
|
||||
eventHandlerClass: TestService,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if the decorated target is not a method', () => {
|
||||
expect(() => {
|
||||
@Service()
|
||||
class TestService {
|
||||
// @ts-expect-error Testing invalid code
|
||||
@OnLeaderTakeover()
|
||||
notAFunction = 'string';
|
||||
}
|
||||
|
||||
new TestService();
|
||||
}).toThrowError(NonMethodError);
|
||||
});
|
||||
|
||||
it('should call decorated methods when events are emitted', async () => {
|
||||
@Service()
|
||||
class TestService {
|
||||
takeoverCalled = false;
|
||||
|
||||
stepdownCalled = false;
|
||||
|
||||
@OnLeaderTakeover()
|
||||
async handleLeaderTakeover() {
|
||||
this.takeoverCalled = true;
|
||||
}
|
||||
|
||||
@OnLeaderStepdown()
|
||||
async handleLeaderStepdown() {
|
||||
this.stepdownCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
const testService = Container.get(TestService);
|
||||
vi.spyOn(testService, 'handleLeaderTakeover');
|
||||
vi.spyOn(testService, 'handleLeaderStepdown');
|
||||
|
||||
multiMainSetup.registerEventHandlers();
|
||||
|
||||
multiMainSetup.emit(LEADER_TAKEOVER_EVENT_NAME);
|
||||
multiMainSetup.emit(LEADER_STEPDOWN_EVENT_NAME);
|
||||
|
||||
expect(testService.handleLeaderTakeover).toHaveBeenCalledTimes(1);
|
||||
expect(testService.handleLeaderStepdown).toHaveBeenCalledTimes(1);
|
||||
expect(testService.takeoverCalled).toBe(true);
|
||||
expect(testService.stepdownCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('should register multiple handlers for the same event', async () => {
|
||||
@Service()
|
||||
class TestService {
|
||||
firstHandlerCalled = false;
|
||||
|
||||
secondHandlerCalled = false;
|
||||
|
||||
@OnLeaderTakeover()
|
||||
async firstHandler() {
|
||||
this.firstHandlerCalled = true;
|
||||
}
|
||||
|
||||
@OnLeaderTakeover()
|
||||
async secondHandler() {
|
||||
this.secondHandlerCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
const testService = Container.get(TestService);
|
||||
|
||||
multiMainSetup.registerEventHandlers();
|
||||
|
||||
multiMainSetup.emit(LEADER_TAKEOVER_EVENT_NAME);
|
||||
|
||||
expect(testService.firstHandlerCalled).toBe(true);
|
||||
expect(testService.secondHandlerCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('should register handlers from multiple service classes', async () => {
|
||||
@Service()
|
||||
class FirstService {
|
||||
handlerCalled = false;
|
||||
|
||||
@OnLeaderTakeover()
|
||||
async handleTakeover() {
|
||||
this.handlerCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Service()
|
||||
class SecondService {
|
||||
handlerCalled = false;
|
||||
|
||||
@OnLeaderTakeover()
|
||||
async handleTakeover() {
|
||||
this.handlerCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
const firstService = Container.get(FirstService);
|
||||
const secondService = Container.get(SecondService);
|
||||
|
||||
multiMainSetup.registerEventHandlers();
|
||||
|
||||
multiMainSetup.emit(LEADER_TAKEOVER_EVENT_NAME);
|
||||
|
||||
expect(firstService.handlerCalled).toBe(true);
|
||||
expect(secondService.handlerCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle async methods correctly', async () => {
|
||||
@Service()
|
||||
class TestService {
|
||||
result = '';
|
||||
|
||||
@OnLeaderTakeover()
|
||||
async handleLeaderTakeover() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
this.result = 'completed';
|
||||
}
|
||||
}
|
||||
|
||||
const testService = Container.get(TestService);
|
||||
|
||||
multiMainSetup.registerEventHandlers();
|
||||
multiMainSetup.emit(LEADER_TAKEOVER_EVENT_NAME);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(testService.result).toBe('completed');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
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 =>
|
||||
(prototype, propertyKey, descriptor) => {
|
||||
const eventHandlerClass = prototype.constructor as EventHandlerClass;
|
||||
const methodName = String(propertyKey);
|
||||
|
||||
if (typeof descriptor?.value !== 'function') {
|
||||
throw new NonMethodError(`${eventHandlerClass.name}.${methodName}()`);
|
||||
}
|
||||
|
||||
Container.get(MultiMainMetadata).register({
|
||||
eventHandlerClass,
|
||||
methodName,
|
||||
eventName,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Decorator that registers a method to be called when this main instance becomes the leader.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* @Service()
|
||||
* class MyService {
|
||||
* @OnLeaderTakeover()
|
||||
* async startDoingThings() {
|
||||
* // ...
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const OnLeaderTakeover = () => OnMultiMainEvent(LEADER_TAKEOVER_EVENT_NAME);
|
||||
|
||||
/**
|
||||
* Decorator that registers a method to be called when this main instance stops being the leader.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* @Service()
|
||||
* class MyService {
|
||||
* @OnLeaderStepdown()
|
||||
* async stopDoingThings() {
|
||||
* // ...
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const OnLeaderStepdown = () => OnMultiMainEvent(LEADER_STEPDOWN_EVENT_NAME);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Container, Service } from '@n8n/di';
|
||||
|
||||
import { NonMethodError } from '../../errors';
|
||||
import { OnPubSubEvent } from '../on-pubsub-event';
|
||||
import { PubSubMetadata } from '../pubsub-metadata';
|
||||
|
||||
describe('@OnPubSubEvent', () => {
|
||||
let metadata: PubSubMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
Container.reset();
|
||||
|
||||
metadata = new PubSubMetadata();
|
||||
Container.set(PubSubMetadata, metadata);
|
||||
});
|
||||
|
||||
it('should register methods decorated with @OnPubSubEvent', () => {
|
||||
vi.spyOn(metadata, 'register');
|
||||
|
||||
@Service()
|
||||
class TestService {
|
||||
@OnPubSubEvent('reload-external-secrets-providers')
|
||||
async reloadProviders() {}
|
||||
|
||||
@OnPubSubEvent('restart-event-bus', { instanceType: 'worker' })
|
||||
async restartEventBus() {}
|
||||
}
|
||||
|
||||
expect(metadata.register).toHaveBeenNthCalledWith(1, {
|
||||
eventName: 'reload-external-secrets-providers',
|
||||
methodName: 'reloadProviders',
|
||||
eventHandlerClass: TestService,
|
||||
});
|
||||
|
||||
expect(metadata.register).toHaveBeenNthCalledWith(2, {
|
||||
eventName: 'restart-event-bus',
|
||||
methodName: 'restartEventBus',
|
||||
eventHandlerClass: TestService,
|
||||
filter: { instanceType: 'worker' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if the decorated target is not a method', () => {
|
||||
expect(() => {
|
||||
@Service()
|
||||
class TestService {
|
||||
// @ts-expect-error Testing invalid code
|
||||
@OnPubSubEvent('reload-external-secrets-providers')
|
||||
notAFunction = 'string';
|
||||
}
|
||||
|
||||
new TestService();
|
||||
}).toThrowError(NonMethodError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { PubSubEventName } from './pubsub-metadata';
|
||||
export { PubSubMetadata } from './pubsub-metadata';
|
||||
export { OnPubSubEvent } from './on-pubsub-event';
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { PubSubMetadata } from './pubsub-metadata';
|
||||
import type { PubSubEventName, PubSubEventFilter } from './pubsub-metadata';
|
||||
import { NonMethodError } from '../errors';
|
||||
import type { EventHandlerClass } from '../types';
|
||||
|
||||
/**
|
||||
* Decorator that registers a method to be called when a specific PubSub event occurs.
|
||||
* Optionally filters event handling based on instance type and role.
|
||||
*
|
||||
* @param eventName - The PubSub event to listen for
|
||||
* @param filter - Optional filter to limit event handling to specific instance types or roles
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* @Service()
|
||||
* class MyService {
|
||||
* @OnPubSubEvent('community-package-install', { instanceType: 'main', instanceRole: 'leader' })
|
||||
* async handlePackageInstall() {
|
||||
* // Handle community package installation
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const OnPubSubEvent =
|
||||
(eventName: PubSubEventName, filter?: PubSubEventFilter): MethodDecorator =>
|
||||
(prototype, propertyKey, descriptor) => {
|
||||
const eventHandlerClass = prototype.constructor as EventHandlerClass;
|
||||
const methodName = String(propertyKey);
|
||||
|
||||
if (typeof descriptor?.value !== 'function') {
|
||||
throw new NonMethodError(`${eventHandlerClass.name}.${methodName}()`);
|
||||
}
|
||||
|
||||
Container.get(PubSubMetadata).register({
|
||||
eventHandlerClass,
|
||||
methodName,
|
||||
eventName,
|
||||
filter,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { InstanceRole, InstanceType } from '@n8n/constants';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type { EventHandler } from '../types';
|
||||
|
||||
export type PubSubEventName =
|
||||
| 'add-webhooks-triggers-and-pollers'
|
||||
| 'remove-triggers-and-pollers'
|
||||
| 'clear-test-webhooks'
|
||||
| 'display-workflow-activation'
|
||||
| 'display-workflow-deactivation'
|
||||
| 'display-workflow-activation-error'
|
||||
| 'community-package-install'
|
||||
| 'community-package-uninstall'
|
||||
| 'community-package-update'
|
||||
| 'get-worker-status'
|
||||
| 'reload-external-secrets-providers'
|
||||
| 'reload-license'
|
||||
| 'reload-oidc-config'
|
||||
| 'reload-saml-config'
|
||||
| 'reload-overwrite-credentials'
|
||||
| 'response-to-get-worker-status'
|
||||
| 'restart-event-bus'
|
||||
| 'relay-execution-lifecycle-event'
|
||||
| 'relay-chat-stream-event'
|
||||
| 'relay-chat-human-message'
|
||||
| 'relay-chat-message-edit'
|
||||
| 'reload-sso-provisioning-configuration'
|
||||
| 'reload-source-control-config'
|
||||
| 'cancel-test-run';
|
||||
|
||||
export type PubSubEventFilter =
|
||||
| {
|
||||
instanceType: 'main';
|
||||
instanceRole?: Omit<InstanceRole, 'unset'>;
|
||||
}
|
||||
| {
|
||||
instanceType: Omit<InstanceType, 'main'>;
|
||||
instanceRole?: never;
|
||||
};
|
||||
|
||||
type PubSubEventHandler = EventHandler<PubSubEventName> & { filter?: PubSubEventFilter };
|
||||
|
||||
@Service()
|
||||
export class PubSubMetadata {
|
||||
private readonly handlers: PubSubEventHandler[] = [];
|
||||
|
||||
register(handler: PubSubEventHandler) {
|
||||
this.handlers.push(handler);
|
||||
}
|
||||
|
||||
getHandlers(): PubSubEventHandler[] {
|
||||
return this.handlers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
type UserLike = {
|
||||
id: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role?: {
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class RedactableError extends UnexpectedError {
|
||||
constructor(fieldName: string, args: string) {
|
||||
super(
|
||||
`Failed to find "${fieldName}" property in argument "${args.toString()}". Please set the decorator \`@Redactable()\` only on \`LogStreamingEventRelay\` methods where the argument contains a "${fieldName}" property.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toRedactable(userLike: UserLike) {
|
||||
return {
|
||||
userId: userLike.id,
|
||||
_email: userLike.email,
|
||||
_firstName: userLike.firstName,
|
||||
_lastName: userLike.lastName,
|
||||
globalRole: userLike.role?.slug,
|
||||
};
|
||||
}
|
||||
|
||||
type FieldName = 'user' | 'inviter' | 'invitee';
|
||||
|
||||
/**
|
||||
* Mark redactable properties in a `{ user: UserLike }` field in an `LogStreamingEventRelay`
|
||||
* method arg. These properties will be later redacted by the log streaming
|
||||
* destination based on user prefs. Only for `n8n.audit.*` logs.
|
||||
*
|
||||
* Also transform `id` to `userId` and `role` to `globalRole`.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* { id: '123'; email: 'test@example.com', role: 'some-role' } ->
|
||||
* { userId: '123'; _email: 'test@example.com', globalRole: 'some-role' }
|
||||
*/
|
||||
export const Redactable =
|
||||
(fieldName: FieldName = 'user'): MethodDecorator =>
|
||||
(_target, _propertyName, propertyDescriptor: PropertyDescriptor) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-types
|
||||
const originalMethod = propertyDescriptor.value as Function;
|
||||
|
||||
type MethodArgs = Array<{ [fieldName: string]: UserLike }>;
|
||||
|
||||
propertyDescriptor.value = function (...args: MethodArgs) {
|
||||
const index = args.findIndex((arg) => arg[fieldName] !== undefined);
|
||||
|
||||
if (index === -1) throw new RedactableError(fieldName, args.toString());
|
||||
|
||||
const userLike = args[index]?.[fieldName];
|
||||
|
||||
// @ts-expect-error Transformation
|
||||
if (userLike) args[index][fieldName] = toRedactable(userLike);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return originalMethod.apply(this, args);
|
||||
};
|
||||
|
||||
return propertyDescriptor;
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Container, Service } from '@n8n/di';
|
||||
|
||||
import { OnShutdown } from '../on-shutdown';
|
||||
import { ShutdownMetadata } from '../shutdown-metadata';
|
||||
|
||||
describe('OnShutdown', () => {
|
||||
let shutdownMetadata: ShutdownMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
shutdownMetadata = new ShutdownMetadata();
|
||||
Container.set(ShutdownMetadata, shutdownMetadata);
|
||||
vi.spyOn(shutdownMetadata, 'register');
|
||||
});
|
||||
|
||||
it('should register a methods that is decorated with OnShutdown', () => {
|
||||
@Service()
|
||||
class TestClass {
|
||||
@OnShutdown()
|
||||
async onShutdown() {}
|
||||
}
|
||||
|
||||
expect(shutdownMetadata.register).toHaveBeenCalledTimes(1);
|
||||
expect(shutdownMetadata.register).toHaveBeenCalledWith(100, {
|
||||
methodName: 'onShutdown',
|
||||
serviceClass: TestClass,
|
||||
});
|
||||
});
|
||||
|
||||
it('should register multiple methods in the same class', () => {
|
||||
@Service()
|
||||
class TestClass {
|
||||
@OnShutdown()
|
||||
async one() {}
|
||||
|
||||
@OnShutdown()
|
||||
async two() {}
|
||||
}
|
||||
|
||||
expect(shutdownMetadata.register).toHaveBeenCalledTimes(2);
|
||||
expect(shutdownMetadata.register).toHaveBeenCalledWith(100, {
|
||||
methodName: 'one',
|
||||
serviceClass: TestClass,
|
||||
});
|
||||
expect(shutdownMetadata.register).toHaveBeenCalledWith(100, {
|
||||
methodName: 'two',
|
||||
serviceClass: TestClass,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the given priority', () => {
|
||||
// @ts-expect-error We are checking the decorator.
|
||||
class TestClass {
|
||||
@OnShutdown(10)
|
||||
async onShutdown() {
|
||||
// Will be called when the app is shutting down
|
||||
}
|
||||
}
|
||||
|
||||
expect(shutdownMetadata.register).toHaveBeenCalledTimes(1);
|
||||
// @ts-expect-error We are checking internal parts of the shutdown service
|
||||
expect(shutdownMetadata.handlersByPriority[10].length).toEqual(1);
|
||||
});
|
||||
|
||||
it('should throw an error if the decorated member is not a function', () => {
|
||||
expect(() => {
|
||||
@Service()
|
||||
class TestClass {
|
||||
// @ts-expect-error Testing invalid code
|
||||
@OnShutdown()
|
||||
onShutdown = 'not a function';
|
||||
}
|
||||
|
||||
new TestClass();
|
||||
}).toThrow('TestClass.onShutdown() must be a method on TestClass to use "OnShutdown"');
|
||||
});
|
||||
|
||||
it('should throw if the priority is invalid', () => {
|
||||
expect(() => {
|
||||
@Service()
|
||||
class TestClass {
|
||||
@OnShutdown(201)
|
||||
async onShutdown() {}
|
||||
}
|
||||
|
||||
new TestClass();
|
||||
}).toThrow('Invalid shutdown priority. Please set it between 0 and 200.');
|
||||
|
||||
expect(() => {
|
||||
@Service()
|
||||
class TestClass {
|
||||
@OnShutdown(-1)
|
||||
async onShutdown() {}
|
||||
}
|
||||
|
||||
new TestClass();
|
||||
}).toThrow('Invalid shutdown priority. Please set it between 0 and 200.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export const LOWEST_SHUTDOWN_PRIORITY = 0;
|
||||
export const DEFAULT_SHUTDOWN_PRIORITY = 100;
|
||||
export const HIGHEST_SHUTDOWN_PRIORITY = 200;
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
HIGHEST_SHUTDOWN_PRIORITY,
|
||||
DEFAULT_SHUTDOWN_PRIORITY,
|
||||
LOWEST_SHUTDOWN_PRIORITY,
|
||||
} from './constants';
|
||||
export { ShutdownMetadata } from './shutdown-metadata';
|
||||
export { OnShutdown } from './on-shutdown';
|
||||
export type { ShutdownHandler, ShutdownServiceClass } from './types';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { DEFAULT_SHUTDOWN_PRIORITY } from './constants';
|
||||
import { ShutdownMetadata } from './shutdown-metadata';
|
||||
import type { ShutdownServiceClass } from './types';
|
||||
|
||||
/**
|
||||
* Decorator that registers a method as a shutdown hook. The method will
|
||||
* be called when the application is shutting down.
|
||||
*
|
||||
* Priority is used to determine the order in which the hooks are called.
|
||||
*
|
||||
* NOTE: Requires also @Service() decorator to be used on the class.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* @Service()
|
||||
* class MyClass {
|
||||
* @OnShutdown()
|
||||
* async shutdown() {
|
||||
* // Will be called when the app is shutting down
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const OnShutdown =
|
||||
(priority = DEFAULT_SHUTDOWN_PRIORITY): MethodDecorator =>
|
||||
(prototype, propertyKey, descriptor) => {
|
||||
const serviceClass = prototype.constructor as ShutdownServiceClass;
|
||||
const methodName = String(propertyKey);
|
||||
// TODO: assert that serviceClass is decorated with @Service
|
||||
if (typeof descriptor?.value === 'function') {
|
||||
Container.get(ShutdownMetadata).register(priority, { serviceClass, methodName });
|
||||
} else {
|
||||
const name = `${serviceClass.name}.${methodName}()`;
|
||||
throw new UnexpectedError(
|
||||
`${name} must be a method on ${serviceClass.name} to use "OnShutdown"`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Service } from '@n8n/di';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { HIGHEST_SHUTDOWN_PRIORITY, LOWEST_SHUTDOWN_PRIORITY } from './constants';
|
||||
import type { ShutdownHandler } from './types';
|
||||
|
||||
@Service()
|
||||
export class ShutdownMetadata {
|
||||
private handlersByPriority: ShutdownHandler[][] = [];
|
||||
|
||||
register(priority: number, handler: ShutdownHandler) {
|
||||
if (priority < LOWEST_SHUTDOWN_PRIORITY || priority > HIGHEST_SHUTDOWN_PRIORITY) {
|
||||
throw new UserError(
|
||||
`Invalid shutdown priority. Please set it between ${LOWEST_SHUTDOWN_PRIORITY} and ${HIGHEST_SHUTDOWN_PRIORITY}.`,
|
||||
{ extra: { priority } },
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.handlersByPriority[priority]) this.handlersByPriority[priority] = [];
|
||||
|
||||
this.handlersByPriority[priority].push(handler);
|
||||
}
|
||||
|
||||
getHandlersByPriority(): ShutdownHandler[][] {
|
||||
return this.handlersByPriority;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.handlersByPriority = [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface TimedOptions {
|
||||
/** Duration (in ms) above which to log a warning. Defaults to `100`. */
|
||||
threshold?: number;
|
||||
/** Whether to include method parameters in the log. Defaults to `false`. */
|
||||
logArgs?: boolean;
|
||||
}
|
||||
|
||||
interface Logger {
|
||||
warn(message: string, meta?: object): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory to create decorators to warn when method calls exceed a duration threshold.
|
||||
*/
|
||||
export const Timed =
|
||||
(logger: Logger, msg = 'Slow method call') =>
|
||||
(options: TimedOptions = {}): MethodDecorator =>
|
||||
(_target, propertyKey, descriptor: PropertyDescriptor) => {
|
||||
const originalMethod = descriptor.value as (...args: unknown[]) => unknown;
|
||||
const thresholdMs = options.threshold ?? 100;
|
||||
const logArgs = options.logArgs ?? false;
|
||||
|
||||
descriptor.value = async function (...args: unknown[]) {
|
||||
const methodName = `${this.constructor.name}.${String(propertyKey)}`;
|
||||
const start = performance.now();
|
||||
const result = await originalMethod.apply(this, args);
|
||||
const durationMs = performance.now() - start;
|
||||
|
||||
if (durationMs > thresholdMs) {
|
||||
logger.warn(msg, {
|
||||
method: methodName,
|
||||
durationMs: Math.round(durationMs),
|
||||
thresholdMs,
|
||||
params: logArgs ? args : '[hidden]',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export type Class<T = object, A extends unknown[] = unknown[]> = new (...args: A) => T;
|
||||
|
||||
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;
|
||||
|
||||
/** Name of the method to call on an event. */
|
||||
methodName: string;
|
||||
|
||||
/** Name of the event to listen to. */
|
||||
eventName: T;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/__tests__/**"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@n8n/typescript-config/tsconfig.common.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"types": ["node", "vitest/globals"],
|
||||
"baseUrl": "src",
|
||||
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../../workflow/tsconfig.build.esm.json" },
|
||||
{ "path": "../constants/tsconfig.build.json" },
|
||||
{ "path": "../di/tsconfig.build.json" },
|
||||
{ "path": "../permissions/tsconfig.build.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createVitestConfigWithDecorators } from '@n8n/vitest-config/node-decorators';
|
||||
|
||||
export default createVitestConfigWithDecorators({
|
||||
coveragePathIgnorePatterns: ['index.ts'],
|
||||
});
|
||||
Reference in New Issue
Block a user