コンテンツにスキップ

OpenAPI Spec管理ポリシー

このドキュメントは、OpenAPI Specification(OAS)でAPIを管理するための原則を示す。 本文は2部構成である。「契約と原則」はAPIの利用者・提供者を問わず全員が想定読者で、以降は主にAPI開発者向けにしてある。

メジャーバージョンを変えない限り、バックエンドはAPIの契約を守る義務を負う。破壊的変更をしてはいけない。契約が意図せず変わっているなら、それはバグである。

  • フィールドの追加はよい。削除・型変更・必須化・意味変更はしてはいけない
  • エンドポイントの追加はよい。削除・パスやメソッドの変更はしてはいけない

破壊的変更の例:

  • default値を変更してはならない(利用者は省略時の挙動を前提にしているため)
  • レスポンスのmaxLengthを大きくしてはならない(利用者が旧上限を前提に固定長バッファ等を組んでいる場合がある)。許容範囲を狭める変更(maxLengthを小さくする、minLength/minimumを大きくする)も同様に破壊。範囲を広げる変更(requestのminLength緩和など)は概ね安全

「追加はよい」には例外がある。レスポンスのenumへの値追加は、利用者のswitch-caseが未知の値をdefaultに落ちる、状態遷移が崩れるなどで破壊になりうる(リクエスト側の追加は概ね安全)。破壊検知ツールも完全には判定できないため、必要なら事前告知や段階導入を行い、人の注意で補う。

内部の振る舞いの変更には、正確にはバグ修正も含まれる。ただしバグ修正は通常、後方互換性の破壊には含めない(契約=スキーマは変わらないため)。 例外として、利用者が明らかにそのバグの振る舞いに依存している場合は、破壊と同じ変更プロセス(利用者側の合意)を踏む。

互換性を守る目的は、APIの利用者側が自分のペースで非同期に開発できるようにすることにある。

バックエンドが複数バージョンにわたって互換性を維持すれば、フロントエンド開発者は先行するバックエンドの最新バージョンに追随しなくても、あとから対応できる。 バックエンドとフロントエンドが足並みをそろえて動く必要がなくなる。

破壊には利用者側の合意が要る

Section titled “破壊には利用者側の合意が要る”

やむを得ず破壊的変更を行う場合も、バックエンド開発者が一方的に決めてよいものではない。

破壊は原則として避けるべき制約であり、例外的に行うときは利用者側(フロントエンドなど)の了承を得る。 承認を記録する仕組みは「バックエンドの自己承認」ではなく「チーム横断の合意の記録」として機能させる(例: PRラベルbreaking-change-approvedの付与)。

OASが唯一の正(source of truth)である。OASからコードを生成し、実装は生成された契約に従う(既存のスキーマファースト方針を土台とする)。 コードからOASを生成する方向は採らない。

APIの開発者は最初の利用者である

Section titled “APIの開発者は最初の利用者である”

APIの開発者は、自分のAPIの最初の利用者であるべき。

スキーマを書いてAPIコードを生成しただけでは設計の良し悪しはわからない。自分で使ってみて(テストを書き、実際に叩いて)はじめて不備に気づくことが多く、公開後に気づくと変更プロセスが重くなる。

公開が遅れるのは望ましくないが、検証していないものを利用者にテストさせるのはもっと悪い。公開前に自分で使い、検証を済ませること。

APIには読者(audience)が存在する。フロントエンド開発者・バックエンド開発者・外部システム関係者などは、それぞれ自分に関係するAPIだけを見たい。 とくに外部向けは部分的に開示したい要求がある。 ドキュメントとコードは、この読者の視点で分割する。

  • メジャーバージョンはURLパスに固定する(.../v1/....../v2/...)。破壊的変更は新しいメジャーを立て、旧メジャーと併存させる
  • info.versionはセマンティックバージョニングで運用する
    • MAJOR: URLパスのvNに対応
    • MINOR: フィールド・エンドポイントの追加(後方互換)
    • PATCH: 記述の明確化・誤記修正
  • 新しいメジャーは新しいドキュメントとして作る。旧メジャーのドキュメントは凍結し、追加(MINOR)のみを許す
  • URLに読者(audience)は現れない想定。バージョンだけがパスに現れる

v1からv2への切り替えは、利用者・提供者ともにコストが大きい。安易にメジャーを上げない。 内部の振る舞いを多少変える程度なら、メジャーを上げずに任意のオプション(切り替えフラグ)を追加して利用者が選べるようにする。スキーマを大きく変えるときだけ、原則どおりメジャーを上げて新ドキュメントを立てる。

