diff --git a/packages/@n8n/decorators/src/controller/__tests__/controller-registry-metadata.test.ts b/packages/@n8n/decorators/src/controller/__tests__/controller-registry-metadata.test.ts index d3904c0b9e2..a5012d97b9f 100644 --- a/packages/@n8n/decorators/src/controller/__tests__/controller-registry-metadata.test.ts +++ b/packages/@n8n/decorators/src/controller/__tests__/controller-registry-metadata.test.ts @@ -26,6 +26,7 @@ describe('ControllerRegistryMetadata', () => { expect(metadata).toEqual({ basePath: '/', + registerOnRootPath: false, middlewares: [], routes: expect.any(Map), }); diff --git a/packages/@n8n/decorators/src/controller/__tests__/rest-controller.test.ts b/packages/@n8n/decorators/src/controller/__tests__/rest-controller.test.ts index e8435a4a471..2339167210d 100644 --- a/packages/@n8n/decorators/src/controller/__tests__/rest-controller.test.ts +++ b/packages/@n8n/decorators/src/controller/__tests__/rest-controller.test.ts @@ -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); }); diff --git a/packages/@n8n/decorators/src/controller/__tests__/root-level-controller.test.ts b/packages/@n8n/decorators/src/controller/__tests__/root-level-controller.test.ts new file mode 100644 index 00000000000..3be1adfc56f --- /dev/null +++ b/packages/@n8n/decorators/src/controller/__tests__/root-level-controller.test.ts @@ -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'); + }); +}); diff --git a/packages/@n8n/decorators/src/controller/controller-registry-metadata.ts b/packages/@n8n/decorators/src/controller/controller-registry-metadata.ts index 5803df41cee..741a1e5931c 100644 --- a/packages/@n8n/decorators/src/controller/controller-registry-metadata.ts +++ b/packages/@n8n/decorators/src/controller/controller-registry-metadata.ts @@ -11,6 +11,7 @@ export class ControllerRegistryMetadata { if (!metadata) { metadata = { basePath: '/', + registerOnRootPath: false, middlewares: [], routes: new Map(), }; diff --git a/packages/@n8n/decorators/src/controller/index.ts b/packages/@n8n/decorators/src/controller/index.ts index 66cbd2da1e8..879f4f5076d 100644 --- a/packages/@n8n/decorators/src/controller/index.ts +++ b/packages/@n8n/decorators/src/controller/index.ts @@ -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'; diff --git a/packages/@n8n/decorators/src/controller/rest-controller.ts b/packages/@n8n/decorators/src/controller/rest-controller.ts index be3c3c0c13a..8dca80c5bcd 100644 --- a/packages/@n8n/decorators/src/controller/rest-controller.ts +++ b/packages/@n8n/decorators/src/controller/rest-controller.ts @@ -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); }; diff --git a/packages/@n8n/decorators/src/controller/root-level-controller.ts b/packages/@n8n/decorators/src/controller/root-level-controller.ts new file mode 100644 index 00000000000..a2efd1f5a1e --- /dev/null +++ b/packages/@n8n/decorators/src/controller/root-level-controller.ts @@ -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); + }; diff --git a/packages/@n8n/decorators/src/controller/types.ts b/packages/@n8n/decorators/src/controller/types.ts index bc5230bf0de..603fdd914a8 100644 --- a/packages/@n8n/decorators/src/controller/types.ts +++ b/packages/@n8n/decorators/src/controller/types.ts @@ -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; } diff --git a/packages/cli/src/__tests__/controller.registry.test.ts b/packages/cli/src/__tests__/controller.registry.test.ts index 1d784c1ad64..2124e6a4e5e 100644 --- a/packages/cli/src/__tests__/controller.registry.test.ts +++ b/packages/cli/src/__tests__/controller.registry.test.ts @@ -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 }); + }); + }); }); diff --git a/packages/cli/src/controller.registry.ts b/packages/cli/src/controller.registry.ts index 1fa8423c247..6b6fdc46742 100644 --- a/packages/cli/src/controller.registry.ts +++ b/packages/cli/src/controller.registry.ts @@ -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(