Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/content/docs/migrations/from-v1.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1064,14 +1064,16 @@ 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:

- `customErrorResponseBodySchema` is now [`errorResponseBodySchema`](/docs/openapi/specification#custom-error-response-schemas) in `generator.generate` options.
- `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).
Expand Down
3 changes: 1 addition & 2 deletions apps/content/docs/openapi/handler.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,7 @@ const handler = new OpenAPIHandler(router, {
}
}

// fallback to default by returning null or undefined
return null
return error.toJSON()
},
})
```
Expand Down
7 changes: 4 additions & 3 deletions apps/content/docs/openapi/link.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Comment thread
dinwwwh marked this conversation as resolved.
Resolved
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()
},
})
```
Expand Down
5 changes: 2 additions & 3 deletions apps/content/docs/openapi/specification.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -252,8 +252,7 @@ const spec = await generator.generate(router, {
}
}

// fallback to default by returning null or undefined
return null
return defaultSchema()
},
})
```
Expand Down
3 changes: 1 addition & 2 deletions packages/client/src/adapters/standard/codec.ts
Original file line number Diff line number Diff line change
@@ -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<T extends ClientContext> {
encodeInput(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
*/
Expand All @@ -56,13 +56,13 @@ export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> {
export class OpenAPIHandlerCodecCore<T extends Context> {
private readonly serializer: Public<OpenAPISerializer>
private readonly errorStatusMap: Exclude<OpenAPIHandlerCodecOptions<T>['errorStatusMap'], undefined>
private readonly errorResponseBodyEncoder: OpenAPIHandlerCodecOptions<T>['errorResponseBodyEncoder']
private readonly errorResponseBodyEncoder: Exclude<OpenAPIHandlerCodecOptions<T>['errorResponseBodyEncoder'], undefined>
private readonly errorResponseContentType: OpenAPIHandlerCodecOptions<T>['errorResponseContentType']

constructor(options: OpenAPIHandlerCodecCoreOptions<T> = {}) {
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
}

Expand Down Expand Up @@ -150,7 +150,7 @@ export class OpenAPIHandlerCodecCore<T extends Context> {
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)),
}
}

Expand Down
34 changes: 22 additions & 12 deletions packages/openapi/src/adapters/standard/openapi-link-codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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([
Expand All @@ -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' } })
Expand Down
36 changes: 18 additions & 18 deletions packages/openapi/src/adapters/standard/openapi-link-codec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -40,13 +40,18 @@ export interface OpenAPILinkCodecOptions<T extends ClientContext> {
serializer?: Public<OpenAPISerializer> | 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`.
Expand All @@ -64,7 +69,7 @@ export class OpenAPILinkCodec<T extends ClientContext> implements StandardLinkCo
private readonly baseUrl: Exclude<OpenAPILinkCodecOptions<T>['url'], undefined>
private readonly headers: Exclude<OpenAPILinkCodecOptions<T>['headers'], undefined>
private readonly serializer: Exclude<OpenAPILinkCodecOptions<T>['serializer'], undefined>
private readonly errorResponseBodyDecoder: OpenAPILinkCodecOptions<T>['errorResponseBodyDecoder']
private readonly errorResponseBodyDecoder: Exclude<OpenAPILinkCodecOptions<T>['errorResponseBodyDecoder'], undefined>
private readonly errorResponseMediaType: string | undefined

constructor(
Expand All @@ -74,7 +79,7 @@ export class OpenAPILinkCodec<T extends ClientContext> 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)
}

Expand Down Expand Up @@ -414,22 +419,17 @@ export class OpenAPILinkCodec<T extends ClientContext> 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
Expand Down
18 changes: 14 additions & 4 deletions packages/openapi/src/openapi-generator-operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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',
Expand All @@ -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', () => {
Expand Down
Loading
Loading