Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/content/docs/integrations/nest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
103 changes: 102 additions & 1 deletion packages/nest/src/implement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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')
Expand All @@ -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({
Expand All @@ -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)
Expand Down Expand Up @@ -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')

Expand Down
82 changes: 57 additions & 25 deletions packages/nest/src/implement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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<string, string> = 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<string, string> } {
const fullPath = prefix ? mergeHttpPath(prefix, path) : path
const params = getDynamicPathParams(fullPath) ?? []
const paramNames = new Map<string, string>()

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
}
Loading