Skip to content

OpenAPI Specification

Learn how to configure metadata and generate OpenAPI documents from your oRPC contracts and routers.

Metadata

Use openapi metadata to control how a procedure appears in the generated OpenAPI document:

ts
import { oc } from '@orpc/contract'
import { openapi } from '@orpc/openapi'
import { z } from 'zod'

const getPlanet = oc
  .meta(openapi({
    method: 'GET',
    path: '/planets/{id}',
    operationId: 'getPlanet',
    summary: 'Get a planet',
    description: 'Returns a single planet.',
    tags: ['planets'],
    successStatus: 200,
    successDescription: 'Planet payload',
  }))
  .input(z.object({
    id: z.string(),
  }))
  .output(z.object({
    id: z.string(),
    name: z.string(),
  }))

INFO

For routing metadata, you can learn more in OpenAPI Routing. For input and output mapping metadata, see OpenAPI Input and Output Mapping.

Customizing the Operation Object

Use spec to customize the generated operation object. If spec is an object, it replaces the generated operation object entirely. If spec is a callback, it receives the final operation object and returns an extended version.

ts
const getPlanet = oc
  .meta(openapi({
    method: 'GET',
    path: '/planets/{id}',
    spec: current => ({
      ...current,
      security: [{ bearerAuth: [] }],
    }),
  }))
  .input(z.object({ id: z.string() }))

Metadata Merging

When openapi is applied multiple times, tags, spec, prefix, paramsStyle, and queryStyles are deep-merged, while operationId, summary, description, successDescription, method, path, successStatus, inputStructure, outputStructure, responseBodyHint, and requestBodyHint are overridden by the most recent call. For full merge behavior, see the source code.

ts
const router = os
  .meta(openapi({
    tags: ['planets'],
    spec: current => ({
      ...current,
      security: [{ bearerAuth: [] }],
    }),
  }))
  .router({
    list: os
      .meta(openapi({ method: 'GET', summary: 'List planets', tags: ['list'] }))
      .meta(openapi({
        spec: {
          operationId: 'getPlanet',
          summary: 'List planets',
          responses: {
            200: {
              description: 'List of planets',
            },
          }
        }
      }))
      .input(z.object({ q: z.string().optional() }))
      .handler(async () => ([])),
  })

These are equivalent to:

ts
const router = {
  list: os
    .meta(openapi({
      method: 'GET',
      tags: ['planets', 'list'],
      summary: 'List planets',
      spec: {
        operationId: 'getPlanet',
        summary: 'List planets',
        responses: {
          200: {
            description: 'List of planets',
          },
        },
        security: [{ bearerAuth: [] }],
      },
    }))
    .input(z.object({ q: z.string().optional() }))
    .handler(async () => ([])),
}

INFO

Metadata resets to its default behavior when set to undefined in subsequent calls:

ts
const example = os
  .meta(openapi({ tags: ['planets'] }))
  .meta(openapi({ tags: undefined }))

In this example, the final tags is undefined, so no tags are applied to example.

OpenAPI Generator

OpenAPIGenerator accepts either a contract or a router and generates an OpenAPI 3.1 document.

ts
import { OpenAPIGenerator } from '@orpc/openapi'

const generator = new OpenAPIGenerator({
  converters: [new ZodToJsonSchemaConverter()],
})

const spec = await generator.generate(router, {
  base: {
    info: {
      title: 'Planet API',
      version: '1.0.0',
    },
    servers: [
      { url: 'https://example.com/api' },
    ],
  },
})

Custom Serializer

If your OpenAPI Handler uses a custom serializer, configure OpenAPIGenerator with the same serializer so the generated document matches the actual formats. For details, see OpenAPI Serializer.

ts
const handler = new OpenAPIGenerator({
  serializer: new OpenAPISerializer({
    handlers: {
      // ...custom handlers
    },
  }),
})

Filtering Procedures

Use filter to exclude procedures from the generated document:

ts
const spec = await generator.generate(router, {
  filter: (_procedure, path) => !path.includes('internal'),
})

Hoisting $defs

By default, root-level $defs generated by your converters are moved into components.schemas. Use shouldHoistDef to keep selected definitions inline:

ts
const spec = await generator.generate(router, {
  shouldHoistDef: defName => !defName.startsWith('_'),
})

