Skip to content

Commit f1160ed

Browse files
dinwwwhclaude
andauthored
feat(openapi): configurable media type for error responses (#2337)
Closes #2317 Adds an `errorResponseContentType` option to `OpenAPIHandler` (and the NestJS integration), `OpenAPILink` and `OpenAPIGenerator.generate()`. Use it to send and document errors under a different media type, such as `application/problem+json` for RFC 9457 Problem Details. Like the custom error format options, set the same value on all three. ```ts const handler = new OpenAPIHandler(router, { customErrorResponseBodyEncoder: toProblemDetails, errorResponseContentType: 'application/problem+json', }) const link = new OpenAPILink(contract, { errorResponseContentType: 'application/problem+json', }) const spec = await generator.generate(router, { customErrorResponseBodySchema: problemDetailsJsonSchema, errorResponseContentType: 'application/problem+json', }) ``` ## Changes - **Handler:** when the option is set, `encodeError` sets the `content-type` header. Since `@standard-server` 0.12.0 (#2342), a `content-type` the response already sets on a JSON body is kept, and a `standard-server: json` hint is added when that type would not otherwise be read as JSON. Since #2344 the compression plugins keep it too. - **NestJS:** works through the same codec, since `NestHandlerCodec` extends `OpenAPIHandlerCodecCore`. Errors still go through Nest's `HttpException` path, which keeps the content type (Nest appends `; charset=utf-8`) and the `standard-server: json` hint. - **Link:** an error response whose media type equals the option is read with `resolveBody('json')`. The comparison ignores case and parameters such as `charset`. Successful responses still use `responseBodyHint`, and a non-JSON error page from a proxy still gives a `MalformedResponseError`. - **Generator:** error responses are documented under the configured type instead of the hardcoded `application/json`. - **Docs:** the handler, link and specification pages describe the option. Without the option, nothing changes. ## Tests - Handler codec: `encodeError` sets the `content-type` header. - Link codec: a table of cases. The link uses `'json'` for error responses whose media type matches, ignoring case and parameters. It uses no hint for other errors and keeps `responseBodyHint` for successful responses. - Generator: a unit test and an end-to-end test for the documented media type. - How adapters send the header is `@standard-server`'s behavior and is covered by its tests, so there are no adapter-level tests here. - `pnpm vitest run packages/openapi`, `tsc`, lint and `pnpm docs:validate` all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012JepnhdVCkPJwexoiHyqRR --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2ed620e commit f1160ed

11 files changed

Lines changed: 124 additions & 4 deletions

File tree

‎apps/content/docs/openapi/handler.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,14 @@ const handler = new OpenAPIHandler(router, {
249249

250250
</Expandable>
251251

252+
Error responses are sent as `application/json` by default. Use `errorResponseContentType` to change the media type, such as `application/problem+json` for [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457):
253+
254+
```ts
255+
const handler = new OpenAPIHandler(router, {
256+
errorResponseContentType: 'application/problem+json',
257+
})
258+
```
259+
252260
:::info
253261
If you use `OpenAPILink` with a custom server-side error format, make sure to configure [Custom Error Decoding](/docs/openapi/link#custom-error-decoding).
254262
:::

‎apps/content/docs/openapi/link.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,14 @@ const link = new OpenAPILink(contract, {
242242
})
243243
```
244244

245+
If the handler sets [`errorResponseContentType`](/docs/openapi/handler#custom-error-response), set the same value on the link so those error responses are parsed as JSON:
246+
247+
```ts
248+
const link = new OpenAPILink(contract, {
249+
errorResponseContentType: 'application/problem+json',
250+
})
251+
```
252+
245253
## Error Responses
246254

247255
When the server answers with an error, `OpenAPILink` throws an `ORPCError` whose `cause` is a `ResponseError` carrying the resolved response, so details that are not part of the error itself, such as its status or headers, stay available (errors returned by [`customErrorResponseBodyDecoder`](#custom-error-decoding) are thrown as-is):

‎apps/content/docs/openapi/specification.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,11 @@ const spec = await generator.generate(router, {
257257
},
258258
})
259259
```
260+
261+
If the handler sets [`errorResponseContentType`](/docs/openapi/handler#custom-error-response), pass the same value to the generator so error responses are documented under that media type:
262+
263+
```ts
264+
const spec = await generator.generate(router, {
265+
errorResponseContentType: 'application/problem+json',
266+
})
267+
```

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,18 @@ describe('openAPIHandlerCodec', () => {
978978
expect(serializer.serialize).toHaveBeenNthCalledWith(2, secondError.toJSON())
979979
})
980980

981+
it('sets content-type header via errorResponseContentType option', () => {
982+
const procedure = os.handler(vi.fn())
983+
const codec = new OpenAPIHandlerCodec(procedure, { errorResponseContentType: 'application/problem+json' })
984+
const error = new ORPCError('BAD_GATEWAY')
985+
986+
expect(codec.encodeError(error)).toEqual({
987+
status: 502,
988+
headers: { 'content-type': 'application/problem+json' },
989+
body: error.toJSON(),
990+
})
991+
})
992+
981993
it('does not resolve error codes through Object.prototype', async () => {
982994
const serializer = { serialize: vi.fn(), deserialize: vi.fn() } as any
983995
const codec = new OpenAPIHandlerCodec({ procedure: os.handler(vi.fn()) }, { serializer })

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,26 @@ export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> {
4444
* @default ((e) => e.toJSON())
4545
*/
4646
customErrorResponseBodyEncoder?: ((error: AnyORPCError) => unknown) | undefined
47+
48+
/**
49+
* The `content-type` of error responses, e.g. `application/problem+json` for RFC 9457 Problem Details.
50+
*
51+
* @default 'application/json'
52+
*/
53+
errorResponseContentType?: string | undefined
4754
}
4855

4956
export class OpenAPIHandlerCodecCore<T extends Context> {
5057
private readonly serializer: Public<OpenAPISerializer>
5158
private readonly errorStatusMap: Exclude<OpenAPIHandlerCodecOptions<T>['errorStatusMap'], undefined>
5259
private readonly customErrorResponseBodySerializer: OpenAPIHandlerCodecOptions<T>['customErrorResponseBodyEncoder']
60+
private readonly errorResponseContentType: OpenAPIHandlerCodecOptions<T>['errorResponseContentType']
5361

5462
constructor(options: OpenAPIHandlerCodecCoreOptions<T> = {}) {
5563
this.serializer = options.serializer ?? new OpenAPISerializer()
5664
this.errorStatusMap = options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP
5765
this.customErrorResponseBodySerializer = options.customErrorResponseBodyEncoder
66+
this.errorResponseContentType = options.errorResponseContentType
5867
}
5968

6069
async decodeInput(
@@ -140,7 +149,7 @@ export class OpenAPIHandlerCodecCore<T extends Context> {
140149

141150
return {
142151
status,
143-
headers: {},
152+
headers: this.errorResponseContentType !== undefined ? { 'content-type': this.errorResponseContentType } : {},
144153
body: this.serializer.serialize(this.customErrorResponseBodySerializer?.(error) ?? error.toJSON()),
145154
}
146155
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,22 @@ describe('openAPILinkCodec', () => {
856856
expectORPCErrorResult(result, 'NOT_FOUND')
857857
})
858858

859+
it.each([
860+
['a matching error', 404, 'application/problem+json', 'json'],
861+
['an error with different case and parameters', 404, 'Application/Problem+JSON; charset=utf-8', 'json'],
862+
['an error with another type', 502, 'text/html', undefined],
863+
['a successful response', 200, 'application/problem+json', 'file'],
864+
] as const)('resolves the body hint of %s with errorResponseContentType', async (_, status, contentType, hint) => {
865+
const codec = new OpenAPILinkCodec({
866+
ping: oc.meta(openapi({ responseBodyHint: 'file' })),
867+
}, { serializer, errorResponseContentType: 'application/problem+json; charset=utf-8' })
868+
869+
const resolveBody = vi.fn(async () => new ORPCError('NOT_FOUND').toJSON())
870+
await codec.decodeResponse({ status, headers: { 'content-type': contentType }, resolveBody }, ['ping'], { context: {} })
871+
872+
expect(resolveBody).toHaveBeenCalledWith(hint)
873+
})
874+
859875
it('throws MalformedResponseError on unknown error payloads', async () => {
860876
const codec = new OpenAPILinkCodec({
861877
ping: oc.meta(openapi({})),

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { createORPCErrorFromJson, isORPCErrorJson, MalformedResponseError, Respo
88
import { getRouterContract, ProcedureContract } from '@orpc/contract'
99
import { unlazy } from '@orpc/server'
1010
import { isTypescriptObject, mergeHttpPath, pathToHttpPath, safeEncodeURIComponent, stringifyJSON, value } from '@orpc/shared'
11-
import { mergeStandardHeaders, parseStandardUrl } from '@standard-server/core'
11+
import { flattenStandardHeader, mergeStandardHeaders, parseStandardUrl } from '@standard-server/core'
1212
import { toStandardHeaders } from '@standard-server/fetch'
1313
import {
1414
DEFAULT_OPENAPI_INPUT_STRUCTURE,
@@ -47,6 +47,14 @@ export interface OpenAPILinkCodecOptions<T extends ClientContext> {
4747
deserializedBody: unknown,
4848
response: StandardLazyResponse,
4949
) => AnyORPCError | null | undefined) | undefined
50+
51+
/**
52+
* Parse error responses with this `content-type` as JSON, ignoring case and parameters such as `charset`.
53+
* Should match the handler's `errorResponseContentType`.
54+
*
55+
* @example 'application/problem+json'
56+
*/
57+
errorResponseContentType?: string | undefined
5058
}
5159

5260
const END_SLASH_REGEX = /\/$/
@@ -57,6 +65,7 @@ export class OpenAPILinkCodec<T extends ClientContext> implements StandardLinkCo
5765
private readonly headers: Exclude<OpenAPILinkCodecOptions<T>['headers'], undefined>
5866
private readonly serializer: Exclude<OpenAPILinkCodecOptions<T>['serializer'], undefined>
5967
private readonly customErrorResponseBodyDecoder: OpenAPILinkCodecOptions<T>['customErrorResponseBodyDecoder']
68+
private readonly errorResponseMediaType: string | undefined
6069

6170
constructor(
6271
private readonly router: RouterContract,
@@ -66,6 +75,7 @@ export class OpenAPILinkCodec<T extends ClientContext> implements StandardLinkCo
6675
this.headers = options.headers ?? {}
6776
this.serializer = options.serializer ?? new OpenAPISerializer()
6877
this.customErrorResponseBodyDecoder = options.customErrorResponseBodyDecoder
78+
this.errorResponseMediaType = toMediaType(options.errorResponseContentType)
6979
}
7080

7181
async encodeInput(input: unknown, path: string[], options: ClientOptions<T>): Promise<StandardRequest> {
@@ -382,7 +392,13 @@ export class OpenAPILinkCodec<T extends ClientContext> implements StandardLinkCo
382392
const procedure = await this.resolveProcedure(path)
383393
const meta = getOpenAPIMeta(procedure)
384394

385-
const body = await response.resolveBody(isOk ? meta?.responseBodyHint : undefined)
395+
const errorBodyHint = !isOk
396+
&& this.errorResponseMediaType !== undefined
397+
&& toMediaType(flattenStandardHeader(response.headers['content-type'])) === this.errorResponseMediaType
398+
? 'json'
399+
: undefined
400+
401+
const body = await response.resolveBody(isOk ? meta?.responseBodyHint : errorBodyHint)
386402

387403
const deserialized = await (async () => {
388404
try {
@@ -457,6 +473,13 @@ function combineSearch(baseSearch: `?${string}` | undefined, additionalSearch: s
457473
return `${baseSearch}&${additionalSearch}` as `?${string}`
458474
}
459475

476+
/**
477+
* Strips parameters and normalizes case, e.g. `Application/Problem+JSON; charset=utf-8` -> `application/problem+json`.
478+
*/
479+
function toMediaType(contentType: string | undefined): string | undefined {
480+
return contentType?.split(';')[0]?.trim().toLowerCase()
481+
}
482+
460483
function toResolvedStandardHeaders(headers: Headers | StandardHeaders): StandardHeaders {
461484
/**
462485
* Headers class might not be available in some environments,

‎packages/openapi/src/openapi-generator-operation.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ describe('openAPIGenerator operation builders', () => {
2020
schemas?: Record<string, any>
2121
errorStatusMap?: Record<string, number>
2222
customErrorResponseBodySchema?: OpenAPIOperationContext['customErrorResponseBodySchema']
23+
errorResponseContentType?: string
2324
} = {}) {
2425
const doc: OpenAPIV3_2.OpenAPIObject = {
2526
openapi: '3.2.0',
@@ -45,6 +46,7 @@ describe('openAPIGenerator operation builders', () => {
4546
},
4647
errorStatusMap: options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP,
4748
customErrorResponseBodySchema: options.customErrorResponseBodySchema,
49+
errorResponseContentType: options.errorResponseContentType ?? 'application/json',
4850
}
4951

5052
const operation: OpenAPIV3_2.OperationObject = {}
@@ -934,5 +936,13 @@ describe('openAPIGenerator operation builders', () => {
934936
expect.objectContaining({ oneOf: expect.any(Array) }),
935937
)
936938
})
939+
940+
it('documents error bodies under the configured content type', () => {
941+
const { ctx, operation } = createContext({ errorResponseContentType: 'application/problem+json' })
942+
943+
buildErrorResponse(ctx, operation, testDef({ errors: { NOT_FOUND: {} } }))
944+
945+
expect(Object.keys((operation.responses?.['404'] as any).content)).toEqual(['application/problem+json'])
946+
})
937947
})
938948
})

‎packages/openapi/src/openapi-generator-operation.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export interface OpenAPIOperationContext {
4949
JsonSchema | undefined | null,
5050
[definedErrors: OpenAPIErrorBodyDefinition[], status: number]
5151
> | undefined
52+
errorResponseContentType: string
5253
}
5354

5455
export function toOpenAPIPath(path: `/${string}`, dynamicPathParams: DynamicPathParam[] | undefined): `/${string}` {
@@ -551,7 +552,7 @@ export function buildErrorResponse(
551552
operation.responses[toResponseStatusKey(status)] = {
552553
description: descriptions.length ? descriptions.join(', ') : status.toString(),
553554
content: {
554-
'application/json': {
555+
[ctx.errorResponseContentType]: {
555556
schema: ctx.registry.toOpenAPISchema(responseSchema, 'output'),
556557
},
557558
},

‎packages/openapi/src/openapi-generator.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ export interface OpenAPIGeneratorGenerateOptions<TVersion extends OpenAPIVersion
8585
[definedErrors: OpenAPIErrorBodyDefinition[], status: number]
8686
> | undefined
8787

88+
/**
89+
* Media type of error responses in the generated document, e.g. `application/problem+json`.
90+
* Should match the handler's `errorResponseContentType`.
91+
*
92+
* @default 'application/json'
93+
*/
94+
errorResponseContentType?: string | undefined
95+
8896
/**
8997
* Mapping ORPCError Code -> HTTP Status Code
9098
* Codes missing from the map fall back to `DEFAULT_ERROR_STATUS` (400).
@@ -129,6 +137,7 @@ export class OpenAPIGenerator {
129137
convertSchemas: (schemas, direction) => this.convertSchemas(schemas, direction),
130138
errorStatusMap: options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP,
131139
customErrorResponseBodySchema: options.customErrorResponseBodySchema,
140+
errorResponseContentType: options.errorResponseContentType ?? 'application/json',
132141
}
133142

134143
const errors: string[] = []

0 commit comments

Comments
 (0)