Skip to content

Commit a0afe48

Browse files
authored
fix(nest): apply method-level decorators to router-contract @Implement routes (#1776)
Method-level NestJS enhancers (`@UseGuards`, `@UsePipes`, `@UseFilters`, `@SetMetadata`, ...) on a router-contract `@Implement` method never reached the synthesized route handlers — guards silently never ran, leaving those routes unprotected regardless of decorator order. Enhancer metadata now reaches every synthesized route, decorator placement around `@Implement` no longer matters for whether enhancers apply, and interceptor execution order follows decorator order exactly like on plain NestJS methods. ## Fixes - Synthesized methods inherit from the original method via the prototype chain, so guards, pipes, filters, and `@SetMetadata` resolve through NestJS's `Reflect.getMetadata` lookups and now run on every route, including nested routers. - `ImplementInterceptor` is registered at the decorated method level, so the interceptor list carries user interceptors and `ImplementInterceptor` in native decorator-evaluation order: interceptors below `@Implement` observe the encoded response, interceptors above it run inside and observe the raw implemented procedure — identical between single-procedure and router-contract implementations. - The router branch no longer recurses through `Implement` itself: routing and status decorators are applied directly to synthesized methods, deferred (with the method-name-keyed metadata copies) until the whole decorator stack has run, so late-running decorators are picked up and route metadata is always written last. - The documented ordering restriction is gone; the docs warning is replaced with a note that decorators combine with `@Implement` in any order and that execution order follows decorator order. ## Testing - Header-based `AuthGuard` e2e test: requests without the token are rejected (403) and with it succeed (200) on all synthesized routes including a nested procedure, in both decorator orders. Before the fix the guard never executed. - Interceptor-order e2e tests for both single-procedure and router-contract `@Implement`, in both decorator orders, asserting what the user interceptor observes (encoded response vs raw procedure). - The conflict-method-names test boots a real app: routes serve correctly, `@Req()` injection works on synthesized methods, and `@SetMetadata` from both sides of `@Implement` is visible via `Reflector` on `ctx.getHandler()` at runtime.
1 parent 5965f87 commit a0afe48

3 files changed

Lines changed: 329 additions & 58 deletions

File tree

apps/content/docs/integrations/nest.md

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -103,22 +103,8 @@ export class PlanetController {
103103
}
104104
```
105105

106-
::: warning
107-
If you using `@Implement` decorator for router contract, underhook it creates corresponding NestJS method for each procedure contract. Therefore, all other decorator should be applied before `@Implement` decorator, otherwise it will not be applied to corresponding NestJS methods.
108-
109-
```ts
110-
@Controller()
111-
export class PlanetController {
112-
@Implement(contract.planet) // ⬇️ other decorators should be below this line
113-
@UseGuards(AuthGuard)
114-
planet(@Req() req: Request) {
115-
return {
116-
// your implementation
117-
}
118-
}
119-
}
120-
```
121-
106+
::: info
107+
When you use the `@Implement` decorator with a router contract, under the hood it creates a corresponding NestJS method for each procedure contract. All decorators applied to the original method are reflected on these methods.
122108
:::
123109

124110
## Error Handling

packages/nest/src/implement.test.ts

Lines changed: 264 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common'
1+
import type { CallHandler, CanActivate, ExecutionContext, NestInterceptor } from '@nestjs/common'
22
import type { Request as ExpressRequest } from 'express'
33
import type { FastifyReply } from 'fastify'
44
import type { NestStandardLazyRequest } from './module'
55
import { Buffer } from 'node:buffer'
66
import FastifyCookie from '@fastify/cookie'
7-
import { Controller, HttpException, Req, Res, StreamableFile } from '@nestjs/common'
7+
import { Controller, HttpException, Req, Res, SetMetadata, StreamableFile, UseGuards, UseInterceptors } from '@nestjs/common'
8+
import { Reflector } from '@nestjs/core'
89
import { FastifyAdapter } from '@nestjs/platform-fastify'
910
import { Test } from '@nestjs/testing'
1011
import { meta, oc } from '@orpc/contract'
1112
import { openapi } from '@orpc/openapi'
12-
import { implement, ORPCError, os } from '@orpc/server'
13+
import { implement, ORPCError, os, Procedure } from '@orpc/server'
1314
import { getOrBind } from '@orpc/shared'
1415
import { catchError, tap } from 'rxjs'
1516
import supertest from 'supertest'
@@ -185,7 +186,7 @@ describe('routing', () => {
185186
staticPath: implement(contract.staticPath).handler(() => 'static'),
186187
dynamicPath: implement(contract.dynamicPath).handler(({ input }) => `param: ${input.param}`),
187188
restPath: implement(contract.restPath).handler(({ input }) => `rest: ${input.rest}`),
188-
prefixedPath: implement(contract.prefixedPath).handler(({ input }) => `prefixed path`),
189+
prefixedPath: implement(contract.prefixedPath).handler(() => `prefixed path`),
189190
dynamicPrefix: implement(contract.dynamicPrefix).handler(({ input }) => `prefix: ${input.prefix}`),
190191
styledParams: implement(contract.styledParams).handler(({ input }) => `params: ${input.params}`),
191192
mixed201Path: implement(contract.mixed201Path).handler(({ input }) => `prefixes: ${input.prefixes} param: ${input.param}, rest: ${input.rest}`),
@@ -841,25 +842,39 @@ describe('compatibility', () => {
841842
expect(req!.url).toBe('/injection')
842843
})
843844

844-
it('router-based implementation controller can handle conflict method names and reflect all metadata on new methods', async () => {
845+
it('router-based implementation controller can handle conflict method names and reflect all metadata on new methods regardless of decorator order', async () => {
845846
const contract = {
846847
ping: oc.meta(openapi({ path: '/ping' })),
847848
pong: oc.meta(openapi({ path: '/pong' })),
848849
}
849850

850-
const Meta: MethodDecorator = (target, propertyKey, descriptor) => {
851-
Reflect.defineMetadata('orpc:meta', 'value', target, propertyKey)
851+
// custom decorator metadata keyed by method name, like some third-party decorators use
852+
const Meta = (key: string): MethodDecorator => (target, propertyKey) => {
853+
Reflect.defineMetadata(key, 'value', target, propertyKey)
854+
}
855+
856+
const handlerMeta = vi.fn()
857+
858+
class MetaGuard implements CanActivate {
859+
canActivate(ctx: ExecutionContext) {
860+
const reflector = new Reflector()
861+
handlerMeta(reflector.get('above', ctx.getHandler()), reflector.get('below', ctx.getHandler()))
862+
return true
863+
}
852864
}
853865

854866
@Controller()
867+
@UseGuards(MetaGuard)
855868
class ImplController {
869+
@SetMetadata('above', 'above-value')
870+
@Meta('orpc:above')
856871
@Implement(contract)
857-
// There is a limitation: @Meta must be used after @Implement.
858-
@Meta
859-
router() {
872+
@Meta('orpc:below')
873+
@SetMetadata('below', 'below-value')
874+
router(@Req() req: ExpressRequest) {
860875
return {
861-
ping: implement(contract.ping).handler(() => {}),
862-
pong: implement(contract.pong).handler(() => {}),
876+
ping: implement(contract.ping).handler(() => `ping:${req.url}`),
877+
pong: implement(contract.pong).handler(() => `pong:${req.url}`),
863878
}
864879
}
865880

@@ -869,10 +884,244 @@ describe('compatibility', () => {
869884
router_ping_1() {}
870885
}
871886

872-
const controller = new ImplController()
887+
const moduleRef = await Test.createTestingModule({
888+
controllers: [ImplController],
889+
}).compile()
890+
891+
const app = moduleRef.createNestApplication()
892+
await app.init()
893+
894+
const pingRes = await supertest(app.getHttpServer()).post('/ping')
895+
expect(pingRes.status).toBe(200)
896+
expect(pingRes.body).toEqual('ping:/ping')
897+
898+
const pongRes = await supertest(app.getHttpServer()).post('/pong')
899+
expect(pongRes.status).toBe(200)
900+
expect(pongRes.body).toEqual('pong:/pong')
901+
902+
// @SetMetadata from both sides of @Implement is visible on the runtime handlers
903+
expect(handlerMeta).toHaveBeenCalledTimes(2)
904+
expect(handlerMeta).toHaveBeenNthCalledWith(1, 'above-value', 'below-value')
905+
expect(handlerMeta).toHaveBeenNthCalledWith(2, 'above-value', 'below-value')
906+
907+
const controller = app.get(ImplController)
908+
909+
for (const key of ['orpc:above', 'orpc:below']) {
910+
expect(Reflect.getMetadata(key, controller, 'router_ping_2')).toEqual('value')
911+
expect(Reflect.getMetadata(key, controller, 'router_pong')).toEqual('value')
912+
}
913+
})
914+
915+
describe('router-based implementation applies method-level guards to synthesized methods', () => {
916+
const contract = {
917+
ping: oc.meta(openapi({ path: '/guarded/ping' })),
918+
nested: {
919+
pong: oc.meta(openapi({ path: '/guarded/pong' })),
920+
},
921+
}
922+
923+
const canActivate = vi.fn((ctx: ExecutionContext) => {
924+
return ctx.switchToHttp().getRequest().headers.authorization === 'valid-token'
925+
})
926+
927+
class AuthGuard implements CanActivate {
928+
canActivate = canActivate
929+
}
930+
931+
const router = () => ({
932+
ping: implement(contract.ping).handler(() => {}),
933+
nested: {
934+
pong: implement(contract.nested.pong).handler(() => {}),
935+
},
936+
})
937+
938+
@Controller()
939+
class GuardAboveController {
940+
@UseGuards(AuthGuard)
941+
@Implement(contract)
942+
router() {
943+
return router()
944+
}
945+
}
946+
947+
@Controller()
948+
class GuardBelowController {
949+
@Implement(contract)
950+
@UseGuards(AuthGuard)
951+
router() {
952+
return router()
953+
}
954+
}
955+
956+
describe.each([
957+
[GuardAboveController, '@UseGuards above @Implement'],
958+
[GuardBelowController, '@UseGuards below @Implement'],
959+
] as const)('order: $1', async (Controller, _) => {
960+
const moduleRef = await Test.createTestingModule({
961+
controllers: [Controller],
962+
}).compile()
963+
964+
const app = moduleRef.createNestApplication()
965+
await app.init()
966+
967+
it('rejects or allows based on the request header', async () => {
968+
expect((await supertest(app.getHttpServer()).post('/guarded/ping')).status).toBe(403)
969+
expect((await supertest(app.getHttpServer()).post('/guarded/pong')).status).toBe(403)
970+
971+
expect((await supertest(app.getHttpServer()).post('/guarded/ping').set('authorization', 'valid-token')).status).toBe(200)
972+
expect((await supertest(app.getHttpServer()).post('/guarded/pong').set('authorization', 'valid-token')).status).toBe(200)
973+
974+
expect(canActivate).toHaveBeenCalledTimes(4)
975+
})
976+
})
977+
})
978+
979+
describe('router-based implementation applies method-level interceptors to synthesized methods', () => {
980+
const contract = {
981+
ping: oc.meta(openapi({ path: '/intercepted/ping' })),
982+
nested: {
983+
pong: oc.meta(openapi({ path: '/intercepted/pong' })),
984+
},
985+
}
986+
987+
const intercepted: unknown[] = []
988+
989+
beforeEach(() => {
990+
intercepted.length = 0
991+
})
992+
993+
const intercept = vi.fn((ctx: ExecutionContext, next: CallHandler) => {
994+
return next.handle().pipe(tap(value => intercepted.push(value)))
995+
})
996+
997+
class SpyInterceptor implements NestInterceptor {
998+
intercept = intercept
999+
}
1000+
1001+
const router = () => ({
1002+
ping: implement(contract.ping).handler(() => 'pong'),
1003+
nested: {
1004+
pong: implement(contract.nested.pong).handler(() => 'peng'),
1005+
},
1006+
})
1007+
1008+
@Controller()
1009+
class InterceptorAboveController {
1010+
@UseInterceptors(SpyInterceptor)
1011+
@Implement(contract)
1012+
router() {
1013+
return router()
1014+
}
1015+
}
1016+
1017+
@Controller()
1018+
class InterceptorBelowController {
1019+
@Implement(contract)
1020+
@UseInterceptors(SpyInterceptor)
1021+
router() {
1022+
return router()
1023+
}
1024+
}
1025+
1026+
describe.each([
1027+
[InterceptorAboveController, '@UseInterceptors above @Implement'],
1028+
[InterceptorBelowController, '@UseInterceptors below @Implement'],
1029+
] as const)('order: $1', async (Controller, order) => {
1030+
const moduleRef = await Test.createTestingModule({
1031+
controllers: [Controller],
1032+
}).compile()
1033+
1034+
const app = moduleRef.createNestApplication()
1035+
await app.init()
1036+
1037+
it('runs the interceptor on synthesized methods following decorator order', async () => {
1038+
const pingRes = await supertest(app.getHttpServer()).post('/intercepted/ping')
1039+
expect(pingRes.status).toBe(200)
1040+
expect(pingRes.body).toEqual('pong')
1041+
1042+
const pongRes = await supertest(app.getHttpServer()).post('/intercepted/pong')
1043+
expect(pongRes.status).toBe(200)
1044+
expect(pongRes.body).toEqual('peng')
1045+
1046+
expect(intercept).toHaveBeenCalledTimes(2)
1047+
1048+
if (order === '@UseInterceptors above @Implement') {
1049+
// evaluates after @Implement, so it runs inside ImplementInterceptor and observes the raw procedures
1050+
expect(intercepted).toHaveLength(2)
1051+
intercepted.forEach(value => expect(value).toBeInstanceOf(Procedure))
1052+
}
1053+
else {
1054+
// evaluates before @Implement, so it runs outside ImplementInterceptor and observes the encoded responses
1055+
expect(intercepted).toEqual(['"pong"', '"peng"'])
1056+
}
1057+
})
1058+
})
1059+
})
1060+
1061+
describe('procedure-based implementation applies method-level interceptors following decorator order', () => {
1062+
const contract = oc.meta(openapi({ path: '/intercepted/procedure' }))
8731063

874-
expect(Reflect.getMetadata('orpc:meta', controller, 'router_ping_2')).toEqual('value')
875-
expect(Reflect.getMetadata('orpc:meta', controller, 'router_pong')).toEqual('value')
1064+
const intercepted: unknown[] = []
1065+
1066+
beforeEach(() => {
1067+
intercepted.length = 0
1068+
})
1069+
1070+
const intercept = vi.fn((ctx: ExecutionContext, next: CallHandler) => {
1071+
return next.handle().pipe(tap(value => intercepted.push(value)))
1072+
})
1073+
1074+
class SpyInterceptor implements NestInterceptor {
1075+
intercept = intercept
1076+
}
1077+
1078+
@Controller()
1079+
class InterceptorAboveController {
1080+
@UseInterceptors(SpyInterceptor)
1081+
@Implement(contract)
1082+
procedure() {
1083+
return implement(contract).handler(() => 'pong')
1084+
}
1085+
}
1086+
1087+
@Controller()
1088+
class InterceptorBelowController {
1089+
@Implement(contract)
1090+
@UseInterceptors(SpyInterceptor)
1091+
procedure() {
1092+
return implement(contract).handler(() => 'pong')
1093+
}
1094+
}
1095+
1096+
describe.each([
1097+
[InterceptorAboveController, '@UseInterceptors above @Implement'],
1098+
[InterceptorBelowController, '@UseInterceptors below @Implement'],
1099+
] as const)('order: $1', async (Controller, order) => {
1100+
const moduleRef = await Test.createTestingModule({
1101+
controllers: [Controller],
1102+
}).compile()
1103+
1104+
const app = moduleRef.createNestApplication()
1105+
await app.init()
1106+
1107+
it('runs the interceptor following decorator order', async () => {
1108+
const res = await supertest(app.getHttpServer()).post('/intercepted/procedure')
1109+
expect(res.status).toBe(200)
1110+
expect(res.body).toEqual('pong')
1111+
1112+
expect(intercept).toHaveBeenCalledTimes(1)
1113+
1114+
if (order === '@UseInterceptors above @Implement') {
1115+
// evaluates after @Implement, so it runs inside ImplementInterceptor and observes the raw procedure
1116+
expect(intercepted).toHaveLength(1)
1117+
expect(intercepted[0]).toBeInstanceOf(Procedure)
1118+
}
1119+
else {
1120+
// evaluates before @Implement, so it runs outside ImplementInterceptor and observes the encoded response
1121+
expect(intercepted).toEqual(['"pong"'])
1122+
}
1123+
})
1124+
})
8761125
})
8771126

8781127
it('should support lazy router/procedure in router-based implementation controller', async () => {

0 commit comments

Comments
 (0)