diff --git a/apps/content/docs/openapi/handler.mdx b/apps/content/docs/openapi/handler.mdx index 711ee74d21..9b28f75942 100644 --- a/apps/content/docs/openapi/handler.mdx +++ b/apps/content/docs/openapi/handler.mdx @@ -249,6 +249,16 @@ const handler = new OpenAPIHandler(router, { +Error responses are sent as `application/json` by default. Use `errorResponseContentType` to change the media type, such as `application/problem+json` for [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457): + +```ts +const handler = new OpenAPIHandler(router, { + errorResponseContentType: 'application/problem+json', +}) +``` + +[OpenAPILink](/docs/openapi/link#custom-error-decoding) only detects `application/json` bodies as JSON, so set the same `errorResponseContentType` there too. + :::info If you use `OpenAPILink` with a custom server-side error format, make sure to configure [Custom Error Decoding](/docs/openapi/link#custom-error-decoding). ::: diff --git a/apps/content/docs/openapi/link.mdx b/apps/content/docs/openapi/link.mdx index dadee9cdfe..339fd9ac4e 100644 --- a/apps/content/docs/openapi/link.mdx +++ b/apps/content/docs/openapi/link.mdx @@ -242,6 +242,14 @@ const link = new OpenAPILink(contract, { }) ``` +If the handler sends errors with a custom [`errorResponseContentType`](/docs/openapi/handler#custom-error-response), set the same value on the link so those error bodies are still parsed as JSON: + +```ts +const link = new OpenAPILink(contract, { + errorResponseContentType: 'application/problem+json', +}) +``` + ## Error Responses When the server answers with an error, `OpenAPILink` throws an `ORPCError` whose `cause` is a `ResponseError` carrying the resolved response, so details that are not part of the error itself, such as its status or headers, stay available (errors returned by [`customErrorResponseBodyDecoder`](#custom-error-decoding) are thrown as-is): diff --git a/apps/content/docs/openapi/specification.mdx b/apps/content/docs/openapi/specification.mdx index 78341221f5..ad73203fb4 100644 --- a/apps/content/docs/openapi/specification.mdx +++ b/apps/content/docs/openapi/specification.mdx @@ -257,3 +257,11 @@ const spec = await generator.generate(router, { }, }) ``` + +If the handler sets [`errorResponseContentType`](/docs/openapi/handler#custom-error-response), pass the same value to the generator so error responses are documented under that media type: + +```ts +const spec = await generator.generate(router, { + errorResponseContentType: 'application/problem+json', +}) +``` diff --git a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts index 2d0fea0fbb..3a7b72650e 100644 --- a/packages/openapi/src/adapters/fetch/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/fetch/openapi-handler.test.ts @@ -1,5 +1,5 @@ import type { StandardHandlerPlugin } from '@orpc/server/standard' -import { createORPCClient } from '@orpc/client' +import { createORPCClient, ORPCError } from '@orpc/client' import { os } from '@orpc/server' import { openapi } from '../../meta' import { OpenAPIHandler } from './openapi-handler' @@ -78,6 +78,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(new OpenAPILink(router, { + fetch: async (url, init) => (await handler.handle(new Request(url, init))).response!, + origin: 'http://localhost:3000', + errorResponseContentType: 'application/problem+json', + })) + + await expect(client.ping()).rejects.toSatisfy((e: any) => e instanceof ORPCError && e.code === 'NOT_FOUND' && e.message === 'Missing') + }) + it('supports standard handler plugins', async () => { const plugin: StandardHandlerPlugin = { name: 'test', diff --git a/packages/openapi/src/adapters/node/openapi-handler.test.ts b/packages/openapi/src/adapters/node/openapi-handler.test.ts index 8bfa19661f..87ecbe62f8 100644 --- a/packages/openapi/src/adapters/node/openapi-handler.test.ts +++ b/packages/openapi/src/adapters/node/openapi-handler.test.ts @@ -1,6 +1,6 @@ import type { StandardHandlerPlugin } from '@orpc/server/standard' import type { IncomingMessage, ServerResponse } from 'node:http' -import { os } from '@orpc/server' +import { ORPCError, os } from '@orpc/server' import request from 'supertest' import { openapi } from '../../meta' import { OpenAPIHandler } from './openapi-handler' @@ -10,6 +10,23 @@ describe('openapiHandler', () => { vi.clearAllMocks() }) + it('sends error responses with errorResponseContentType', async () => { + const handler = new OpenAPIHandler({ + ping: os.handler(() => { + throw new ORPCError('NOT_FOUND') + }), + }, { errorResponseContentType: 'application/problem+json' }) + + const res = await request(async (req: IncomingMessage, response: ServerResponse) => { + await handler.handle(req as any, response as any) + }).post('/ping') + + expect(res.status).toBe(404) + expect(res.headers['content-type']).toBe('application/problem+json') + expect(res.headers['standard-server']).toBeUndefined() + expect(JSON.parse(res.text)).toEqual(new ORPCError('NOT_FOUND').toJSON()) + }) + it('accepts context and prefix options in handle method', async () => { const handler = new OpenAPIHandler({ ping: os diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts index e81b2725f4..c378041fc6 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts @@ -978,6 +978,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 }) diff --git a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts index 0bcf89d6d2..08228c7a37 100644 --- a/packages/openapi/src/adapters/standard/openapi-handler-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-handler-codec.ts @@ -44,17 +44,27 @@ export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> { * @default ((e) => e.toJSON()) */ customErrorResponseBodyEncoder?: ((error: AnyORPCError) => unknown) | undefined + + /** + * The `content-type` 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 { private readonly serializer: Public private readonly errorStatusMap: Exclude['errorStatusMap'], undefined> private readonly customErrorResponseBodySerializer: OpenAPIHandlerCodecOptions['customErrorResponseBodyEncoder'] + private readonly errorResponseContentType: OpenAPIHandlerCodecOptions['errorResponseContentType'] constructor(options: OpenAPIHandlerCodecCoreOptions = {}) { this.serializer = options.serializer ?? new OpenAPISerializer() this.errorStatusMap = options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP this.customErrorResponseBodySerializer = options.customErrorResponseBodyEncoder + this.errorResponseContentType = options.errorResponseContentType } async decodeInput( @@ -138,10 +148,18 @@ export class OpenAPIHandlerCodecCore { encodeError(error: AnyORPCError): Promisable { const status = getOwn(this.errorStatusMap, error.code) ?? DEFAULT_ERROR_STATUS + const body = this.serializer.serialize(this.customErrorResponseBodySerializer?.(error) ?? error.toJSON()) + + 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 }), } } diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts index 6be483192f..f3606c527c 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.test.ts @@ -837,6 +837,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({})), diff --git a/packages/openapi/src/adapters/standard/openapi-link-codec.ts b/packages/openapi/src/adapters/standard/openapi-link-codec.ts index 544a5d6ef7..bfb73aac08 100644 --- a/packages/openapi/src/adapters/standard/openapi-link-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-link-codec.ts @@ -8,7 +8,7 @@ import { createORPCErrorFromJson, isORPCErrorJson, MalformedResponseError, Respo import { getRouterContract, ProcedureContract } from '@orpc/contract' import { unlazy } from '@orpc/server' import { isTypescriptObject, mergeHttpPath, pathToHttpPath, safeEncodeURIComponent, stringifyJSON, value } from '@orpc/shared' -import { mergeStandardHeaders, parseStandardUrl } from '@standard-server/core' +import { flattenStandardHeader, mergeStandardHeaders, parseStandardUrl } from '@standard-server/core' import { toStandardHeaders } from '@standard-server/fetch' import { DEFAULT_OPENAPI_INPUT_STRUCTURE, @@ -47,6 +47,14 @@ export interface OpenAPILinkCodecOptions { deserializedBody: unknown, response: StandardLazyResponse, ) => AnyORPCError | null | undefined) | undefined + + /** + * Error responses with this `content-type` are parsed as JSON (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 = /\/$/ @@ -57,6 +65,7 @@ export class OpenAPILinkCodec implements StandardLinkCo private readonly headers: Exclude['headers'], undefined> private readonly serializer: Exclude['serializer'], undefined> private readonly customErrorResponseBodyDecoder: OpenAPILinkCodecOptions['customErrorResponseBodyDecoder'] + private readonly errorResponseContentType: OpenAPILinkCodecOptions['errorResponseContentType'] constructor( private readonly router: RouterContract, @@ -66,6 +75,7 @@ export class OpenAPILinkCodec implements StandardLinkCo this.headers = options.headers ?? {} this.serializer = options.serializer ?? new OpenAPISerializer() this.customErrorResponseBodyDecoder = options.customErrorResponseBodyDecoder + this.errorResponseContentType = toMediaType(options.errorResponseContentType) } async encodeInput(input: unknown, path: string[], options: ClientOptions): Promise { @@ -382,7 +392,11 @@ export class OpenAPILinkCodec implements StandardLinkCo const procedure = await this.resolveProcedure(path) const meta = getOpenAPIMeta(procedure) - const body = await response.resolveBody(meta?.responseBodyHint) + const isCustomErrorContentType = !isOk + && this.errorResponseContentType !== undefined + && toMediaType(flattenStandardHeader(response.headers['content-type'])) === this.errorResponseContentType + + const body = await response.resolveBody(isCustomErrorContentType ? 'json' : meta?.responseBodyHint) const deserialized = await (async () => { try { @@ -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, diff --git a/packages/openapi/src/openapi-generator-operation.test.ts b/packages/openapi/src/openapi-generator-operation.test.ts index a24b83be6d..750e2c3c0f 100644 --- a/packages/openapi/src/openapi-generator-operation.test.ts +++ b/packages/openapi/src/openapi-generator-operation.test.ts @@ -20,6 +20,7 @@ describe('openAPIGenerator operation builders', () => { schemas?: Record errorStatusMap?: Record customErrorResponseBodySchema?: OpenAPIOperationContext['customErrorResponseBodySchema'] + errorResponseContentType?: string } = {}) { const doc: OpenAPIV3_2.OpenAPIObject = { openapi: '3.2.0', @@ -45,6 +46,7 @@ describe('openAPIGenerator operation builders', () => { }, errorStatusMap: options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP, customErrorResponseBodySchema: options.customErrorResponseBodySchema, + errorResponseContentType: options.errorResponseContentType ?? 'application/json', } const operation: OpenAPIV3_2.OperationObject = {} @@ -934,5 +936,16 @@ describe('openAPIGenerator operation builders', () => { expect.objectContaining({ oneOf: expect.any(Array) }), ) }) + + it('documents error bodies under the configured content type', () => { + const { ctx, operation } = createContext({ errorResponseContentType: 'application/problem+json' }) + + buildErrorResponse(ctx, operation, testDef({ + errors: { BAD_REQUEST: {}, NOT_FOUND: {} }, + })) + + expect(Object.keys((operation.responses?.['400'] as any).content)).toEqual(['application/problem+json']) + expect(Object.keys((operation.responses?.['404'] as any).content)).toEqual(['application/problem+json']) + }) }) }) diff --git a/packages/openapi/src/openapi-generator-operation.ts b/packages/openapi/src/openapi-generator-operation.ts index 681a090931..520927a8ed 100644 --- a/packages/openapi/src/openapi-generator-operation.ts +++ b/packages/openapi/src/openapi-generator-operation.ts @@ -49,6 +49,7 @@ export interface OpenAPIOperationContext { JsonSchema | undefined | null, [definedErrors: OpenAPIErrorBodyDefinition[], status: number] > | undefined + errorResponseContentType: string } export function toOpenAPIPath(path: `/${string}`, dynamicPathParams: DynamicPathParam[] | undefined): `/${string}` { @@ -551,7 +552,7 @@ export function buildErrorResponse( operation.responses[toResponseStatusKey(status)] = { description: descriptions.length ? descriptions.join(', ') : status.toString(), content: { - 'application/json': { + [ctx.errorResponseContentType]: { schema: ctx.registry.toOpenAPISchema(responseSchema, 'output'), }, }, diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index a6e6684666..8685efeec3 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -85,6 +85,14 @@ export interface OpenAPIGeneratorGenerateOptions | undefined + /** + * The media type error response bodies are documented under, + * e.g. `application/problem+json` for RFC 9457 Problem Details. + * + * @default 'application/json' + */ + errorResponseContentType?: string | undefined + /** * Mapping ORPCError Code -> HTTP Status Code * Codes missing from the map fall back to `DEFAULT_ERROR_STATUS` (400). @@ -129,6 +137,7 @@ export class OpenAPIGenerator { convertSchemas: (schemas, direction) => this.convertSchemas(schemas, direction), errorStatusMap: options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP, customErrorResponseBodySchema: options.customErrorResponseBodySchema, + errorResponseContentType: options.errorResponseContentType ?? 'application/json', } const errors: string[] = [] diff --git a/packages/openapi/tests/openapi-generator/typed-errors.test.ts b/packages/openapi/tests/openapi-generator/typed-errors.test.ts index 4f455f95ab..62a73d02cc 100644 --- a/packages/openapi/tests/openapi-generator/typed-errors.test.ts +++ b/packages/openapi/tests/openapi-generator/typed-errors.test.ts @@ -201,4 +201,20 @@ describe('openAPIGenerator e2e: typed errors', () => { // only the always-available UndefinedError component is registered expect(Object.keys(doc.components?.schemas ?? {})).toEqual(['UndefinedError']) }) + + it('supports a custom error response content type', async () => { + const doc = await generator.generate({ + getPlanet: oc + .meta(openapi({ method: 'GET', path: '/planets/{id}' })) + .errors({ NOT_FOUND: {} }) + .input(z.object({ id: z.string() })) + .output(z.object({ name: z.string() })), + }, { + errorResponseContentType: 'application/problem+json', + }) + + const responses = doc.paths?.['/planets/{id}']?.get?.responses as any + expect(Object.keys(responses['200'].content)).toEqual(['application/json']) + expect(Object.keys(responses['404'].content)).toEqual(['application/problem+json']) + }) })