fix(express): apply middleware to controller root routes in strict mode

With the "strict routing" setting, the router serves the root route of a controller without a trailing slash ("/users"), while the middleware of forRoutes() was registered with one ("/users/") and did not run for it. The path is now registered without the trailing slash too.
This commit is contained in:
Kamil Myśliwiec committed 2026-10-21 10:54:57 +02:00
1 parent 29e2d3ac6f
commit 769e89e8e6
2 files changed
+114 -4

No files matched your search

@@ -0,0 +1,100 @@
import {
Controller,
Get,
INestApplication,
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import express from 'express';
import request from 'supertest';
import { listenOnLoopback } from '../../_support/listen-on-loopback.js';
let middlewareCalls = 0;
@Controller('health')
class HealthController {
@Get()
root() {
return 'root';
}
@Get('deep/')
deep() {
return 'deep';
}
}
@Controller('open')
class OpenController {
@Get()
root() {
return 'open';
}
}
@Module({ controllers: [HealthController, OpenController] })
class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply((req, res, next) => {
middlewareCalls++;
res.statusCode = 403;
res.end('blocked');
})
.exclude({ path: 'open', method: RequestMethod.GET })
.forRoutes(HealthController, OpenController);
}
}
describe.each([[''], ['api']])(
'Middleware with strict routing (global prefix "%s")',
prefix => {
let app: INestApplication;
const base = prefix ? `/${prefix}` : '';
beforeAll(async () => {
const server = express();
server.set('strict routing', true);
app = await NestFactory.create(AppModule, new ExpressAdapter(server), {
logger: false,
});
if (prefix) {
app.setGlobalPrefix(prefix);
}
await listenOnLoopback(app);
});
beforeEach(() => {
middlewareCalls = 0;
});
afterAll(async () => {
await app.close();
});
it('should run the middleware for the root route of a controller', async () => {
await request(app.getHttpServer())
.get(`${base}/health`)
.expect(403, 'blocked');
expect(middlewareCalls).toBe(1);
});
it('should run the middleware for a route declared with a trailing slash', async () => {
await request(app.getHttpServer())
.get(`${base}/health/deep`)
.expect(403, 'blocked');
expect(middlewareCalls).toBe(1);
});
it('should still skip the middleware for an excluded root route', async () => {
await request(app.getHttpServer())
.get(`${base}/open`)
.expect(200, 'open');
expect(middlewareCalls).toBe(0);
});
},
);
@@ -425,10 +425,20 @@ export class ExpressAdapter extends AbstractHttpAdapter<
if (isExactPath && router === this.instance.use) {
router = this.instance.all;
}
// Express routes are not strict, so "/api" also matches "/api/", which
// the wildcard path registered next to it may match too ("/api/{*path}"
// does, "/api/*path" does not). The core runs the middleware for the
// first of its paths that matches a request only.
// Express routes are not strict by default, so "/api" also matches
// "/api/", which the wildcard path registered next to it may match too
// ("/api/{*path}" does, "/api/*path" does not). The core runs the
// middleware for the first of its paths that matches a request only.
// With "strict routing", the router serves a route the core passes
// with a trailing slash (the root route of a controller, "/users/")
// without it ("/users"), so the path is registered without it too.
if (
this.instance.router?.strict &&
convertedPath.length > 1 &&
convertedPath.endsWith('/')
) {
router.call(this.instance, stripEndSlash(convertedPath), callback);
}
return router.call(this.instance, convertedPath, callback);
} catch (e) {
if (e instanceof TypeError) {