From 18e26a4e4a55e6a2422b792bb71ae2199884d7c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:49:08 +0000 Subject: [PATCH 1/4] feat(openapi): configurable media type for error responses Add `errorResponseContentType` to `OpenAPIHandler` and `OpenAPIGenerator.generate()`, e.g. `application/problem+json` for RFC 9457 Problem Details. The generator documents error responses under the configured media type. The handler codec sets the `content-type` header on error responses; it reaches the wire once `@standard-server` preserves a provided content type for JSON bodies. Refs #2317 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012JepnhdVCkPJwexoiHyqRR --- apps/content/docs/openapi/handler.mdx | 8 ++++++++ apps/content/docs/openapi/specification.mdx | 8 ++++++++ .../standard/openapi-handler-codec.test.ts | 11 +++++++++++ .../adapters/standard/openapi-handler-codec.ts | 12 +++++++++++- .../src/openapi-generator-operation.test.ts | 13 +++++++++++++ .../openapi/src/openapi-generator-operation.ts | 3 ++- packages/openapi/src/openapi-generator.ts | 9 +++++++++ .../tests/openapi-generator/typed-errors.test.ts | 16 ++++++++++++++++ 8 files changed, 78 insertions(+), 2 deletions(-) diff --git a/apps/content/docs/openapi/handler.mdx b/apps/content/docs/openapi/handler.mdx index 711ee74d21..5ce6ba00cc 100644 --- a/apps/content/docs/openapi/handler.mdx +++ b/apps/content/docs/openapi/handler.mdx @@ -249,6 +249,14 @@ const handler = new OpenAPIHandler(router, { +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): + +```ts +const handler = new OpenAPIHandler(router, { + errorResponseContentType: 'application/problem+json', +}) +``` + :::info If you use `OpenAPILink` with a custom server-side error format, make sure to configure [Custom Error Decoding](/docs/openapi/link#custom-error-decoding). ::: diff --git a/apps/content/docs/openapi/specification.mdx b/apps/content/docs/openapi/specification.mdx index 78341221f5..ad73203fb4 100644 --- a/apps/content/docs/openapi/specification.mdx +++ b/apps/content/docs/openapi/specification.mdx @@ -257,3 +257,11 @@ const spec = await generator.generate(router, { }, }) ``` + +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: + +```ts +const spec = await generator.generate(router, { + errorResponseContentType: 'application/problem+json', +}) +``` diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts index e81b2725f4..411a1ea21b 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts @@ -978,6 +978,17 @@ describe('openAPIHandlerCodec', () => { expect(serializer.serialize).toHaveBeenNthCalledWith(2, secondError.toJSON()) }) + it('sets content-type header via errorResponseContentType option', () => { + const procedure = os.handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure, { errorResponseContentType: 'application/problem+json' }) + + expect(codec.encodeError(new ORPCError('BAD_GATEWAY'))).toEqual({ + status: 502, + headers: { 'content-type': 'application/problem+json' }, + body: new ORPCError('BAD_GATEWAY').toJSON(), + }) + }) + it('does not resolve error codes through Object.prototype', async () => { const serializer = { serialize: vi.fn(), deserialize: vi.fn() } as any const codec = new OpenAPIHandlerCodec({ procedure: os.handler(vi.fn()) }, { serializer }) diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts index 0bcf89d6d2..06bedf81c9 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts @@ -44,17 +44,27 @@ export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> { * @default ((e) => e.toJSON()) */ customErrorResponseBodyEncoder?: ((error: AnyORPCError) => unknown) | undefined + + /** + * The `content-type` header sent with JSON error responses, + * e.g. `application/problem+json` for RFC 9457 Problem Details. + * + * @default 'application/json' + */ + errorResponseContentType?: string | undefined } export class OpenAPIHandlerCodecCore { private readonly serializer: Public private readonly errorStatusMap: Exclude['errorStatusMap'], undefined> private readonly customErrorResponseBodySerializer: OpenAPIHandlerCodecOptions['customErrorResponseBodyEncoder'] + private readonly errorResponseContentType: OpenAPIHandlerCodecOptions['errorResponseContentType'] constructor(options: OpenAPIHandlerCodecCoreOptions = {}) { this.serializer = options.serializer ?? new OpenAPISerializer() this.errorStatusMap = options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP this.customErrorResponseBodySerializer = options.customErrorResponseBodyEncoder + this.errorResponseContentType = options.errorResponseContentType } async decodeInput( @@ -140,7 +150,7 @@ export class OpenAPIHandlerCodecCore { return { status, - headers: {}, + headers: this.errorResponseContentType !== undefined ? { 'content-type': this.errorResponseContentType } : {}, body: this.serializer.serialize(this.customErrorResponseBodySerializer?.(error) ?? error.toJSON()), } } diff --git a/packages/openapi/src/openapi-generator-operation.test.ts b/packages/openapi/src/openapi-generator-operation.test.ts index a24b83be6d..750e2c3c0f 100644 --- a/packages/openapi/src/openapi-generator-operation.test.ts +++ b/packages/openapi/src/openapi-generator-operation.test.ts @@ -20,6 +20,7 @@ describe('openAPIGenerator operation builders', () => { schemas?: Record errorStatusMap?: Record customErrorResponseBodySchema?: OpenAPIOperationContext['customErrorResponseBodySchema'] + errorResponseContentType?: string } = {}) { const doc: OpenAPIV3_2.OpenAPIObject = { openapi: '3.2.0', @@ -45,6 +46,7 @@ describe('openAPIGenerator operation builders', () => { }, errorStatusMap: options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP, customErrorResponseBodySchema: options.customErrorResponseBodySchema, + errorResponseContentType: options.errorResponseContentType ?? 'application/json', } const operation: OpenAPIV3_2.OperationObject = {} @@ -934,5 +936,16 @@ describe('openAPIGenerator operation builders', () => { expect.objectContaining({ oneOf: expect.any(Array) }), ) }) + + it('documents error bodies under the configured content type', () => { + const { ctx, operation } = createContext({ errorResponseContentType: 'application/problem+json' }) + + buildErrorResponse(ctx, operation, testDef({ + errors: { BAD_REQUEST: {}, NOT_FOUND: {} }, + })) + + expect(Object.keys((operation.responses?.['400'] as any).content)).toEqual(['application/problem+json']) + expect(Object.keys((operation.responses?.['404'] as any).content)).toEqual(['application/problem+json']) + }) }) }) diff --git a/packages/openapi/src/openapi-generator-operation.ts b/packages/openapi/src/openapi-generator-operation.ts index 681a090931..520927a8ed 100644 --- a/packages/openapi/src/openapi-generator-operation.ts +++ b/packages/openapi/src/openapi-generator-operation.ts @@ -49,6 +49,7 @@ export interface OpenAPIOperationContext { JsonSchema | undefined | null, [definedErrors: OpenAPIErrorBodyDefinition[], status: number] > | undefined + errorResponseContentType: string } export function toOpenAPIPath(path: `/${string}`, dynamicPathParams: DynamicPathParam[] | undefined): `/${string}` { @@ -551,7 +552,7 @@ export function buildErrorResponse( operation.responses[toResponseStatusKey(status)] = { description: descriptions.length ? descriptions.join(', ') : status.toString(), content: { - 'application/json': { + [ctx.errorResponseContentType]: { schema: ctx.registry.toOpenAPISchema(responseSchema, 'output'), }, }, diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index a6e6684666..8685efeec3 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -85,6 +85,14 @@ export interface OpenAPIGeneratorGenerateOptions | undefined + /** + * The media type error response bodies are documented under, + * e.g. `application/problem+json` for RFC 9457 Problem Details. + * + * @default 'application/json' + */ + errorResponseContentType?: string | undefined + /** * Mapping ORPCError Code -> HTTP Status Code * Codes missing from the map fall back to `DEFAULT_ERROR_STATUS` (400). @@ -129,6 +137,7 @@ export class OpenAPIGenerator { convertSchemas: (schemas, direction) => this.convertSchemas(schemas, direction), errorStatusMap: options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP, customErrorResponseBodySchema: options.customErrorResponseBodySchema, + errorResponseContentType: options.errorResponseContentType ?? 'application/json', } const errors: string[] = [] diff --git a/packages/openapi/tests/openapi-generator/typed-errors.test.ts b/packages/openapi/tests/openapi-generator/typed-errors.test.ts index 4f455f95ab..62a73d02cc 100644 --- a/packages/openapi/tests/openapi-generator/typed-errors.test.ts +++ b/packages/openapi/tests/openapi-generator/typed-errors.test.ts @@ -201,4 +201,20 @@ describe('openAPIGenerator e2e: typed errors', () => { // only the always-available UndefinedError component is registered expect(Object.keys(doc.components?.schemas ?? {})).toEqual(['UndefinedError']) }) + + it('supports a custom error response content type', async () => { + const doc = await generator.generate({ + getPlanet: oc + .meta(openapi({ method: 'GET', path: '/planets/{id}' })) + .errors({ NOT_FOUND: {} }) + .input(z.object({ id: z.string() })) + .output(z.object({ name: z.string() })), + }, { + errorResponseContentType: 'application/problem+json', + }) + + const responses = doc.paths?.['/planets/{id}']?.get?.responses as any + expect(Object.keys(responses['200'].content)).toEqual(['application/json']) + expect(Object.keys(responses['404'].content)).toEqual(['application/problem+json']) + }) }) From 61bf41d06a203824d7e523bf8b7034fed4b4232d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:54:13 +0000 Subject: [PATCH 2/4] feat(openapi): send custom error content type without standard-server changes With `errorResponseContentType` set, the handler sends the error body as a JSON Blob of that type, since standard-server always sends plain JSON bodies as `application/json`. `OpenAPILink` gets the matching option and parses error responses with that content type as JSON. Refs #2317 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012JepnhdVCkPJwexoiHyqRR --- apps/content/docs/openapi/handler.mdx | 2 ++ apps/content/docs/openapi/link.mdx | 8 +++++ .../adapters/fetch/openapi-handler.test.ts | 25 +++++++++++++++- .../standard/openapi-handler-codec.test.ts | 15 ++++++---- .../standard/openapi-handler-codec.ts | 13 +++++--- .../standard/openapi-link-codec.test.ts | 30 +++++++++++++++++++ .../adapters/standard/openapi-link-codec.ts | 18 +++++++++-- 7 files changed, 98 insertions(+), 13 deletions(-) diff --git a/apps/content/docs/openapi/handler.mdx b/apps/content/docs/openapi/handler.mdx index 5ce6ba00cc..250b5dabce 100644 --- a/apps/content/docs/openapi/handler.mdx +++ b/apps/content/docs/openapi/handler.mdx @@ -257,6 +257,8 @@ const handler = new OpenAPIHandler(router, { }) ``` +With this option, the JSON error body is sent as a file of that media type, so set the same `errorResponseContentType` on [OpenAPILink](/docs/openapi/link#custom-error-decoding) to keep parsing it as JSON. + :::info If you use `OpenAPILink` with a custom server-side error format, make sure to configure [Custom Error Decoding](/docs/openapi/link#custom-error-decoding). ::: diff --git a/apps/content/docs/openapi/link.mdx b/apps/content/docs/openapi/link.mdx index dadee9cdfe..339fd9ac4e 100644 --- a/apps/content/docs/openapi/link.mdx +++ b/apps/content/docs/openapi/link.mdx @@ -242,6 +242,14 @@ const link = new OpenAPILink(contract, { }) ``` +If the handler sends errors with a custom [`errorResponseContentType`](/docs/openapi/handler#custom-error-response), set the same value on the link so those error bodies are still parsed as JSON: + +```ts +const link = new OpenAPILink(contract, { + errorResponseContentType: 'application/problem+json', +}) +``` + ## Error Responses 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): diff --git a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts index 2d0fea0fbb..3d8a576a76 100644 --- a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts @@ -1,5 +1,5 @@ import type { StandardHandlerPlugin } from '@orpc/server/standard' -import { createORPCClient } from '@orpc/client' +import { createORPCClient, ORPCError } from '@orpc/client' import { os } from '@orpc/server' import { openapi } from '../../meta' import { OpenAPIHandler } from './openapi-handler' @@ -78,6 +78,29 @@ describe('openapiHandler', () => { expect(fetch).toHaveBeenCalledWith('http://localhost:3000/api/v1^beta/items', expect.anything(), expect.anything(), ['beta']) }) + it('sends and decodes error responses with errorResponseContentType', async () => { + const router = { + ping: os.meta(openapi({ path: '/ping' })).handler(() => { + throw new ORPCError('NOT_FOUND', { message: 'Missing' }) + }), + } + + const handler = new OpenAPIHandler(router, { errorResponseContentType: 'application/problem+json' }) + + const { response } = await handler.handle(new Request('http://localhost:3000/ping', { method: 'POST' })) + expect(response!.status).toBe(404) + expect(response!.headers.get('content-type')).toBe('application/problem+json') + await expect(response!.json()).resolves.toEqual(new ORPCError('NOT_FOUND', { message: 'Missing' }).toJSON()) + + const client = createORPCClient(new OpenAPILink(router, { + fetch: async (url, init) => (await handler.handle(new Request(url, init))).response!, + origin: 'http://localhost:3000', + errorResponseContentType: 'application/problem+json', + })) + + await expect(client.ping()).rejects.toSatisfy((e: any) => e instanceof ORPCError && e.code === 'NOT_FOUND' && e.message === 'Missing') + }) + it('supports standard handler plugins', async () => { const plugin: StandardHandlerPlugin = { name: 'test', diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts index 411a1ea21b..705e8ac9a9 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts @@ -978,15 +978,18 @@ describe('openAPIHandlerCodec', () => { expect(serializer.serialize).toHaveBeenNthCalledWith(2, secondError.toJSON()) }) - it('sets content-type header via errorResponseContentType option', () => { + it('encodes the body as a JSON Blob with errorResponseContentType', async () => { const procedure = os.handler(vi.fn()) const codec = new OpenAPIHandlerCodec(procedure, { errorResponseContentType: 'application/problem+json' }) + const error = new ORPCError('BAD_GATEWAY') - expect(codec.encodeError(new ORPCError('BAD_GATEWAY'))).toEqual({ - status: 502, - headers: { 'content-type': 'application/problem+json' }, - body: new ORPCError('BAD_GATEWAY').toJSON(), - }) + const response = await codec.encodeError(error) + + expect(response.status).toBe(502) + expect(response.headers).toEqual({}) + expect(response.body).toBeInstanceOf(Blob) + expect((response.body as Blob).type).toBe('application/problem+json') + expect(JSON.parse(await (response.body as Blob).text())).toEqual(error.toJSON()) }) it('does not resolve error codes through Object.prototype', async () => { diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts index 06bedf81c9..6ac4530d6b 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts @@ -46,8 +46,8 @@ export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> { customErrorResponseBodyEncoder?: ((error: AnyORPCError) => unknown) | undefined /** - * The `content-type` header sent with JSON error responses, - * e.g. `application/problem+json` for RFC 9457 Problem Details. + * The `content-type` of error responses, e.g. `application/problem+json` for RFC 9457 Problem Details. + * Set the same value on `OpenAPILink` so it still parses error bodies as JSON. * * @default 'application/json' */ @@ -148,10 +148,15 @@ export class OpenAPIHandlerCodecCore { encodeError(error: AnyORPCError): Promisable { const status = getOwn(this.errorStatusMap, error.code) ?? DEFAULT_ERROR_STATUS + const body = this.serializer.serialize(this.customErrorResponseBodySerializer?.(error) ?? error.toJSON()) + return { status, - headers: this.errorResponseContentType !== undefined ? { 'content-type': this.errorResponseContentType } : {}, - body: this.serializer.serialize(this.customErrorResponseBodySerializer?.(error) ?? error.toJSON()), + headers: {}, + // A Blob keeps its own content-type, while JSON bodies are always sent as `application/json` + body: this.errorResponseContentType !== undefined + ? new Blob([stringifyJSON(body) ?? ''], { type: this.errorResponseContentType }) + : body, } } diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts index 6be483192f..7170e6ddc6 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts @@ -837,6 +837,36 @@ describe('openAPILinkCodec', () => { expectORPCErrorResult(result, 'NOT_FOUND') }) + it('parses error responses with errorResponseContentType as JSON', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({ responseBodyHint: 'file' })), + }, { serializer, errorResponseContentType: 'application/problem+json' }) + + const resolveBody = vi.fn(async () => new ORPCError('NOT_FOUND').toJSON()) + const result = await codec.decodeResponse({ + status: 404, + headers: { 'content-type': 'application/problem+json' }, + resolveBody, + }, ['ping'], { context: {} }) + + expectORPCErrorResult(result, 'NOT_FOUND') + expect(resolveBody).toHaveBeenCalledWith('json') + + await codec.decodeResponse({ + status: 502, + headers: { 'content-type': 'text/html' }, + resolveBody, + }, ['ping'], { context: {} }) + expect(resolveBody).toHaveBeenLastCalledWith('file') + + await codec.decodeResponse({ + status: 200, + headers: { 'content-type': 'application/problem+json' }, + resolveBody, + }, ['ping'], { context: {} }) + expect(resolveBody).toHaveBeenLastCalledWith('file') + }) + it('throws MalformedResponseError on unknown error payloads', async () => { const codec = new OpenAPILinkCodec({ ping: oc.meta(openapi({})), diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.ts index 544a5d6ef7..e7d5a38ffd 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.ts @@ -8,7 +8,7 @@ import { createORPCErrorFromJson, isORPCErrorJson, MalformedResponseError, Respo import { getRouterContract, ProcedureContract } from '@orpc/contract' import { unlazy } from '@orpc/server' import { isTypescriptObject, mergeHttpPath, pathToHttpPath, safeEncodeURIComponent, stringifyJSON, value } from '@orpc/shared' -import { mergeStandardHeaders, parseStandardUrl } from '@standard-server/core' +import { flattenStandardHeader, mergeStandardHeaders, parseStandardUrl } from '@standard-server/core' import { toStandardHeaders } from '@standard-server/fetch' import { DEFAULT_OPENAPI_INPUT_STRUCTURE, @@ -47,6 +47,14 @@ export interface OpenAPILinkCodecOptions { deserializedBody: unknown, response: StandardLazyResponse, ) => AnyORPCError | null | undefined) | undefined + + /** + * Error responses with this `content-type` are parsed as JSON. + * Should match `errorResponseContentType` of the OpenAPI handler. + * + * @example 'application/problem+json' + */ + errorResponseContentType?: string | undefined } const END_SLASH_REGEX = /\/$/ @@ -57,6 +65,7 @@ export class OpenAPILinkCodec implements StandardLinkCo private readonly headers: Exclude['headers'], undefined> private readonly serializer: Exclude['serializer'], undefined> private readonly customErrorResponseBodyDecoder: OpenAPILinkCodecOptions['customErrorResponseBodyDecoder'] + private readonly errorResponseContentType: OpenAPILinkCodecOptions['errorResponseContentType'] constructor( private readonly router: RouterContract, @@ -66,6 +75,7 @@ export class OpenAPILinkCodec implements StandardLinkCo this.headers = options.headers ?? {} this.serializer = options.serializer ?? new OpenAPISerializer() this.customErrorResponseBodyDecoder = options.customErrorResponseBodyDecoder + this.errorResponseContentType = options.errorResponseContentType } async encodeInput(input: unknown, path: string[], options: ClientOptions): Promise { @@ -382,7 +392,11 @@ export class OpenAPILinkCodec implements StandardLinkCo const procedure = await this.resolveProcedure(path) const meta = getOpenAPIMeta(procedure) - const body = await response.resolveBody(meta?.responseBodyHint) + const isCustomErrorContentType = !isOk + && this.errorResponseContentType !== undefined + && flattenStandardHeader(response.headers['content-type']) === this.errorResponseContentType + + const body = await response.resolveBody(isCustomErrorContentType ? 'json' : meta?.responseBodyHint) const deserialized = await (async () => { try { From 05b75720055d0e5d9fe7f5db976e731ff93edfe7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 07:34:26 +0000 Subject: [PATCH 3/4] fix(openapi): set content-type and drop standard-server header on custom error responses Set `content-type` and an empty `standard-server` header on error responses with `errorResponseContentType`, so the Blob body is not marked as a file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012JepnhdVCkPJwexoiHyqRR --- apps/content/docs/openapi/handler.mdx | 2 +- .../adapters/fetch/openapi-handler.test.ts | 1 + .../src/adapters/node/openapi-handler.test.ts | 19 ++++++++++++++++++- .../standard/openapi-handler-codec.test.ts | 2 +- .../standard/openapi-handler-codec.ts | 13 ++++++++----- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/content/docs/openapi/handler.mdx b/apps/content/docs/openapi/handler.mdx index 250b5dabce..9b28f75942 100644 --- a/apps/content/docs/openapi/handler.mdx +++ b/apps/content/docs/openapi/handler.mdx @@ -257,7 +257,7 @@ const handler = new OpenAPIHandler(router, { }) ``` -With this option, the JSON error body is sent as a file of that media type, so set the same `errorResponseContentType` on [OpenAPILink](/docs/openapi/link#custom-error-decoding) to keep parsing it as JSON. +[OpenAPILink](/docs/openapi/link#custom-error-decoding) only detects `application/json` bodies as JSON, so set the same `errorResponseContentType` there too. :::info If you use `OpenAPILink` with a custom server-side error format, make sure to configure [Custom Error Decoding](/docs/openapi/link#custom-error-decoding). diff --git a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts index 3d8a576a76..3a7b72650e 100644 --- a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts @@ -90,6 +90,7 @@ describe('openapiHandler', () => { const { response } = await handler.handle(new Request('http://localhost:3000/ping', { method: 'POST' })) expect(response!.status).toBe(404) expect(response!.headers.get('content-type')).toBe('application/problem+json') + expect(response!.headers.has('standard-server')).toBe(false) await expect(response!.json()).resolves.toEqual(new ORPCError('NOT_FOUND', { message: 'Missing' }).toJSON()) const client = createORPCClient(new OpenAPILink(router, { diff --git a/packages/openapi/src/adapters/node/openapi-handler.test.ts b/packages/openapi/src/adapters/node/openapi-handler.test.ts index 8bfa19661f..87ecbe62f8 100644 --- a/packages/openapi/src/adapters/node/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/node/openapi-handler.test.ts @@ -1,6 +1,6 @@ import type { StandardHandlerPlugin } from '@orpc/server/standard' import type { IncomingMessage, ServerResponse } from 'node:http' -import { os } from '@orpc/server' +import { ORPCError, os } from '@orpc/server' import request from 'supertest' import { openapi } from '../../meta' import { OpenAPIHandler } from './openapi-handler' @@ -10,6 +10,23 @@ describe('openapiHandler', () => { vi.clearAllMocks() }) + it('sends error responses with errorResponseContentType', async () => { + const handler = new OpenAPIHandler({ + ping: os.handler(() => { + throw new ORPCError('NOT_FOUND') + }), + }, { errorResponseContentType: 'application/problem+json' }) + + const res = await request(async (req: IncomingMessage, response: ServerResponse) => { + await handler.handle(req as any, response as any) + }).post('/ping') + + expect(res.status).toBe(404) + expect(res.headers['content-type']).toBe('application/problem+json') + expect(res.headers['standard-server']).toBeUndefined() + expect(JSON.parse(res.text)).toEqual(new ORPCError('NOT_FOUND').toJSON()) + }) + it('accepts context and prefix options in handle method', async () => { const handler = new OpenAPIHandler({ ping: os diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts index 705e8ac9a9..c378041fc6 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts @@ -986,7 +986,7 @@ describe('openAPIHandlerCodec', () => { const response = await codec.encodeError(error) expect(response.status).toBe(502) - expect(response.headers).toEqual({}) + expect(response.headers).toEqual({ 'content-type': 'application/problem+json', 'standard-server': [] }) expect(response.body).toBeInstanceOf(Blob) expect((response.body as Blob).type).toBe('application/problem+json') expect(JSON.parse(await (response.body as Blob).text())).toEqual(error.toJSON()) diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts index 6ac4530d6b..08228c7a37 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts @@ -150,13 +150,16 @@ export class OpenAPIHandlerCodecCore { const body = this.serializer.serialize(this.customErrorResponseBodySerializer?.(error) ?? error.toJSON()) + if (this.errorResponseContentType === undefined) { + return { status, headers: {}, body } + } + + // JSON bodies are always sent as `application/json`, a Blob keeps the given content-type. + // An empty `standard-server` header stops the Blob from being marked as a file. return { status, - headers: {}, - // A Blob keeps its own content-type, while JSON bodies are always sent as `application/json` - body: this.errorResponseContentType !== undefined - ? new Blob([stringifyJSON(body) ?? ''], { type: this.errorResponseContentType }) - : body, + headers: { 'content-type': this.errorResponseContentType, 'standard-server': [] }, + body: new Blob([stringifyJSON(body) ?? ''], { type: this.errorResponseContentType }), } } From 001fd324b23d368354f4e604f278029cd3f98bc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 07:36:23 +0000 Subject: [PATCH 4/4] fix(openapi): compare error content type by media type in OpenAPILink Strip parameters and lowercase both the response `content-type` and `errorResponseContentType` before comparing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012JepnhdVCkPJwexoiHyqRR --- .../standard/openapi-link-codec.test.ts | 22 +++++++++++++++++++ .../adapters/standard/openapi-link-codec.ts | 13 ++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts index 7170e6ddc6..f3606c527c 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts @@ -852,6 +852,13 @@ describe('openAPILinkCodec', () => { expectORPCErrorResult(result, 'NOT_FOUND') expect(resolveBody).toHaveBeenCalledWith('json') + await codec.decodeResponse({ + status: 404, + headers: { 'content-type': 'Application/Problem+JSON; charset=utf-8' }, + resolveBody, + }, ['ping'], { context: {} }) + expect(resolveBody).toHaveBeenLastCalledWith('json') + await codec.decodeResponse({ status: 502, headers: { 'content-type': 'text/html' }, @@ -867,6 +874,21 @@ describe('openAPILinkCodec', () => { expect(resolveBody).toHaveBeenLastCalledWith('file') }) + it('ignores parameters of errorResponseContentType', async () => { + const codec = new OpenAPILinkCodec({ + ping: oc.meta(openapi({})), + }, { serializer, errorResponseContentType: 'application/problem+json; charset=utf-8' }) + + const resolveBody = vi.fn(async () => new ORPCError('NOT_FOUND').toJSON()) + await codec.decodeResponse({ + status: 404, + headers: { 'content-type': 'application/problem+json' }, + resolveBody, + }, ['ping'], { context: {} }) + + expect(resolveBody).toHaveBeenCalledWith('json') + }) + it('throws MalformedResponseError on unknown error payloads', async () => { const codec = new OpenAPILinkCodec({ ping: oc.meta(openapi({})), diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.ts index e7d5a38ffd..bfb73aac08 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.ts @@ -49,7 +49,7 @@ export interface OpenAPILinkCodecOptions { ) => AnyORPCError | null | undefined) | undefined /** - * Error responses with this `content-type` are parsed as JSON. + * Error responses with this `content-type` are parsed as JSON (compared case-insensitively, ignoring parameters like `charset`). * Should match `errorResponseContentType` of the OpenAPI handler. * * @example 'application/problem+json' @@ -75,7 +75,7 @@ export class OpenAPILinkCodec implements StandardLinkCo this.headers = options.headers ?? {} this.serializer = options.serializer ?? new OpenAPISerializer() this.customErrorResponseBodyDecoder = options.customErrorResponseBodyDecoder - this.errorResponseContentType = options.errorResponseContentType + this.errorResponseContentType = toMediaType(options.errorResponseContentType) } async encodeInput(input: unknown, path: string[], options: ClientOptions): Promise { @@ -394,7 +394,7 @@ export class OpenAPILinkCodec implements StandardLinkCo const isCustomErrorContentType = !isOk && this.errorResponseContentType !== undefined - && flattenStandardHeader(response.headers['content-type']) === this.errorResponseContentType + && toMediaType(flattenStandardHeader(response.headers['content-type'])) === this.errorResponseContentType const body = await response.resolveBody(isCustomErrorContentType ? 'json' : meta?.responseBodyHint) @@ -471,6 +471,13 @@ function combineSearch(baseSearch: `?${string}` | undefined, additionalSearch: s return `${baseSearch}&${additionalSearch}` as `?${string}` } +/** + * Strips parameters and normalizes case, e.g. `Application/Problem+JSON; charset=utf-8` -> `application/problem+json`. + */ +function toMediaType(contentType: string | undefined): string | undefined { + return contentType?.split(';')[0]?.trim().toLowerCase() +} + function toResolvedStandardHeaders(headers: Headers | StandardHeaders): StandardHeaders { /** * Headers class might not be available in some environments,