REST APIを設計する

HTTPとRESTを、users APIの具体例から学びます。本文だけで基本を理解でき、折りたたみの「数学的に見る」では同じ概念を集合や写像として捉え直せます。

この教材の範囲 ここではREST APIの設計に焦点を当てます。RESTは単なるCRUDの対応表ではなく、JWTもRESTの必須要素ではありません。

読み方

各章は「直感的な説明 → HTTP例 → 数学的な補足 → 確認問題」の順です。数式を読まなくても次の章へ進めます。

数学的に見る: この教材で使う共通記法

まず、システムを集合で定義します。

  • RR: リソースの集合
  • UU: URIの集合
  • MM: HTTPメソッドの集合
  • PP: 表現(Representation)の集合
  • SS: サーバー状態の集合

この教材ではスキームやホストを固定して捨象し、HTTP APIで扱うURIをパスとクエリパラメータに分解します。厳密には、ここでモデル化しているのはURI全体ではなく、そのパスとクエリ部分です。

  • LL: パスの集合
  • KK: クエリパラメータ名の集合
  • VV: クエリパラメータ値の集合
  • Θ\Theta: KK から VV への有限部分写像全体の集合

Θ={θθ:KfinV} \Theta = \{\theta \mid \theta: K \rightharpoonup_{\mathrm{fin}} V\}

U=L×Θ U = L \times \Theta

したがって、個々のURIを (l,θ)U(l, \theta) \in U と表せます。例えば /users?role=admin は、パス l=/𝚞𝚜𝚎𝚛𝚜l=\mathtt{/users} と、θ(𝚛𝚘𝚕𝚎)=𝚊𝚍𝚖𝚒𝚗\theta(\mathtt{role})=\mathtt{admin} を満たす有限部分写像の組です。

このモデルでは、同じ名前を複数回使う tag=a&tag=b を単純化のため扱いません。扱う場合は、値域を有限列の集合 V*V^* に置き換えます。

さらに、ヘッダー集合を HH、HTTPステータスコードの集合を CC とします。Option(P)\operatorname{Option}(P) は、表現を持つ場合とボディがない場合を合わせた集合です。

Option(P)=P{None} \operatorname{Option}(P) = P \sqcup \{\operatorname{None}\}

リクエスト集合 QQ とレスポンス集合 AA を、単純化して

Q=M×U×H×Option(P) Q = M \times U \times H \times \operatorname{Option}(P)

A=C×H×Option(P) A = C \times H \times \operatorname{Option}(P)

と定義します。HTTPリクエストの処理は、現在の状態とリクエストから次の状態とレスポンスを求める状態遷移

δ:S×QS×A \delta: S \times Q \to S \times A

として扱います。実際のHTTPにはさらに多くの要素がありますが、以降の議論にはこのモデルで十分です。

1. HTTPとリソース

リソースと表現

リソースは、APIが名前を付けて扱う対象です。ユーザー、記事、注文などが該当します。JSONはリソースそのものではなく、ある時点のリソースを通信するための表現です。

GET /users/1 HTTP/1.1
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 1,
  "name": "Alice",
  "email": "alice@example.com"
}

同じユーザーをJSON以外の形式で表現することも理論上は可能です。Accept はクライアントが受け取りたい表現を、Content-Type は実際の表現形式を示します。

数学的に見る: URIは部分写像

URIからリソースを解決する操作は、サーバー状態 sSs \in S ごとに

resolves:UR \operatorname{resolve}_s: U \rightharpoonup R

と表せます。すべてのURIにリソースが存在するとは限らないため、全域写像ではなく部分写像です。また、リソースの作成や削除によって定義域が変わるため、写像は状態 ss に依存します。未定義の場合、HTTPでは典型的に 404 Not Found を返します。

リクエストの構造

HTTPリクエストの主要部分は次の4つです。

部分 役割
メソッド 何をしたいか GET
ターゲット どのリソースか /users/1
ヘッダー 通信上の付加情報 Accept: application/json
ボディ 送信する表現 {"name":"Alice"}

よくある誤解: URIに動詞を並べる必要はありません。GET /getUser/1 より、メソッドとリソースを分離した GET /users/1 の方が一貫します。

2. RESTの設計原則

統一インターフェース

RESTでは、リソースをURIで識別し、標準化されたHTTPメソッドと表現を通して操作します。API独自の動詞を増やすより、HTTPの意味を利用することが重要です。

