Skip to content

Commit 66a829d

Browse files
authored
feat(openapi): support custom error response format (#1137)
- [x] logic - [x] docs - [x] test Fixes #1124 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Add pluggable error-response customization: custom encoding (handler), custom schema generation (spec), and custom decoding (client) with clear fallback behavior. * **Documentation** * Revise advanced customization guide to show option-based, selective customization and clarify default/fallback semantics. * **Tests** * Add tests covering custom encode/decode hooks, schema hook invocation, and fallback scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 34b362b commit 66a829d

8 files changed

Lines changed: 327 additions & 64 deletions

File tree

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,96 @@
11
---
22
title: Customizing Error Response Format
3-
description: Learn how to customize the error response format in oRPC OpenAPI handlers to match your application's requirements and improve client compatibility.
3+
description: Learn how to customize the error response format in oRPC OpenAPI to match your application's requirements and improve client compatibility.
44
---
55

66
# Customizing Error Response Format
77

8-
If you need to customize the error response format to match your application's requirements, you can use [`rootInterceptors`](/docs/rpc-handler#lifecycle) in the handler.
8+
By default, [OpenAPIHandler](/docs/openapi/openapi-handler), [OpenAPIGenerator](/docs/openapi/openapi-specification), and [OpenAPILink](/docs/openapi/client/openapi-link) share the same error response format. You can customize one, some, or all of them based on your requirements.
99

10-
::: warning
11-
Avoid combining this with [Type‑Safe Error Handling](/docs/error-handling#type‐safe-error-handling), as the [OpenAPI Specification Generator](/docs/openapi/openapi-specification) does not yet support custom error response formats.
10+
::: info
11+
The examples below use options very close to the default behavior.
1212
:::
1313

14-
```ts
15-
import { isORPCErrorJson, isORPCErrorStatus } from '@orpc/client'
14+
## `OpenAPIHandler`
15+
16+
Use `customErrorResponseBodyEncoder` in [OpenAPIHandler](/docs/openapi/openapi-handler) to customize how an `ORPCError` is formatted in the response.
1617

18+
```ts
1719
const handler = new OpenAPIHandler(router, {
18-
rootInterceptors: [
19-
async ({ next }) => {
20-
const result = await next()
21-
22-
if (
23-
result.matched
24-
&& isORPCErrorStatus(result.response.status)
25-
&& isORPCErrorJson(result.response.body)
26-
) {
27-
return {
28-
...result,
29-
response: {
30-
...result.response,
31-
body: {
32-
...result.response.body,
33-
message: 'custom error shape',
34-
},
20+
customErrorResponseBodyEncoder(error) {
21+
return error.toJSON()
22+
},
23+
})
24+
```
25+
26+
::: info
27+
Return `null` or `undefined` from `customErrorResponseBodyEncoder` to fallback to the default behavior.
28+
:::
29+
30+
## `OpenAPIGenerator`
31+
32+
When using [type-safe errors](/docs/error-handling#type‐safe-error-handling), customize the error response format in [OpenAPIGenerator](/docs/openapi/openapi-specification) with `customErrorResponseBodySchema` to match your application's actual error responses.
33+
34+
```ts
35+
const generator = new OpenAPIGenerator()
36+
37+
const spec = await generator.generate(router, {
38+
customErrorResponseBodySchema: (definedErrorDefinitions, status) => {
39+
const result: Record<any, any> = {
40+
oneOf: [
41+
{
42+
type: 'object',
43+
properties: {
44+
defined: { const: false }, // for normal errors
45+
code: { type: 'string' },
46+
status: { type: 'number' },
47+
message: { type: 'string' },
48+
data: {},
3549
},
36-
}
37-
}
50+
required: ['defined', 'code', 'status', 'message'],
51+
},
52+
],
53+
}
3854

39-
return result
40-
},
41-
],
55+
for (const [code, defaultMessage, dataRequired, dataSchema] of definedErrorDefinitions) {
56+
result.oneOf.push({
57+
type: 'object',
58+
properties: {
59+
defined: { const: true }, // for typesafe errors
60+
code: { const: code },
61+
status: { const: status },
62+
message: { type: 'string', default: defaultMessage },
63+
data: dataSchema,
64+
},
65+
required: dataRequired ? ['defined', 'code', 'status', 'message', 'data'] : ['defined', 'code', 'status', 'message'],
66+
})
67+
}
68+
69+
return result
70+
}
71+
})
72+
```
73+
74+
::: info
75+
Return `null` or `undefined` from `customErrorResponseBodySchema` to fallback to the default behavior.
76+
:::
77+
78+
## `OpenAPILink`
79+
80+
When your backend isn't oRPC or uses a custom error format, you can instruct [OpenAPILink](/docs/openapi/client/openapi-link) how to parse it to an `ORPCError` using the `customErrorResponseBodyDecoder` option.
81+
82+
```ts
83+
const link = OpenAPILink(contract, {
84+
customErrorResponseBodyDecoder: (body, response) => {
85+
if (isORPCErrorJson(body)) {
86+
return createORPCErrorFromJson(body)
87+
}
88+
89+
return null // default behavior supports any error format
90+
}
4291
})
4392
```
93+
94+
::: info
95+
Return `null` or `undefined` from `customErrorResponseBodyDecoder` to fallback to the default behavior.
96+
:::

packages/openapi-client/src/adapters/standard/openapi-link-codec.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,5 +459,31 @@ describe('standardOpenapiLinkCodecOptions', () => {
459459
status: 201,
460460
}, { context: {}, signal }, ['ping'])).rejects.toThrow('Invalid OpenAPI response format.')
461461
})
462+
463+
it('customErrorResponseBodyDecoder', async () => {
464+
const error = new ORPCError('TEST')
465+
let time = 1
466+
const customErrorResponseBodyDecoder = vi.fn(() => {
467+
if (time++ === 2) {
468+
return null // fallback to default
469+
}
470+
return error
471+
})
472+
473+
const codec = new StandardOpenapiLinkCodec({ ping: oc }, serializer, {
474+
url: 'http://localhost:3000',
475+
customErrorResponseBodyDecoder,
476+
})
477+
478+
const response1 = { headers: { 'x-custom': 'value' }, body: async () => 'body', status: 400 }
479+
await expect(codec.decode(response1, { context: {} }, ['ping'])).rejects.toSatisfy(e => e === error)
480+
481+
const response2 = { headers: { 'x-custom': 'value2' }, body: async () => 'body2', status: 405 }
482+
await expect(codec.decode(response2, { context: {} }, ['ping'])).rejects.toSatisfy(e => e.status === 405) // default behavior
483+
484+
expect(customErrorResponseBodyDecoder).toHaveBeenCalledTimes(2)
485+
expect(customErrorResponseBodyDecoder).toHaveBeenCalledWith(deserialize.mock.results[0]!.value, response1)
486+
expect(customErrorResponseBodyDecoder).toHaveBeenCalledWith(deserialize.mock.results[1]!.value, response2)
487+
})
462488
})
463489
})

packages/openapi-client/src/adapters/standard/openapi-link-codec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,22 @@ export interface StandardOpenapiLinkCodecOptions<T extends ClientContext> {
2929
path: readonly string[],
3030
input: unknown,
3131
]>
32+
33+
/**
34+
* Customize how a response body is decoded into an ORPC error.
35+
* Useful when the default decoder cannot fully interpret
36+
* your server's error format.
37+
*
38+
* @remarks
39+
* - Return `null | undefined` to fallback to default behavior.
40+
*/
41+
customErrorResponseBodyDecoder?: (deserializedBody: unknown, response: StandardLazyResponse) => ORPCError<any, any> | null | undefined
3242
}
3343

3444
export class StandardOpenapiLinkCodec<T extends ClientContext> implements StandardLinkCodec<T> {
3545
private readonly baseUrl: Exclude<StandardOpenapiLinkCodecOptions<T>['url'], undefined>
3646
private readonly headers: Exclude<StandardOpenapiLinkCodecOptions<T>['headers'], undefined>
47+
private readonly customErrorResponseBodyDecoder: StandardOpenapiLinkCodecOptions<T>['customErrorResponseBodyDecoder']
3748

3849
constructor(
3950
private readonly contract: AnyContractRouter,
@@ -42,6 +53,7 @@ export class StandardOpenapiLinkCodec<T extends ClientContext> implements Standa
4253
) {
4354
this.baseUrl = options.url
4455
this.headers = options.headers ?? {}
56+
this.customErrorResponseBodyDecoder = options.customErrorResponseBodyDecoder
4557
}
4658

4759
async encode(path: readonly string[], input: unknown, options: ClientOptions<T>): Promise<StandardRequest> {
@@ -216,6 +228,12 @@ export class StandardOpenapiLinkCodec<T extends ClientContext> implements Standa
216228
})()
217229

218230
if (!isOk) {
231+
const error = this.customErrorResponseBodyDecoder?.(deserialized, response)
232+
233+
if (error !== null && error !== undefined) {
234+
throw error
235+
}
236+
219237
if (isORPCErrorJson(deserialized)) {
220238
throw createORPCErrorFromJson(deserialized)
221239
}

packages/openapi/src/adapters/standard/openapi-codec.test.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -264,21 +264,57 @@ describe('standardOpenAPICodec', () => {
264264
})
265265
})
266266

267-
it('.encodeError', async () => {
268-
serializer.serialize.mockReturnValueOnce('__serialized__')
267+
describe('.encodeError', () => {
268+
it('works', async () => {
269+
serializer.serialize.mockReturnValueOnce('__serialized__')
269270

270-
const error = new ORPCError('BAD_GATEWAY', {
271-
data: '__data__',
272-
})
273-
const response = codec.encodeError(error)
271+
const error = new ORPCError('BAD_GATEWAY', {
272+
data: '__data__',
273+
})
274+
const response = codec.encodeError(error)
275+
276+
expect(response).toEqual({
277+
status: error.status,
278+
headers: {},
279+
body: '__serialized__',
280+
})
274281

275-
expect(response).toEqual({
276-
status: error.status,
277-
headers: {},
278-
body: '__serialized__',
282+
expect(serializer.serialize).toHaveBeenCalledOnce()
283+
expect(serializer.serialize).toHaveBeenCalledWith(error.toJSON(), { outputFormat: 'plain' })
279284
})
280285

281-
expect(serializer.serialize).toHaveBeenCalledOnce()
282-
expect(serializer.serialize).toHaveBeenCalledWith(error.toJSON(), { outputFormat: 'plain' })
286+
it('customErrorResponseBodyEncoder', async () => {
287+
let time = 1
288+
const customErrorResponseBodyEncoder = vi.fn(() => {
289+
if (time++ === 2) {
290+
return null // default behavior
291+
}
292+
293+
return '__custom_error_body__'
294+
})
295+
296+
const codec = new StandardOpenAPICodec(serializer, {
297+
customErrorResponseBodyEncoder,
298+
})
299+
300+
let time2 = 1
301+
serializer.serialize.mockImplementation(() => `__serialized${time2++}__`)
302+
303+
const error1 = new ORPCError('BAD_GATEWAY', { data: '__data1__' })
304+
const response1 = codec.encodeError(error1)
305+
expect(response1).toEqual({ status: error1.status, headers: {}, body: '__serialized1__' })
306+
307+
const error2 = new ORPCError('TEST_2', { data: '__data2__' })
308+
const response2 = codec.encodeError(error2)
309+
expect(response2).toEqual({ status: error2.status, headers: {}, body: '__serialized2__' })
310+
311+
expect(customErrorResponseBodyEncoder).toHaveBeenCalledTimes(2)
312+
expect(customErrorResponseBodyEncoder).toHaveBeenNthCalledWith(1, error1)
313+
expect(customErrorResponseBodyEncoder).toHaveBeenNthCalledWith(2, error2)
314+
315+
expect(serializer.serialize).toHaveBeenCalledTimes(2)
316+
expect(serializer.serialize).toHaveBeenNthCalledWith(1, '__custom_error_body__', { outputFormat: 'plain' })
317+
expect(serializer.serialize).toHaveBeenNthCalledWith(2, error2.toJSON(), { outputFormat: 'plain' }) // default behavior
318+
})
283319
})
284320
})

packages/openapi/src/adapters/standard/openapi-codec.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,27 @@ import { isORPCErrorStatus } from '@orpc/client'
77
import { fallbackContractConfig } from '@orpc/contract'
88
import { isObject, stringifyJSON } from '@orpc/shared'
99

10+
export interface StandardOpenAPICodecOptions {
11+
/**
12+
* Customize how an ORPC error is encoded into a response body.
13+
* Use this if your API needs a different error output structure.
14+
*
15+
* @remarks
16+
* - Return `null | undefined` to fallback to default behavior
17+
*
18+
* @default ((e) => e.toJSON())
19+
*/
20+
customErrorResponseBodyEncoder?: (error: ORPCError<any, any>) => unknown
21+
}
22+
1023
export class StandardOpenAPICodec implements StandardCodec {
24+
private readonly customErrorResponseBodyEncoder: StandardOpenAPICodecOptions['customErrorResponseBodyEncoder']
25+
1126
constructor(
1227
private readonly serializer: StandardOpenAPISerializer,
28+
options: StandardOpenAPICodecOptions = {},
1329
) {
30+
this.customErrorResponseBodyEncoder = options.customErrorResponseBodyEncoder
1431
}
1532

1633
async decode(request: StandardLazyRequest, params: StandardParams | undefined, procedure: AnyProcedure): Promise<unknown> {
@@ -88,10 +105,12 @@ export class StandardOpenAPICodec implements StandardCodec {
88105
}
89106

90107
encodeError(error: ORPCError<any, any>): StandardResponse {
108+
const body = this.customErrorResponseBodyEncoder?.(error) ?? error.toJSON()
109+
91110
return {
92111
status: error.status,
93112
headers: {},
94-
body: this.serializer.serialize(error.toJSON(), { outputFormat: 'plain' }),
113+
body: this.serializer.serialize(body, { outputFormat: 'plain' }),
95114
}
96115
}
97116

packages/openapi/src/adapters/standard/openapi-handler.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { StandardBracketNotationSerializerOptions, StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard'
22
import type { Context, Router } from '@orpc/server'
33
import type { StandardHandlerOptions } from '@orpc/server/standard'
4+
import type { StandardOpenAPICodecOptions } from './openapi-codec'
45
import type { StandardOpenAPIMatcherOptions } from './openapi-matcher'
56
import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard'
67
import { StandardHandler } from '@orpc/server/standard'
@@ -9,15 +10,15 @@ import { StandardOpenAPIMatcher } from './openapi-matcher'
910

1011
export interface StandardOpenAPIHandlerOptions<T extends Context>
1112
extends StandardHandlerOptions<T>, StandardOpenAPIJsonSerializerOptions,
12-
StandardBracketNotationSerializerOptions, StandardOpenAPIMatcherOptions {}
13+
StandardBracketNotationSerializerOptions, StandardOpenAPIMatcherOptions, StandardOpenAPICodecOptions {}
1314

1415
export class StandardOpenAPIHandler<T extends Context> extends StandardHandler<T> {
1516
constructor(router: Router<any, T>, options: NoInfer<StandardOpenAPIHandlerOptions<T>>) {
1617
const jsonSerializer = new StandardOpenAPIJsonSerializer(options)
1718
const bracketNotationSerializer = new StandardBracketNotationSerializer(options)
1819
const serializer = new StandardOpenAPISerializer(jsonSerializer, bracketNotationSerializer)
1920
const matcher = new StandardOpenAPIMatcher(options)
20-
const codec = new StandardOpenAPICodec(serializer)
21+
const codec = new StandardOpenAPICodec(serializer, options)
2122

2223
super(router, matcher, codec, options)
2324
}

0 commit comments

Comments
 (0)