freeeの開発情報ポータルサイト

freee請求書 33,000 行の openapi.yml を TypeSpec に移行した話

債権・請求書ドメインでエンジニアをやっている jaxx です。 好きな ESP32 デバイスは M5Stack ATOMS3 Lite です。

freee請求書では、Public API だけでなく、内部的なAPIも含めると複数あり、最も多いAPI群は、生成後のOpenAPIが 33,000行ありましたが、今回 API 定義のソースを OpenAPI (YAML) から TypeSpec へ全面的に移行しました。 今回はその話を備忘録としてエントリーにしたいと思います。

なぜ既存の技術スタックを変更するのか

freee請求書はスキーマ駆動開発を採用していて、OpenAPI を起点にバックエンドのリクエスト検証とフロントエンドの API クライアント・型・モックを生成しています。プロダクトは請求書・見積書・納品書・発注書・領収書・支払通知書という 6 つの帳票種別があり、それぞれがほぼ同型の API 群 (一覧・作成・更新・PDF・メール送付・ステータス変更など) を持ちます。OpenAPI には同型の API 群を束ねる抽象化手段がないため ERB テンプレートを用いて帳票種別の openapi.yml を展開する独自実装があり、ここをエンジニアやAIにとって認知負荷の低い、標準的な技術スタックに置き換えたいなと思っていました。

なぜ TypeSpec にするのか

TypeSpec は Microsoft 製の API 定義言語で、TypeScript に似た構文から OpenAPI を出力できます。freee 社内では支出管理チームが先行導入していて、その経緯は OpenAPI ではなく TypeSpec を読み書きするスキーマ駆動開発 にまとまっています。

developers.freee.co.jp

上記から1年経ってプラスでメリットだなと思っているのが

  • コーディングエージェントでは苦手な再現性や速度の問題をコンパイラとジェネレーターに寄せることで決定論的な処理にできること
  • TypeScript ライクな構文によって記述量を減らすことによる生成やレビューコストの削減になること

の2点が挙げられます。債権請求書チームとして freee請求書への TypeSpec の導入を決めました。

段階的な移行戦略

移行にあたって制約が1つありました。freee請求書では、既存の OpenAPI 上で3〜5チームが同時に機能改修を行っています。移行中の API スキーマの凍結は難しく、機能開発に影響なく段階的に移行し、各フェーズで壊れていないことを機械的に確認できる必要がありました。この移行手法が、本記事で一番伝えたい部分です。

まずは tsp-openapi3 という移行ツールが TypeSpec 公式から出ているので機械的に変換して、変換する際のオペミスが無いようにしました。そして後処理で正規化した openapi.yml が移行前と等価であることを diff で確認して切り替えていきました。generics への集約のような抽象化は人間がやりますが、これも diff が出なければ API の形は変わっていないという、同じ検証で確認できます。

段階移行なので、生成フローは「移行済みの API」と「未移行の API」が共存する前提で設計しました。

TypeSpec段階移行の生成フロー
TypeSpec段階移行の生成フロー

肝は root.yml と tsp-converted.yaml です。root.yml は全エンドポイントを $ref で列挙したマニフェストになっていて、TypeSpec のコンパイル結果と既存 YAML という複数の YAML を 1 つの openapi.yml に bundle します。API を移行するたびに、その path の参照先を YAML から TypeSpec のコンパイル結果へ張り替える。移行が進むほど YAML 側への参照は減っていき、ゼロになった時点でこの共存機構ごと撤去しました。

また冒頭で触れた「6 帳票 × 同型 API」は、TypeSpec の generics で正面から解決できました。帳票共通の API 群を generic interface として 1 箇所に定義します。

@doc("帳票種別ごとの private API path 集合")
interface ReportPaths<Resource, CreateReq, IndexResp, AccDocResp> {
  @get
  index(...IndexQueryParams): IndexResp;

  @post
  create(@body body: CreateReq): Resource;

  @put
  @route("/{report_id}")
  update(@path report_id: integer, @body body: CreateReq): Resource;

  @post
  @route("/{report_id}/deliveries/email")
  deliverEmail(
    @path report_id: integer,
    @body body: DeliveryEmailParams,
  ): Resource;

  // ... PDF、ステータス変更、操作履歴など約 30 エンドポイント
}

各帳票はこれを 1 回インスタンス化 (instantiate) するだけです。

@route("/api/p/reports/receipts")
namespace ReportsReceipts {
  interface ReportsReceipts
    extends ReportPaths<
        Receipt,
        ReceiptCreateRequest,
        ReceiptIndexResponse,
        ReceiptAccountingDocShowResponse
      > {}
}

ERB テンプレートで YAML を展開していた独自機構が、言語機能としての generics に置き換わりました。帳票横断の API 変更は generic interface の 1 箇所を直せば 6 帳票に波及します。帳票の paths は 6 帳票で約 300 エンドポイントあり、抽象化せずベタ書きすると openapi.yml 上で約 8,500 行になりますが、generic interface を使えば共通定義 245 行 + 各帳票の継承で、ソースは約 1,300 行 (6 分の 1 以下) に収まります。

移行手順を Agent Skills に残す