ステートレス

各リクエストは、その処理に必要な情報を自分で持ちます。前のリクエストでだけ渡した暗黙の会話状態に依存しません。

「ステートレス」は、サーバーがデータを保存してはいけないという意味ではありません。ユーザーや注文のようなリソース状態は保存できます。避けるのは、リクエストの意味を決める隠れたセッション文脈への依存です。

GET /users/1 HTTP/1.1
Authorization: Bearer example-token
Accept: application/json

このリクエストだけを見て、対象、希望する表現、認証情報を判断できます。

数学的に見る: 明示された入力から応答を決める

共通記法を使うと、リクエスト処理は

δ:S×QS×A \delta: S \times Q \to S \times A

と表されます。ここで QQ に含まれない、クライアント固有の隠れた会話状態へ依存しないことがステートレス制約の要点です。

その他の制約

  • クライアント・サーバー分離: UIとデータ管理の責務を分ける
  • キャッシュ可能性: 応答が再利用可能か明示する
  • 階層化システム: クライアントは途中のプロキシなどを意識しなくてよい
  • Code on Demand: サーバーからコードを配る任意の制約

3. CRUDとHTTPメソッド

メソッドの使い分け

操作 メソッド
一覧取得 GET GET /users
単体取得 GET GET /users/1
作成 POST POST /users
全体置換 PUT PUT /users/1
部分更新 PATCH PATCH /users/1
削除 DELETE DELETE /users/1

PUT は対象リソースの全体を指定した表現で置き換え、PATCH は指定した部分だけを変更する、と区別すると設計が明確になります。

メソッドの安全性と冪等性

メソッド 安全 冪等 主な意味
GET リソースの表現を取得する
HEAD レスポンスボディなしでメタデータを取得する
OPTIONS 利用可能な通信方法を取得する
POST × × 対象リソースに応じた処理や作成を依頼する
PUT × 対象リソース全体を置換する
PATCH × × 対象リソースを部分的に変更する
DELETE × 対象リソースを削除する

この表はHTTPメソッドに定義された性質を示します。個別のAPIが偶然同じ結果を返すかどうかではありません。PATCH は更新内容を慎重に設計すれば冪等にできますが、HTTPメソッドとして冪等性は保証されません。

安全性と冪等性

安全なメソッドは、クライアントがサーバー状態の変更を意図しません。GET が代表例です。アクセスログなどの副作用まで禁止する定義ではありません。

冪等なメソッドは、同じリクエストを複数回送ったとき、サーバーに対する意図された最終効果が1回の場合と同じです。PUTDELETE は冪等になるよう設計します。

数学的に見る: 冪等写像

状態集合 SS に対する操作を f:SSf: S \to S とすると、冪等性は

ff=f f \circ f = f

すなわち、任意の sSs \in S に対して f(f(s))=f(s)f(f(s)) = f(s) となる性質です。レスポンスの時刻やログまで同一である必要はなく、比較対象は操作の意図された効果です。

PUT /users/1 HTTP/1.1
Content-Type: application/json

{
  "name": "Alice",
  "email": "alice@example.com"
}

同じ表現による置換を繰り返しても、最終的なユーザーの内容は変わりません。一方、POST /users を繰り返すと複数のユーザーが作られ得るため、通常は冪等ではありません。

4. ステータスコードとエラー表現

結果を二つの層で伝える

HTTPステータスコードは結果の大分類を、レスポンスボディのコードはアプリケーション固有の理由を表します。

コード 主な用途
200 OK 取得・更新に成功
201 Created 作成に成功
204 No Content 本文なしで成功
400 Bad Request JSON不正など、リクエストを解釈できない
401 Unauthorized 有効な認証情報がない
403 Forbidden 認証済みだが操作を許可されない
404 Not Found 対象リソースが存在しない
409 Conflict 現在の状態と競合する
422 Unprocessable Content 形式は読めるが値を処理できない
500 Internal Server Error サーバー内部の予期しない失敗
{
  "code": "VALIDATION_ERROR",
  "message": "入力内容を確認してください",
  "details": [
    {
      "field": "email",
      "reason": "invalid_format"
    }
  ]
}

エラー形式を統一すると、クライアントは文章を解析せず code に基づいて処理できます。内部例外やスタックトレースは公開しません。

