OpenAPIの仕様は、有効なリクエストがどのような形をしていて、有効なレスポンスがどのような形をしているかを教えてくれる。しかし、POST /ordersPOST /auth の前に呼び出してもよいのか、DELETE /invoice/{id} の後に GET /invoice/{id} を呼び出したらどうなるのかまでは教えてくれない。その情報はプロトコル仕様に存在し、OpenAPIはプロトコル仕様ではない。

これが隔たりだ。OpenAPI文書からシステム内のあらゆるメッセージ型を生成することはできる。しかし、それらのメッセージがいつ飛んでよいかというルールは生成できない。そのルールこそがsession typeであり、それは別の抽象化の層に存在する。

OpenAPIが捉えるものと無視するもの

OpenAPIはHTTPの表面領域に対するcontractだ。paths、methods、query parameters、request bodies、response codes、JSONスキーマを定義する。これは価値のあることだ。同時に、根本的に静的でもある。各エンドポイントは独立して記述される。エンドポイント間の関係性、それらが引き起こす状態遷移、合法な順序については、読者の課題として残される。

Session typesはその対極にある。Session typeは、通信プロトコルを形式的に記述したものだ。メッセージを送受信しなければならない順序、誰が何を送るか、メッセージの内容に基づいてプロトコルがどう分岐するかを規定する。バイナリsession typeはこう述べるかもしれない:クライアントがLoginメッセージを送信し、サーバーがSuccessで応答すればプロトコルは継続し、Failureで応答すればプロトコルは終了する。

そのような順序をOpenAPIの仕様から導出することはできない。なぜなら、その順序は最初から記述されていないからだ。OpenAPI文書はエンドポイントを列挙するにとどまる。/auth/orders の前に来なければならないという制約は、ドキュメントやコード、あるいはそのAPIを設計したエンジニアの頭の中にしかない。

抽出できるもの:メッセージ層

OpenAPIが確かに与えてくれるのは、正確で機械可読なメッセージ定義だ。request bodyスキーマ、responseスキーマ、すべての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

これから、2つのメッセージ型を抽出できる。AuthRequest はusernameとpasswordを含み、AuthResponse はtokenを持つSuccessFailure のいずれかである。Session typeはこれらの型を必要とする。また、クライアントがリクエストを送信しサーバーがレスポンスを返すということも、OpenAPIはHTTPのセマンティクスを通じて暗に示している。

この抽出は自動化できる。次のPythonスクリプトは、OpenAPIのJSON仕様を解析し、Rust風のメッセージ定義を生成する:

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に変換する。型は正確だ。名前は文字通りだ。関係性は欠落している。

順序情報がどこにあるのか

プロトコルを得るには、state machineを知る必要がある。一部のAPIはこれを暗にエンコードしている。POST /orders は注文IDを返し、後続の GET /orders/{id} の呼び出しはそれを参照する。プロトコル状態には「注文が作成された」という情報が含まれる。OpenAPIの仕様はこの依存関係をモデル化しない。

これを埋める新興規格も存在する。AsyncAPIはチャネルのセマンティクスを持つイベント駆動型プロトコルを扱うが、グローバルなsession typeはまだ与えてくれない。Smithyはoperationsとtraitsを定義し、カスタムtraitを通じて有限state machineをモデル化できるが、明示的な注釈が必要だ。JSON Hyper-Schemaはリソース間のリンクを試みたが、広く普及することはなかった。

現時点で実用的なアプローチは、プロトコルを別のアーティファクトとして扱うことだ。OpenAPIからメッセージ型を生成し、その上にsession typeを手書きで記述する。

実用的なハイブリッド:生成された型と手書きのプロトコル

実際にこれがどう見えるか、Rustと session_types クレートを使って見てみよう。まず、上記のスクリプトや 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スキーマ内の oneOf は、2つの異なる成功の形を表しているのか、あるいはプロトコルの分岐を表しているのか。どちらなのかは、仕様を読まなければわからない。

第二はHTTP特有のセマンティクスだ。Session typesはトランスポートに依存しない。OpenAPIはHTTPのmethods、status codes、headersに深く結びついている。メッセージを抽出する際、メッセージ名にエンコードしない限り、methodとpath情報は失われる。そのため、上記のPythonスクリプトは POSTAuthResponse200 のような名前を生成する。見た目は悪いが、出自を保持している。

第三は不完全な仕様だ。多くのOpenAPI文書はコードから生成され、すべてのエラーレスポンスを含んでいない。session typeがあらゆる可能な分岐を扱う必要がある場合、不完全なOpenAPI仕様は不完全なsession typeを生み出す。型チェッカーは、元の文書に存在しなかった分岐の欠落を検出できない。

codebaseでの実装方法

メッセージから始める。openapi-typescripttypify、あるいはカスタムスクリプトを使って、OpenAPIのスキーマから言語固有の型を生成する。この段階でプロトコルを生成しようとしない。structを得るだけだ。

次に、システム内で最も重要なフローのsession typeを手書きする。順序のバグが最も高いコストを招くフローを選ぶ。認証と決済は良い候補だ。お好みの言語でsession typeを定義する。Rustには session_types がある。TypeScript、OCaml、Scala用の実験的なライブラリも存在する。言語にsession typeライブラリがない場合、有効な順序をアサートするstate machineテストを書く。同じ保証ではないが、同じクラスのバグを捉える。

最後に、CIで2つのアーティファクトを同期させる。OpenAPIの仕様が変更されたら、メッセージ型を再生成する。新しい必須フィールドが追加されたら、session typeのコードはコンパイルに失敗する。それがあなたが得る安全性だ。

FAQ

WebSocketやgRPCのAPIでも使えるか?

直接的には使えない。OpenAPIはHTTPを記述する。gRPCの場合、protobufのスキーマがあり、メッセージ型は与えてくれるが順序は与えてくれない。WebSocketの場合、AsyncAPIの方が近いが、同じ制限が適用される:チャネルを記述するが、グローバルなsession protocolではない。

AsyncAPIからsession typeを生成できるか?

AsyncAPIはチャネルのセマンティクスとメッセージルーティングを追加するため、より近づく。しかし、グローバルなプロトコルstate machineを規定するわけではない。AsyncAPIからメッセージ型を抽出し、OpenAPIと同じように手でsession typeを書くことになる。

APIに50個のエンドポイントがあったらどうする?

50個のエンドポイントは、50個のメッセージ型を意味し、50ステップのsession typeを意味するわけではない。実際のプロトコルは、より小さなsessionに分解できる。EコマースAPIであれば、認証、決済、在庫管理といった個別のsessionがある。それらを合成する。

これを全部自動でやってくれるツールはあるか?

まだない。研究は存在する。REST APIやchoreographiesからsession typeを抽出する論文もある。プロダクション対応のツールは存在しない。現時点では、ハイブリッドアプローチが実用的な道だ。

OpenAPIの仕様は辞書だ。単語の意味を教えてくれる。Session typeは文法だ。どのような文が合法かを教えてくれる。語彙はOpenAPIから生成する。文法は自分で書く。