From 327fe2118b9dc0ca8782b5761775100d70a01542 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 05:03:21 +0000 Subject: [PATCH 1/2] fix(nest): route non-identifier path params and reject literal colons `@Implement` wrote `:` for every path param and copied literal text verbatim, so on both the Express and Fastify adapters: - params like `{user-id}` ended at the `-` (`GET /users/42` was a 404 while `GET /users/42-id` matched with `{ user: '42' }`), and digit-leading names like `{1st}` made Express throw at startup; - a literal `:` or `*` (e.g. `/models:predict`) was parsed as route syntax, so `POST /modelsXYZ` was handled with input `{ predict: 'XYZ' }`. Build the Nest route with a single `toNestRoute` helper shared by route registration and param decoding. Params whose names are not identifiers are registered under generated keys (avoiding names already used in the path) and mapped back to their original names when decoding; identifier-safe names and controller-level params are unchanged. Literal `:` / `*` can't be escaped portably (Express needs `\:`, Fastify `::`, which Express rejects), so `@Implement` now throws a descriptive TypeError at decoration time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wd5XPg3bL7UKDGdi4akyu6 --- apps/content/docs/integrations/nest.mdx | 8 ++ packages/nest/src/implement.test.ts | 103 +++++++++++++++++++++++- packages/nest/src/implement.ts | 81 +++++++++++++------ 3 files changed, 166 insertions(+), 26 deletions(-) diff --git a/apps/content/docs/integrations/nest.mdx b/apps/content/docs/integrations/nest.mdx index fcfdffe8a..90d3a61ba 100644 --- a/apps/content/docs/integrations/nest.mdx +++ b/apps/content/docs/integrations/nest.mdx @@ -44,6 +44,14 @@ const contract = populateRouterContractOpenAPIPaths({ }) ``` +:::warning +NestJS routing can't represent a literal `:` or `*` in a path across adapters, so `@Implement` throws a `TypeError` for contract paths (including the `openapi.prefix`) that contain them outside of a [path parameter](/docs/openapi/routing#path-parameters), such as `/models:predict`. Use a separate segment instead, e.g. `/models/predict`. +::: + +:::info +Path parameters whose names are not valid identifiers (e.g. `{user-id}`) are registered with NestJS under generated names. They still reach your procedure `input` under their original names, but NestJS guards and pipes won't find them under those names in `req.params`. +::: + ## Implement Your Contract To implement your contract in NestJS, use the `@Implement` decorator and the `implement` function. The `@Implement` very similar to NestJS built-in HTTP method decorators (e.g., `@Get`, `@Post`) and can be used to implement either a single procedure contract or an [router contract](/docs/contract/router) or combine with other NestJS decorators. diff --git a/packages/nest/src/implement.test.ts b/packages/nest/src/implement.test.ts index f1e4fbb3c..b8b0f2b2f 100644 --- a/packages/nest/src/implement.test.ts +++ b/packages/nest/src/implement.test.ts @@ -43,6 +43,35 @@ describe('requirements', () => { }).toThrow(/openapi\.path/) }) + it.each([ + ['a colon', { path: '/models:predict' }], + ['an asterisk', { path: '/files/*.txt' }], + ['a colon in a non-parameter brace segment', { path: '/users/{user:id}' }], + ['a colon in the prefix', { path: '/predict', prefix: '/models:v1' }], + ] as const)('should throw if the contract path literally contains %s', (_, route) => { + const contract = oc.meta(openapi({ method: 'POST', ...route })) + + expect(() => { + @Controller() + class ImplController { + @Implement(contract) + procedure() { + return implement(contract).handler(() => {}) + } + } + }).toThrow(/NestJS routing can't represent/) + + expect(() => { + @Controller() + class ImplController { + @Implement({ nested: { contract } }) + router() { + return { nested: { contract: implement(contract).handler(() => {}) } } + } + } + }).toThrow(/NestJS routing can't represent/) + }) + it('should throw if @Implement uses the QUERY HTTP method when QueryMethod is not available (NestJS < 11.2)', async () => { vi.resetModules() vi.doMock('@nestjs/common', async (importOriginal) => { @@ -445,6 +474,11 @@ describe('routing', () => { path: '/files/{path}', method: 'GET', })).input(z.object({ tenant: z.string(), path: z.string() })), + + hyphenParam: oc.meta(openapi({ + path: '/members/{user-id}', + method: 'GET', + })).input(z.object({ 'tenant': z.string(), 'user-id': z.string() })), } @Controller('/:tenant') @@ -468,6 +502,48 @@ describe('routing', () => { pathNamedParam() { return implement(contract.pathNamedParam).handler(({ input }) => input) } + + @Implement(contract.hyphenParam) + hyphenParam() { + return implement(contract.hyphenParam).handler(({ input }) => input) + } + } + + const nonIdentifierContract = { + hyphenParam: oc.meta(openapi({ + path: '/users/{user-id}', + method: 'GET', + })).input(z.object({ 'user-id': z.string() })), + + digitParam: oc.meta(openapi({ + path: '/ranks/{1st}', + method: 'GET', + })).input(z.object({ '1st': z.string() })), + + mixedParams: oc.meta(openapi({ + // `orpc_p0` is the key a non-identifier param would naturally be generated under + path: '/{orpc_p0}/{a-b}/{id}', + prefix: '/mixed/{x-y}', + method: 'GET', + })).input(z.object({ 'x-y': z.string(), 'orpc_p0': z.string(), 'a-b': z.string(), 'id': z.string() })), + } + + @Controller() + class NonIdentifierController { + @Implement(nonIdentifierContract.hyphenParam) + hyphenParam() { + return implement(nonIdentifierContract.hyphenParam).handler(({ input }) => input) + } + + @Implement(nonIdentifierContract.digitParam) + digitParam() { + return implement(nonIdentifierContract.digitParam).handler(({ input }) => input) + } + + @Implement(nonIdentifierContract.mixedParams) + mixedParams() { + return implement(nonIdentifierContract.mixedParams).handler(({ input }) => input) + } } const protoContract = oc.meta(openapi({ @@ -491,7 +567,7 @@ describe('routing', () => { } const moduleRef = await Test.createTestingModule({ - controllers: [TenantController, ProtoController], + controllers: [TenantController, NonIdentifierController, ProtoController], }).compile() const app = moduleRef.createNestApplication(adapter as any) @@ -531,6 +607,31 @@ describe('routing', () => { expect(res.body).toEqual({ tenant: 'acme', path: 'xxx' }) }) + it('should route params whose names are not identifiers', async () => { + const hyphen = await supertest(httpServer).get('/users/42') + expect(hyphen.statusCode).toEqual(200) + expect(hyphen.body).toEqual({ 'user-id': '42' }) + + const hyphenInValue = await supertest(httpServer).get('/users/42-id') + expect(hyphenInValue.statusCode).toEqual(200) + expect(hyphenInValue.body).toEqual({ 'user-id': '42-id' }) + + const digit = await supertest(httpServer).get('/ranks/gold') + expect(digit.statusCode).toEqual(200) + expect(digit.body).toEqual({ '1st': 'gold' }) + + const mixed = await supertest(httpServer).get('/mixed/1/2/3/4') + expect(mixed.statusCode).toEqual(200) + expect(mixed.body).toEqual({ 'x-y': '1', 'orpc_p0': '2', 'a-b': '3', 'id': '4' }) + }) + + it('should keep dynamic controller prefix params alongside non-identifier contract params', async () => { + const res = await supertest(httpServer).get('/acme/members/42') + + expect(res.statusCode).toEqual(200) + expect(res.body).toEqual({ 'tenant': 'acme', 'user-id': '42' }) + }) + it('should treat params named like `__proto__` as own properties without polluting the prototype', async () => { const res = await supertest(httpServer).post('/proto/some/value') diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts index 27b6b0c2e..287189adf 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -44,6 +44,7 @@ const MethodDecoratorMap = { * @remarks * **Note**: Every procedure contract must define an `openapi.path` meta; * use `populateRouterContractOpenAPIPaths` from `@orpc/openapi` to fill in missing paths. + * **Note**: NestJS routing can't represent a literal `:` or `*` across adapters, so contract paths containing them outside of a path parameter (e.g. `/models:predict`) throw. * **Note**: The HTTP `QUERY` method requires NestJS v11.2+ (`QueryMethod`). Older NestJS versions throw; use `GET` instead. * * @see {@link https://orpc.dev/docs/integrations/nest#implement-your-contract | Implement oRPC contract with NestJS - Implement Your Contract} @@ -85,7 +86,7 @@ function toNestRouteDecorator(contract: AnyProcedureContract): MethodDecorator { } const method = meta.method ?? DEFAULT_OPENAPI_METHOD - const path = toNestPattern(meta.prefix ? mergeHttpPath(meta.prefix, meta.path) : meta.path) + const path = toNestRoute(meta.prefix ? mergeHttpPath(meta.prefix, meta.path) : meta.path).pattern const successStatus = meta.successStatus ?? DEFAULT_SUCCESS_STATUS if (method === 'QUERY') { @@ -337,44 +338,74 @@ function toORPCOpenAPIParams(contract: AnyProcedureContract, params: NestStandar return undefined } + const { paramNames } = toNestRoute(meta.prefix ? mergeHttpPath(meta.prefix, meta.path) : meta.path) // NullProtoObj prevents prototype injection when a param is named like `__proto__` const orpcParams: Record = new NullProtoObj() // express use `path` while fastify use `*` for rest matching const restKey = Object.hasOwn(params, '*') ? '*' : 'path' for (const [key, value] of Object.entries(params)) { - if (key === restKey) { - const restParams = getDynamicPathParams( - meta.prefix ? mergeHttpPath(meta.prefix, meta.path) : meta.path, - )?.filter(c => c.allowsSlash) - - if (restParams?.length) { - for (const c of restParams) { - orpcParams[c.parameterName] = flattenParamValue(value) - } - - continue - } - } - - orpcParams[key] = flattenParamValue(value) + // keys the route did not generate (e.g. controller-level params) pass through as is + const name = paramNames.get(key === restKey ? '*' : key) ?? key + orpcParams[name] = flattenParamValue(value) } return orpcParams } -function toNestPattern(path: `/${string}`): `/${string}` { - const params = getDynamicPathParams(path) +const NEST_PARAM_NAME_REGEX = /^[a-z_]\w*$/i + +/** + * Converts an oRPC OpenAPI path into a NestJS route pattern. + * + * Express and Fastify end a `:name` param at the first non-identifier character, + * so params like `{user-id}` are routed under generated keys instead. + * `paramNames` maps each generated key (and `*` for the rest param) back to its oRPC param name. + */ +function toNestRoute(path: `/${string}`): { pattern: `/${string}`, paramNames: Map } { + const params = getDynamicPathParams(path) ?? [] + const paramNames = new Map() + + let pattern = '' + let literalStart = 0 + + for (const [index, param] of params.entries()) { + pattern += toNestLiteral(path.slice(literalStart, param.startIndex), path) + literalStart = param.startIndex + param.segment.length - if (!params?.length) { - return path + if (param.allowsSlash) { + paramNames.set('*', param.parameterName) + pattern += '*' + } + else if (NEST_PARAM_NAME_REGEX.test(param.parameterName)) { + // kept as is so NestJS guards and pipes can still read `req.params[name]` + pattern += `:${param.parameterName}` + } + else { + let key = `orpc_p${index}` + while (params.some(p => p.parameterName === key)) { + key = `_${key}` + } + + paramNames.set(key, param.parameterName) + pattern += `:${key}` + } } - for (let i = params.length - 1; i >= 0; i--) { - const param = params[i]! - const pattern = param.allowsSlash ? `*` : `:${param.parameterName}` - path = path.slice(0, param.startIndex) + pattern + path.slice(param.startIndex + param.segment.length) + pattern += toNestLiteral(path.slice(literalStart), path) + + return { pattern: pattern as `/${string}`, paramNames } +} + +function toNestLiteral(text: string, path: string): string { + // escaping is adapter-specific (Express needs `\:` while Fastify needs `::`), and the adapter is unknown here + if (text.includes(':') || text.includes('*')) { + throw new TypeError(` + @Implement decorator does not support a literal ':' or '*' in the contract path "${path}", + because NestJS routing can't represent these characters across adapters (Express and Fastify parse them as route syntax). + Please remove them from the path, e.g. use '/models/predict' instead of '/models:predict'. + `) } - return path + return text } From 38247200b944ab8a6f61abc6942d689cca05bd28 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 10:05:40 +0000 Subject: [PATCH 2/2] refactor(nest): merge the openapi prefix inside toNestRoute Both call sites merged `meta.prefix` before calling `toNestRoute`. Since the generated param keys are indexed across prefix + path, the decorator and the param decoder must see the exact same full path; doing the merge in one place keeps them from drifting. Also clarify the docs note on `req.params`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wd5XPg3bL7UKDGdi4akyu6 --- apps/content/docs/integrations/nest.mdx | 2 +- packages/nest/src/implement.ts | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/content/docs/integrations/nest.mdx b/apps/content/docs/integrations/nest.mdx index 90d3a61ba..7ed7ade2e 100644 --- a/apps/content/docs/integrations/nest.mdx +++ b/apps/content/docs/integrations/nest.mdx @@ -49,7 +49,7 @@ NestJS routing can't represent a literal `:` or `*` in a path across adapters, s ::: :::info -Path parameters whose names are not valid identifiers (e.g. `{user-id}`) are registered with NestJS under generated names. They still reach your procedure `input` under their original names, but NestJS guards and pipes won't find them under those names in `req.params`. +Path parameters whose names are not valid identifiers (e.g. `{user-id}`) are registered with NestJS under generated names. They still reach your procedure `input` under their original names, but NestJS guards and pipes see them in `req.params` under the generated names. ::: ## Implement Your Contract diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts index 287189adf..bc83cf39e 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -86,7 +86,7 @@ function toNestRouteDecorator(contract: AnyProcedureContract): MethodDecorator { } const method = meta.method ?? DEFAULT_OPENAPI_METHOD - const path = toNestRoute(meta.prefix ? mergeHttpPath(meta.prefix, meta.path) : meta.path).pattern + const path = toNestRoute(meta.path, meta.prefix).pattern const successStatus = meta.successStatus ?? DEFAULT_SUCCESS_STATUS if (method === 'QUERY') { @@ -338,7 +338,7 @@ function toORPCOpenAPIParams(contract: AnyProcedureContract, params: NestStandar return undefined } - const { paramNames } = toNestRoute(meta.prefix ? mergeHttpPath(meta.prefix, meta.path) : meta.path) + const { paramNames } = toNestRoute(meta.path, meta.prefix) // NullProtoObj prevents prototype injection when a param is named like `__proto__` const orpcParams: Record = new NullProtoObj() // express use `path` while fastify use `*` for rest matching @@ -356,21 +356,22 @@ function toORPCOpenAPIParams(contract: AnyProcedureContract, params: NestStandar const NEST_PARAM_NAME_REGEX = /^[a-z_]\w*$/i /** - * Converts an oRPC OpenAPI path into a NestJS route pattern. + * Converts an oRPC OpenAPI path (with its optional prefix) into a NestJS route pattern. * * Express and Fastify end a `:name` param at the first non-identifier character, * so params like `{user-id}` are routed under generated keys instead. * `paramNames` maps each generated key (and `*` for the rest param) back to its oRPC param name. */ -function toNestRoute(path: `/${string}`): { pattern: `/${string}`, paramNames: Map } { - const params = getDynamicPathParams(path) ?? [] +function toNestRoute(path: `/${string}`, prefix?: `/${string}`): { pattern: `/${string}`, paramNames: Map } { + const fullPath = prefix ? mergeHttpPath(prefix, path) : path + const params = getDynamicPathParams(fullPath) ?? [] const paramNames = new Map() let pattern = '' let literalStart = 0 for (const [index, param] of params.entries()) { - pattern += toNestLiteral(path.slice(literalStart, param.startIndex), path) + pattern += toNestLiteral(fullPath.slice(literalStart, param.startIndex), fullPath) literalStart = param.startIndex + param.segment.length if (param.allowsSlash) { @@ -392,7 +393,7 @@ function toNestRoute(path: `/${string}`): { pattern: `/${string}`, paramNames: M } } - pattern += toNestLiteral(path.slice(literalStart), path) + pattern += toNestLiteral(fullPath.slice(literalStart), fullPath) return { pattern: pattern as `/${string}`, paramNames } }