数学的に見る: 結果の直和

成功結果の集合を XX、エラーの集合を EE とすると、処理結果を直和

XE X \sqcup E

として扱えます。成功値とエラー値のどちらであるかをステータスコードで識別し、それぞれの詳しい値をボディで返すと考えられます。

5. 検索・ソート・ページネーション

コレクションをクエリで変換する

一覧取得では、URIのクエリパラメータを使って返す要素と順序を指定します。操作ごとに別のパスを作るより、/users という同じコレクションに対する条件として表すと一貫します。

GET /users?q=ali&sort=name&order=asc&page=1&per_page=20 HTTP/1.1
Accept: application/json

この教材の仮想APIでは、次のパラメータを扱います。

パラメータ 意味
q name または email の部分一致検索 q=ali
sort ソート対象 sort=name
order 昇順または降順 order=asc
page 1から始まるページ番号 page=2
per_page 1ページの最大件数 per_page=20

パラメータ名やページ番号の起点はHTTPで標準化されていません。API内で一貫させ、OpenAPIなどで契約として明示する必要があります。

レスポンスにページ情報を含める

{
  "data": [
    {
      "id": 1,
      "name": "Alice",
      "email": "alice@example.com"
    }
  ],
  "meta": {
    "page": 1,
    "perPage": 20,
    "total": 1,
    "totalPages": 1
  }
}

現在位置だけでなく総件数や総ページ数も返すと、クライアントがページ操作を構築できます。ただし、巨大なデータに対する総件数の計算は高価な場合があるため、常に必要とは限りません。

数学的に見る: 選択・順序付け・部分列

コレクションを有限列 X=(x1,,xn)X=(x_1,\ldots,x_n) とします。検索やフィルタリング条件を述語

φ:R{true,false} \varphi: R \to \{\operatorname{true},\operatorname{false}\}

で表すと、条件に合う要素の列は

filterφ(X)=(xiφ(xi)=true) \operatorname{filter}_{\varphi}(X)=(x_i \mid \varphi(x_i)=\operatorname{true})

です。ソート条件を全順序 \preceq とすると、これを sort\operatorname{sort}_{\preceq} で並べ替えます。

1から始まるページ番号を pp、1ページの件数を kk とすると、ページネーションは変換後の列から添字

(p1)k+1,,min(pk,n) (p-1)k+1,\ldots,\min(pk,n)

の要素を取り出す操作です。実装順序は原則として、フィルタリング、ソート、ページネーションです。先にページを切り出すと、ページごとに検索結果や順序が変わってしまいます。

よくある誤解: ページネーションだけを指定しても、安定した並び順が自動的に得られるとは限りません。同じソート値を持つ要素がある場合は、id などを第2キーにして順序を一意にすると、ページ間の重複や欠落を抑えられます。

6. OpenAPIで契約を記述する

実装とは別にインターフェースを記述する

OpenAPIはHTTP APIのインターフェースを、プログラミング言語に依存しない文書として記述する仕様です。人が読むドキュメントだけでなく、入力検証、クライアント生成、モック、テストなどの入力として利用できます。

この教材では、広く対応されているOpenAPI 3.1形式で仮想APIを記述します。

openapi: 3.1.0
info:
  title: Learn REST Users API
  version: 1.0.0
paths:
  /users/{id}:
    get:
      operationId: getUser
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
      responses:
        "200":
          description: ユーザー
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "404":
          $ref: "#/components/responses/NotFound"

主な構成要素は次の通りです。

要素 記述するもの
info APIの名前やバージョン
paths パスと、そのパスで使えるHTTPメソッド
parameters パス、クエリ、ヘッダーなどの入力
requestBody リクエストボディのメディアタイプと構造
responses ステータスコードごとのレスポンス
components 再利用するスキーマやレスポンス

スキーマを再利用する

components:
  schemas:
    User:
      type: object
      additionalProperties: false
      required: [id, name, email]
      properties:
        id:
          type: integer
          minimum: 1
        name:
          type: string
          minLength: 1
        email:
          type: string
          format: email

$ref を使うと、同じ User 定義を一覧取得、単体取得、更新結果から参照できます。実装と仕様の重複を完全になくすものではありませんが、API利用者が参照する契約を一箇所にできます。

数学的に見る: 仕様が許す入出力関係

