OpenAPI 规范告诉你合法的请求长什么样,合法的响应又长什么样。但它不会告诉你,在调用 POST /auth 之前能否调用 POST /orders;也不会告诉你,在调用 DELETE /invoice/{id} 之后再调用 GET /invoice/{id} 会发生什么。这些信息存在于 protocol spec 中,而 OpenAPI 并不是 protocol spec。
这就是缺口所在。你可以从 OpenAPI 文档生成系统中所有的消息类型,却无法生成这些消息何时允许发送的规则。这些规则就是 session type,它们存在于另一个抽象层中。
OpenAPI 捕获了什么,又忽略了什么
OpenAPI 是 HTTP 接口层面的 contract。它定义了路径、方法、查询参数、请求体、响应码和 JSON schema。这很有价值,但也从根本上是静态的。每个端点都被孤立描述,端点之间的关系、它们触发的状态转换,以及合法的序列,都被留给了阅读者自行理解。
Session types 正好相反。Session type 是对通信协议的正式描述。它规定了消息必须按什么顺序收发、由谁发送什么内容,以及协议如何根据消息内容产生分支。一个二元 session type 可能会这样描述:客户端发送一条 Login 消息,然后服务器要么回复 Success 并继续协议,要么回复 Failure 并结束协议。
你无法从 OpenAPI 规范中推导出这样的序列,因为这个序列从来就没有被写下来。OpenAPI 文档列出了端点,却没有规定 /auth 必须在 /orders 之前。这个约束存在于文档、代码,或者设计该 API 的工程师的脑海中。
你能提取什么:消息层
OpenAPI 确实能提供的是精确的、机器可读的消息定义。每个请求体 schema、每个响应 schema、每个枚举和 discriminator 都在 JSON Schema 中有明确定义。这些是 session type 规范中消息类型的原材料。
来看一个具体的例子。假设有以下 OpenAPI 片段:
paths:
/auth:
post:
requestBody:
content:
application/json:
schema:
type: object
properties:
username: { type: string }
password: { type: string }
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
token: { type: string }
'401':
description: Unauthorized
从中你可以提取两种消息类型:一个包含用户名和密码的 AuthRequest,以及一个要么是带 token 的 Success 要么是 Failure 的 AuthResponse。Session type 需要这些类型,还需要知道客户端发送请求、服务器发送响应——这一点 OpenAPI 通过 HTTP 语义隐含地表达了。
你可以将这种提取自动化。下面是一个解析 OpenAPI JSON 规范并生成类似 Rust 的消息定义的 Python 脚本:
import json
from dataclasses import dataclass
@dataclass
class Field:
name: str
type: str
@dataclass
class Message:
name: str
fields: list[Field]
TYPE_MAP = {"string": "String", "integer": "i64", "boolean": "bool"}
def sanitize_name(path: str, method: str, suffix: str) -> str:
cleaned = path.replace("/", "").replace("{", "").replace("}", "")
return f"{method.upper()}{cleaned}{suffix}"
def extract_messages(openapi_path: str) -> list[Message]:
with open(openapi_path) as f:
spec = json.load(f)
messages = []
for path, methods in spec.get("paths", {}).items():
for method, operation in methods.items():
if not isinstance(operation, dict):
continue
req = operation.get("requestBody", {})
schema = req.get("content", {}).get("application/json", {}).get("schema", {})
if schema:
fields = [
Field(name=k, type=TYPE_MAP.get(v.get("type"), "serde_json::Value"))
for k, v in schema.get("properties", {}).items()
]
messages.append(Message(name=sanitize_name(path, method, "Request"), fields=fields))
for code, resp in operation.get("responses", {}).items():
schema = resp.get("content", {}).get("application/json", {}).get("schema", {})
if schema:
fields = [
Field(name=k, type=TYPE_MAP.get(v.get("type"), "serde_json::Value"))
for k, v in schema.get("properties", {}).items()
]
messages.append(Message(name=sanitize_name(path, method, f"Response{code}"), fields=fields))
return messages
if __name__ == "__main__":
for m in extract_messages("api.json"):
print(f"struct {m.name} {{")
for f in m.fields:
print(f" {f.name}: {f.type},")
print("}")
这很机械,但确实有效。它把 OpenAPI 文档转换成 session type 实现可以引用的 struct。类型是准确的,名字是字面的,关系却是缺失的。
序列信息在哪里
要得到协议,你需要了解状态机。有些 API 隐式地编码了这一点。POST /orders 返回一个订单 ID,随后的 GET /orders/{id} 调用会引用它。协议状态包含”订单已创建”这一信息。OpenAPI 规范并不会对这种依赖关系建模。
有一些新兴标准试图弥合这一差距。AsyncAPI 用 channel 语义处理事件驱动协议,但仍然无法给出全局的 session type。Smithy 定义了 operation 和 trait,可以通过自定义 trait 来建模有限状态机,但需要显式注解。JSON Hyper-Schema 曾尝试链接资源,但从未获得广泛采用。
目前,务实的做法是将协议视为独立的产物。你从 OpenAPI 生成消息类型,然后在其之上手写 session type。
一种实用的混合方案:生成类型 + 手写协议
下面是使用 Rust 和 session_types crate 的实际做法。首先,使用上面的脚本或 typify 之类的工具从 OpenAPI 生成消息类型,然后显式定义 session type:
use session_types::*;
// Generated from OpenAPI
struct AuthRequest { username: String, password: String }
struct AuthSuccess { token: String }
struct AuthFailure { reason: String }
struct OrderRequest { item_id: u64, quantity: u64 }
struct OrderConfirmation { order_id: u64 }
// The protocol: authenticate, then optionally place an order
type ServerProto = Receive<AuthRequest, Offer<
Send<AuthSuccess, Receive<OrderRequest, Send<OrderConfirmation, Eps>>>,
Send<AuthFailure, Eps>
>>;
fn server(c: Chan<(), ServerProto>) {
let (c, req) = c.recv();
if authenticate(&req) {
let c = c.sel1().send(AuthSuccess { token: "abc".into() });
let (c, order) = c.recv();
let c = c.send(OrderConfirmation { order_id: 42 });
c.close();
} else {
let c = c.sel2().send(AuthFailure { reason: "bad creds".into() });
c.close();
}
}
Session type ServerProto 精确规定了服务器的行为:它接收一个 AuthRequest,然后提供一个选择:要么发送 AuthSuccess 并继续接收 OrderRequest,要么发送 AuthFailure 并结束。OpenAPI 规范给了我们 struct,session type 给了我们语法。
这就是分工。OpenAPI 负责消息形态,session types 负责消息顺序。一个是生成的,另一个是设计的。
你需要了解的权衡
从 OpenAPI 自动提取并非没有棱角。首先是多态性。OpenAPI 使用 oneOf 和 anyOf 表示联合类型,但 discriminator 信息无法干净地映射到 session type 的分支。响应 schema 中的 oneOf 可能代表两种不同的成功形态,也可能代表一个协议分支。你必须阅读规范才能判断。
其次是 HTTP 专属语义。Session types 是与传输层无关的,而 OpenAPI 与 HTTP 方法、状态码和请求头深度绑定。当你提取消息时,除非把方法和路径信息编码进消息名称,否则就会丢失这些信息。这就是为什么上面的 Python 脚本会生成 POSTAuthResponse200 这样的名字。它很丑,但保留了来源信息。
第三是不完整的规范。许多 OpenAPI 文档是从代码生成的,并不包含所有错误响应。如果你的 session type 需要处理每一个可能的分支,不完整的 OpenAPI 规范就会产生不完整的 session type。如果分支从未出现在源文档中,类型检查器也无法发现缺失的分支。
如何在你的 codebase 中实现
从消息开始。使用 openapi-typescript、typify 或自定义脚本从 OpenAPI schema 生成语言相关的类型。这个阶段不要尝试生成协议,先把 struct 拿到手。
接下来,为系统中最关键的流程编写 session type。选择那些序列错误代价最高的流程,身份验证和支付都是不错的候选。用你选择的语言定义 session type。Rust 有 session_types,TypeScript、OCaml 和 Scala 也有实验性库。如果你的语言没有 session type 库,那就写一个断言合法序列的状态机测试。它提供的保证并不相同,但能捕获同一类 bug。
最后,通过 CI 让这两份产物保持同步。当 OpenAPI 规范发生变化时,重新生成消息类型。如果新增了必填字段,session type 代码就会编译失败。这就是你买来的安全性。
常见问题
这适用于 WebSocket 或 gRPC API 吗?
不能直接适用。OpenAPI 描述的是 HTTP。对于 gRPC,你有 protobuf schema,它能提供消息类型,但仍然无法提供序列。对于 WebSocket,AsyncAPI 更接近,但同样的限制依然存在:它描述的是 channel,而不是全局的 session protocol。
我可以改用 AsyncAPI 生成 session types 吗?
AsyncAPI 增加了 channel 语义和消息路由,让你更接近目标。但它仍然无法指定全局的协议状态机。你需要从 AsyncAPI 提取消息类型,然后像处理 OpenAPI 一样手写 session type。
如果我的 API 有五十个端点怎么办?
五十个端点意味着五十种消息类型,而不是一个有五十步的 session type。真实的协议会分解成更小的 session。一个电商 API 可能为身份验证、结账和库存分别设置独立的 session,然后将它们组合起来。
有没有工具可以全自动完成这些?
目前还没有。相关的研究已经存在,也有从 REST API 和 choreographies 中提取 session types 的论文。但生产级的工具尚未出现。目前,混合方案是最务实的路径。
你的 OpenAPI 规范是一本字典,它告诉你每个词的含义。Session type 是一套语法,它告诉你哪些句子是合法的。从 OpenAPI 生成词汇,自己编写语法。