フィールドやoperationには、追加・変更・非推奨を記録するx-ix-changes拡張が付く場合がある。 バンドル後はdescriptionにも説明が付く。

x-ix-changes:
- { kind: added, version: "1.2.0" }
- { kind: changed, version: "1.3.0", note: "上限を100件から500件に拡大" }
- { kind: deprecated, version: "1.4.0", note: "後継は #/paths/... を参照" }

非推奨(kind: deprecated)にはOAS nativeのdeprecated: trueも付き、表示ツールが取り消し線で示す。noteの案内に従って移行すること。

読者が自分のAPIだけを意識できるように、1つのaudience = 1つのバンドル済みOASドキュメントとするとわかりやすい。 これがそのまま、1つの表示ページ・1つのコード生成単位になる。

  • audienceの例: frontendbackendexternal(外部システム向け)
  • ドメイン(todoなど)はaudience内でtagsにより束ねる。表示ツールはタグ単位でグルーピングするため、1ドキュメント内でもドメインごとに整理されて見える
  • 同一サービス内に複数のaudienceが存在しうる。読者は自分のドキュメントだけを開けばよい

OASには@since相当のnative記法がないため、自前のx-ix-changes拡張で補う(Sphinxのversionadded/deprecatedと同じ考え方)。

  • フィールド追加時はMINORバージョンアップとあわせてkind: addedを刻む
  • descriptionへの表示はバンドル時にツールがx-ix-changesから生成する。視覚的バッジ(x-badges)はビューアーの対応範囲が狭いため使わない
docs/openapi/
├── problem.yaml # RFC 9457 エラー型。複数audienceが参照する横断的関心事
├── domain.yaml # 基本ドメイン型など
├── frontend/
│ └── openapi.yaml # フロントエンド向け。$ref で problem.yaml を参照
├── backend/
│ └── openapi.yaml
└── external/
└── openapi.yaml
  • 横断的関心事(エラー型・基本ドメイン型など)は「shared」のような寄せ集めフォルダに入れず、関心事ごとのファイル(problem.yamlなど)としてaudienceフォルダと同階層に置く。共有物であることは「audienceの一段上」という位置で表す
  • サービス境界が見えていない初期は、ドメインをaudienceドキュメント内でtagsにより束ねるだけにとどめ、ファイルを分けない
  • ソースはリモートURL参照にせずリポジトリに同梱し、生成・表示の前にバンドルして$refを解決する

audienceごとに素朴にスキーマからコード生成すると、横断的関心事のスキーマ(エラー型、TimeLatLonのような基本ドメイン型)がaudienceパッケージごとに重複生成され、別々のJava型になってしまう。 共通ハンドラや共通値オブジェクトは「1つの型」であってほしい。

これを防ぐには、生成ツールの外部モデルマッピング(openapi-generatorのschemaMappings/importMappingsなど)を使う。

  • 関心事ごとのファイル(problem.yamlなど)から型を1度だけ生成し、関心事ごとのパッケージに置く
  • 各audienceの生成では、その型を外部モデルへマッピングして再生成せずimportする
  • 結果として、ドキュメント上は各audienceに横断スキーマが載り、コード上は1つの型に収束する

横断的関心事のバージョニングと変更検知

Section titled “横断的関心事のバージョニングと変更検知”

横断的関心事のファイル(problem.yamlなど)は単体で妥当なOASドキュメントにし(openapiinfo.versionpaths: {}を持つ)、独立した生成単位とする。info.versionはその共有コンポーネント自身のsemverであり、audienceドキュメントのinfo.versionとは独立に管理する。

  • バンドル時、audienceドキュメントは$ref先のcomponentsだけを取り込み、共有ファイルのinfoはマージしない。バンドル後の版はaudience側の版であり、共有ファイルの版はバンドルに現れない
  • 共有ファイル自身の変更はx-ix-changesでは表現せず、各共有ファイルが持つ単体の表示ページで人間がレビューする
  • 破壊検知ツール(openapi-diffなど)はpaths: {}のcomponents専用ドキュメントの変更を検知できないことがある(operationからの到達可能性しか比較しない実装が多いため)。掛ける前にこの点を確認すること
  • 共有スキーマの中身が変わったら、その共有ファイルの版を上げ、参照する各audienceも再バンドル時に自分の版を上げる
  • x-ix-changesは共有スキーマ自身には書けない(複数audienceにバンドルされるため、自身の版を刻むと「バンドル先の版に統一する」原則と衝突する)。audienceが所有する要素(自身のpath・固有のschema)にのみ付与し、その版はaudienceのinfo.versionで書く
  • バンドルへ共有版のpinを埋め込む案は、リリース自動化などで実際に必要になるまでやらない

