☰
oRPC OpenAPI 实战指南:一套 Router 同时提供 RPC 与 REST 双协议 API
2026/10/12 3:09:10 网站建设 项目流程
  • 后端
  • RPC框架
  • API设计

【免费下载链接】orpc

Typesafe APIs Made Simple 🪄

项目地址:https://gitcode.com/gh_mirrors/or/orpc
点击查看免费下载

本指南以 oRPC 官方技能文档 skills/orpc-openapi/SKILL.md 为骨架,系统讲解如何把 oRPC Router 同时暴露为 RPC 协议与符合规范的 OpenAPI HTTP 接口:从openapi()元数据与.route扩展定义路由,到用OpenAPIHandler提供服务、用OpenAPILink实现端到端类型安全调用,再到用OpenAPIGenerator生成 OpenAPI 3.2/3.1/3.0 规范文档并托管 Scalar/Swagger 交互文档。读完你可以独立配置一套"双协议、单 Router"的 Typesafe API,并理解各配置项在 packages/openapi/src/meta.ts 等源码中的底层合并与默认值规则。

概览:一个 Router,两种协议

oRPC 的核心设计是让同一套 procedure 同时"说"两种语言:

  • RPC 协议:由RPCHandler/RPCLink提供服务与调用,适合内部端到端类型安全场景;
  • OpenAPI HTTP 协议(REST 风格):由OpenAPIHandler/OpenAPILink提供服务与调用,兼容任何 OpenAPI 生态工具链。

两者接受同一个 Router 对象,因此无需为 REST 单独维护一份定义。@orpc/openapi包(源码见 packages/openapi)承担了 OpenAPI 侧的全部能力:路由元数据、输入输出编解码、规范文档生成与交互文档托管。

本文面向oRPC v2。如果你需要契约优先(contract-first)工作流,请参见orpc-contract技能;核心 builder、middleware、context 与客户端基础概念,请参见orpc技能。

路由(Routing):从默认 POST 到完全自定义

默认路由规则

一个 procedure 默认使用POST方法,路径由 Router 结构推导:planet.create会暴露为POST /planet/create。默认值定义在 packages/openapi/src/constants.ts:

export const DEFAULT_OPENAPI_METHOD = 'POST' export const DEFAULT_OPENAPI_SUCCESS_DESCRIPTION = 'OK' export const DEFAULT_OPENAPI_INPUT_STRUCTURE = 'compact' export const DEFAULT_OPENAPI_OUTPUT_STRUCTURE = 'compact'

通过 openapi() 元数据覆盖

在 server(os)与 contract(oc)builder 上均可使用openapi元数据覆盖方法、路径与成功状态码:

import { openapi } from '@orpc/openapi' import { os } from '@orpc/server' import { z } from 'zod' const getPlanet = os .meta(openapi({ method: 'GET', path: '/planets/{id}', successStatus: 200 })) .input(z.object({ id: z.string() })) .handler(async ({ input }) => ({ id: input.id, name: 'Earth' }))

其中openapi的完整可配置字段(见 packages/openapi/src/meta.ts)包括:

字段默认值说明
method'POST'支持的 HTTP 方法:HEAD、GET、POST、PUT、DELETE、PATCH、QUERY(QUERY需要 OpenAPI 3.2,见下文生成器小节)
pathRouter 段以/连接支持{param}动态参数语法
operationIdRouter 段以.连接规范文档中的唯一操作标识
summary/description无生成 spec 中的操作摘要与描述
deprecatedfalse在生成 spec 中标记为已废弃
tags无操作标签
successStatus200成功响应状态码,须小于 400
successDescription'OK'成功响应描述
paramsStyles/queryStyles见下文路径/查询参数解码策略
requestBodyHint/responseBodyHint根据头部推断请求/响应体解析提示
inputStructure'compact'输入结构:compact或detailed
outputStructure'compact'输出结构:compact或detailed
spec无覆盖或扩展生成的 Operation 对象
prefix无路径前缀

路径参数

  • 在path中写{id},并在 input schema 中定义同名必填字段;
  • 使用{+path}表示 catch-all 段,匹配值可以包含/(例如/files/{+path})。

prefix:为 procedure 或整个 Router 加前缀

os.meta(openapi({ prefix: '/api/v2' })).router({...})

