diff --git a/apps/content/docs/migrations/from-v1.mdx b/apps/content/docs/migrations/from-v1.mdx index f1af18a61..7258c14b3 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 ebfdac9a0..8b520409a 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 1006bd313..951dcbf0a 100644 --- a/apps/content/docs/openapi/link.mdx +++ b/apps/content/docs/openapi/link.mdx @@ -228,16 +228,17 @@ 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) => { + 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 248f203e8..24ae558c4 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 66409cc56..deefe7136 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 16c78fc06..cbc9ad8bc 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,32 @@ 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 when errorResponseBodyEncoder returns undefined', () => { + const procedure = os.handler(vi.fn()) + const codec = new OpenAPIHandlerCodec(procedure, { + errorResponseBodyEncoder: () => undefined, + }) - 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 fcc17196c..ed8085dae 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 } @@ -150,7 +150,7 @@ export class OpenAPIHandlerCodecCore { return { status, headers: this.errorResponseContentType !== undefined ? { 'content-type': this.errorResponseContentType } : {}, - body: this.serializer.serialize(this.errorResponseBodyEncoder?.(error) ?? error.toJSON()), + body: this.serializer.serialize(this.errorResponseBodyEncoder(error)), } } 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 0d7039edc..7cb9fa5c9 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 995b86715..208bfbcac 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 0ac72f99a..532ccb3e5 100644 --- a/packages/openapi/src/openapi-generator-operation.test.ts +++ b/packages/openapi/src/openapi-generator-operation.test.ts @@ -903,15 +903,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: { @@ -928,7 +928,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', @@ -937,6 +937,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 7dcc6620c..c99fa804c 100644 --- a/packages/openapi/src/openapi-generator-operation.ts +++ b/packages/openapi/src/openapi-generator-operation.ts @@ -47,8 +47,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 } @@ -529,13 +529,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([ @@ -543,22 +541,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 37fc34678..b7ab2e2b0 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -77,12 +77,12 @@ export interface OpenAPIGeneratorGenerateOptions JsonSchema] > | undefined /**