Skip to content

Commit 5a170fe

Browse files
authored
feat(contract): Response Validation Plugin (#953)
Closes: #947 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Response Validation Plugin added to validate and coerce server responses; validateORPCError implemented and made available from the contract package (re-exported by server). * **Documentation** * New docs: Response Validation Plugin and Expanding Type Support for OpenAPI Link. * OpenAPILink guidance updated on JSON limitations and alternatives. * Client Retry docs clarified and site navigation updated. * **Chores / Tests** * Public plugin entrypoint exposed and comprehensive tests added; tests adjusted to reflect export relocations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 4b3a4c5 commit 5a170fe

17 files changed

+457
-108
lines changed

apps/content/.vitepress/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export default withMermaid(defineConfig({
137137
{ text: 'CORS', link: '/docs/plugins/cors' },
138138
{ text: 'Request Headers', link: '/docs/plugins/request-headers' },
139139
{ text: 'Response Headers', link: '/docs/plugins/response-headers' },
140+
{ text: 'Response Validation', link: '/docs/plugins/response-validation' },
140141
{ text: 'Hibernation', link: '/docs/plugins/hibernation' },
141142
{ text: 'Dedupe Requests', link: '/docs/plugins/dedupe-requests' },
142143
{ text: 'Batch Requests', link: '/docs/plugins/batch-requests' },
@@ -276,6 +277,7 @@ export default withMermaid(defineConfig({
276277
collapsed: true,
277278
items: [
278279
{ text: 'Customizing Error Response', link: '/docs/openapi/advanced/customizing-error-response' },
280+
{ text: 'Expanding Type Support for OpenAPI Link', link: '/docs/openapi/advanced/expanding-type-support-for-openapi-link' },
279281
{ text: 'OpenAPI JSON Serializer', link: '/docs/openapi/advanced/openapi-json-serializer' },
280282
{ text: 'Redirect Response', link: '/docs/openapi/advanced/redirect-response' },
281283
],
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
title: Expanding Type Support for OpenAPI Link
3+
description: Learn how to extend OpenAPILink to support additional data types beyond JSON's native capabilities using the Response Validation Plugin and schema coercion.
4+
---
5+
6+
# Expanding Type Support for OpenAPI Link
7+
8+
This guide will show you how to extend [OpenAPILink](/docs/openapi/client/openapi-link) to support additional data types beyond JSON's native capabilities using the [Response Validation Plugin](/docs/plugins/response-validation).
9+
10+
## How It Works
11+
12+
To enable this functionality, you need to customize your output schema with proper coercion logic.
13+
14+
**Why?** OpenAPI response data only represents JSON's native capabilities. We use schema coercion logic in output schemas to convert the data to the desired type.
15+
16+
::: warning
17+
Beyond JSON limitations, outputs containing `Blob` or `File` types (outside the root level) also face [Bracket Notation](/docs/openapi/bracket-notation#limitations) limitations.
18+
:::
19+
20+
```ts
21+
const contract = oc.output(z.object({
22+
date: z.coerce.date(), // [!code highlight]
23+
bigint: z.coerce.bigint(), // [!code highlight]
24+
}))
25+
26+
const procedure = implement(contract).handler(() => ({
27+
date: new Date(),
28+
bigint: 123n,
29+
}))
30+
```
31+
32+
On the client side, you'll receive the output like this:
33+
34+
```ts
35+
const beforeValidation = {
36+
date: '2025-09-01T07:24:39.000Z',
37+
bigint: '123'
38+
}
39+
```
40+
41+
Since your output schema contains coercion logic, the Response Validation Plugin will convert the data to the desired type after validation.
42+
43+
```ts
44+
const afterValidation = {
45+
date: new Date('2025-09-01T07:24:39.000Z'),
46+
bigint: 123n
47+
}
48+
```
49+
50+
::: warning
51+
To support more types than those in [OpenAPI Handler](/docs/openapi/openapi-handler#supported-data-types), you must first extend the [OpenAPI JSON Serializer](/docs/openapi/advanced/openapi-json-serializer) first.
52+
:::
53+
54+
## Setup
55+
56+
After understanding how it works and expanding output schemas with coercion logic, you only need to set up the [Response Validation Plugin](/docs/plugins/response-validation) and remove the `JsonifiedClient` wrapper.
57+
58+
```diff
59+
import type { ContractRouterClient } from '@orpc/contract'
60+
import { createORPCClient } from '@orpc/client'
61+
import { OpenAPILink } from '@orpc/openapi-client/fetch'
62+
import { ResponseValidationPlugin } from '@orpc/contract/plugins'
63+
64+
const link = new OpenAPILink(contract, {
65+
url: 'http://localhost:3000/api',
66+
plugins: [
67+
+ new ResponseValidationPlugin(contract),
68+
]
69+
})
70+
71+
-const client: JsonifiedClient<ContractRouterClient<typeof contract>> = createORPCClient(link)
72+
+const client: ContractRouterClient<typeof contract> = createORPCClient(link)
73+
```

apps/content/docs/openapi/client/openapi-link.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ const client: JsonifiedClient<ContractRouterClient<typeof contract>> = createORP
7171
```
7272

7373
:::warning
74-
Wrap your client with `JsonifiedClient` to ensure it accurately reflects the server responses.
74+
Due to JSON limitations, you must wrap your client with `JsonifiedClient` to ensure type safety. Alternatively, follow the [Expanding Type Support for OpenAPI Link](/docs/openapi/advanced/expanding-type-support-for-openapi-link) guide to preserve original types without the wrapper.
7575
:::
7676

7777
## Limitations

apps/content/docs/plugins/client-retry.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ const link = new RPCLink<ORPCClientContext>({
4141
const client: RouterClient<typeof router, ORPCClientContext> = createORPCClient(link)
4242
```
4343

44+
::: info
45+
The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations.
46+
:::
47+
4448
## Usage
4549

4650
```ts twoslash
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
title: Response Validation Plugin
3+
description: A plugin that validates server responses against the contract schema to ensure that the data returned from your server matches the expected types defined in your contract.
4+
---
5+
6+
# Response Validation Plugin
7+
8+
The **Response Validation Plugin** validates server responses against your contract schema, ensuring that data returned from your server matches the expected types defined in your contract.
9+
10+
::: info
11+
This plugin is best suited for [Contract-First Development](/docs/contract-first/define-contract). [Minified Contract](/docs/contract-first/router-to-contract#minify-export-the-contract-router-for-the-client) is **not supported** because it removes the schema from the contract.
12+
:::
13+
14+
## Setup
15+
16+
```ts twoslash
17+
import { contract } from './shared/planet'
18+
import { createORPCClient } from '@orpc/client'
19+
import type { ContractRouterClient } from '@orpc/contract'
20+
// ---cut---
21+
import { RPCLink } from '@orpc/client/fetch'
22+
import { ResponseValidationPlugin } from '@orpc/contract/plugins'
23+
24+
const link = new RPCLink({
25+
url: 'http://localhost:3000/rpc',
26+
plugins: [
27+
new ResponseValidationPlugin(contract),
28+
],
29+
})
30+
31+
const client: ContractRouterClient<typeof contract> = createORPCClient(link)
32+
```
33+
34+
::: info
35+
The `link` can be any supported oRPC link, such as [RPCLink](/docs/client/rpc-link), [OpenAPILink](/docs/openapi/client/openapi-link), or custom implementations.
36+
:::
37+
38+
## Limitations
39+
40+
Schemas that transform data into different types than the expected schema types are not supported.
41+
42+
**Why?** Consider this example schema that accepts a `number` and transforms it into a `string` after validation:
43+
44+
```ts
45+
const unsupported = z.number().transform(value => value.toString())
46+
```
47+
48+
When the server validates output, it transforms the `number` into a `string`. The client receives a `string`, but the `string` no longer matches the original schema, causing validation to fail.
49+
50+
## Advanced Usage
51+
52+
Beyond response validation, this plugin also serves special purposes such as [Expanding Type Support for OpenAPI Link](/docs/openapi/advanced/expanding-type-support-for-openapi-link).

packages/contract/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,17 @@
1919
"types": "./dist/index.d.mts",
2020
"import": "./dist/index.mjs",
2121
"default": "./dist/index.mjs"
22+
},
23+
"./plugins": {
24+
"types": "./dist/plugins/index.d.mts",
25+
"import": "./dist/plugins/index.mjs",
26+
"default": "./dist/plugins/index.mjs"
2227
}
2328
}
2429
},
2530
"exports": {
26-
".": "./src/index.ts"
31+
".": "./src/index.ts",
32+
"./plugins": "./src/plugins/index.ts"
2733
},
2834
"files": [
2935
"dist"

packages/contract/src/error.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import type { ErrorMap } from './error'
2+
import { ORPCError } from '@orpc/client'
3+
import z from 'zod'
14
import { baseErrorMap } from '../tests/shared'
2-
import { mergeErrorMap, ValidationError } from './error'
5+
import { mergeErrorMap, validateORPCError, ValidationError } from './error'
36

47
it('validationError', () => {
58
const error = new ValidationError({ message: 'message', issues: [{ message: 'message' }] })
@@ -13,3 +16,71 @@ it('mergeErrorMap', () => {
1316
{ OVERRIDE: {}, INVALID: {}, BASE: baseErrorMap.BASE },
1417
)
1518
})
19+
20+
describe('validateORPCError', () => {
21+
const errors: ErrorMap = {
22+
BAD_GATEWAY: {
23+
data: z.object({
24+
value: z.string().transform(v => Number.parseInt(v)),
25+
}),
26+
},
27+
CONFLICT: {
28+
status: 483,
29+
},
30+
}
31+
32+
it('ignore not-match errors when defined=false', async () => {
33+
const e1 = new ORPCError('BAD_GATEWAY', { status: 501, data: { value: '123' } })
34+
expect(await validateORPCError(errors, e1)).toBe(e1)
35+
36+
const e2 = new ORPCError('NOT_FOUND')
37+
expect(await validateORPCError(errors, e2)).toBe(e2)
38+
39+
const e3 = new ORPCError('BAD_GATEWAY', { data: 'invalid' })
40+
expect(await validateORPCError(errors, e3)).toBe(e3)
41+
42+
const e4 = new ORPCError('CONFLICT')
43+
expect(await validateORPCError(errors, e4)).toBe(e4)
44+
})
45+
46+
it('modify not-match errors when defined=true', async () => {
47+
const e1 = new ORPCError('BAD_GATEWAY', { defined: true, status: 501 })
48+
const v1 = await validateORPCError(errors, e1)
49+
expect(v1).not.toBe(e1)
50+
expect({ ...v1 }).toEqual({ ...e1, defined: false })
51+
52+
const e2 = new ORPCError('NOT_FOUND', { defined: true })
53+
const v2 = await validateORPCError(errors, e2)
54+
expect(v2).not.toBe(e2)
55+
expect({ ...v2 }).toEqual({ ...e2, defined: false })
56+
57+
const e3 = new ORPCError('BAD_GATEWAY', { defined: true, data: 'invalid' })
58+
const v3 = await validateORPCError(errors, e3)
59+
expect(v3).not.toBe(e3)
60+
expect({ ...v3 }).toEqual({ ...e3, defined: false })
61+
62+
const e4 = new ORPCError('CONFLICT', { defined: true })
63+
const v4 = await validateORPCError(errors, e4)
64+
expect(v4).not.toBe(e4)
65+
expect({ ...v4 }).toEqual({ ...e4, defined: false })
66+
})
67+
68+
it('ignore match errors when defined=true and data schema is undefined', async () => {
69+
const e1 = new ORPCError('CONFLICT', { defined: true, status: 483 })
70+
expect(await validateORPCError(errors, e1)).toBe(e1)
71+
})
72+
73+
it('return new error when defined=true and data schema is undefined with match error', async () => {
74+
const e1 = new ORPCError('CONFLICT', { status: 483 })
75+
const v1 = await validateORPCError(errors, e1)
76+
expect(v1).not.toBe(e1)
77+
expect({ ...v1 }).toEqual({ ...e1, defined: true })
78+
})
79+
80+
it('return new with defined=true and validated data with match errors', async () => {
81+
const e1 = new ORPCError('BAD_GATEWAY', { data: { value: '123' } })
82+
const v1 = await validateORPCError(errors, e1)
83+
expect(v1).not.toBe(e1)
84+
expect({ ...v1 }).toEqual({ ...e1, defined: true, data: { value: 123 } })
85+
})
86+
})

packages/contract/src/error.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { ORPCError, ORPCErrorCode } from '@orpc/client'
1+
import type { ORPCErrorCode } from '@orpc/client'
22
import type { ThrowableError } from '@orpc/shared'
33
import type { AnySchema, InferSchemaOutput, Schema, SchemaIssue } from './schema'
4+
import { fallbackORPCErrorStatus, ORPCError } from '@orpc/client'
45

56
export interface ValidationErrorOptions extends ErrorOptions {
67
message: string
@@ -53,3 +54,30 @@ export type ORPCErrorFromErrorMap<TErrorMap extends ErrorMap> = {
5354
}[keyof TErrorMap]
5455

5556
export type ErrorFromErrorMap<TErrorMap extends ErrorMap> = ORPCErrorFromErrorMap<TErrorMap> | ThrowableError
57+
58+
export async function validateORPCError(map: ErrorMap, error: ORPCError<any, any>): Promise<ORPCError<string, unknown>> {
59+
const { code, status, message, data, cause, defined } = error
60+
const config = map?.[error.code]
61+
62+
if (!config || fallbackORPCErrorStatus(error.code, config.status) !== error.status) {
63+
return defined
64+
? new ORPCError(code, { defined: false, status, message, data, cause })
65+
: error
66+
}
67+
68+
if (!config.data) {
69+
return defined
70+
? error
71+
: new ORPCError(code, { defined: true, status, message, data, cause })
72+
}
73+
74+
const validated = await config.data['~standard'].validate(error.data)
75+
76+
if (validated.issues) {
77+
return defined
78+
? new ORPCError(code, { defined: false, status, message, data, cause })
79+
: error
80+
}
81+
82+
return new ORPCError(code, { defined: true, status, message, data: validated.value, cause })
83+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
it('exports something', async () => {
2+
expect(await import('./index')).toHaveProperty('ResponseValidationPlugin')
3+
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './response-validation'

0 commit comments

Comments
 (0)