移行作業そのものは、大部分を AI コーディングエージェントで実施しています。やり方はシンプルで、移行手順を Agent Skill に書き、チケット単位でエージェントに実装から PR 作成、CI の監視までを任せる、という運用です。

Agent Skill には移行中に踏んだエラーを蓄積していきました。たとえば OpenAPI と TypeSpec の型対応表、生成クライアントのフック名を固定するために operationId の明示が必須であること、参照型のフィールドに @doc() を付けると allOf が生成されて nullable と誤解釈されること。人間のチームでいう「経験者の暗黙知」を Agent Skills に必ず追記していくようにしていました。

また検証は次の 2 つの観点を見て進めました

  • operationId の一致。生成されるフック名が変わっていないこと
  • 同名スキーマの衝突検知。TypeSpec 由来と YAML 由来で同名のスキーマは、内容が完全一致すれば 1 つにマージされ、不一致なら _1 サフィックス付きの別スキーマになる。_1 が生成物に出てきたらズレの可能性がある

実際、独立した小さい API ファイルの移行は、この検証と openapi.yml の diff 確認で淡々と進めることが出来ました。

移行で最も難しかった部分

ERB で展開される帳票種別の部分は、他のファイルと同じ「YAML を 1 ファイルずつ TypeSpec に変換する」手順では流せませんでした。

難しさは、ステータス変更やメール送付といった帳票間の共有スキーマの移行という1点です。 移行期間中は移行済み帳票 (TypeSpec) と未移行帳票 (YAML) が同じ共有スキーマを参照し続けるからです。

ここは消去法で選択肢を潰していきました

ボツになった案

  • 移行済み帳票 (TypeSpec) と未移行帳票 (YAML) に同じ定義を置く案:
    • 内容が完全一致しないと別物扱いになる。emitter は type: object や description を勝手に足すので手書き YAML と一致せず、DeliverStatusOnly_1 のような重複となるので良くない
  • 共有スキーマだけ先に TypeSpec 化する案:
    • generic interface が参照するモデルは、interface をインスタンス化するまで生成されない。1 帳票も切り替えていない段階では共有スキーマが出力されず、未移行帳票からの $ref が解決できず、複数 YAML を 1 つの openapi.yml に bundle できない
  • namespace に入れて名前の衝突を避ける案:
    • bundle 自体は通る。だが emit 名が DeliverStatusOnly から ReportsDeliverStatusOnly に変わり、これが一番危険だった

最後の案がなぜ危険か。emit 名 (生成される OpenAPI 上のスキーマ名) は、生成クライアントの関数の引数名・型名に直結しているからです。生成クライアントはこうなります。

 export const reportsReceiptsDeliverStatus = async (reportId: number,
-    deliverStatusOnly: DeliverStatusOnly, options?: RequestInit): Promise<DeliverStatusOnly> => {
+    reportsDeliverStatusOnly: ReportsDeliverStatusOnly, options?: RequestInit): Promise<ReportsDeliverStatusOnly> => {

API のパスもリクエストの中身も変わっていないのに、引数名が変わっています。呼び出し側は 6 帳票共通のコードなので、この rename は実行時に値が渡らなくなる、つまりプロダクションに影響します。

しかも検出が難しい。移行期間中は新旧の生成型が混在するため、codegen 境界の型の食い違いを「互換吸収」の as never キャストで埋めたくなりますが、これが引数名の不一致という本物の型エラーまで握りつぶします。序盤の移行で頼っていた検証は operationId の一致と _1 の検知でしたが、emit 名の変化はそのどちらにも引っかかりません。

採用した案

ではどうやったかでいうと TypeSpec 側の 1 箇所だけに置き、YAML 側はそこを $ref で参照することで single-source となるようにしました。

  • 共有スキーマは namespace に入れず、元の名前のままファイルスコープで宣言する。これで emit 名が DeliverStatusOnly のまま保たれる
  • YAML 側の定義は削除し、ERB や未移行帳票からの参照は tsp-converted.yaml への $ref に張り替える
  • generics の emit 遅延があるため独立 PR にはできず、最初の帳票の切り替えと同じ PR で行う

定義が 1 箇所になれば、重複スキーマ (_1) も emit 名の揺れも構造的に起きません。

この移行を通じての学び

この移行を通じての学びは 2 つあります。

  • 既存 API を壊さず移行するため「OpenAPI を中間成果物に残し、diff で等価であることを確認できる」設計にしたこと。TypeSpec をどう書き換えても、最終的な openapi.yml に diff が出なければ API の形は変わらない。この機械的な確認があったから、複数チームが開発を続けるなかでも少しずつ安全に移すことが出来ました。
  • TypeSpec 移行によって、ベタ書きすると約 8,500 行あった openapi.yml が generics に置き換わり約 1,300 行 (6 分の 1 以下) になり記述量が少なく、人やAIにとっても読み書きしやすく、独自実装ではなく標準的な技術スタックにすることが出来ました。

freee請求書の TypeSpec 移行はこれで完了し、API 定義の変更はすべて TypeSpec を起点に運用されています。 みなさんのプロダクトでも検討の材料になれば幸いです。

TypeSpec移行を完了した時のSlack投稿
TypeSpec移行を完了した時のSlack投稿