前章までのリクエスト集合を QQ、レスポンス集合を AA とします。OpenAPI文書が記述する契約を、許可されたリクエストとレスポンスの関係

𝒪Q×A \mathcal{O} \subseteq Q \times A

として捉えます。状態遷移 δ\delta に対し、ある状態 ss と契約上有効なリクエスト qq について

δ(s,q)=(s,a)(q,a)𝒪 \delta(s,q)=(s',a) \implies (q,a)\in\mathcal{O}

が成り立つなら、実装がその入出力について契約に適合していると考えられます。ただし、OpenAPIだけでは状態に依存するすべての業務規則を表現できません。例えば「同じメールアドレスは登録できない」という制約は、文章や別の形式による補足が必要です。

よくある誤解: OpenAPI文書が存在するだけでは、実装との一致は保証されません。CIで仕様ファイルを検査し、契約テストによって実際のレスポンスを照合して初めて、不一致を継続的に検出できます。

完全なopenapi.yamlを表示
---
openapi: 3.1.0
info:
  title: Learn REST Users API
  version: 1.0.0
  description: Learn RESTのブラウザ内シミュレーターが実装する仮想API
servers:
  - url: https://learn-rest.invalid
    description: 説明用の仮想サーバー。実際の通信先ではありません。
paths:
  /users:
    get:
      operationId: listUsers
      summary: ユーザー一覧を取得する
      parameters:
        - name: q
          in: query
          description: nameまたはemailに対する部分一致検索
          schema:
            type: string
        - name: sort
          in: query
          description: ソート対象
          schema:
            type: string
            enum: [id, name, email]
            default: id
        - name: order
          in: query
          description: ソート方向
          schema:
            type: string
            enum: [asc, desc]
            default: asc
        - name: page
          in: query
          description: 1から始まるページ番号
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: per_page
          in: query
          description: 1ページの最大件数
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 10
      responses:
        "200":
          description: ユーザー一覧
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserList"
        "400":
          $ref: "#/components/responses/BadRequest"
    post:
      operationId: createUser
      summary: ユーザーを作成する
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UserInput"
      responses:
        "201":
          description: 作成したユーザー
          headers:
            Location:
              description: 作成したリソースのURI
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "422":
          $ref: "#/components/responses/ValidationError"
  /users/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: ユーザーID
        schema:
          type: integer
          minimum: 1
    get:
      operationId: getUser
      summary: ユーザーを取得する
      responses:
        "200":
          description: ユーザー
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      operationId: replaceUser
      summary: ユーザー全体を置換する
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UserInput"
      responses:
        "200":
          description: 置換後のユーザー
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
    patch:
      operationId: updateUser
      summary: ユーザーを部分更新する
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UserPatch"
      responses:
        "200":
          description: 更新後のユーザー
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
    delete:
      operationId: deleteUser
      summary: ユーザーを削除する
      responses:
        "204":
          description: 削除に成功。レスポンスボディはありません。
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
components:
  schemas:
    User:
      type: object
      additionalProperties: false
      required: [id, name, email]
      properties:
        id:
          type: integer
          minimum: 1
        name:
          type: string
          minLength: 1
          pattern: "\\S"
        email:
          type: string
          format: email
    UserInput:
      type: object
      additionalProperties: false
      required: [name, email]
      properties:
        name:
          type: string
          minLength: 1
          pattern: "\\S"
        email:
          type: string
          format: email
    UserPatch:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        name:
          type: string
          minLength: 1
          pattern: "\\S"
        email:
          type: string
          format: email
    PageMeta:
      type: object
      additionalProperties: false
      required: [page, perPage, total, totalPages]
      properties:
        page:
          type: integer
          minimum: 1
        perPage:
          type: integer
          minimum: 1
          maximum: 100
        total:
          type: integer
          minimum: 0
        totalPages:
          type: integer
          minimum: 0
    UserList:
      type: object
      additionalProperties: false
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/User"
        meta:
          $ref: "#/components/schemas/PageMeta"
    Error:
      type: object
      additionalProperties: false
      required: [code, message]
      properties:
        code:
          type: string
        message:
          type: string
  responses:
    BadRequest:
      description: クエリパラメータなどが不正
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: 対象ユーザーが存在しない
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    ValidationError:
      description: JSONは解釈できるが入力値が不正
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
...

7. 認証・認可とJWT

認証と認可を分ける

認証(Authentication)は「誰であるか」を確認する処理、認可(Authorization)は「その主体が何をしてよいか」を判定する処理です。認証に成功しても、すべての操作が許可されるわけではありません。

段階 問い 失敗時の代表的な応答
認証 このリクエストの主体は誰か 401 Unauthorized
認可 この主体に操作を許可するか 403 Forbidden

401 Unauthorized は名前に反して、有効な認証情報がない場合に使います。HTTP認証を要求する401応答では、利用可能な認証方式を WWW-Authenticate ヘッダーで示します。認証情報は有効だが権限がない場合は 403 Forbidden が対応します。

GET /users/1 HTTP/1.1
Authorization: Bearer eyJhbGciOi...
数学的に見る: 認証関数と認可関係

主体の集合を II、認証情報の集合を TT とします。認証処理を部分性のある関数

authenticate:TOption(I) \operatorname{authenticate}: T \to \operatorname{Option}(I)

とします。無効な認証情報なら None\operatorname{None}、有効なら主体 iIi\in I を返します。

認可規則は、許可された主体、メソッド、URIの関係

𝒜I×M×U \mathcal{A}\subseteq I\times M\times U

として表せます。認証結果が ii であるリクエスト (m,u)(m,u) を許可する条件は

(i,m,u)𝒜 (i,m,u)\in\mathcal{A}

です。このように認証関数と認可関係は別の対象です。

JWTの構造

署名付きJWTは、Base64urlで表現された3つの部分をピリオドで連結します。

base64url(header).base64url(payload).base64url(signature)
  • Header: 署名アルゴリズムやトークン種別
  • Payload: subissaudexp などのクレーム
  • Signature: HeaderとPayloadが改変されていないことを検証する値

Base64urlは暗号化ではありません。HeaderとPayloadはトークンを入手した人が読めるため、パスワードや秘密情報を格納してはいけません。

代表的な登録済みクレームには次があります。

クレーム 意味
iss 発行者(Issuer)
sub 対象となる主体(Subject)
aud 想定する受信者(Audience)
exp 有効期限(Expiration Time)
nbf 使用可能になる時刻(Not Before)
iat 発行時刻(Issued At)
jti JWTの識別子

検証で確認するもの

JWTを受け取ったAPIは、少なくとも用途に応じて次を確認します。

  1. 許可したアルゴリズムで署名されていること
  2. 正しい鍵で署名を検証できること
  3. iss が期待する発行者であること
  4. aud に自分自身が含まれること
  5. 現在時刻が expnbf の範囲内であること
  6. そのトークン種別をこのAPIで受け入れてよいこと

トークンのHeaderに書かれたアルゴリズムを無条件に信用してはいけません。API側が許可するアルゴリズムを設定し、署名とクレームの両方を検証します。

JWTインスペクター

HeaderとPayloadをブラウザ内でデコードします。署名の検証は行いません。

デコード結果を認証や認可に使用しないでください。

よくある誤解: JWTを使えばサーバーが完全に「状態を持たない」わけではありません。鍵のローテーション、ユーザーの無効化、トークン失効など、運用上の状態管理が必要になる場合があります。また、REST APIにJWTは必須ではありません。

8. APIバージョニングと後方互換性

バージョンを増やす前に互換性を考える

APIバージョンは、同じ目的を持つ複数の契約を並行して提供する仕組みです。変更のたびにバージョンを増やすのではなく、既存クライアントを壊す変更が避けられない場合に使います。

変更 一般的な判定 注意点
新しいエンドポイントを追加する 非破壊的 既存操作の意味を変えない
任意のリクエスト項目を追加する 非破壊的 省略時の挙動を維持する
必須のリクエスト項目を追加する 破壊的 既存リクエストが検証に失敗する
項目を削除・改名する 破壊的 既存クライアントが値を取得できない
項目の型を変更する 破壊的 デシリアライズに失敗し得る
入力の許容値を減らす 破壊的 以前有効だった入力が無効になる
レスポンスの列挙値を増やす 破壊的になり得る 網羅的に分岐するクライアントが壊れ得る
任意のレスポンス項目を追加する 条件付き 未知項目を拒否するクライアントには破壊的
ステータスコードや意味を変える 破壊的 構造が同じでも振る舞いが変わる

互換性はJSONの形だけでは決まりません。ソート順、丸め方、権限判定など、観測できる意味の変更も契約の変更です。

バージョンを指定する場所

バージョンの指定方法はHTTPで一つに標準化されていません。APIの利用者、キャッシュ、ルーティング、ドキュメント生成との相性を考えて一つの方式を選び、一貫して使います。

方式 特徴
パス /v1/users 発見・ルーティングが容易だが、バージョンごとにURIが変わる
独自ヘッダー API-Version: 1 URIを維持できるが、ブラウザやキャッシュから見えにくい
メディアタイプ Accept: application/vnd.example.v1+json 表現の版を明示できるが、運用とツール設定が複雑
クエリ /users?version=1 試しやすいが、省略時の規則やキャッシュキーに注意が必要

小規模な公開APIでは、明示的で扱いやすいパス方式が現実的です。ただし、URLに版を置くこと自体がRESTの必須条件ではありません。

GET /v1/users/1 HTTP/1.1
Accept: application/json
数学的に見る: 後方互換性を契約の精緻化として捉える

APIバージョンの集合を 𝒱\mathcal{V} とし、バージョン v𝒱v\in\mathcal{V} が受理するリクエスト集合を DvQD_v\subseteq Q とします。リクエスト qq に対して契約が許すレスポンス集合を Av(q)AA_v(q)\subseteq A とします。

新しいバージョン ww が古いバージョン vv に強い意味で後方互換である条件を

DvDw D_v\subseteq D_w

かつ

qDv,Aw(q)Av(q) \forall q\in D_v,\quad A_w(q)\subseteq A_v(q)

と表せます。新しい版は古い入力を引き続き受理し、その入力に対して古い契約が許していないレスポンスを返さない、という条件です。

レスポンス側の包含方向が逆に見える点が重要です。新しい契約が返し得る結果を増やすと、古いクライアントが想定していない結果が現れるためです。実務では、クライアントが観測する項目や副作用も含めて互換性を評価します。

廃止から停止までを分ける

古いバージョンを即座に停止せず、次の順序で移行します。

  1. 後継バージョンと移行手順を公開する
  2. OpenAPIで対象操作を deprecated: true にする
  3. レスポンスで廃止予定を通知する
  4. 利用状況を計測し、利用者を移行する
  5. 告知した日時以降に停止する
HTTP/1.1 200 OK
Deprecation: @1798761600
Sunset: Thu, 01 Jul 2027 00:00:00 GMT
Link: <https://example.com/migrations/v2>; rel="deprecation"
Content-Type: application/json

Deprecation はそのリソースが非推奨になる、またはなったことを示します。Sunset は応答しなくなる可能性のある将来日時を示します。非推奨になっても直ちに挙動は変えず、停止日と移行先を別途伝えます。

よくある誤解: v2 を作っただけでは移行は完了しません。旧版の利用者、期限、移行手順、監視、停止後の応答まで決めることがバージョニング運用です。

9. APIをテストする

テストの境界を分ける

APIテストは、対象とする境界によって検出できる問題が異なります。一種類のテストですべてを確認するのではなく、役割を分けます。

種類 主な対象 検出する問題の例
ユニットテスト 検証、状態遷移、整形などの小さな単位 境界値、分岐、冪等性の崩れ
統合テスト ルーティング、永続化、複数操作の連携 作成したリソースを取得できない、設定差異
契約テスト OpenAPIなどの公開契約と実際の入出力 ステータスやスキーマの不一致
E2Eテスト クライアントからAPIまでのシステム全体 認証やネットワークを含む利用手順の失敗

このアプリでは、API状態遷移を ApiSimulator としてDOMから分離しています。Node.js上で同じ実装を直接呼び、外部サーバーなしでユニットテストと一連の操作を確認できます。

make test

仕様の性質をテストする

個別の例だけでなく、前章までに定義した性質をテスト対象にします。

  • GET の前後でサーバー状態が等しい
  • 同じ PUT を2回適用した状態が1回適用した状態と等しい
  • DELETE のレスポンスが変わっても、2回目以降の最終状態は変わらない
  • 無効な入力が4xxとなり、状態を変更しない
  • POST 後に Location のURIから作成したリソースを取得できる

実際のテストでは、PUTの冪等性を次のように確認しています。

const api = new ApiSimulator();
const body = {
  name: "Alice Updated",
  email: "alice@example.com",
};

api.handleRequest("PUT", "/users/1", body);
const afterFirst = JSON.stringify(api.snapshot());
api.handleRequest("PUT", "/users/1", body);

assert.equal(JSON.stringify(api.snapshot()), afterFirst);
数学的に見る: テストを述語として捉える

状態遷移を

δ(s,q)=(s,a) \delta(s,q)=(s',a)

とします。期待する性質を判定するテストオラクルは、例えば述語

P:S×Q×S×A{true,false} P:S\times Q\times S\times A\to\{\operatorname{true},\operatorname{false}\}

として表せます。あるテストケース (s,q)(s,q) が成功する条件は

δ(s,q)=(s,a)P(s,q,s,a)=true \delta(s,q)=(s',a)\land P(s,q,s',a)=\operatorname{true}

です。δ(s,q)=(s,a)\delta(s,q)=(s',a) のとき Pδ(s,q)=P(s,q,s,a)P_\delta(s,q)=P(s,q,s',a) と置きます。

有限個のテスト集合 TS×QT\subseteq S\times Q がすべて成功しても、一般には

(s,q)S×Q,Pδ(s,q)=true \forall(s,q)\in S\times Q,\quad P_\delta(s,q)=\operatorname{true}

を証明したことにはなりません。境界値分析やプロパティベーステストは、限られた実行回数で反例を見つけやすくする方法です。型、形式仕様、レビューも組み合わせて欠陥を減らします。

契約のずれを検出する

OpenAPIに 200 と書かれていても、実装が 201 を返す可能性はあります。仕様ファイルの構文検査だけでは、この不一致を検出できません。

契約テストでは、実装へリクエストを送り、次をOpenAPIと照合します。

  1. パスとメソッドが定義されている
  2. 返されたステータスコードが定義されている
  3. ヘッダーとボディが対応するスキーマを満たす
  4. エラー応答も共通スキーマを満たす

このリポジトリの契約テストは、既存のPandocで openapi.yaml を構造化データとして読み込みます。代表的な正常・異常リクエストを実装へ送り、ステータスコードが定義されていることと、JSONボディが参照先を含むスキーマを満たすことを自動照合します。

node --test tests/openapi-contract.test.mjs

このテストは代表ケースに対する照合であり、OpenAPIの全キーワードや全入力を検証する汎用バリデーターではありません。未検証のケースが契約に適合することまでは保証しません。

よくある誤解: カバレッジ100%は、すべての入力や性質を検証したという意味ではありません。通過したコードの割合と、仕様を十分に検証したかどうかは別の指標です。

APIシミュレーター

次の仮想APIはブラウザ内だけで動き、外部へ通信しません。メソッドとパスを変え、操作前後の状態を比較してください。GET /users?q=ali&sort=name&order=asc&page=1&per_page=10 のような一覧クエリも試せます。

リクエストを試す

GETではボディを使用しません。

まだ送信していません。

レスポンス

現在の状態


            

確認問題

問1

同じ内容の PUT /users/1 を2回送ると、最終的なリソース状態は1回送った場合と同じでした。この性質は何ですか。

問2

存在しない GET /users/999 に最も適切なステータスコードはどれですか。

問3

RESTにおける「ステートレス」の説明として適切なのはどれですか。

問4

OpenAPI文書について正しい説明はどれですか。

問5

有効な認証情報はあるものの、対象リソースを操作する権限がない場合の代表的なステータスコードはどれですか。

問6

JWTのHeaderとPayloadをBase64urlデコードできたとき、何が確認できますか。

問7

既存APIへの変更として、一般に破壊的なのはどれですか。

問8

古いAPIが非推奨になったが、まだ利用可能であることを通知する目的に対応するヘッダーはどれですか。

問9

PUTの冪等性を直接確認するテストはどれですか。

問10

有限個のテストケースがすべて成功したとき、一般に言えることはどれですか。

まとめ

  • リソースと、そのJSON表現を区別する
  • URIはリソースを識別し、HTTPメソッドは操作の意味を伝える
  • ステートレスとは、各リクエストが必要な文脈を持つこと
  • 安全性と冪等性は異なる性質
  • HTTPステータスとアプリケーション固有エラーを組み合わせる

ここまでの設計原則は独立した規則ではありません。リソース、HTTPメソッド、状態遷移、契約、認証、互換性、テストを一つのAPI設計として整合させることが重要です。