From 06ef511f9dc5c194ceefdd263f6fa7c5f367d449 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 07:25:05 +0000 Subject: [PATCH 1/4] feat(openapi)!: no default fallback for error response body options Returning `null` or `undefined` from `errorResponseBodyEncoder`, `errorResponseBodyDecoder` or `errorResponseBodySchema` no longer falls back to the default behavior. Each option always decides, and the default stays reachable from its arguments. - Handler: the encoder's return value is always the body (`undefined` sends an empty one, without the configured `errorResponseContentType`); it defaults to `error.toJSON()`. - Link: the decoder must return an `Error` and receives a lazy `defaultError()` that returns the decoded ORPC error or a `MalformedResponseError`. `StandardLinkCodecDecodedResponse` now accepts any `Error`. - Generator: `errorResponseBodySchema` receives a lazy `defaultSchema()` (per-code error components are only registered when it is called), and `null`/`undefined` documents an empty body. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7SJxePN5EdCgmjsAN5 --- apps/content/docs/migrations/from-v1.mdx | 4 +- apps/content/docs/openapi/handler.mdx | 3 +- apps/content/docs/openapi/link.mdx | 5 +- apps/content/docs/openapi/specification.mdx | 5 +- .../client/src/adapters/standard/codec.ts | 3 +- .../standard/openapi-handler-codec.test.ts | 51 ++++++++----------- .../standard/openapi-handler-codec.ts | 11 ++-- .../standard/openapi-link-codec.test.ts | 34 ++++++++----- .../adapters/standard/openapi-link-codec.ts | 36 ++++++------- .../src/openapi-generator-operation.test.ts | 18 +++++-- .../src/openapi-generator-operation.ts | 37 ++++++++------ packages/openapi/src/openapi-generator.ts | 6 +-- 12 files changed, 115 insertions(+), 98 deletions(-) diff --git a/apps/content/docs/migrations/from-v1.mdx b/apps/content/docs/migrations/from-v1.mdx index f1af18a61d..7258c14b3f 100644 --- a/apps/content/docs/migrations/from-v1.mdx +++ b/apps/content/docs/migrations/from-v1.mdx @@ -1064,7 +1064,7 @@ Three smaller changes in the same area: - The `shouldHoistDef` option was replaced by [`componentName`](/docs/openapi/specification#hoisting-defs). Root `$defs` are now always hoisted into `components.schemas`; this option only renames them. - Documents are generated as OpenAPI 3.2.0 by default. The example above passes [`version: '3.1.1'`](/docs/openapi/specification#openapi-version) to keep the v1 behavior. -### Error response options renamed +### Error response options changed The error response options dropped their `custom` prefix: @@ -1072,6 +1072,8 @@ The error response options dropped their `custom` prefix: - `customErrorResponseBodyEncoder` is now [`errorResponseBodyEncoder`](/docs/openapi/handler#custom-error-response) on `OpenAPIHandler`. - `customErrorResponseBodyDecoder` is now [`errorResponseBodyDecoder`](/docs/openapi/link#custom-error-decoding) on `OpenAPILink`. +They also no longer fall back to the default when returning `null` or `undefined`. Return `error.toJSON()`, `defaultSchema()` or `defaultError()` instead; the latter two are new third arguments. + ### `OpenAPIReferencePlugin` renamed and reshaped The plugin is now `OpenAPIReferenceHandlerPlugin`, and you provide the spec yourself instead of passing converters and generate options. See [OpenAPI Reference Plugin](/docs/plugins/openapi-reference). diff --git a/apps/content/docs/openapi/handler.mdx b/apps/content/docs/openapi/handler.mdx index ebfdac9a03..8b520409ad 100644 --- a/apps/content/docs/openapi/handler.mdx +++ b/apps/content/docs/openapi/handler.mdx @@ -213,8 +213,7 @@ const handler = new OpenAPIHandler(router, { } } - // fallback to default by returning null or undefined - return null + return error.toJSON() }, }) ``` diff --git a/apps/content/docs/openapi/link.mdx b/apps/content/docs/openapi/link.mdx index 1006bd313d..235e3cc324 100644 --- a/apps/content/docs/openapi/link.mdx +++ b/apps/content/docs/openapi/link.mdx @@ -229,15 +229,14 @@ If your server returns error responses that don't match oRPC's expected format, ```ts const link = new OpenAPILink(contract, { - errorResponseBodyDecoder: (body, response) => { + errorResponseBodyDecoder: (body, response, defaultError) => { if (response.status === 422 && typeof body === 'object' && body && 'detail' in body) { return new ORPCError('BAD_REQUEST', { message: String(body.detail), }) } - // fallback to default error decoding logic by returning null or undefined - return null + return defaultError() }, }) ``` diff --git a/apps/content/docs/openapi/specification.mdx b/apps/content/docs/openapi/specification.mdx index 248f203e8e..24ae558c4c 100644 --- a/apps/content/docs/openapi/specification.mdx +++ b/apps/content/docs/openapi/specification.mdx @@ -240,7 +240,7 @@ const spec = await generator.generate(router, { ...COMMON_ERROR_STATUS_MAP, PLANET_GONE: 410, }, - errorResponseBodySchema: (definedErrors, status) => { + errorResponseBodySchema: (definedErrors, status, defaultSchema) => { if (status === 410) { return { type: 'object', @@ -252,8 +252,7 @@ const spec = await generator.generate(router, { } } - // fallback to default by returning null or undefined - return null + return defaultSchema() }, }) ``` diff --git a/packages/client/src/adapters/standard/codec.ts b/packages/client/src/adapters/standard/codec.ts index 66409cc56a..deefe7136c 100644 --- a/packages/client/src/adapters/standard/codec.ts +++ b/packages/client/src/adapters/standard/codec.ts @@ -1,9 +1,8 @@ import type { Promisable } from '@orpc/shared' import type { StandardLazyResponse, StandardRequest } from '@standard-server/core' -import type { AnyORPCError } from '../../error' import type { ClientContext, ClientOptions } from '../../types' -export type StandardLinkCodecDecodedResponse = { kind: 'output', output: unknown } | { kind: 'error', error: AnyORPCError } +export type StandardLinkCodecDecodedResponse = { kind: 'output', output: unknown } | { kind: 'error', error: Error } export interface StandardLinkCodec { encodeInput( 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 16c78fc06c..413593d358 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts @@ -926,22 +926,11 @@ describe('openAPIHandlerCodec', () => { expect(serializer.serialize).toHaveBeenCalledWith(error.toJSON()) }) - it('uses errorResponseBodyEncoder and falls back on null', () => { - let attempt = 1 - const errorResponseBodyEncoder = vi.fn(() => { - if (attempt++ === 2) { - return null - } - - return { - message: 'custom error body', - } - }) + it('uses errorResponseBodyEncoder without falling back on null', () => { + const errorResponseBodyEncoder = vi.fn(() => null) const serializer = { - serialize: vi.fn() - .mockReturnValueOnce('__serialized_custom__') - .mockReturnValueOnce('__serialized_default__'), + serialize: vi.fn(() => '__serialized_custom__'), deserialize: vi.fn(), } as any @@ -951,31 +940,33 @@ describe('openAPIHandlerCodec', () => { errorResponseBodyEncoder, }) - const firstError = new ORPCError('BAD_GATEWAY', { data: '__data1__' }) - const firstResponse = codec.encodeError(firstError) + const error = new ORPCError('BAD_GATEWAY', { data: '__data__' }) - expect(firstResponse).toEqual({ + expect(codec.encodeError(error)).toEqual({ status: 502, headers: {}, body: '__serialized_custom__', }) - const secondError = new ORPCError('UNKNOWN_CODE' as any, { data: '__data2__' }) - const secondResponse = codec.encodeError(secondError) + expect(errorResponseBodyEncoder).toHaveBeenCalledOnce() + expect(errorResponseBodyEncoder).toHaveBeenCalledWith(error) - expect(secondResponse).toEqual({ - status: DEFAULT_ERROR_STATUS, - headers: {}, - body: '__serialized_default__', - }) + expect(serializer.serialize).toHaveBeenCalledOnce() + expect(serializer.serialize).toHaveBeenCalledWith(null) + }) - expect(errorResponseBodyEncoder).toHaveBeenCalledTimes(2) - expect(errorResponseBodyEncoder).toHaveBeenNthCalledWith(1, firstError) - expect(errorResponseBodyEncoder).toHaveBeenNthCalledWith(2, secondError) + it('sends an empty body without content-type when errorResponseBodyEncoder returns undefined', () => { + const procedure = os.handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure, { + errorResponseBodyEncoder: () => undefined, + errorResponseContentType: 'application/problem+json', + }) - expect(serializer.serialize).toHaveBeenCalledTimes(2) - expect(serializer.serialize).toHaveBeenNthCalledWith(1, { message: 'custom error body' }) - expect(serializer.serialize).toHaveBeenNthCalledWith(2, secondError.toJSON()) + expect(codec.encodeError(new ORPCError('NOT_FOUND'))).toEqual({ + status: 404, + headers: {}, + body: undefined, + }) }) it('sets content-type header via errorResponseContentType option', () => { diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts index fcc17196c1..2bbe9b9707 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts @@ -39,7 +39,7 @@ export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> { * Use this if your API needs a different error output structure. * * @remarks - * - Return `null | undefined` to fallback to default behavior + * - Return `error.toJSON()` to keep the default body, or `undefined` to send an empty body * * @default ((e) => e.toJSON()) */ @@ -56,13 +56,13 @@ export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> { export class OpenAPIHandlerCodecCore { private readonly serializer: Public private readonly errorStatusMap: Exclude['errorStatusMap'], undefined> - private readonly errorResponseBodyEncoder: OpenAPIHandlerCodecOptions['errorResponseBodyEncoder'] + private readonly errorResponseBodyEncoder: Exclude['errorResponseBodyEncoder'], undefined> private readonly errorResponseContentType: OpenAPIHandlerCodecOptions['errorResponseContentType'] constructor(options: OpenAPIHandlerCodecCoreOptions = {}) { this.serializer = options.serializer ?? new OpenAPISerializer() this.errorStatusMap = options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP - this.errorResponseBodyEncoder = options.errorResponseBodyEncoder + this.errorResponseBodyEncoder = options.errorResponseBodyEncoder ?? (e => e.toJSON()) this.errorResponseContentType = options.errorResponseContentType } @@ -146,11 +146,12 @@ export class OpenAPIHandlerCodecCore { encodeError(error: AnyORPCError): Promisable { const status = getOwn(this.errorStatusMap, error.code) ?? DEFAULT_ERROR_STATUS + const body = this.serializer.serialize(this.errorResponseBodyEncoder(error)) return { status, - headers: this.errorResponseContentType !== undefined ? { 'content-type': this.errorResponseContentType } : {}, - body: this.serializer.serialize(this.errorResponseBodyEncoder?.(error) ?? error.toJSON()), + headers: body !== undefined && this.errorResponseContentType !== undefined ? { 'content-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 0d7039edc0..7cb9fa5c9d 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts @@ -814,7 +814,7 @@ describe('openAPILinkCodec', () => { expect(resolveBody).toHaveBeenCalledWith(undefined) }) - it('uses a custom error decoder when it returns a value', async () => { + it('uses a custom error decoder', async () => { const customError = new ORPCError('BAD_GATEWAY', { data: 'custom' }) const errorResponseBodyDecoder = vi.fn(() => customError) const codec = new OpenAPILinkCodec({ @@ -824,36 +824,44 @@ describe('openAPILinkCodec', () => { errorResponseBodyDecoder, }) + // an ORPC error body, to check the default decoding is not used as a fallback const response = { status: 502, headers: { 'x-trace': '1' }, - resolveBody: async () => ({ detail: 'bad gateway' }), + resolveBody: async () => new ORPCError('NOT_FOUND').toJSON(), } const result = await codec.decodeResponse(response, ['ping'], { context: {} }) expect(result).toEqual({ kind: 'error', error: customError }) expect(errorResponseBodyDecoder).toHaveBeenCalledOnce() - expect(errorResponseBodyDecoder).toHaveBeenCalledWith({ detail: 'bad gateway' }, response) + expect(errorResponseBodyDecoder).toHaveBeenCalledWith(new ORPCError('NOT_FOUND').toJSON(), response, expect.any(Function)) }) - it('falls back to standard ORPC error decoding when the custom decoder returns null', async () => { + it('passes the default decoding to a custom error decoder as defaultError', async () => { const codec = new OpenAPILinkCodec({ ping: oc.meta(openapi({})), }, { serializer, - errorResponseBodyDecoder: () => null, + errorResponseBodyDecoder: (_body, _response, defaultError) => defaultError(), }) - const error = new ORPCError('NOT_FOUND', { message: 'Not found' }) - - const result = await codec.decodeResponse({ + const result: any = await codec.decodeResponse({ status: 404, headers: {}, - resolveBody: async () => error.toJSON(), + resolveBody: async () => new ORPCError('NOT_FOUND').toJSON(), }, ['ping'], { context: {} }) expectORPCErrorResult(result, 'NOT_FOUND') + expect(result.error.cause).toBeInstanceOf(ResponseError) + + const malformed: any = await codec.decodeResponse({ + status: 503, + headers: {}, + resolveBody: async () => ({ detail: 'service unavailable' }), + }, ['ping'], { context: {} }) + + expect(malformed.error).toBeInstanceOf(MalformedResponseError) }) it.each([ @@ -872,17 +880,19 @@ describe('openAPILinkCodec', () => { expect(resolveBody).toHaveBeenCalledWith(hint) }) - it('throws MalformedResponseError on unknown error payloads', async () => { + it('returns MalformedResponseError on unknown error payloads', async () => { const codec = new OpenAPILinkCodec({ ping: oc.meta(openapi({})), }, { serializer }) - const error: any = await codec.decodeResponse({ + const result: any = await codec.decodeResponse({ status: 503, headers: {}, resolveBody: async () => ({ detail: 'service unavailable' }), - }, ['ping'], { context: {} }).then(() => null, e => e) + }, ['ping'], { context: {} }) + expect(result.kind).toBe('error') + const error = result.error expect(error).toBeInstanceOf(MalformedResponseError) expect(error.message).toBe('Service Unavailable') expect(error.response).toEqual({ status: 503, headers: {}, body: { detail: 'service unavailable' } }) diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.ts index 995b86715d..208bfbcacb 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.ts @@ -1,4 +1,4 @@ -import type { AnyORPCError, ClientContext, ClientOptions } from '@orpc/client' +import type { ClientContext, ClientOptions } from '@orpc/client' import type { StandardLinkCodec, StandardLinkCodecDecodedResponse } from '@orpc/client/standard' import type { AnyProcedureContract, RouterContract } from '@orpc/contract' import type { Promisable, Public, Value } from '@orpc/shared' @@ -40,13 +40,18 @@ export interface OpenAPILinkCodecOptions { serializer?: Public | undefined /** - * Customize how an error response body is converted into an ORPC error. - * Return `null` or `undefined` to fall back to the default decoding behavior. + * Customize how an error response body is converted into the error thrown by the client. + * + * @remarks + * - Return `defaultError()` to keep the default decoding (a `MalformedResponseError` for bodies that are not ORPC errors) + * + * @default ((body, response, defaultError) => defaultError()) */ errorResponseBodyDecoder?: (( deserializedBody: unknown, response: StandardLazyResponse, - ) => AnyORPCError | null | undefined) | undefined + defaultError: () => Error, + ) => Error) | undefined /** * Parse error responses with this `content-type` as JSON, ignoring case and parameters such as `charset`. @@ -64,7 +69,7 @@ export class OpenAPILinkCodec implements StandardLinkCo private readonly baseUrl: Exclude['url'], undefined> private readonly headers: Exclude['headers'], undefined> private readonly serializer: Exclude['serializer'], undefined> - private readonly errorResponseBodyDecoder: OpenAPILinkCodecOptions['errorResponseBodyDecoder'] + private readonly errorResponseBodyDecoder: Exclude['errorResponseBodyDecoder'], undefined> private readonly errorResponseMediaType: string | undefined constructor( @@ -74,7 +79,7 @@ export class OpenAPILinkCodec implements StandardLinkCo this.baseUrl = options.url ?? '/' this.headers = options.headers ?? {} this.serializer = options.serializer ?? new OpenAPISerializer() - this.errorResponseBodyDecoder = options.errorResponseBodyDecoder + this.errorResponseBodyDecoder = options.errorResponseBodyDecoder ?? ((_body, _response, defaultError) => defaultError()) this.errorResponseMediaType = toMediaType(options.errorResponseContentType) } @@ -414,22 +419,17 @@ export class OpenAPILinkCodec implements StandardLinkCo })() if (!isOk) { - const customError = this.errorResponseBodyDecoder?.(deserialized, response) - - if (customError !== undefined && customError !== null) { - return { kind: 'error', error: customError } - } - - if (isORPCErrorJson(deserialized)) { - return { - kind: 'error', - error: createORPCErrorFromJson(deserialized, { + const defaultError = (): Error => { + if (isORPCErrorJson(deserialized)) { + return createORPCErrorFromJson(deserialized, { cause: new ResponseError({ response: { status: response.status, headers: response.headers, body } }), - }), + }) } + + return new MalformedResponseError({ response: { headers: response.headers, status: response.status, body } }) } - throw new MalformedResponseError({ response: { headers: response.headers, status: response.status, body } }) + return { kind: 'error', error: this.errorResponseBodyDecoder(deserialized, response, defaultError) } } const outputStructure = meta?.outputStructure ?? DEFAULT_OPENAPI_OUTPUT_STRUCTURE diff --git a/packages/openapi/src/openapi-generator-operation.test.ts b/packages/openapi/src/openapi-generator-operation.test.ts index 5cb38ad96a..66743f3693 100644 --- a/packages/openapi/src/openapi-generator-operation.test.ts +++ b/packages/openapi/src/openapi-generator-operation.test.ts @@ -901,15 +901,15 @@ describe('openAPIGenerator operation builders', () => { }) it('allows overriding the error body schema per status', () => { - const errorResponseBodySchema = vi.fn((definedErrors, status) => { + const errorResponseBodySchema = vi.fn((definedErrors, status, defaultSchema) => { if (status === 400) { return { type: 'object' as const, description: 'custom-400' } } - return undefined + return defaultSchema() }) - const { ctx, operation } = createContext({ errorResponseBodySchema }) + const { doc, ctx, operation } = createContext({ errorResponseBodySchema }) buildErrorResponse(ctx, operation, testDef({ errors: { @@ -926,7 +926,7 @@ describe('openAPIGenerator operation builders', () => { dataOptional: false, dataJsonSchema: { type: 'object' }, }, - ], 400) + ], 400, expect.any(Function)) expect((operation.responses?.['400'] as any).content['application/json'].schema).toEqual({ type: 'object', @@ -935,6 +935,16 @@ describe('openAPIGenerator operation builders', () => { expect((operation.responses?.['404'] as any).content['application/json'].schema).toEqual( expect.objectContaining({ oneOf: expect.any(Array) }), ) + // the default schema of 400 was not used, so its error component is not registered + expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['NotFound', 'UndefinedError']) + }) + + it.each([null, undefined])('documents an empty error body when the custom schema is %s', (schema) => { + const { ctx, operation } = createContext({ errorResponseBodySchema: () => schema }) + + buildErrorResponse(ctx, operation, testDef({ errors: { NOT_FOUND: { message: 'Missing' } } })) + + expect(operation.responses?.['404']).toEqual({ description: 'Missing' }) }) it('documents error bodies under the configured content type', () => { diff --git a/packages/openapi/src/openapi-generator-operation.ts b/packages/openapi/src/openapi-generator-operation.ts index 18d29a08ef..bac5b681e7 100644 --- a/packages/openapi/src/openapi-generator-operation.ts +++ b/packages/openapi/src/openapi-generator-operation.ts @@ -46,8 +46,8 @@ export interface OpenAPIOperationContext { convertSchemas: (schemas: AnySchema[] | undefined, direction: JsonSchemaConverterDirection) => [JsonSchema, boolean] errorStatusMap: Record errorResponseBodySchema: Value< - JsonSchema | undefined | null, - [definedErrors: OpenAPIErrorBodyDefinition[], status: number] + JsonSchema | null | undefined, + [definedErrors: OpenAPIErrorBodyDefinition[], status: number, defaultSchema: () => JsonSchema] > | undefined errorResponseContentType: string } @@ -528,13 +528,11 @@ export function buildErrorResponse( for (const [status, definitions] of definitionsByStatus.entries()) { const descriptions = definitions.map(({ defaultMessage }) => defaultMessage).filter(m => m !== undefined) - const customBodySchema = value( - ctx.errorResponseBodySchema, - definitions.map(def => ({ ...def, dataJsonSchema: ctx.registry.hoistDefs(def.dataJsonSchema, 'output') })), - status, - ) - const responseSchema = customBodySchema ?? combineJsonSchemasWithComposition('oneOf', [ - ...definitions.map(({ code, dataJsonSchema, dataOptional }) => { + const definedErrors = definitions.map(def => ({ ...def, dataJsonSchema: ctx.registry.hoistDefs(def.dataJsonSchema, 'output') })) + + // lazy, so per-code error components are only registered when the default schema is actually used + const defaultSchema = (): JsonSchema => combineJsonSchemasWithComposition('oneOf', [ + ...definedErrors.map(({ code, dataJsonSchema, dataOptional }) => { return ctx.registry.register(toErrorComponentName(code), { title: code, ...combineJsonObjectSchemaEntries([ @@ -542,22 +540,31 @@ export function buildErrorResponse( ['code', { const: code }, false], // avoid using the defaultMessage here to improve component reusability ['message', { type: 'string' }, false], - ['data', ctx.registry.hoistDefs(dataJsonSchema, 'output'), dataOptional], + ['data', dataJsonSchema, dataOptional], ]), }) }), undefinedErrorSchema, ]) - operation.responses ??= {} - operation.responses[toResponseStatusKey(status)] = { + const responseSchema = ctx.errorResponseBodySchema === undefined + ? defaultSchema() + : value(ctx.errorResponseBodySchema, definedErrors, status, defaultSchema) + + const responseObject: OpenAPIV3_2.ResponseObject = { description: descriptions.length ? descriptions.join(', ') : status.toString(), - content: { + } + + if (responseSchema !== undefined && responseSchema !== null) { + responseObject.content = { [ctx.errorResponseContentType]: { schema: ctx.registry.toOpenAPISchema(responseSchema, 'output'), }, - }, - } satisfies OpenAPIV3_2.ResponseObject + } + } + + operation.responses ??= {} + operation.responses[toResponseStatusKey(status)] = responseObject } } diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index 37fc346787..b7ab2e2b0d 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -77,12 +77,12 @@ export interface OpenAPIGeneratorGenerateOptions JsonSchema] > | undefined /** From ef1eab1bc3aa0d44c488eb4b9fa3dc9772080ed6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:49:51 +0000 Subject: [PATCH 2/4] refactor(openapi): leave dropping empty-body content-type to the adapter `@standard-server` adapters drop the `content-type` of an `undefined` body (middleapi/standard-server#178), so the handler codec no longer special-cases it. Also import `ORPCError` in the link decoder docs example. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7SJxePN5EdCgmjsAN5 --- apps/content/docs/openapi/link.mdx | 2 ++ .../src/adapters/standard/openapi-handler-codec.test.ts | 3 +-- .../openapi/src/adapters/standard/openapi-handler-codec.ts | 5 ++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/content/docs/openapi/link.mdx b/apps/content/docs/openapi/link.mdx index 235e3cc324..951dcbf0ae 100644 --- a/apps/content/docs/openapi/link.mdx +++ b/apps/content/docs/openapi/link.mdx @@ -228,6 +228,8 @@ const link = new OpenAPILink(contract, { If your server returns error responses that don't match oRPC's expected format, use `errorResponseBodyDecoder` to customize the decoding logic. This works together with [Custom Error Response](/docs/openapi/handler#custom-error-response) on the server. ```ts +import { ORPCError } from '@orpc/client' + const link = new OpenAPILink(contract, { errorResponseBodyDecoder: (body, response, defaultError) => { if (response.status === 422 && typeof body === 'object' && body && 'detail' in body) { 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 413593d358..cbc9ad8bca 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts @@ -955,11 +955,10 @@ describe('openAPIHandlerCodec', () => { expect(serializer.serialize).toHaveBeenCalledWith(null) }) - it('sends an empty body without content-type when errorResponseBodyEncoder returns undefined', () => { + it('sends an empty body when errorResponseBodyEncoder returns undefined', () => { const procedure = os.handler(vi.fn()) const codec = new OpenAPIHandlerCodec(procedure, { errorResponseBodyEncoder: () => undefined, - errorResponseContentType: 'application/problem+json', }) expect(codec.encodeError(new ORPCError('NOT_FOUND'))).toEqual({ diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts index 2bbe9b9707..ed8085dae7 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts @@ -146,12 +146,11 @@ export class OpenAPIHandlerCodecCore { encodeError(error: AnyORPCError): Promisable { const status = getOwn(this.errorStatusMap, error.code) ?? DEFAULT_ERROR_STATUS - const body = this.serializer.serialize(this.errorResponseBodyEncoder(error)) return { status, - headers: body !== undefined && this.errorResponseContentType !== undefined ? { 'content-type': this.errorResponseContentType } : {}, - body, + headers: this.errorResponseContentType !== undefined ? { 'content-type': this.errorResponseContentType } : {}, + body: this.serializer.serialize(this.errorResponseBodyEncoder(error)), } } From e837f7e2a51fbe23b12fb457261c2a9b64a436ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:03:46 +0000 Subject: [PATCH 3/4] feat(openapi)!: document empty error bodies only with a null schema `errorResponseBodySchema` now returns `JsonSchema | null`: `null` documents an empty body, and `undefined` (including a missing return) is a type error, so a forgotten `return defaultSchema()` can't silently drop a body. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7SJxePN5EdCgmjsAN5 --- .../openapi/src/openapi-generator-operation.test.ts | 4 ++-- packages/openapi/src/openapi-generator-operation.ts | 4 ++-- packages/openapi/src/openapi-generator.test-d.ts | 13 +++++++++++++ packages/openapi/src/openapi-generator.ts | 4 ++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/openapi/src/openapi-generator-operation.test.ts b/packages/openapi/src/openapi-generator-operation.test.ts index 532ccb3e59..3496381955 100644 --- a/packages/openapi/src/openapi-generator-operation.test.ts +++ b/packages/openapi/src/openapi-generator-operation.test.ts @@ -941,8 +941,8 @@ describe('openAPIGenerator operation builders', () => { expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['NotFound', 'UndefinedError']) }) - it.each([null, undefined])('documents an empty error body when the custom schema is %s', (schema) => { - const { ctx, operation } = createContext({ errorResponseBodySchema: () => schema }) + it('documents an empty error body when the custom schema is null', () => { + const { ctx, operation } = createContext({ errorResponseBodySchema: () => null }) buildErrorResponse(ctx, operation, testDef({ errors: { NOT_FOUND: { message: 'Missing' } } })) diff --git a/packages/openapi/src/openapi-generator-operation.ts b/packages/openapi/src/openapi-generator-operation.ts index c99fa804c0..527e46a50c 100644 --- a/packages/openapi/src/openapi-generator-operation.ts +++ b/packages/openapi/src/openapi-generator-operation.ts @@ -47,7 +47,7 @@ export interface OpenAPIOperationContext { convertSchemas: (schemas: AnySchema[] | undefined, direction: JsonSchemaConverterDirection) => [JsonSchema, boolean] errorStatusMap: Record errorResponseBodySchema: Value< - JsonSchema | null | undefined, + JsonSchema | null, [definedErrors: OpenAPIErrorBodyDefinition[], status: number, defaultSchema: () => JsonSchema] > | undefined errorResponseContentType: string @@ -556,7 +556,7 @@ export function buildErrorResponse( description: descriptions.length ? descriptions.join(', ') : status.toString(), } - if (responseSchema !== undefined && responseSchema !== null) { + if (responseSchema !== null) { responseObject.content = { [ctx.errorResponseContentType]: { schema: ctx.registry.toOpenAPISchema(responseSchema, 'output'), diff --git a/packages/openapi/src/openapi-generator.test-d.ts b/packages/openapi/src/openapi-generator.test-d.ts index 8792f75c4c..6ee9a18f47 100644 --- a/packages/openapi/src/openapi-generator.test-d.ts +++ b/packages/openapi/src/openapi-generator.test-d.ts @@ -30,4 +30,17 @@ describe('openAPIGenerator.generate', () => { // @ts-expect-error the openapi field is derived from version generator.generate({}, { base: { openapi: '3.2.0' } }) }) + + it('requires errorResponseBodySchema to return a schema or null', () => { + generator.generate({}, { errorResponseBodySchema: () => null }) + generator.generate({}, { errorResponseBodySchema: (_errors, _status, defaultSchema) => defaultSchema() }) + // @ts-expect-error undefined is not allowed, return null for an empty body + generator.generate({}, { errorResponseBodySchema: () => undefined }) + // @ts-expect-error a missing return is not allowed + generator.generate({}, { errorResponseBodySchema: (_errors, status) => { + if (status === 404) { + return null + } + } }) + }) }) diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index b7ab2e2b0d..35e5d3f3a8 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -77,11 +77,11 @@ export interface OpenAPIGeneratorGenerateOptions JsonSchema] > | undefined From 6100238030795ac64bc8746b14feedde2d37e70c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:14:25 +0000 Subject: [PATCH 4/4] Revert "feat(openapi)!: document empty error bodies only with a null schema" This reverts commit e837f7e2a51fbe23b12fb457261c2a9b64a436ff, so `errorResponseBodySchema` documents an empty body for both `null` and `undefined` again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7SJxePN5EdCgmjsAN5 --- .../openapi/src/openapi-generator-operation.test.ts | 4 ++-- packages/openapi/src/openapi-generator-operation.ts | 4 ++-- packages/openapi/src/openapi-generator.test-d.ts | 13 ------------- packages/openapi/src/openapi-generator.ts | 4 ++-- 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/packages/openapi/src/openapi-generator-operation.test.ts b/packages/openapi/src/openapi-generator-operation.test.ts index 3496381955..532ccb3e59 100644 --- a/packages/openapi/src/openapi-generator-operation.test.ts +++ b/packages/openapi/src/openapi-generator-operation.test.ts @@ -941,8 +941,8 @@ describe('openAPIGenerator operation builders', () => { expect(Object.keys(doc.components?.schemas ?? {}).sort()).toEqual(['NotFound', 'UndefinedError']) }) - it('documents an empty error body when the custom schema is null', () => { - const { ctx, operation } = createContext({ errorResponseBodySchema: () => null }) + it.each([null, undefined])('documents an empty error body when the custom schema is %s', (schema) => { + const { ctx, operation } = createContext({ errorResponseBodySchema: () => schema }) buildErrorResponse(ctx, operation, testDef({ errors: { NOT_FOUND: { message: 'Missing' } } })) diff --git a/packages/openapi/src/openapi-generator-operation.ts b/packages/openapi/src/openapi-generator-operation.ts index 527e46a50c..c99fa804c0 100644 --- a/packages/openapi/src/openapi-generator-operation.ts +++ b/packages/openapi/src/openapi-generator-operation.ts @@ -47,7 +47,7 @@ export interface OpenAPIOperationContext { convertSchemas: (schemas: AnySchema[] | undefined, direction: JsonSchemaConverterDirection) => [JsonSchema, boolean] errorStatusMap: Record errorResponseBodySchema: Value< - JsonSchema | null, + JsonSchema | null | undefined, [definedErrors: OpenAPIErrorBodyDefinition[], status: number, defaultSchema: () => JsonSchema] > | undefined errorResponseContentType: string @@ -556,7 +556,7 @@ export function buildErrorResponse( description: descriptions.length ? descriptions.join(', ') : status.toString(), } - if (responseSchema !== null) { + if (responseSchema !== undefined && responseSchema !== null) { responseObject.content = { [ctx.errorResponseContentType]: { schema: ctx.registry.toOpenAPISchema(responseSchema, 'output'), diff --git a/packages/openapi/src/openapi-generator.test-d.ts b/packages/openapi/src/openapi-generator.test-d.ts index 6ee9a18f47..8792f75c4c 100644 --- a/packages/openapi/src/openapi-generator.test-d.ts +++ b/packages/openapi/src/openapi-generator.test-d.ts @@ -30,17 +30,4 @@ describe('openAPIGenerator.generate', () => { // @ts-expect-error the openapi field is derived from version generator.generate({}, { base: { openapi: '3.2.0' } }) }) - - it('requires errorResponseBodySchema to return a schema or null', () => { - generator.generate({}, { errorResponseBodySchema: () => null }) - generator.generate({}, { errorResponseBodySchema: (_errors, _status, defaultSchema) => defaultSchema() }) - // @ts-expect-error undefined is not allowed, return null for an empty body - generator.generate({}, { errorResponseBodySchema: () => undefined }) - // @ts-expect-error a missing return is not allowed - generator.generate({}, { errorResponseBodySchema: (_errors, status) => { - if (status === 404) { - return null - } - } }) - }) }) diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index 35e5d3f3a8..b7ab2e2b0d 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -77,11 +77,11 @@ export interface OpenAPIGeneratorGenerateOptions JsonSchema] > | undefined