feat(core): Add RootLevelController decorator (no-changelog) (#19972)

This commit is contained in:
Milorad FIlipović 2025-09-26 15:14:27 +02:00 committed by GitHub
parent 249d8f6ee6
commit 9fef8c19c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 134 additions and 5 deletions

View File

@ -26,6 +26,7 @@ describe('ControllerRegistryMetadata', () => {
expect(metadata).toEqual({
basePath: '/',
registerOnRootPath: false,
middlewares: [],
routes: expect.any(Map),
});

View File

@ -9,6 +9,7 @@ describe('@RestController Decorator', () => {
beforeEach(() => {
jest.resetAllMocks();
Container.reset();
controllerRegistryMetadata = new ControllerRegistryMetadata();
Container.set(ControllerRegistryMetadata, controllerRegistryMetadata);
@ -20,6 +21,7 @@ describe('@RestController Decorator', () => {
const metadata = controllerRegistryMetadata.getControllerMetadata(TestController as Controller);
expect(metadata.basePath).toBe('/');
expect(metadata.registerOnRootPath).toBe(false);
expect(Container.has(TestController)).toBe(true);
});
@ -29,6 +31,7 @@ describe('@RestController Decorator', () => {
const metadata = controllerRegistryMetadata.getControllerMetadata(TestController as Controller);
expect(metadata.basePath).toBe('/test');
expect(metadata.registerOnRootPath).toBe(false);
expect(Container.has(TestController)).toBe(true);
});

View File

@ -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(() => {
jest.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');
});
});

View File

@ -11,6 +11,7 @@ export class ControllerRegistryMetadata {
if (!metadata) {
metadata = {
basePath: '/',
registerOnRootPath: false,
middlewares: [],
routes: new Map(),
};

View File

@ -1,5 +1,6 @@
export { Body, Query, Param } from './args';
export { RestController } from './rest-controller';
export { RootLevelController } from './root-level-controller';
export { Get, Post, Put, Patch, Delete } from './route';
export { Middleware } from './middleware';
export { ControllerRegistryMetadata } from './controller-registry-metadata';

View File

@ -10,6 +10,7 @@ export const RestController =
target as unknown as Controller,
);
metadata.basePath = basePath;
metadata.registerOnRootPath = false;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service()(target);
};

View File

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

View File

@ -43,6 +43,8 @@ export interface RouteMetadata {
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>;
}

View File

@ -6,7 +6,14 @@ jest.mock('@n8n/backend-common', () => {
});
import type { GlobalConfig } from '@n8n/config';
import { ControllerRegistryMetadata, Param, Get, Licensed, RestController } from '@n8n/decorators';
import {
ControllerRegistryMetadata,
Param,
Get,
Licensed,
RestController,
RootLevelController,
} from '@n8n/decorators';
import { Container } from '@n8n/di';
import express from 'express';
import { mock } from 'jest-mock-extended';
@ -155,4 +162,40 @@ describe('ControllerRegistry', () => {
expect(body.data).toEqual({ url: '/args/1234', id: '1234' });
});
});
describe('Root-level controllers', () => {
@RootLevelController('/public')
// @ts-expect-error tsc complains about unused class
class PublicController {
@Get('/info')
info() {
return { ok: true };
}
}
@RootLevelController()
// @ts-expect-error tsc complains about unused class
class RootController {
@Get('/ping')
ping() {
return { ok: true };
}
}
beforeEach(() => {
authMiddleware.mockImplementation(async (_req, _res, next) => next());
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
});
it('should mount controller without rest prefix', async () => {
const { body } = await agent.get('/public/info').expect(200);
expect(body.data).toEqual({ ok: true });
await agent.get('/rest/public/info').expect(404);
});
it('should mount default controller at root path', async () => {
const { body } = await agent.get('/ping').expect(200);
expect(body.data).toEqual({ ok: true });
});
});
});

View File

@ -41,10 +41,11 @@ export class ControllerRegistry {
const metadata = this.metadata.getControllerMetadata(controllerClass);
const router = Router({ mergeParams: true });
const prefix = `/${this.globalConfig.endpoints.rest}/${metadata.basePath}`
.replace(/\/+/g, '/')
.replace(/\/$/, '');
app.use(prefix, router);
const basePath = metadata.registerOnRootPath
? metadata.basePath
: `/${this.globalConfig.endpoints.rest}/${metadata.basePath}`;
const prefix = basePath.replace(/\/+/g, '/').replace(/\/$/, '');
app.use(prefix === '' ? '/' : prefix, router);
const controller = Container.get(controllerClass) as Controller;
const controllerMiddlewares = metadata.middlewares.map(