关键实践:lazy Router 上务必设置prefix,这样只有匹配前缀的请求才会触发懒加载,避免无关请求加载子 Router。从源码看,prefix的合并使用mergeHttpPath按定义顺序拼接(packages/openapi/src/meta.ts)。

多次 .meta(openapi(...)) 的合并规则

openapi元数据是可重复叠加的,packages/openapi/src/meta.ts 中的init实现体现了精确的合并语义:

  • prefix与tags:按定义顺序拼接(tags数组连接、prefix路径拼接);
  • method/path/successStatus:后定义者覆盖前者(last-wins);
  • queryStyles/paramsStyles:按参数键逐项合并,同一参数以最近定义为准;
  • spec:两个函数会链式组合(后者接收前者的结果);函数与对象混用时函数作用于对象;两个对象时后者获胜;
  • 显式设为undefined:重置为默认值,而不是继续合并。

使用 .route 扩展直接定义路由

如果不希望每次调用.meta(openapi(...)),可以启用.route扩展。它需要一个在启动时必定执行的模块(基础 builder 文件或服务入口)中的副作用导入:

import '@orpc/openapi/extensions/route' const ping = os.route({ method: 'GET', path: '/ping' }).handler(async () => 'pong')

从源码看,route本质上是meta(openapi(meta))的语法糖:它在@orpc/server的Builder与@orpc/contract的ContractBuilder上通过模块扩展注入了同名方法(见 packages/openapi/src/extensions/route.ts)。

输入与输出映射(Input & Output Mapping)

compact 模式(默认)

路径参数与查询参数或请求体会合并为扁平对象,具体取决于 HTTP 方法:GET /planets/earth?q=life得到输入{ id: 'earth', q: 'life' }。handler 的返回值直接作为响应体,状态码取successStatus。

从 packages/openapi/src/adapters/standard/openapi-handler-codec.ts 的实现看,compact 模式下:无 body 的方法(如 GET)走 query 解码,有 body 的方法走 body 解码;若既有 params 又有数据,则合并;非对象数据(原始值、数组、Blob、ReadableStream 等)无法与 params 合并,会直接返回。

detailed 模式

设置inputStructure: 'detailed'后,输入变为{ params, query, headers, body }四段,schema 只需定义实际需要的字段:

// GET /users/42?search=hello const inputValue = { params: { id: 1 }, query: { search: 'hello' }, headers: { 'content-type': 'application/json' }, body: 'body value', } const inputSchema = z.object({ params: z.object({ id: z.coerce.number() }), query: z.object({ search: z.string() }), headers: z.object({ 'content-type': z.string() }), body: z.string(), })

outputStructure: 'detailed'则允许返回{ status?, headers?, body? },按响应动态设置状态码:

const outputValue = { status: 201, headers: { 'x-custom-header': 'value' }, body: 'body value', } const outputSchema = z.object({ status: z.literal(201).meta({ description: 'Record Created' }), headers: z.object({ 'x-custom-header': z.string() }), body: z.string(), })

status未提供时回退到successStatus;建议用字面量类型(如z.literal(201))以便生成的 spec 能反映精确状态码。headers为Record<string, string | string[] | undefined>。

查询字符串与表单数据的 bracket notation 解码

查询字符串和表单数据默认使用bracket notation解码(实现见 packages/openapi/src/bracket-notation.ts)。设计嵌套 query 输入的 schema 前务必阅读仓库文档 apps/content/docs/openapi/bracket-notation.mdx,其能力边界如下:

  • 重复键成为数组:?color=red&color=blue得到['red', 'blue'];color[]=red也会追加;
  • [number]定位数组下标,[key]定位对象属性:?filter[status]=active得到{ filter: { status: 'active' } };
  • 无法表达:空对象/空数组、根级数组、键全为数字的对象;query/form 值永远以字符串到达(form data 中可能是文件)。

实现层面,BracketNotationSerializer支持maxDeserializingEmptySlots(默认 1000)来限制显式数组下标产生的空槽数量:例如?arr[5000]=x会因超出上限而把数组降级为对象,防止内存被超大稀疏数组占用。

paramsStyles 与 queryStyles:逐参数覆盖解码策略

paramsStyles可选值:

策略编码段解码结果OpenAPI 参数风格
primitive(默认)/users/42{ id: '42' }simple
comma-delimited-array/users/a,b,c{ id: ['a', 'b', 'c'] }simple
comma-delimited-object/users/a,1,b,2{ id: { a: '1', b: '2' } }simple

