Part 1 では URL の設計を扱った。今回はレスポンスとして返すリソースオブジェクトと、一覧エンドポイントを設計する。
レスポンスのオブジェクトはリソースの契約である。どのエンドポイントから返っても同じ形をしていること、値が無い状態を 1 つの方法で表すこと、この 2 つを軸に決める。
すべてのオブジェクトに id と object を持たせる
{
"id": 12345,
"object": "customer",
"name": "山田太郎",
"email": "yamada@example.com",
"status": "active",
"created_at": "2026-01-15T10:30:00.000Z"
}
-
id: サーバーが生成する一意識別子。クライアントが提供するキー(アップサート用の外部識別子など)には別の名前を付け、idとは区別する -
object: オブジェクトの種別を表す文字列定数
object を持たない設計も成立する。URL を見れば種別は分かるからである。それでも持たせるのは、JSON 単体で種別が判別できると、埋め込みオブジェクトや種別混在の配列をクライアントが型で扱えるからである。ログを 1 行見たときに何のオブジェクトか分かる利点もある。
シングルトンリソースは id を持たない
ログインユーザーに対して一意に定まる集約情報(サマリ、残高など)は、個別に識別される実体を持たない。この場合 id は不要とし、object だけを必須とする。
欠損値は null で表す
// 避ける
{ "email": "", "read_at": "1970-01-01T00:00:00.000Z" }
// こうする
{ "email": null, "read_at": null }
値が存在しないことは常に null で表し、空文字列・0・エポック時刻などのゼロ値では表さない。
- 型の対応: TypeScript の
T | null、Dart のT?、Kotlin のT?と直接対応し、未設定状態を型システムで表現できる - 構文の保証: ISO 8601 のようにフォーマットを持つフィールドでは、空文字列はパースできない。クライアントごとに例外処理が要る
- 意味の区別:
""は「空の値」、0は「ゼロという値」であり、「値が無い」とは別の状態を指す
クライアント実装が煩雑になるという理由でゼロ値に置き換えない。置き換えるとサーバー側の意味論が壊れ、影響範囲が全クライアントに広がる。
条件付きフィールドはフラットに置く
あるフィールドの値によって他のフィールドの有無が決まる組み合わせがある。通知の配信対象がその例で、target_type が "segment" のときだけ target_segment_ids が現れる。
// 避ける
{ "target_type": "segment", "target": { "segment_ids": [1, 2] } }
// こうする
{ "target_type": "segment", "target_segment_ids": [1, 2] }
- 条件付きフィールドは特定のリソース固有であり、サブオブジェクトに包んでも他のリソースで再利用されない
- フラットのほうがクエリパラメーターやバリデーションルールと 1 対 1 で対応する
サブオブジェクトにするのは、address のように独立した値オブジェクトとして複数のリソースで再利用される場合だけである。
関連リソースをどこまで埋め込むか
小さい関連リソースは常に展開する
フィールド数が少ない関連リソース(カテゴリ、タグなど)は、全フィールドをインラインで返す。
{
"id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"object": "notification",
"title": "メンテナンスのお知らせ",
"category": {
"id": 3,
"object": "notification_category",
"name": "システム"
}
}
埋め込まれたオブジェクトにも id と object を含める。 トップレベルで返る場合と埋め込みで返る場合で型が一致し、クライアントは同じ型定義を使い回せる。
大きい関連リソースは id だけ返す
フィールド数が多く利用頻度が低い関連リソース(ユーザープロフィールなど)は、デフォルトでは id だけを返し、expand パラメーターで全フィールドの展開を可能にする。
すべての関連リソースを id だけにする設計も成立する。レスポンスは小さくなり、サーバー側の結合も減る。代わりにクライアントは一覧の各行について追加リクエストを出すことになる。小さいリソースを常に展開するのは、この往復を避けるためである。
DELETE は削除されたことを返す
{
"id": 12345,
"object": "customer",
"deleted": true
}
空オブジェクト {} は返さない。204 No Content も妥当な選択で、ボディが無いぶん転送量も実装も小さい。それでも最小オブジェクトを返すのは、クライアントが削除の結果を他のレスポンスと同じ形で扱えるからである。
削除済みリソースへの再 DELETE はエラーにする
HTTP の冪等性(同じリクエストを繰り返しても同じ結果になる)よりも、クライアントへの情報提供を優先する。
- 「自分が削除した」と「既に他者が削除していた」を区別できる。管理画面で複数人が同時に操作するときに効く
- リトライ安全性が必要なクライアントは、エラーコードを見て成功として扱える
冪等に成功を返す設計も正しい。ネットワークの再送やジョブのリトライが多い API では、そちらのほうが素直である。区別できる情報を捨てる代わりに、クライアントの分岐が 1 つ消える。
フィールドの命名
真偽値
paid、captured のような状態を表す名詞・形容詞で書く。is_ / has_ を付ける規約も、API 全体で統一されていれば問題ない。is_paid が paid に足している情報は無い。
日時
用途で使い分ける。
- イベントタイムスタンプ(何かが起きた時点):
_atを付ける。created_at、updated_at、read_at - 期間の境界(スケジュールされた開始・終了): サフィックスを付けない。
delivery_start、delivery_end
delivery_start_at のように両方を付けると冗長になる。
ユーザースコープのレスポンスに user_id を含めない
/v1/notifications のようなユーザー向けエンドポイントは、リクエスト元ユーザーのコンテキストで動作する。レスポンスに user_id を含めない。 オーナー情報が必要なのは /v1/admin/... の管理者 API だけである。
一覧 API の設計
一覧エンドポイントは、data に配列、meta にページネーション情報を入れたオブジェクトを返す。
{
"data": [{ "id": 12345, "object": "customer", "name": "山田太郎" }],
"meta": { "page": 1, "limit": 20, "total": 132, "total_pages": 7 }
}
配列を裸で返さない。 後からページネーション情報や集計値を足すときに、レスポンスの型を壊さずに済む。
ソート
sort クエリパラメーターを使う。フィールド名をそのまま指定すると昇順、- を前置すると降順を表す。
GET /v1/notifications?sort=-created_at
sort_by=created_at&order=desc の 2 パラメーター方式も読みやすく、統一されていれば問題ない。1 つのパラメーターにまとめると、複数キーのソートを sort=-created_at&sort=title と同じ記法のまま書き足せる。
デフォルトのソート順はエンドポイントごとに決まる。仕様書に明記する。
複数値のフィルターは同じキーを繰り返す
# こうする
GET /v1/notifications?status=scheduled&status=active
# 避ける
GET /v1/notifications?status[]=scheduled&status[]=active
同一キーの繰り返しは RFC 6570 と HTML フォーム仕様に沿い、Express・Spring・Django・Go が追加設定なしで配列として解析する。ブラケット記法は PHP 由来で、URL エンコードが必要になる。
カンマ区切り(status=scheduled,active)も選択肢である。URL は短くなるが、サーバー側に分割処理を書くことになり、値そのものにカンマを含められない。
フィルターを省略したときは絞り込まない
フィルターパラメーターを省略した場合、そのフィールドでは絞り込まない。 絞り込みを解除するための "all" は原則設けない。
例外は、一部の値をデフォルトで除外するフィルターである。GitHub Issues API の state はデフォルトが "open" で、"closed" を含めるには state=all が必要になる。この場合は取りうる値に "all" を含め、デフォルト挙動を仕様書に書く。
次回は、エラーの設計を書く。