diff --git a/apps/content/.vitepress/config.ts b/apps/content/.vitepress/config.ts index 85308e3c2..dae1e00db 100644 --- a/apps/content/.vitepress/config.ts +++ b/apps/content/.vitepress/config.ts @@ -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' }, ], }, { diff --git a/apps/content/docs/client/rpc-link.md b/apps/content/docs/client/rpc-link.md index fe5b2bc8b..47ff76a9c 100644 --- a/apps/content/docs/client/rpc-link.md +++ b/apps/content/docs/client/rpc-link.md @@ -105,6 +105,23 @@ const link = new RPCLink({ }) ``` +::: 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. diff --git a/apps/content/docs/contract-first/router-to-contract.md b/apps/content/docs/contract-first/router-to-contract.md new file mode 100644 index 000000000..4a86e7e96 --- /dev/null +++ b/apps/content/docs/contract-first/router-to-contract.md @@ -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', + }) + ``` diff --git a/apps/content/docs/openapi/client/openapi-link.md b/apps/content/docs/openapi/client/openapi-link.md index d23adc87d..5854cd3cb 100644 --- a/apps/content/docs/openapi/client/openapi-link.md +++ b/apps/content/docs/openapi/client/openapi-link.md @@ -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 diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index 9222a60ab..a7d7a38af 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -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' diff --git a/packages/contract/src/link-utils.test-d.ts b/packages/contract/src/link-utils.test-d.ts new file mode 100644 index 000000000..2011d0844 --- /dev/null +++ b/packages/contract/src/link-utils.test-d.ts @@ -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), + }) +}) diff --git a/packages/contract/src/link-utils.test.ts b/packages/contract/src/link-utils.test.ts new file mode 100644 index 000000000..f76b4692e --- /dev/null +++ b/packages/contract/src/link-utils.test.ts @@ -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/) +}) diff --git a/packages/contract/src/link-utils.ts b/packages/contract/src/link-utils.ts new file mode 100644 index 000000000..55b6bbd50 --- /dev/null +++ b/packages/contract/src/link-utils.ts @@ -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 { + 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 + } +} diff --git a/packages/contract/src/router-utils.test.ts b/packages/contract/src/router-utils.test.ts index 321c04fe2..5cfa86f77 100644 --- a/packages/contract/src/router-utils.test.ts +++ b/packages/contract/src/router-utils.test.ts @@ -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) @@ -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) +}) diff --git a/packages/contract/src/router-utils.ts b/packages/contract/src/router-utils.ts index d93317c45..86822bcb4 100644 --- a/packages/contract/src/router-utils.ts +++ b/packages/contract/src/router-utils.ts @@ -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' @@ -58,3 +59,33 @@ export function enhanceContractRouter = {} + + for (const key in router) { + json[key] = minifyContractRouter(router[key]!) + } + + return json +}