Custom Error Response Schemas

If your OpenAPI Handler uses custom error response formats, configure OpenAPIGenerator with the same logic so the generated document matches the actual error response formats.

ts
import { COMMON_ERROR_STATUS_MAP } from '@orpc/openapi'

const spec = await generator.generate(router, {
  errorStatusMap: {
    ...COMMON_ERROR_STATUS_MAP,
    PLANET_GONE: 410,
  },
  customErrorResponseBodySchema: (definedErrors, status) => {
    if (status === 410) {
      return {
        type: 'object',
        properties: {
          code: { type: 'string' },
          message: { type: 'string' },
        },
        required: ['code', 'message'],
      }
    }

    // fallback to default by returning null or undefined
    return null
  },
})

Json Schema Converters

OpenAPIGenerator relies on JSON Schema converters to translate your input, output, and error schemas into JSON Schemas. oRPC provides built-in for Zod, Valibot, and Arktype:

ts
import { ZodToJsonSchemaConverter } from '@orpc/zod'
import { ValibotToJsonSchemaConverter } from '@orpc/valibot'
import { ArkTypeToJsonSchemaConverter } from '@orpc/arktype'

const generator = new OpenAPIGenerator({
  converters: [
    new ZodToJsonSchemaConverter(),
    new ValibotToJsonSchemaConverter(),
    new ArkTypeToJsonSchemaConverter(),
  ],
})

INFO

OpenAPIGenerator falls back to Standard Json Schema conversion when the required converter is missing.

Building Your Own Converter?

Building your own converter is straightforward. You can add support for another Standard Schema library by implementing the JsonSchemaConverter interface:

ts
import type { AnySchema } from '@orpc/contract'
import type {
  JsonSchema,
  JsonSchemaConverter,
  JsonSchemaConverterDirection
} from '@orpc/json-schema'
import { toJsonSchema } from '@valibot/to-json-schema'

class MyCustomConverter implements JsonSchemaConverter {
  condition(schema: AnySchema | undefined, _direction: JsonSchemaConverterDirection): boolean {
    return schema?.['~standard'].vendor === 'valibot'
  }

  convert(
    schema: AnySchema | undefined,
    direction: JsonSchemaConverterDirection
  ): [jsonSchema: JsonSchema, optional: boolean] {
    // In most cases, treating the schema as required is acceptable.
    return [toJsonSchema(schema as any), false] as any
  }
}

Customizing ZodToJsonSchemaConverter

ZodToJsonSchemaConverter wraps Zod's built-in toJSONSchema and adds support for additional types. See the source code for implementation details.

A common pattern is defining reusable schemas with id metadata. The converter places them in $defs, which OpenAPIGenerator then hoists into components.schemas. For more on id and $ref in Zod, see Zod JSON Schema Registries.

ts
import { z } from 'zod'

const PlanetSchema = z.object({
  id: z.string(),
  name: z.string(),
}).meta({ id: 'Planet' })

Customizing ValibotToJsonSchemaConverter

ValibotToJsonSchemaConverter wraps Valibot's built-in toJsonSchema. See the source code for implementation details.

A common pattern is defining reusable or recursive schemas via definitions. The converter preserves them in $defs, which OpenAPIGenerator can then hoist into components.schemas. For more on how definitions work in Valibot, see Valibot JSON Schema Definitions.

ts
import * as v from 'valibot'

const PlanetSchema = v.object({
  id: v.string(),
  name: v.string(),
})

const generator = new OpenAPIGenerator({
  converters: [
    new ValibotToJsonSchemaConverter({
      definitions: { PlanetSchema },
    }),
  ],
})

Customizing ArkTypeToJsonSchemaConverter

ArkTypeToJsonSchemaConverter wraps ArkType's built-in toJsonSchema. See the source code and ArkType's JSON Schema configuration docs for implementation details.

A common pattern is defining reusable or recursive types using scopes. The converter preserves them in $defs, which OpenAPIGenerator can then hoist into components.schemas.

ts
import { scope } from 'arktype'

const types = scope({
  Planet: {
    name: 'string',
    neighbors: 'Planet[]',
  },
})

const PlanetSchema = types.export().Planet

Released under the MIT License.