Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ Before implementation, define your oRPC contract. This process is consistent wit
::: details Example Contract

```ts
import { populateContractRouterPaths } from '@orpc/nest'
import { oc } from '@orpc/contract'
import { oc, populateContractRouterPaths } from '@orpc/contract'
import * as z from 'zod'

export const PlanetSchema = z.object({
Expand Down
26 changes: 24 additions & 2 deletions packages/contract/src/router-utils.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { baseErrorMap, BaseMeta, inputSchema, outputSchema, router } from '../tests/shared'
import type { BaseMeta } from '../tests/shared'
import type { MergedErrorMap } from './error'
import type { Meta } from './meta'
import type { ContractProcedure } from './procedure'
import type { EnhancedContractRouter } from './router-utils'
import type { EnhancedContractRouter, PopulatedContractRouterPaths } from './router-utils'
import type { Schema } from './schema'
import { baseErrorMap, inputSchema, outputSchema, router } from '../tests/shared'
import { oc } from './builder'

it('EnhancedContractRouter', () => {
const enhanced = {} as EnhancedContractRouter<typeof router, { INVALID: { status: number }, BASE2: { message: string } }>
Expand Down Expand Up @@ -44,3 +46,23 @@ it('EnhancedContractRouter', () => {
>
>()
})

it('PopulatedContractRouterPaths', () => {
expectTypeOf<PopulatedContractRouterPaths<typeof router>>().toEqualTypeOf(router)

const ping = oc
.$meta({ meta: true })
.input(inputSchema)
.errors(baseErrorMap)
.output(outputSchema)
.route({ path: '/ping' })

expectTypeOf<PopulatedContractRouterPaths<typeof ping>>().toEqualTypeOf<
ContractProcedure<
typeof inputSchema,
typeof outputSchema,
typeof baseErrorMap & Record<never, never>,
{ meta: boolean } & Record<never, never>
>
>()
})
31 changes: 29 additions & 2 deletions packages/contract/src/router-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ping, pong, router } from '../tests/shared'
import { inputSchema, outputSchema, ping, pong, router } from '../tests/shared'
import { oc } from './builder'
import { isContractProcedure } from './procedure'
import { enhanceRoute } from './route'
import { enhanceContractRouter, getContractRouter, minifyContractRouter } from './router-utils'
import { enhanceContractRouter, getContractRouter, minifyContractRouter, populateContractRouterPaths } from './router-utils'

it('getContractRouter', () => {
expect(getContractRouter(router, [])).toEqual(router)
Expand Down Expand Up @@ -72,3 +73,29 @@ it('minifyContractRouter', () => {
expect((minified as any).nested.pong).toSatisfy(isContractProcedure)
expect((minified as any).nested.pong).toEqual(minifiedPong)
})

it('populateContractRouterPaths', () => {
const contract = {
ping: oc.input(inputSchema),
pong: oc.route({
path: '/pong/{id}',
}),
nested: {
ping: oc.output(outputSchema),
pong: oc.route({
path: '/pong2/{id}',
}),
},
}

const populated = populateContractRouterPaths(contract)

expect(populated.pong['~orpc'].route.path).toBe('/pong/{id}')
expect(populated.nested.pong['~orpc'].route.path).toBe('/pong2/{id}')

expect(populated.ping['~orpc'].route.path).toBe('/ping')
expect(populated.ping['~orpc'].inputSchema).toBe(inputSchema)

expect(populated.nested.ping['~orpc'].route.path).toBe('/nested/ping')
expect(populated.nested.ping['~orpc'].outputSchema).toBe(outputSchema)
})
47 changes: 47 additions & 0 deletions packages/contract/src/router-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { ErrorMap, MergedErrorMap } from './error'
import type { AnyContractProcedure } from './procedure'
import type { EnhanceRouteOptions } from './route'
import type { AnyContractRouter } from './router'
import { toHttpPath } from '@orpc/client/standard'
import { toArray } from '@orpc/shared'
import { mergeErrorMap } from './error'
import { ContractProcedure, isContractProcedure } from './procedure'
import { enhanceRoute } from './route'
Expand Down Expand Up @@ -89,3 +91,48 @@ export function minifyContractRouter(router: AnyContractRouter): AnyContractRout

return json
}

export type PopulatedContractRouterPaths<T extends AnyContractRouter>
= T extends ContractProcedure<infer UInputSchema, infer UOutputSchema, infer UErrors, infer UMeta>
? ContractProcedure<UInputSchema, UOutputSchema, UErrors, UMeta>
: {
[K in keyof T]: T[K] extends AnyContractRouter ? PopulatedContractRouterPaths<T[K]> : never
}

export interface PopulateContractRouterPathsOptions {
path?: readonly string[]
}

/**
* Automatically populates missing route paths using the router's nested keys.
*
* Constructs paths by joining router keys with `/`.
* Useful for NestJS integration that require explicit route paths.
*
* @see {@link https://orpc.dev/docs/openapi/integrations/implement-contract-in-nest#define-your-contract NestJS Implement Contract Docs}
*/
export function populateContractRouterPaths<T extends AnyContractRouter>(router: T, options: PopulateContractRouterPathsOptions = {}): PopulatedContractRouterPaths<T> {
const path = toArray(options.path)

if (isContractProcedure(router)) {
if (router['~orpc'].route.path === undefined) {
return new ContractProcedure({
...router['~orpc'],
route: {
...router['~orpc'].route,
path: toHttpPath(path),
},
}) as any
}

return router as any
}

const populated: Record<string, any> = {}

for (const key in router) {
populated[key] = populateContractRouterPaths(router[key]!, { ...options, path: [...path, key] })
}
Comment on lines +133 to +135
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The for...in loop iterates over all enumerable properties of an object, including those on its prototype chain. To ensure you're only processing properties of the router object itself, it's a good practice to include a hasOwnProperty check.

Suggested change
for (const key in router) {
populated[key] = populateContractRouterPaths(router[key]!, { ...options, path: [...path, key] })
}
for (const key in router) {
if (Object.prototype.hasOwnProperty.call(router, key)) {
populated[key] = populateContractRouterPaths(router[key]!, { ...options, path: [...path, key] })
}
}


return populated as any
}
Comment on lines +114 to +138
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The function populateContractRouterPaths uses as any in three places (lines 125, 128, 137), which undermines TypeScript's type safety. While complex recursive types can make strict typing difficult, it's worth exploring alternatives to any to prevent potential runtime bugs. For example, on line 128, return router; might work without a cast. In other places, a more specific cast like as PopulatedContractRouterPaths<T> would be safer than any.

2 changes: 1 addition & 1 deletion packages/nest/src/implement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function Implement<T extends ContractRouter<any>>(
throw new Error(`
@Implement decorator requires contract to have a 'path'.
Please define one using 'path' property on the '.route' method.
Or use "populateContractRouterPaths" utility to automatically fill in any missing paths.
Or use "populateContractRouterPaths" from "@orpc/contract" utility to automatically fill in any missing paths.
`)
}

Expand Down
17 changes: 17 additions & 0 deletions packages/nest/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ export { Implement as Impl } from './implement'
export * from './module'
export * from './utils'

export {
/**
* @deprecated Import from `@orpc/contract` instead for better compatibility.
*/
populateContractRouterPaths,
} from '@orpc/contract'
export type {
/**
* @deprecated Import from `@orpc/contract` instead for better compatibility.
*/
PopulateContractRouterPathsOptions,
/**
* @deprecated Import from `@orpc/contract` instead for better compatibility.
*/
PopulatedContractRouterPaths,
} from '@orpc/contract'

export { onError, onFinish, onStart, onSuccess, ORPCError } from '@orpc/server'
export type {
ImplementedProcedure,
Expand Down
25 changes: 0 additions & 25 deletions packages/nest/src/utils.test-d.ts

This file was deleted.

30 changes: 1 addition & 29 deletions packages/nest/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { oc } from '@orpc/contract'
import { inputSchema, outputSchema } from '../../contract/tests/shared'
import { populateContractRouterPaths, toNestPattern } from './utils'
import { toNestPattern } from './utils'

it('toNestPattern', () => {
expect(toNestPattern('/ping')).toBe('/ping')
Expand All @@ -10,29 +8,3 @@ it('toNestPattern', () => {

expect(toNestPattern('/{id}/name{name}')).toBe('/:id/name{name}')
})

it('populateContractRouterPaths', () => {
const contract = {
ping: oc.input(inputSchema),
pong: oc.route({
path: '/pong/{id}',
}),
nested: {
ping: oc.output(outputSchema),
pong: oc.route({
path: '/pong2/{id}',
}),
},
}

const populated = populateContractRouterPaths(contract)

expect(populated.pong['~orpc'].route.path).toBe('/pong/{id}')
expect(populated.nested.pong['~orpc'].route.path).toBe('/pong2/{id}')

expect(populated.ping['~orpc'].route.path).toBe('/ping')
expect(populated.ping['~orpc'].inputSchema).toBe(inputSchema)

expect(populated.nested.ping['~orpc'].route.path).toBe('/nested/ping')
expect(populated.nested.ping['~orpc'].outputSchema).toBe(outputSchema)
})
50 changes: 1 addition & 49 deletions packages/nest/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,8 @@
import type { AnyContractRouter, HTTPPath } from '@orpc/contract'
import { toHttpPath } from '@orpc/client/standard'
import { ContractProcedure, isContractProcedure } from '@orpc/contract'
import type { HTTPPath } from '@orpc/contract'
import { standardizeHTTPPath } from '@orpc/openapi-client/standard'
import { toArray } from '@orpc/shared'

export function toNestPattern(path: HTTPPath): string {
return standardizeHTTPPath(path)
.replace(/\/\{\+([^}]+)\}/g, '/*$1')
.replace(/\/\{([^}]+)\}/g, '/:$1')
}

export type PopulatedContractRouterPaths<T extends AnyContractRouter>
= T extends ContractProcedure<infer UInputSchema, infer UOutputSchema, infer UErrors, infer UMeta>
? ContractProcedure<UInputSchema, UOutputSchema, UErrors, UMeta>
: {
[K in keyof T]: T[K] extends AnyContractRouter ? PopulatedContractRouterPaths<T[K]> : never
}

export interface PopulateContractRouterPathsOptions {
path?: readonly string[]
}

/**
* populateContractRouterPaths is completely optional,
* because the procedure's path is required for NestJS implementation.
* This utility automatically populates any missing paths
* Using the router's keys + `/`.
*
* @see {@link https://orpc.dev/docs/openapi/integrations/implement-contract-in-nest#define-your-contract NestJS Implement Contract Docs}
*/
export function populateContractRouterPaths<T extends AnyContractRouter>(router: T, options: PopulateContractRouterPathsOptions = {}): PopulatedContractRouterPaths<T> {
const path = toArray(options.path)

if (isContractProcedure(router)) {
if (router['~orpc'].route.path === undefined) {
return new ContractProcedure({
...router['~orpc'],
route: {
...router['~orpc'].route,
path: toHttpPath(path),
},
}) as any
}

return router as any
}

const populated: Record<string, any> = {}

for (const key in router) {
populated[key] = populateContractRouterPaths(router[key]!, { ...options, path: [...path, key] })
}

return populated as any
}
Loading