queryStyles在paramsStyles三种策略之外还支持:

策略编码解码结果
primitive?a=1&a=2{ a: '2' }(取最后一次出现)
array?a=1&a=2{ a: ['1', '2'] }(单值也产出数组)
comma-delimited-array?a=1,2,3{ a: ['1', '2', '3'] }
comma-delimited-object?a=A,1,B,2{ a: { A: '1', B: '2' } }
space-delimited-array?a=1 2 3{ a: ['1', '2', '3'] }
pipe-delimited-array?a=1\|2\|3{ a: ['1', '2', '3'] }
json?meta={"key":"value"}{ meta: { key: 'value' } }(解析失败回退原始字符串)

注意:所有*-delimited-*策略都不支持键或值包含分隔符本身。例如openapi.path = '/users/{id}/{tags}/{filters}'配合GET /users/42/red,blue/size,large,brand,nike,可用paramsStyles分别指定id: 'primitive'、tags: 'comma-delimited-array'、filters: 'comma-delimited-object'。

requestBodyHint 与 responseBodyHint

当仅凭 HTTP 头无法让解析器确定 body 处理方式时(例如裸ReadableStream上传),使用requestBodyHint/responseBodyHint给出提示,取值包括'json'、'form-data'、'event-stream'、'octet-stream'、'file'等。注意:standard-server 的Content-Type头优先级高于此提示;form-data与url-search-params同样使用 bracket notation 解码,结果会是对象或数组。

服务端:OpenAPIHandler

基本用法

OpenAPIHandler从适配器子路径导入(@orpc/openapi/fetch、@orpc/openapi/node等,仓库适配器目录见 packages/openapi/src/adapters):

import { SmartCoercionHandlerPlugin } from '@orpc/json-schema' import { OpenAPIHandler } from '@orpc/openapi/fetch' import { ZodToJsonSchemaConverter } from '@orpc/zod' const handler = new OpenAPIHandler(router, { plugins: [ new SmartCoercionHandlerPlugin({ converters: [new ZodToJsonSchemaConverter()] }), ], }) export async function fetch(request: Request): Promise<Response> { const { matched, response } = await handler.handle(request, { prefix: '/api', context: {} }) return matched ? response : new Response('Not Found', { status: 404 }) }

从源码看,OpenAPIHandler由三部分组装而成:OpenAPIHandlerCodec(负责 OpenAPI 编解码)、StandardHandler(通用标准 handler)与FetchHandler(Fetch API 适配层),见 packages/openapi/src/adapters/fetch/openapi-handler.ts。handle返回的matched标志用于判断是否命中路由,便于与其他 handler 串联。

与 RPCHandler 共存

OpenAPIHandler与RPCHandler接受同一个 Router,因此可分别挂载在不同前缀(如/api与/rpc),逐个尝试并返回第一个matched的响应:

const openapi = new OpenAPIHandler(router, openapiOptions) const rpc = new RPCHandler(router, rpcOptions) export async function fetch(request: Request): Promise<Response> { const api = await openapi.handle(request, { prefix: '/api', context: {} }) if (api.matched) return api.response const rpcRes = await rpc.handle(request, { prefix: '/rpc', context: {} }) return rpcRes.matched ? rpcRes.response : new Response('Not Found', { status: 404 }) }

Smart Coercion:字符串到原生类型

query、path、form 的值以字符串到达,当 input schema 期望非字符串类型时,应添加SmartCoercionHandlerPlugin。它只做schema 驱动、无损的转换:

  • '123'→123
  • 'true'/'on'→true
  • ISO 字符串 →Date
  • 数组 →Set/Map(通过x-native-type)

模糊的值(无法确定如何转换)保持原样,交给 schema 校验。其实现(packages/json-schema/src/smart-coercion-handler-plugin.ts)基于 JSON Schema converter 产出 schema,再用JsonSchemaCoercer在clientInterceptors阶段、schema 校验之前完成转换,并用WeakMap缓存转换结果。

何时可以跳过:如果已在 schema 中自行coerce,或对性能敏感(它带来额外运行时开销)。

