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
10 changes: 10 additions & 0 deletions apps/content/docs/openapi/handler.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,16 @@ const handler = new OpenAPIHandler(router, {

</Expandable>

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',
})
```

[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).
:::
Expand Down
8 changes: 8 additions & 0 deletions apps/content/docs/openapi/link.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions apps/content/docs/openapi/specification.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
```
26 changes: 25 additions & 1 deletion packages/openapi/src/adapters/fetch/openapi-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -78,6 +78,30 @@ 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')
expect(response!.headers.has('standard-server')).toBe(false)
await expect(response!.json()).resolves.toEqual(new ORPCError('NOT_FOUND', { message: 'Missing' }).toJSON())

const client = createORPCClient<any>(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<any> = {
name: 'test',
Expand Down
19 changes: 18 additions & 1 deletion packages/openapi/src/adapters/node/openapi-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,20 @@ describe('openAPIHandlerCodec', () => {
expect(serializer.serialize).toHaveBeenNthCalledWith(2, secondError.toJSON())
})

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')

const response = await codec.encodeError(error)

expect(response.status).toBe(502)
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())
})

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 })
Expand Down
22 changes: 20 additions & 2 deletions packages/openapi/src/adapters/standard/openapi-handler-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,27 @@ export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> {
* @default ((e) => e.toJSON())
*/
customErrorResponseBodyEncoder?: ((error: AnyORPCError) => unknown) | undefined

/**
* 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'
*/
errorResponseContentType?: string | undefined
}

export class OpenAPIHandlerCodecCore<T extends Context> {
private readonly serializer: Public<OpenAPISerializer>
private readonly errorStatusMap: Exclude<OpenAPIHandlerCodecOptions<T>['errorStatusMap'], undefined>
private readonly customErrorResponseBodySerializer: OpenAPIHandlerCodecOptions<T>['customErrorResponseBodyEncoder']
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.customErrorResponseBodySerializer = options.customErrorResponseBodyEncoder
this.errorResponseContentType = options.errorResponseContentType
}

async decodeInput(
Expand Down Expand Up @@ -138,10 +148,18 @@ export class OpenAPIHandlerCodecCore<T extends Context> {
encodeError(error: AnyORPCError): Promisable<StandardResponse> {
const status = getOwn(this.errorStatusMap, error.code) ?? DEFAULT_ERROR_STATUS

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: {},
body: this.serializer.serialize(this.customErrorResponseBodySerializer?.(error) ?? error.toJSON()),
headers: { 'content-type': this.errorResponseContentType, 'standard-server': [] },
body: new Blob([stringifyJSON(body) ?? ''], { type: this.errorResponseContentType }),
}
}

Expand Down
52 changes: 52 additions & 0 deletions packages/openapi/src/adapters/standard/openapi-link-codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,58 @@ 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: 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' },
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('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({})),
Expand Down
25 changes: 23 additions & 2 deletions packages/openapi/src/adapters/standard/openapi-link-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -47,6 +47,14 @@ export interface OpenAPILinkCodecOptions<T extends ClientContext> {
deserializedBody: unknown,
response: StandardLazyResponse,
) => AnyORPCError | null | undefined) | undefined

/**
* 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'
*/
errorResponseContentType?: string | undefined
}

const END_SLASH_REGEX = /\/$/
Expand All @@ -57,6 +65,7 @@ export class OpenAPILinkCodec<T extends ClientContext> implements StandardLinkCo
private readonly headers: Exclude<OpenAPILinkCodecOptions<T>['headers'], undefined>
private readonly serializer: Exclude<OpenAPILinkCodecOptions<T>['serializer'], undefined>
private readonly customErrorResponseBodyDecoder: OpenAPILinkCodecOptions<T>['customErrorResponseBodyDecoder']
private readonly errorResponseContentType: OpenAPILinkCodecOptions<T>['errorResponseContentType']

constructor(
private readonly router: RouterContract,
Expand All @@ -66,6 +75,7 @@ export class OpenAPILinkCodec<T extends ClientContext> implements StandardLinkCo
this.headers = options.headers ?? {}
this.serializer = options.serializer ?? new OpenAPISerializer()
this.customErrorResponseBodyDecoder = options.customErrorResponseBodyDecoder
this.errorResponseContentType = toMediaType(options.errorResponseContentType)
}

async encodeInput(input: unknown, path: string[], options: ClientOptions<T>): Promise<StandardRequest> {
Expand Down Expand Up @@ -382,7 +392,11 @@ export class OpenAPILinkCodec<T extends ClientContext> 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
&& toMediaType(flattenStandardHeader(response.headers['content-type'])) === this.errorResponseContentType

const body = await response.resolveBody(isCustomErrorContentType ? 'json' : meta?.responseBodyHint)

const deserialized = await (async () => {
try {
Expand Down Expand Up @@ -457,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,
Expand Down
13 changes: 13 additions & 0 deletions packages/openapi/src/openapi-generator-operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('openAPIGenerator operation builders', () => {
schemas?: Record<string, any>
errorStatusMap?: Record<string, number>
customErrorResponseBodySchema?: OpenAPIOperationContext['customErrorResponseBodySchema']
errorResponseContentType?: string
} = {}) {
const doc: OpenAPIV3_2.OpenAPIObject = {
openapi: '3.2.0',
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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'])
})
})
})
3 changes: 2 additions & 1 deletion packages/openapi/src/openapi-generator-operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` {
Expand Down Expand Up @@ -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'),
},
},
Expand Down
9 changes: 9 additions & 0 deletions packages/openapi/src/openapi-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ export interface OpenAPIGeneratorGenerateOptions<TVersion extends OpenAPIVersion
[definedErrors: OpenAPIErrorBodyDefinition[], status: number]
> | 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).
Expand Down Expand Up @@ -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[] = []
Expand Down
Loading
Loading