横断的関心事のパッケージは、サービス(todouserなど)の一段上、アプリのルート直下に置く。「shared」のようなセグメントは設けない。ルートにあること自体が「配下の全サービスから使える」ことを表す。

サービス境界が見えていない初期は、すべてをアプリルートにフラットに置く。境界が見えてきたらサービスごとのパッケージへ手書きコードと生成コードをセットで移す。

自動生成物と手書き値オブジェクトの使い分け

Section titled “自動生成物と手書き値オブジェクトの使い分け”

横断スキーマの多くは「ただのデータの入れ物」で、DTOのままで十分(例: エラー型ProblemDetail)。OASで書ける制約(formatpatternminimum/maximumenum・必須)は生成コードのBean Validationが強制するため、範囲チェック程度に手書きの型は要らない。

手書きの値オブジェクトへ格上げするのは、OASでは表現・強制しきれないとき、代表的には型変換(parse)が要るときである。たとえばdueDatetype: string, format: date)はOASでは書式までしか表せず、LocalDateへの変換は保証できない。

// 手書き値オブジェクト: 生文字列を型に変換し、成否を型で返す
public record DueDate(LocalDate value) {
public static ParseResult<DueDate> parse(String raw) {
try {
return ParseResult.ok(new DueDate(LocalDate.parse(raw)));
} catch (DateTimeParseException e) {
return ParseResult.err("dueDate must be in ISO format (yyyy-MM-dd)");
}
}
}

DTOは文字列のまま受け取り、handler/usecaseの境界でparseして変換する(失敗はParseResultでエラー応答にする)。複数audienceで型を1つに収束させたい場合は、外部モデルマッピング(schemaMappings)で生成をスキップしてimportする(parse変換とは別の手段で、併用可)。

判断基準はOAS+生成バリデーションで表現・強制しきれるかどうかであり、しきれないなら手書き値オブジェクトへ格上げする。

契約由来のコードは、意図しない変更を機械的に検知する。自動生成物と手書きで検知手段が分かれる。

  • 自動生成物: すべて単一のgeneratedツリーに置く。CIで「OASから再生成した結果」と「コミット済みコード」の差分を検査する(再生成漏れ・手編集を検知)。たとえばproblem.yamlのフィールドを変えたのに再生成し忘れると、差分が出てCIが落ちる
  • 手書き値オブジェクト: generatedの外に置く(生成ツリーは再生成で上書きされ、手書きが同居できないため)。git差分では検知できないので、OASスキーマと手書き型の一致はテストで担保する。たとえば「OASが許さない文字列をDueDate.parseがErrで返す」テストを置けば、OASのformat/patternを変えたのに型を直し忘れた乖離に気づける

契約を守れているかを、人手のレビュー任せだけにしない。CIで機械的に検査する。現在の実装は次のとおり。

  • 対象はバンドル済みドキュメント(docs/openapi/<audience>/openapi.bundled.yaml)。横断的関心事(problem.yamlなど)は対象外とする(理由は前述「横断的関心事のバージョニングと変更検知」を参照)
  • PRイベントでのみ実行する。baseブランチの同ドキュメントをgit showで取り出し、PR側の再バンドル結果と比較する。mainへの直接pushには比較対象(PR base)が存在しないため実行しない
  • baseに該当するドキュメントがないaudienceは新規ドキュメントとして扱い、比較をスキップする
  • 検査は最初から有効にする。バージョンが安定するまで待たず初回公開から実施する。これはバックエンド都合の破壊が利用者側を困らせるため
  • 意図的な破壊は、消費側の合意を示す明示的なマーク(PRラベルbreaking-change-approved)がある場合のみ、CIジョブの失敗を許容する。検査とレポート出力自体は常に行い、ラベルは失敗判定にのみ影響する。
  • ゲートは機械的な最後の砦にすぎない。人間向けの通知(リリースノートなど)は別途設計する。通知チャネルは開発者の裁量とする

OASの管理には以下を利用する。

  • Redocly CLI: 複数のOASのファイルをBundle
  • openapi-diffopenapi-diff-coreをGradleタスクへ組み込んで利用): 破壊的変更を含む差分検知
  • Scalar: OASのHTMLでのプレビューとAPI実行