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
1 change: 1 addition & 0 deletions apps/content/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export default defineConfig({
items: [
{ text: 'Define Contract', link: '/docs/contract-first/define-contract' },
{ text: 'Implement Contract', link: '/docs/contract-first/implement-contract' },
{ text: 'Router to Contract', link: '/docs/contract-first/router-to-contract' },
],
},
{
Expand Down
17 changes: 17 additions & 0 deletions apps/content/docs/client/rpc-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,23 @@ const link = new RPCLink<ClientContext>({
})
```

::: details Automatically use method specified in contract?

By using `inferRPCMethodFromContractRouter`, the `RPCLink` automatically uses the method specified in the contract when sending requests.

```ts
import { inferRPCMethodFromContractRouter } from '@orpc/contract'

const link = new RPCLink({
url: 'http://localhost:3000/rpc',
method: inferRPCMethodFromContractRouter(contract),
})
```

::: info
A normal [router](/docs/router) works as a contract router as long as it does not include a [lazy router](/docs/router#lazy-router). For more advanced use cases, refer to the [Router to Contract](/docs/contract-first/router-to-contract) guide.
:::

## Lazy URL

You can define `url` as a function, ensuring compatibility with environments that may lack certain runtime APIs.
Expand Down
51 changes: 51 additions & 0 deletions apps/content/docs/contract-first/router-to-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
title: Router to Contract
description: Learn how to convert a router into a contract, safely export it, and prevent exposing internal details to the client.
---

# Router to Contract

A normal [router](/docs/router) works as a contract router as long as it does not include a [lazy router](/docs/router#lazy-router). This guide not only shows you how to **unlazy** a router to make it compatible with contracts, but also how to **minify** it and **prevent internal business logic from being exposed to the client**.

## Unlazy the Router

If your router includes a [lazy router](/docs/router#lazy-router), you need to fully resolve it to make it compatible with contract.

```ts
import { unlazyRouter } from '@orpc/server'

const resolvedRouter = await unlazyRouter(router)
```

## Minify & Export the Contract Router for the Client

Sometimes, you'll need to import the contract on the client - for example, to use [OpenAPILink](/docs/openapi/client/openapi-link) or define request methods in [RPCLink](/docs/client/rpc-link#custom-request-method).

If you're using [Contract First](/docs/contract-first/define-contract), this is safe: your contract is already lightweight and free of business logic.

However, if you're deriving the contract from a [router](/docs/router), importing it directly can be heavy and may leak internal logic. To prevent this, follow the steps below to safely minify and export your contract.

1. **Minify the Contract Router and Export to JSON**

```ts
import fs from 'node:fs'
import { minifyContractRouter } from '@orpc/contract'

const minifiedRouter = minifyContractRouter(router)

fs.writeFileSync('./contract.json', JSON.stringify(minifiedRouter))
```

::: warning
`minifyContractRouter` preserves only the metadata and routing information necessary for the client, all other data will be stripped out.
:::

2. **Import the Contract JSON on the Client Side**

```ts
import contract from './contract.json' // [!code highlight]

const link = new OpenAPILink(contract as any, {
url: 'http://localhost:3000/api',
})
```
2 changes: 1 addition & 1 deletion apps/content/docs/openapi/client/openapi-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ deno install npm:@orpc/openapi-client@latest
To use `OpenAPILink`, ensure you have a [contract router](/docs/contract-first/define-contract#contract-router) and that your server is set up with [OpenAPIHandler](/docs/openapi/openapi-handler) or any API that follows the [OpenAPI Specification](https://swagger.io/specification/).

::: info
A normal [router](/docs/router) works as a contract router as long as it does not include a [lazy router](/docs/router#lazy-router). You can also unlazy a router using the [unlazyRouter](/docs/advanced/mocking#using-the-implementer) utility.
A normal [router](/docs/router) works as a contract router as long as it does not include a [lazy router](/docs/router#lazy-router). For more advanced use cases, refer to the [Router to Contract](/docs/contract-first/router-to-contract) guide.
:::

```ts twoslash
Expand Down
1 change: 1 addition & 0 deletions packages/contract/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './builder-variants'
export * from './config'
export * from './error'
export * from './event-iterator'
export * from './link-utils'
export * from './meta'
export * from './procedure'
export * from './procedure-client'
Expand Down
10 changes: 10 additions & 0 deletions packages/contract/src/link-utils.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { RPCLink } from '@orpc/client/fetch'
import { router as contract } from '../tests/shared'
import { inferRPCMethodFromContractRouter } from './link-utils'

it('inferRPCMethodFromContractRouter', () => {
const link = new RPCLink({
url: 'http://localhost:3000/rpc',
method: inferRPCMethodFromContractRouter(contract),
})
})
23 changes: 23 additions & 0 deletions packages/contract/src/link-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { oc } from './builder'
import { inferRPCMethodFromContractRouter } from './link-utils'
import { minifyContractRouter } from './router-utils'

it('inferRPCMethodFromContractRouter', () => {
const method = inferRPCMethodFromContractRouter(minifyContractRouter({
head: oc.route({ method: 'HEAD' }),
get: oc.route({ method: 'GET' }),
post: oc.route({}),
nested: {
get: oc.route({ method: 'GET' }),
delete: oc.route({ method: 'DELETE' }),
},
}))

expect(method({}, ['head'])).toBe('GET')
expect(method({}, ['get'])).toBe('GET')
expect(method({}, ['post'])).toBe('POST')
expect(method({}, ['nested', 'get'])).toBe('GET')
expect(method({}, ['nested', 'delete'])).toBe('DELETE')

expect(() => method({}, ['nested', 'not-exist'])).toThrow(/No valid procedure found at path/)
})
27 changes: 27 additions & 0 deletions packages/contract/src/link-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { HTTPMethod } from '@orpc/client'
import type { AnyContractRouter } from './router'
import { get } from '@orpc/shared'
import { fallbackContractConfig } from './config'
import { isContractProcedure } from './procedure'

/**
* Help RPCLink automatically send requests using the specified HTTP method in the contract.
*
* @see {@link https://orpc.unnoq.com/docs/client/rpc-link#custom-request-method RPCLink Custom Request Method}
*/
export function inferRPCMethodFromContractRouter(contract: AnyContractRouter): (options: unknown, path: readonly string[]) => Exclude<HTTPMethod, 'HEAD'> {
return (_, path) => {
const procedure = get(contract, path)

if (!isContractProcedure(procedure)) {
throw new Error(
`[inferRPCMethodFromContractRouter] No valid procedure found at path "${path.join('.')}". `
+ `This may happen when the contract router is not properly configured.`,
)
}

const method = fallbackContractConfig('defaultMethod', procedure['~orpc'].route.method)

return method === 'HEAD' ? 'GET' : method
}
}
39 changes: 38 additions & 1 deletion packages/contract/src/router-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ping, pong, router } from '../tests/shared'
import { isContractProcedure } from './procedure'
import { enhanceRoute } from './route'
import { enhanceContractRouter, getContractRouter } from './router-utils'
import { enhanceContractRouter, getContractRouter, minifyContractRouter } from './router-utils'

it('getContractRouter', () => {
expect(getContractRouter(router, [])).toEqual(router)
Expand Down Expand Up @@ -35,3 +36,39 @@ it('enhanceContractRouter', async () => {
expect(enhanced.nested.pong['~orpc'].errorMap).toEqual({ ...errorMap, ...pong['~orpc'].errorMap })
expect(enhanced.nested.pong['~orpc'].route).toEqual(enhanceRoute(pong['~orpc'].route, options))
})

it('minifyContractRouter', () => {
const minified = minifyContractRouter(router)

const minifiedPing = {
'~orpc': {
errorMap: {},
meta: {
mode: 'dev',
},
route: {
path: '/base',
},
},
}

const minifiedPong = {
'~orpc': {
errorMap: {},
meta: {},
route: {},
},
}

expect((minified as any).ping).toSatisfy(isContractProcedure)
expect((minified as any).ping).toEqual(minifiedPing)

expect((minified as any).pong).toSatisfy(isContractProcedure)
expect((minified as any).pong).toEqual(minifiedPong)

expect((minified as any).nested.ping).toSatisfy(isContractProcedure)
expect((minified as any).nested.ping).toEqual(minifiedPing)

expect((minified as any).nested.pong).toSatisfy(isContractProcedure)
expect((minified as any).nested.pong).toEqual(minifiedPong)
})
31 changes: 31 additions & 0 deletions packages/contract/src/router-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ErrorMap, MergedErrorMap } from './error'
import type { AnyContractProcedure } from './procedure'
import type { EnhanceRouteOptions } from './route'
import type { AnyContractRouter } from './router'
import { mergeErrorMap } from './error'
Expand Down Expand Up @@ -58,3 +59,33 @@ export function enhanceContractRouter<T extends AnyContractRouter, TErrorMap ext

return enhanced as any
}

/**
* Minify a contract router into a smaller object.
*
* You should export the result to a JSON file. On the client side, you can import this JSON file and use it as a contract router.
* This reduces the size of the contract and helps prevent leaking internal details of the router to the client.
*
* @see {@link https://orpc.unnoq.com/docs/contract-first/router-to-contract#minify-export-the-contract-router-for-the-client Router to Contract Docs}
*/
export function minifyContractRouter(router: AnyContractRouter): AnyContractRouter {
if (isContractProcedure(router)) {
const procedure: AnyContractProcedure = {
'~orpc': {
errorMap: {},
meta: router['~orpc'].meta,
route: router['~orpc'].route,
},
}

return procedure
}

const json: Record<string, AnyContractRouter> = {}

for (const key in router) {
json[key] = minifyContractRouter(router[key]!)
}

return json
}