diff --git a/apps/content/docs/integrations/nest.mdx b/apps/content/docs/integrations/nest.mdx index fcfdffe8a..7ed7ade2e 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 see them in `req.params` under the generated names. +::: + ## 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..bc83cf39e 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.path, meta.prefix).pattern const successStatus = meta.successStatus ?? DEFAULT_SUCCESS_STATUS if (method === 'QUERY') { @@ -337,44 +338,75 @@ function toORPCOpenAPIParams(contract: AnyProcedureContract, params: NestStandar return undefined } + 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 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 (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}`, 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(fullPath.slice(literalStart, param.startIndex), fullPath) + 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(fullPath.slice(literalStart), fullPath) + + 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 }