diff --git a/integration/hello-world/e2e/middleware-fastify.spec.ts b/integration/hello-world/e2e/middleware-fastify.spec.ts index 457faa2a99b..af34fa22003 100644 --- a/integration/hello-world/e2e/middleware-fastify.spec.ts +++ b/integration/hello-world/e2e/middleware-fastify.spec.ts @@ -793,4 +793,156 @@ describe('Middleware (FastifyAdapter)', () => { }); }); }); + + describe('should apply middleware to routes registered via Fastify plugin with prefix', () => { + const MIDDLEWARE_VALUE = 'middleware_applied'; + + @Controller() + class PluginPrefixController { + @Get('other') + other() { + return 'other_route'; + } + } + + @Module({ + controllers: [PluginPrefixController], + }) + class PluginPrefixModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply((req, res, next) => { + req.middlewareApplied = true; + next(); + }) + .forRoutes('/my-prefix'); + } + } + + beforeEach(async () => { + const fastifyAdapter = new FastifyAdapter(); + + app = ( + await Test.createTestingModule({ + imports: [PluginPrefixModule], + }).compile() + ).createNestApplication(fastifyAdapter); + + // Register a Fastify plugin with a prefix (simulating third-party plugin) + fastifyAdapter.getInstance().register( + async instance => { + instance.get('/', async req => { + return (req.raw as any).middlewareApplied + ? MIDDLEWARE_VALUE + : 'no_middleware'; + }); + instance.get('/sub-route', async req => { + return (req.raw as any).middlewareApplied + ? MIDDLEWARE_VALUE + : 'no_middleware'; + }); + }, + { prefix: '/my-prefix' }, + ); + + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + it(`forRoutes('/my-prefix') should match the prefix root`, () => { + return app + .inject({ + method: 'GET', + url: '/my-prefix', + }) + .then(({ payload }) => expect(payload).to.be.eql(MIDDLEWARE_VALUE)); + }); + + it(`forRoutes('/my-prefix') should match sub-routes under the prefix`, () => { + return app + .inject({ + method: 'GET', + url: '/my-prefix/sub-route', + }) + .then(({ payload }) => expect(payload).to.be.eql(MIDDLEWARE_VALUE)); + }); + + afterEach(async () => { + await app.close(); + }); + }); + + describe('RequestMethod.ALL should use exact matching (not prefix)', () => { + @Controller() + class AllMethodController { + @Get('/a') + getA(@Req() request: any) { + return { + middlewareApplied: !!(request.raw as any).middlewareApplied, + }; + } + + @Get('/a/b') + getAB(@Req() request: any) { + return { + middlewareApplied: !!(request.raw as any).middlewareApplied, + }; + } + } + + @Module({ + controllers: [AllMethodController], + }) + class AllMethodModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply((req, res, next) => { + req.middlewareApplied = true; + next(); + }) + .forRoutes({ path: '/a', method: RequestMethod.ALL }); + } + } + + beforeEach(async () => { + app = ( + await Test.createTestingModule({ + imports: [AllMethodModule], + }).compile() + ).createNestApplication(new FastifyAdapter()); + + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + it(`forRoutes({ path: '/a', method: ALL }) should match /a exactly`, () => { + return app + .inject({ + method: 'GET', + url: '/a', + }) + .then(({ payload }) => + expect(JSON.parse(payload)).to.deep.equal({ + middlewareApplied: true, + }), + ); + }); + + it(`forRoutes({ path: '/a', method: ALL }) should NOT match /a/b (no prefix matching)`, () => { + return app + .inject({ + method: 'GET', + url: '/a/b', + }) + .then(({ payload }) => + expect(JSON.parse(payload)).to.deep.equal({ + middlewareApplied: false, + }), + ); + }); + + afterEach(async () => { + await app.close(); + }); + }); }); diff --git a/packages/platform-fastify/adapters/fastify-adapter.ts b/packages/platform-fastify/adapters/fastify-adapter.ts index 842aa5374ac..49fa0716a62 100644 --- a/packages/platform-fastify/adapters/fastify-adapter.ts +++ b/packages/platform-fastify/adapters/fastify-adapter.ts @@ -675,6 +675,18 @@ export class FastifyAdapter< if (!this.isMiddieRegistered) { await this.registerMiddie(); } + + // Only string routes from forRoutes('/prefix') — represented internally + // as method -1 — should use prefix matching (end: false) to match + // sub-routes under the given path. This mirrors Express's app.use() + // behavior for prefix-based middleware. + // + // RequestMethod.ALL maps to the HTTP adapter's .all() which is exact-match + // semantics (match all HTTP methods on this exact path). Using prefix + // matching for ALL would cause forRoutes({ path: '/a', method: ALL }) to + // incorrectly match '/a/b/c', breaking the "execute only once" guarantee. + const isStringRoute = (requestMethod as number) === -1; + return (path: string, callback: Function) => { const hasEndOfStringCharacter = path.endsWith('$'); path = hasEndOfStringCharacter ? path.slice(0, -1) : path; @@ -693,10 +705,10 @@ export class FastifyAdapter< } try { - let { regexp: re } = pathToRegexp(normalizedPath); - re = hasEndOfStringCharacter - ? new RegExp(re.source + '$', re.flags) - : re; + const endMatch = hasEndOfStringCharacter || !isStringRoute; + const { regexp: re } = pathToRegexp(normalizedPath, { + end: endMatch, + }); // The following type assertion is valid as we use import('@fastify/middie') rather than require('@fastify/middie') // ref https://github.com/fastify/middie/pull/55