プルリクエストがopenapi.yamlを編集しました。CIチェックはグリーンになります。仕様は有効で、リンターはクリーンで、2人のレビュー担当者が承認しました。3日後、モバイルクライアントが、以前は存在していたレスポンスフィールドがなくなったために、ヌルポインタクラッシュを投げ始めました。誰も意図的に削除したわけではありません。誰かがリファクタリング中にプロパティの名前を変更し、レビューでは誰もそれに気づきませんでした。
これは、通常のバリデーターでは決して見抜けないギャップです。仕様は完全に整形式であっても、それに依存するすべてのコンシューマーを壊す可能性があります。それを知る唯一の方法は、新しい仕様を置き換えるバージョンと変更ごとに比較し、1つの質問をすることです。これは昨日まで動作していたクライアントを壊すか?その比較がOpenAPI diffであり、それをマージゲートとして実行することは、APIリポジトリに追加できる最も効果の高いチェックの1つです。
OpenAPI diffが実際に比較するもの
OpenAPI diffは、ベースとヘッドの2つの仕様を受け取り、それらの間の変更点を報告します。ベースは通常、ターゲットブランチ上の仕様(稼働中のもの)です。ヘッドは、プルリクエストが提案する仕様です。優れたdiffツールは、git diffのようにテキストの差分を単にダンプするだけではありません。OpenAPIの構造を理解しているため、見かけ上の編集と契約破壊的な変更の違いを区別できます。
重要な区別は次のとおりです。いくつかの変更は追加的で安全です。
- 新しいオプションの要求パラメーターの追加
- 新しいレスポンスフィールドの追加
- まったく新しいエンドポイントの追加
- リクエストボディへの新しいEnum値の追加
既存のクライアントはこれらすべてを通じて動作し続けます。彼らは常に送信していたものを送信し、常に読み取っていたものを読み取ります。他の変更は後方非互換であり、これらは問題を引き起こすものです。
- クライアントが読み取るレスポンスフィールドの削除
- プロパティの名前変更(クライアントから見れば削除と追加)
- 以前はオプションだったパラメーターを必須にする
stringからintegerへの型を狭める変更- クライアントが送信する可能性のあるEnum値の削除
- エンドポイントまたはHTTPメソッドの削除
OpenAPI diffツールの仕事は、両方のドキュメントのすべてのパス、パラメーター、スキーマ、およびレスポンスをスキャンし、それぞれの変更をこれらのいずれかのバケットに分類することです。その分類が全体のポイントです。生の行差分では、書式設定の50行の変更の下に削除されたrequiredフィールドが埋もれてしまいます。構造的な差分は、それを破壊的変更として表面化させ、それがどのパスに存在するかを教えてくれます。
なぜ一部の変更が破壊的で、他の変更がそうでないのかという根底にあるメンタルモデルを知りたい場合は、大規模なAPIのバージョン管理と非推奨化戦略に関するガイドが、互換性ルールを深くカバーしています。diffツールは、レビュー担当者がそれらのルールを覚えておくことに期待する代わりに、機械的にそれらのルールを強制する方法です。
oasdiff: オープンソースの主力ツール
oasdiffは、ほとんどのチームが利用するオープンソースツールです。単一のGoバイナリであり、高速で、特に破壊的変更の問題を中心に構築されています。OpenAPI 3.0および3.1ドキュメントを読み取り、比較から何を得たいかに応じていくつかのサブコマンドを提供します。
最もよく使用する3つは次のとおりです。
diffは、2つの仕様間のすべての差分を報告します。breakingは、後方非互換な変更のみを報告します。changelogは、破壊的か否かにかかわらず、すべての重要な変更の人間が読めるリストを生成します。
マージゲートでは、breakingが重要です。ベース仕様とヘッド仕様を指定します。
oasdiff breaking base-openapi.yaml head-openapi.yaml --fail-on ERR
base-openapi.yamlはターゲットブランチの仕様であり、head-openapi.yamlはプルリクエスト内のものです。breakingサブコマンドは、互換性のない変更のみを出力します。--fail-on ERRフラグはこれをゲートに変えます。これは、ERRレベルに分類された変更が見つかったときに、コマンドがゼロ以外のステータスで終了するようにします。ゼロ以外の終了は、CIが失敗として読み取る普遍的な信号です。
この重大度モデルを理解する価値があります。oasdiffは、破壊的変更をレベルに分類し、ERRは深刻なもので、クライアントを壊す変更です。WARNは、記述方法によっては一部のクライアントを壊す可能性のある変更をカバーし、INFOは情報提供です。どこで線引きするかはあなたが決めます。--fail-on ERRは、明確な破壊のみをブロックします。--fail-on WARNはより厳格で、可能性のあるものも検出します。
パス/フェイルではなく、変更履歴やPRコメントのために読みやすい要約が必要な場合は、changelogサブコマンドの方が親しみやすい出力です。
oasdiff changelog base-openapi.yaml head-openapi.yaml
oasdiffには、いくつかの非常に便利な機能があります。パスパラメーターの名前変更を生き残るエンドポイントマッチングを行うため、パスが同一である場合に{userId}が{id}になったことを削除と追加としてフラグを立てません。比較前にallOfスキーマをマージできるため、継承がノイズを生成しません。そして、プレーンテキストだけでなく、HTML、JSON、YAML、Markdownのすべてが、出力フラグを通じて利用可能であり、結果をCIアノテーションや生成された変更履歴に簡単にフィードできます。5分でパイプラインに投入でき、破壊的と呼ぶものについて保守的であると信頼できるツールとしては、これに勝るものはありません。
openapi-diff: JVMの代替案
スタックがすでにJVM上にある場合、OpenAPITools/openapi-diffは堅実な2番目の選択肢であり、知っておく価値があります。これはJavaベースのツール(Java 8以降)で、2つのOpenAPI 3.x仕様を比較し、差分をHTML、Markdown、AsciiDoc、JSON、またはコンソールテキストとしてレンダリングします。ビルド済みのjarから、Maven経由で、Homebrew経由で、またはDockerイメージとして実行できるため、さまざまなビルド設定に手間なく適合します。
その比較は、パラメーター、レスポンス、エンドポイント、HTTPメソッドに深く入り込み、誰もが気にするのと同じ線引きをします。つまり、後方互換性を維持した変更と、それを破壊した変更です。CLIは簡単です。
openapi-diff old-openapi.yaml new-openapi.yaml --fail-on-incompatible
--fail-on-incompatibleフラグは、変更が後方互換性を破壊した場合にのみゼロ以外の終了コードを返します。これはまさにあなたが望むゲートの動作です。あらゆる変更で失敗させたい場合は、より厳格な--fail-on-changedがあり、スクリプトで1語の答えが必要な場合は、no_changes、compatible、またはincompatibleのみを出力する--stateモードがあります。
その真価はレンダリングされた出力にあります。HTMLとMarkdownのレポートはクリーンで詳細であり、openapi-diffは、CIの終了コードだけでなく、人間が実際に読む差分成果物が必要な場合に強力な選択肢となります。トレードオフは、JVMの依存関係と、Goバイナリよりも起動が重いことです。チームがすでにJavaショップである場合、そのコストはゼロで、ツールはすぐに組み込まれます。そうでない場合は、oasdiffの方が軽量です。どちらも破壊的変更の問題によく答えます。すでに管理しているランタイムに合ったものを選んでください。
CIにdiffをマージゲートとして組み込む
手動で実行するdiffは何も検出しません。なぜなら、実行を忘れたときに問題がリリースされるからです。ゲートはパイプライン内に存在し、仕様に触れるすべてのプルリクエストで起動する必要があります。
CIにおける唯一の厄介な点は、ターゲットブランチからのベースとPRからのヘッドという、2つのバージョンの仕様が同時に存在する必要があることです。PRのチェックアウトはヘッドを提供します。ベースは2回目のチェックアウトなしでgit履歴から直接取得します。
name: openapi-diff
on:
pull_request:
paths:
- "openapi.yaml"
jobs:
breaking-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get base spec
run: git show origin/${{ github.base_ref }}:openapi.yaml > base-openapi.yaml
- name: Install oasdiff
run: |
curl -fsSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh | sh
- name: Diff for breaking changes
run: oasdiff breaking base-openapi.yaml openapi.yaml --fail-on ERR
ここにはいくつかの詳細が重要です。fetch-depth: 0は完全な履歴をプルし、git showがベースブランチにアクセスできるようにします。git show origin/<base>:openapi.yamlの行は、ターゲットブランチに存在する仕様を読み取り、ファイルに書き込みます。追加のクローンは必要ありません。pathsフィルターは、仕様が実際に変更された場合にのみジョブが実行されることを意味し、関連のないPRがコストを払うことはありません。そして、最後のステップがゲートです。oasdiff breakingがERRレベルの変更を見つけると、ゼロ以外の終了コードで終了し、ジョブが赤くなり、誰かがマージをクリックする前にPRが失敗したチェックを表示します。
作成者は、コードがレビュー中である間に、どのパスでどの変更が互換性を破壊したかを正確に確認できます。これこそが価値全体です。問題は、顧客のクラッシュレポートでではなく、可能な限り最も安価な時点で捕捉されます。
もちろん、すべての破壊的変更が間違いであるわけではありません。意図的なメジャーバージョンをリリースしており、変更が意図的である場合もあります。クリーンなパターンは、デフォルトでゲートを設け、例外に対して明示的なオーバーライド(PRのラベル、info.versionのバージョンアップ、または別の承認済みワークフロー)を要求することです。そうすれば、破壊は常に誰かが意図的に下した決定であり、見過ごされた偶発的な事故ではありません。APIバージョン管理戦略ガイドでは、破壊が新しいメジャーバージョンに値するか、あるいは避けるべきかを詳しく解説しています。
diffが埋められないギャップ
ここに、上記のすべてのツールの限界があり、それは重要なことです。diffは2つのファイルを比較します。新しいドキュメントが古いドキュメントと後方互換性があることを伝えます。しかし、実行中のサービスがどちらかのドキュメントと実際に一致しているかどうかについては何も語りません。
これは別の種類の障害であり、本番環境で最も問題を引き起こすものです。仕様ではcreated_atフィールドが約束されているのに、実装は3スプリント前に密かにその返却を停止しました。仕様ではエンドポイントが200を返すとされているのに、ライブサービスは誰もテストしなかった条件下で500を返します。両方の仕様バージョンが一致しているため、diffはクリーンです。契約とコードは一致していません。静的なdiffは、APIと通信しないため、それを知る方法がありません。
このギャップを埋めるには、契約自体とdiffするだけでなく、ライブAPIを契約と照合してテストする必要があります。仕様からテストを生成し、実行中のサービスに対して実行し、実際のレスポンスがドキュメント化された形状と一致することを確認します。これが契約テストであり、書かれたものと実際にリリースされたものとの間のずれを捕捉するレイヤーです。
ApidogとApidog CLIでギャップを埋める
Apidogは、このループのために構築されており、diffステップの代替ではなく、自然な相補関係にあります。OpenAPI仕様をApidogプロジェクトにインポートまたは同期すると、Apidogは仕様から直接テストシナリオを生成でき、スキーマから導出されたアサーションが付属します。テストは、実際のレスポンスが文書化された型、必須フィールド、およびステータスコードと一致するかどうかをチェックします。契約が変更されるたびに同期がずれる並行するテストスクリプトを手動で記述する代わりに、それらのシナリオを視覚的に構築および維持します。
Apidogは設計、モック、テストを1つのワークスペースに保持するため、仕様はそれらすべてにおいて信頼できる唯一の情報源であり続けます。Apidogをダウンロードし、既存の仕様をインポートして、自分のAPIでこのループを試すことができます。そもそも、複数のバージョン間でその仕様をどのように管理するかをまだ決めている段階であれば、GitによるOpenAPI仕様のバージョン管理に関するウォークスルーが、このワークフローとよく合います。
Apidog CLIは、パイプラインでそれらのシナリオをヘッドレスで実行するものです。これはnpmパッケージです。
npm install -g apidog-cli
シナリオをIDで実行し、検証したい環境に向け、CIに適したレポートを要求します。
apidog run \
--access-token $APIDOG_ACCESS_TOKEN \
-t <scenarioId> \
-e <environmentId> \
-r junit,cli \
--out-dir ./apidog-reports
アクセストークンは実行を認証し、コミットされたファイルではなく、CIシークレット内に存在します。-tフラグはシナリオを選択し、-eは環境を選択し、-r junit,cliはビルドログ用の読み取り可能なターミナル出力と共に、CIダッシュボード用の機械可読なJUnit XMLを出力します。IDを推測する必要はありません。実際のシナリオIDと環境IDがすでに記入された正確なコマンドを、ApidogのシナリオのCI/CDタブからコピーします。すべてのオプションが必要な場合は、完全なCLIガイドがすべてのフラグを文書化しており、apidog run --helpは要求に応じてそれらを出力します。
ゲートの動作はdiffと同じ原則です。ライブレスポンスが契約と一致しなくなったためにアサーションが失敗すると、apidog runはゼロ以外の終了コードで終了します。CIは終了コードを読み取り、ステップを失敗とマークし、マージをブロックします。追加の設定は不要です。実行ステップがパイプラインにある限り、契約の回帰は、破壊的変更のdiffと同じようにラインを停止させます。
完全なマージ前シーケンス
2つの部分を組み合わせると、両方の種類の問題を捕捉するパイプラインが得られます。diffは、仕様を読み取ってクライアントを壊す可能性のある変更を捕捉します。契約テストは、実行中のAPIをテストすることで、仕様を遵守しなくなったサービスを捕捉します。これらを別々のジョブとして実行します。
jobs:
breaking-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: git show origin/${{ github.base_ref }}:openapi.yaml > base-openapi.yaml
- run: curl -fsSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh | sh
- run: oasdiff breaking base-openapi.yaml openapi.yaml --fail-on ERR
contract-conformance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm install -g apidog-cli
- name: Run contract tests
run: |
apidog run \
--access-token "$APIDOG_ACCESS_TOKEN" \
-t 605067 \
-e 1629989 \
-r junit,cli \
--out-dir ./apidog-reports
env:
APIDOG_ACCESS_TOKEN: ${{ secrets.APIDOG_ACCESS_TOKEN }}
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: apidog-report
path: ./apidog-reports
2つのジョブは並行して実行されます。diffジョブはファイルを読み取るだけで、git以外は何も必要としないため、数秒で完了します。適合性ジョブは到達可能な環境を必要とするため、通常はデプロイされたステージングビルドに対して実行されます。アップロードのif: always()は、テストが失敗した場合でもレポートが流れるようにします。これは、まさにレポートを読みたいときです。どちらかのジョブが赤くなると、PRはブロックされます。実際のパイプラインでCLIを実行する方法の詳細については、Apidog CLI GitHub Actionsガイドと、より広範なCI/CDパイプラインウォークスルーで、配線についてさらに深く掘り下げています。
