OpenAPI 規格會告訴你合法的請求長什麼樣子,以及合法的回應長什麼樣子。它不會告訴你,在呼叫 POST /orders 之前是否允許先呼叫 POST /auth,或者在你呼叫 DELETE /invoice/{id} 之後再呼叫 GET /invoice/{id} 會發生什麼事。這些資訊存在於protocol規格中,而 OpenAPI 並不是protocol規格。

這就是落差所在。你可以從 OpenAPI 文件產生系統中的每一種訊息型別。但你無法產生關於這些訊息何時允許傳送的規則。這些規則就是 session type,而它們存在於另一個抽象層次。

OpenAPI 捕捉了什麼、忽略了什麼

OpenAPI 是 HTTP 介面的 contract。它定義了 paths、methods、query parameters、request bodies、response codes 與 JSON schemas。這很有價值。但它在本質上也是靜態的。每個端點都是孤立描述的。端點之間的關係、它們觸發的狀態轉換,以及合法的順序,都留給讀者自行推敲。

Session types 則相反。Session type 是通訊protocol的正式描述。它規定了訊息必須傳送與接收的順序、誰傳送什麼,以及protocol如何依訊息內容分支。一個二元 session type 可能會說:用戶端傳送一個 Login 訊息,然後伺服器要麼回應 Success 且protocol繼續,要麼回應 Failure 且protocol結束。

你無法從 OpenAPI 規格推導出該順序,因為這個順序從未被寫下來。OpenAPI 文件列出了端點。它並沒有規定 /auth 必須先於 /orders。這條限制條件存在於文件、程式碼,或設計該 API 的工程師腦袋裡。

你能萃取什麼:訊息層

OpenAPI 真正能給你的,是精確、可機讀的訊息定義。每個 request body schema、每個 response schema、每個 enum 與 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

從這裡,你可以萃取出兩種訊息型別:一個包含 username 與 password 的 AuthRequest,以及一個要麼是帶有 token 的 Success、要麼是 FailureAuthResponse。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。型別是準確的。名稱是照字面的。關係被遺漏了。

順序資訊存在於何處

要取得protocol,你需要知道state machine。有些 API 隱含地編碼了這一點。POST /orders 會回傳一個訂單 ID,而後續的 GET /orders/{id} 呼叫會引用它。protocol狀態包含「已建立一筆訂單」。OpenAPI 規格並未對這種相依關係建模。

有一些新興標準試圖彌補這個缺口。AsyncAPI 以 channel 語意處理事件驅動protocol,但它仍然無法給你全域的 session type。Smithy 定義了 operations 與 traits,並且可以透過自訂 traits 來建模finite state machine,但這需要明確標註。JSON Hyper-Schema 嘗試連結資源,但從未獲得廣泛採用。

目前為止,務實的做法是將protocol視為一份獨立產物。你從 OpenAPI 產生訊息型別,然後在它們之上手寫 session type。

務實的混合做法:自動產生的型別加上手寫的protocol

實務上,使用 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 使用 oneOfanyOf 來表示聯合型別,但 discriminator 資訊無法乾淨對應到 session type 的分支。response schema 裡的 oneOf 可能代表兩種不同的成功形狀,也可能代表一個protocol分支。你必須讀過規格才知道是哪一種。

第二個是 HTTP 特定語意。Session types 與傳輸層無關。OpenAPI 則深度綁定於 HTTP methods、狀態碼與 headers。當你萃取訊息時,除非你將它編碼進訊息名稱,否則會遺失 method 與 path 資訊。這就是為什麼上面的 Python 腳本會產生 POSTAuthResponse200 這樣的名稱。它很醜,但保留了來源。

第三個是不完整的規格。許多 OpenAPI 文件是從程式碼產生的,並未包含所有錯誤回應。如果你的 session type 需要處理每一個可能的分支,一份不完整的 OpenAPI 規格就會產生不完整的 session type。如果分支從未出現在原始文件中,型別檢查器也無法抓到遺漏的分支。

如何在你的 codebase 中實作

從訊息開始。使用 openapi-typescripttypify 或自訂腳本,從你的 OpenAPI schemas 產生特定語言的型別。這個階段不要嘗試產生protocol。先把 struct 拿到手。

接著,為系統中最關鍵的流程手寫 session type。挑選一個順序錯誤代價最高的流程。驗證與付款都是很好的候選。用你選擇的語言定義 session type。Rust 有 session_types。TypeScript、OCaml 與 Scala 也有實驗性函式庫。如果你的語言缺少 session type 函式庫,那就寫一個state machine test來斷言合法順序。這不是同樣的保證,但能抓到同一類缺陷。

最後,透過 CI 讓兩份產物保持同步。當 OpenAPI 規格變更時,重新產生訊息型別。如果新增了必填欄位,session type 程式碼會編譯失敗。這就是你換取到的安全性。

常見問題

這對 WebSocket 或 gRPC API 也適用嗎?

不直接適用。OpenAPI 描述的是 HTTP。對於 gRPC,你有 protobuf schemas,它給了你訊息型別但仍然沒有順序。對於 WebSocket,AsyncAPI 比較接近,但同樣的限制適用:它描述的是 channels,而非全域 session protocol。

我可以改從 AsyncAPI 產生 session types 嗎?

AsyncAPI 加入了 channel 語意與訊息路由,讓你更接近目標。但它仍然沒有指定全域protocolstate machine。你會從 AsyncAPI 萃取訊息型別,然後像對待 OpenAPI 一樣手寫 session type。

如果我的 API 有五十個端點呢?

五十個端點代表五十種訊息型別,而不是一個有五十個步驟的 session type。真實的protocol會分解成更小的 session。一個電子商務 API 可能有分別用於驗證、結帳與庫存的獨立 session。將它們組合起來。

有工具可以自動完成所有這些步驟嗎?

還沒有。相關研究存在。有論文探討如何從 REST API 與 choreographies 萃取 session type。但生產級工具尚未出現。目前為止,混合做法是最務實的路徑。

你的 OpenAPI 規格是一本字典。它告訴你每個詞的意義。Session type 是一套文法。它告訴你哪些句子是合法的。從 OpenAPI 產生詞彙。文法則由你自己撰寫。