Abbeal

Architecture

URLを変えずにREST APIをバージョン管理する:Spring 7での実践

URLに /v1 を入れる代わりにコンテントネゴシエーションを選んだ理由と、Spring Framework 7 なら3つのプロパティで実現できる方法を紹介します。

6 min

課題:APIを直接呼び出すクライアント

私は Spring Boot 4、つまり Spring Framework 7 で小さな個人用 REST API を運用しています。AIエージェント、スクリプト、いくつかの連携など、複数のクライアントがこのAPIを直接呼び出しています。/api/shorten や /api/random といったルートをそのまま叩いています。

どのエンドポイントもバージョン管理されていませんでした。何も変えなければ問題ありません。しかしレスポンス形式を変えたい日、たとえば単なる文字列ではなくJSONオブジェクトを返したくなった日には、すべてのクライアントが一度に壊れます。

そこで、バージョン1を壊さずにバージョン2をリリースする方法が必要でした。本当の問いは、クライアントが受け取るバージョンをどう選ぶか、です。

3つの選択肢

URLのプレフィックス(/api/v1/...)は最もよくある選択です。明示的でルーティングも簡単です。ただし、ルートごとにエイリアスを維持しない限り、既存の呼び出しはすべて壊れます。さらに、v1 と v2 のパッケージにコントローラーやDTOを複製する方向に流れがちです。

独自ヘッダー(X-API-Version)やクエリパラメータ(?version=1)ならURLはそのままです。こちらの方がましです。しかし、HTTPにすでに仕組みがあるのに、わざわざ新しい仕組みを作ることになります。

コンテントネゴシエーションは、まさにその標準の仕組みを使います。クライアントは Accept ヘッダーで特定の表現を要求します。

http
Accept: application/json;version=1

URLは変わりません。バージョンは要求するメディアタイプの一部であり、それこそが本来の役割です。

選んだ方法と、コストがほぼゼロの理由

コンテントネゴシエーションを選んだ理由はシンプルです。どのクライアントも移行する必要がないからです。バージョン指定のないリクエストは、これまで通りバージョン1を受け取ります。

うれしい驚きは Spring Framework 7 でした。APIのバージョン管理が標準で組み込まれています。インターセプターを書く必要も、パス定数を用意する必要もありません。application.properties に3行書くだけです。

properties
spring.mvc.apiversion.supported=1.0 spring.mvc.apiversion.default=1.0 spring.mvc.apiversion.use.media-type-parameter[application/json]=version

1行目は受け付けるバージョンの一覧です。2行目は、クライアントが何も指定しない場合に1.0を返します。3行目は、application/json タイプの version パラメータからバージョンを読み取るよう Spring に指示します。

HTMLページ(Thymeleaf)は対象外です。このルールは application/json にのみ適用されます。

クライアント側から見た動き

クライアント側では、バージョン指定のないリクエストは通常どおり処理されます。

bash
curl -H "Accept: application/json" "https://api.example.com/api/random?min=1&max=6" # {"value":6,"min":1,"max":6,"timestamp":"..."}

未知のバージョンを指定すると、APIの他のエラーと同じ形式で400エラーが返ります。

bash
curl -H "Accept: application/json;version=9" "https://api.example.com/api/random?min=1&max=6" # {"status":400,"error":"Bad Request","message":"Invalid API version: '9.0.0'.","path":"/api/random"}

細かい点が2つあります。まず、406ではなく400です。Spring が InvalidApiVersionException を投げ、それをグローバルなエラーハンドラーが標準のレスポンスに変換しています。次に、Spring はバージョンを正規化します。メッセージの中では 9 が 9.0.0 になります。メッセージの文字列そのものをテストする前に知っておくと役立ちます。

バージョン2はこうなる

破壊的変更が必要になったら、同じコントローラー内で既存のメソッドの隣にメソッドを1つ追加します。

kotlin
@GetMapping("/shorten") // version 1, implicite fun shorten(@RequestParam url: String): String = ... @GetMapping("/shorten", version = "2") // Accept: application/json;version=2 fun shortenV2(@RequestParam url: String): ShortenResponseV2 = ...

v2 パッケージも、コントローラーの複製も不要です。spring.mvc.apiversion.supported に 2.0 を追加するだけです。

テスト

@WebMvcTest で、JSONエンドポイントとHTMLページの両方の動作を確認します。必要なのは次の5ケースです。

  • バージョン指定なし:バージョン1として処理される
  • version=1 指定:処理される
  • 未知のバージョン(version=9):400レスポンス
  • 不正な形式のバージョン(version=abc):400レスポンス
  • HTMLページ:通常どおり応答する
kotlin
@Test fun unsupportedVersionReturns400() { mockMvc.perform(get("/api/random").accept("application/json;version=9")) .andExpect(status().isBadRequest) .andExpect(jsonPath("$.status").value(400)) }

v2 を出す日までに残っていること

3つのテーマは意図的に後回しにしました。バージョンが1つしかない間は、どれも必要ないからです。

まずキャッシュです。私のAPIはCDNの背後にあります。同じURLに2つのバージョンがあると、共有キャッシュはレスポンスが Accept ヘッダーに依存することを知る必要があります。Spring は Vary: Accept を自動では付けないので、自分で追加する必要があります。

次に非推奨化です。Spring 7 は Deprecation、Sunset、Link ヘッダーでバージョンの終了を告知できます。バージョン1を廃止する日に役立ちます。

最後に OpenAPI Generator です。同じパスに複数のメディアタイプがあるケースの扱いが苦手です(プロジェクトの issue #11000)。今のところ仕様を公開していないので困ってはいませんが、公開する場合は注意が必要です。

振り返って

最小限のコストで、とても良い結果になりました。プロパティ3つ、移行なし、URLの重複なし。必要になる前からバージョン管理が整っていて、誰の邪魔にもなりません。

知っておくべきトレードオフが2つあります。バージョンがURLにもアクセスログにも出なくなるので、調査で必要なら明示的にログに出す必要があります。また、2つ目のバージョンができた時点で、キャッシュまわりには少し多めの注意が必要になります。

すでにクライアントがいるAPIで Spring Boot 4 を使っているなら、現時点で最もシンプルな選択だと私は考えています。検討して見送った選択肢も含めてプロジェクトのADRに記録してあるので、半年後に読み返しても判断の経緯が分かります。

似たような案件がありますか?

アーキテクトと話す