其他 handler 选项

  • interceptors/routingInterceptors/clientInterceptors:日志、错误映射等拦截器;
  • filter:从匹配中排除某些 procedure;
  • errorStatusMap+customErrorResponseBodyEncoder:自定义错误响应。默认情况下,ORPCError的 code 通过COMMON_ERROR_STATUS_MAP映射到 HTTP 状态码(见 packages/client/src/error.ts),例如NOT_FOUND→404、BAD_REQUEST→400、UNAUTHORIZED→401、TOO_MANY_REQUESTS→429、INTERNAL_SERVER_ERROR→500。errorStatusMap的值必须为 4xx 或 5xx(≥400),可以直接展开COMMON_ERROR_STATUS_MAP再覆盖个别项。

客户端:OpenAPILink

基本用法

OpenAPILink通过类型安全客户端调用 OpenAPI 形态的 oRPC API(或任何符合规范的服务端)。它需要 contract 或 router 类型来获知每个 procedure 的路由:

import type { RouterContractClient } from '@orpc/contract' import type { JsonifiedClient } from '@orpc/openapi' import { createORPCClient } from '@orpc/client' import { OpenAPILink } from '@orpc/openapi/fetch' const link = new OpenAPILink(contract, { origin: 'https://api.example.com', url: '/api', headers: ({ context }) => ({ authorization: context?.token ? `Bearer ${context.token}` : undefined, }), }) const client: JsonifiedClient<RouterContractClient<typeof contract>> = createORPCClient(link)

从源码看,OpenAPILink由OpenAPILinkCodec(编解码,含url、headers、serializer、customErrorResponseBodyDecoder等选项,见 packages/openapi/src/adapters/standard/openapi-link-codec.ts)与FetchLinkTransport(Fetch 传输层)组成,见 packages/openapi/src/adapters/fetch/openapi-link.ts。url是相对 base URL(不含 origin,默认/),应与 OpenAPI handler 的挂载路径一致。

JsonifiedClient:为什么输出类型需要包装

OpenAPI 序列化是单向的:Date在响应中会变成字符串。因此@orpc/openapi提供了JsonifiedClient类型(packages/openapi/src/types.ts),把输出与错误数据中的Date、bigint、URL映射为string,Map/Set映射为数组等,File/Blob保持原样。

如果使用 Router 而非 contract,客户端类型写成JsonifiedClient<RouterClient<typeof router>>(RouterClient来自@orpc/server)。

用 SmartCoercionLinkPlugin 恢复原生类型

在响应侧添加SmartCoercionLinkPlugin可以恢复原生类型,之后便可从客户端类型中去掉JsonifiedClient包装。注意它的第一个参数是 contract:

import { SmartCoercionLinkPlugin } from '@orpc/json-schema' const link = new OpenAPILink(contract, { plugins: [new SmartCoercionLinkPlugin(contract, { converters: [new ZodToJsonSchemaConverter()] })], })

其实现(packages/json-schema/src/smart-coercion-link-plugin.ts)通过getProcedureContractOrThrow按 path 定位 procedure,在~response-validation之后对输出与错误值做 schema 驱动的无损 coercion。

向客户端分发契约

如果想把契约分发给客户端而不打包服务端代码,可将其 minify 为 JSON 后导入(配合类型断言),完整流程见orpc-contract技能中的 "Ship the contract" 章节;仓库中也有无运行时导入的用法文档 apps/content/docs/openapi/link-without-runtime-imports.mdx。

规范文档与交互文档

OpenAPIGenerator:生成 OpenAPI 3.2(可降级)

OpenAPIGenerator将 Router 或 contract 转为 OpenAPI 文档:

import { OpenAPIGenerator } from '@orpc/openapi' import { OpenAPIReferenceHandlerPlugin } from '@orpc/openapi/plugins' import { ZodToJsonSchemaConverter } from '@orpc/zod' const generator = new OpenAPIGenerator({ converters: [new ZodToJsonSchemaConverter()] }) const handler = new OpenAPIHandler(router, { plugins: [ new OpenAPIReferenceHandlerPlugin({ spec: () => generator.generate(router, { base: { info: { title: 'Planet API', version: '1.0.0' }, servers: [{ url: '/api' }], // 生产环境请使用绝对 URL }, }), }), ], })

生成器的关键行为(源码见 packages/openapi/src/openapi-generator.ts):

  • 默认版本3.2.0;可通过version指定任意3.0.x或3.1.x,文档会先按 3.2 生成再整体降级(内部使用@openapi-spec/downgrader的downgradeSpecV32ToV31/downgradeSpecV31ToV30);
  • QUERYprocedure 必须使用 3.2,否则生成时报OpenAPIGeneratorError;
  • base提供info、servers、components等起始字段,同样随版本降级,openapi字段由version派生;
  • filter可排除部分 procedure;errorStatusMap与customErrorResponseBodySchema控制错误响应的 schema;
  • 根级$defs会被提升到components.schemas,customComponentName可自定义提升后组件名(重名时自动加后缀);
  • 未提供 converter 的 schema 回退到StandardJsonSchemaConverter(标准 JSON Schema 转换)。

用 OpenAPI 元数据丰富文档

通过openapi元数据可丰富生成的操作对象:operationId、summary、description、tags、successDescription,以及spec回调——它接收生成的 operation 对象并返回扩展后的对象(如 security requirements、额外响应)。即使目标是 3.1/3.0,spec与base也应写成 OpenAPI 3.2 对象,生成器负责整体降级。

除 Zod 外,Valibot(@orpc/valibot)与 ArkType(@orpc/arktype)同样提供 converter,仓库中对应实现见 packages/valibot/src/converter.ts 与 packages/arktype/src/converter.ts。

OpenAPIReferenceHandlerPlugin:Scalar 与 Swagger

OpenAPIReferenceHandlerPlugin默认在 handler 前缀下提供:

  • GET /spec.json:OpenAPI 规范 JSON;
  • GET /:Scalar API 参考 UI(provider: 'swagger'时切换为 Swagger UI)。

可配置项(源码见 packages/openapi/src/plugins/openapi-reference.ts):

选项默认值说明
spec必填静态或动态(函数)的 OpenAPI 文档
allow始终允许返回false时该请求视为未匹配,可用于鉴权限制
specPath/spec.json规范 JSON 路径
docsPath/文档 UI 路径
provider'scalar''scalar'或'swagger'
providerConfig无透传给 Scalar / Swagger UI 的配置
providerScriptUrl/providerCssUrl各 CDN 默认地址覆盖 UI 资源地址
docsTitle/docsHeadspec.info.title/ 空页面标题与<head>注入 HTML

从实现细节看,该插件以routingInterceptors方式注入,仅在GET请求且未命中其他 procedure 时介入;响应会序列化 spec JSON 文件或生成嵌入 UI 的 HTML 页面,并对标题/配置做 HTML 与 JSON 双重转义以防注入。

Contract-first 工作流

契约优先工作流属于orpc-contract技能范畴:用oc(@orpc/contract)定义契约形状、用implement(@orpc/server)实现、从客户端消费契约、以 minified JSON 分发契约,或用 Hey API 从现有 OpenAPI spec 反向生成契约。

在 OpenAPI 侧,contract 与 Router 行为完全一致:

  • 用.meta(openapi({...}))在oc上附加路由(见上文 Routing 一节);
  • 照常使用OpenAPIHandler服务实现后的 Router;
  • 直接从 contract 生成 spec 文档;
  • 将 contract 作为运行时值传给OpenAPILink。

收尾验证清单

在宣布完成前,务必实测以下端点:

  1. 请求 handler 前缀下的/spec.json,确认操作对象存在;
  2. 请求一个已配置的路由端点(如GET /api/planets/earth),确认方法、路径、状态码与配置一致;
  3. 若启用了 Smart Coercion,确认 query/path 中的字符串被正确转换为 schema 期望的类型;
  4. 若同时挂载了RPCHandler,确认两者互不干扰、matched判定符合预期。

更详细的参数说明与边界情况可继续阅读仓库文档:apps/content/docs/openapi/routing.mdx、apps/content/docs/openapi/input-and-output-mapping.mdx、apps/content/docs/openapi/handler.mdx、apps/content/docs/openapi/link.mdx、apps/content/docs/openapi/specification.mdx 与 apps/content/docs/openapi/scalar.mdx;源码级实现可分别在 packages/openapi、packages/json-schema/src/smart-coercion-handler-plugin.ts 与 packages/json-schema/src/smart-coercion-link-plugin.ts 中深入研读。

  • 后端
  • RPC框架
  • API设计

【免费下载链接】orpc

Typesafe APIs Made Simple 🪄

项目地址:https://gitcode.com/gh_mirrors/or/orpc
点击查看免费下载
上一篇:三步装好免费漫画阅读器:Kotatsu 新手完整使用指南
下一篇:BMLongPressDragCellCollectionView自定义拖拽动画:打造独特的用户体验

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询