リクエストとレスポンスの設計 — 一貫性のある形を作る
バラバラなレスポンスの問題
「サクラさん、ちょっと見てほしいんだけど」
パートナー企業のエンジニアからSlackが来た。添付されていたのは、テックブリッジAPIのレスポンス例だった。
// /api/v1/users/1
{ "id": 1, "name": "田中太郎", "email": "tanaka@example.com" }
// /api/v1/articles/1
{ "article": { "articleId": 1, "Title": "Railsの基本", "body": "..." } }
// /api/v1/errors (エラー時)
{ "message": "not found" }「フィールド名がバラバラで、エラーの形式も違う。何を信じればいいんですか?」
サクラは顔を赤らめた。これは設計の失敗だ。
一貫したレスポンス形式の重要性
APIレスポンスの一貫性は、SDKやドキュメントよりも重要かもしれない。開発者は一度パターンを学べば、残りは予測できる。
エンベロープパターン
成功・エラーを統一した形式でラップする「エンベロープ」パターンを採用する。
成功レスポンス
// 単一リソース
{
"data": {
"id": 1,
"type": "user",
"attributes": {
"name": "田中太郎",
"email": "tanaka@example.com",
"created_at": "2024-01-15T09:00:00Z"
}
}
}
// コレクション
{
"data": [
{
"id": 1,
"type": "user",
"attributes": {
"name": "田中太郎",
"email": "tanaka@example.com"
}
},
{
"id": 2,
"type": "user",
"attributes": {
"name": "鈴木花子",
"email": "suzuki@example.com"
}
}
],
"meta": {
"total": 100,
"page": 1,
"per_page": 20
}
}エラーレスポンス
{
"errors": [
{
"code": "validation_error",
"field": "email",
"message": "メールアドレスの形式が正しくありません"
}
]
}INFO
JSON:API仕様(jsonapi.org)はこのパターンを標準化した仕様です。完全準拠は複雑ですが、考え方を参考にするのは有益です。
フィールド名の命名規則
APIのフィールド名は一貫したスタイルを選ぶ。
| スタイル | 例 | 採用例 |
|---|---|---|
| snake_case | user_name | Ruby, Python系API |
| camelCase | userName | JavaScript系API |
| PascalCase | UserName | C#系API |
Railsの場合、内部はsnake_caseだが、JSONレスポンスをcamelCaseに変換するパターンもある。
# Gemfile
gem 'active_model_serializers'
# または
gem 'jsonapi-serializer'このガイドではsnake_caseを採用する(Railsの自然なスタイル)。
Railsシリアライザーの実装
ActiveModel::Serializers
# Gemfile
gem 'active_model_serializers'
# app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email, :created_at
has_many :articles
def created_at
object.created_at.iso8601
end
end
# app/controllers/api/v1/users_controller.rb
def show
render json: @user # 自動的にUserSerializerを使用
endjsonapi-serializerを使った実装
# Gemfile
gem 'jsonapi-serializer'
# app/serializers/user_serializer.rb
class UserSerializer
include JSONAPI::Serializer
attributes :name, :email
attribute :created_at do |user|
user.created_at.iso8601
end
has_many :articles
end
# コントローラー
def show
render json: UserSerializer.new(@user).serializable_hash
end
def index
users = User.all
render json: UserSerializer.new(users, {
meta: { total: users.count }
}).serializable_hash
endレスポンス例:
{
"data": {
"id": "1",
"type": "user",
"attributes": {
"name": "田中太郎",
"email": "tanaka@example.com",
"created_at": "2024-01-15T09:00:00Z"
},
"relationships": {
"articles": {
"data": [
{ "id": "1", "type": "article" }
]
}
}
}
}カスタムレスポンス形式
jsonapi-serializerが重すぎる場合は、シンプルな独自形式を作る。
# app/controllers/concerns/api_response.rb
module ApiResponse
extend ActiveSupport::Concern
def render_success(data, status: :ok, meta: {})
response = { data: data }
response[:meta] = meta unless meta.empty?
render json: response, status: status
end
def render_errors(errors, status: :unprocessable_entity)
render json: {
errors: errors.map { |field, messages|
messages.map { |message|
{
code: "validation_error",
field: field.to_s,
message: "#{field} #{message}"
}
}
}.flatten
}, status: status
end
def render_error(code:, message:, status:)
render json: {
errors: [{ code: code, message: message }]
}, status: status
end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include ApiResponse
rescue_from ActiveRecord::RecordNotFound do |e|
render_error(
code: "not_found",
message: "リソースが見つかりません",
status: :not_found
)
end
end
# コントローラーでの使用
def show
render_success(UserSerializer.new(@user).serializable_hash)
end
def create
if @user.save
render_success(UserSerializer.new(@user).serializable_hash, status: :created)
else
render_errors(@user.errors)
end
end日時フォーマット
日時は必ずISO 8601形式(UTC)で返す。
# NG
"created_at": "2024/01/15 09:00:00" # ローカル時刻、非標準
# OK
"created_at": "2024-01-15T09:00:00Z" # UTC, ISO 8601
"created_at": "2024-01-15T18:00:00+09:00" # タイムゾーン付き
# Railsでの設定
# config/initializers/time_formats.rb
Time::DATE_FORMATS[:default] = "%Y-%m-%dT%H:%M:%SZ"
# シリアライザーで明示的に変換
attribute :created_at do |object|
object.created_at.utc.iso8601
endヌル値の扱い
// NG: フィールド自体を省略
{
"id": 1,
"name": "田中太郎"
// middle_nameが省略されている
}
// OK: nullで明示
{
"id": 1,
"name": "田中太郎",
"middle_name": null
}フィールドが存在するかどうかがわかると、クライアントがnullチェックを正確に実装できる。
WARNING
フィールドの有無に一貫性を持たせてください。「あるときは存在し、ないときは省略」という設計は、クライアント側で防御的プログラミングが必要になります。
リクエストの検証
# app/controllers/api/v1/users_controller.rb
def create
@user = User.new(user_params)
# 明示的な検証
unless request.content_type == "application/json"
return render_error(
code: "invalid_content_type",
message: "Content-Type は application/json である必要があります",
status: :unsupported_media_type
)
end
if @user.save
render_success(UserSerializer.new(@user).serializable_hash, status: :created)
else
render_errors(@user.errors)
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password)
rescue ActionController::ParameterMissing
render_error(
code: "missing_parameter",
message: "必須パラメーター 'user' がありません",
status: :bad_request
)
endページネーション付きメタ情報
{
"data": [...],
"meta": {
"pagination": {
"current_page": 1,
"per_page": 20,
"total_pages": 5,
"total_count": 100
}
},
"links": {
"self": "https://api.techbridge.jp/v1/users?page=1",
"next": "https://api.techbridge.jp/v1/users?page=2",
"last": "https://api.techbridge.jp/v1/users?page=5"
}
}Content-Typeヘッダー
# config/application.rb
class Application < Rails::Application
config.api_only = true
# すべてのレスポンスにContent-Typeを設定
config.middleware.use ActionDispatch::ContentType
end
# application_controller.rb
before_action :set_default_response_format
def set_default_response_format
request.format = :json unless params[:format]
endクライアントはリクエスト時に必ず指定する:
Content-Type: application/json
Accept: application/json
AWS API Gatewayでのレスポンス変換
API Gatewayはバックエンドのレスポンスをマッピングテンプレートで変換できる。
// mapping-template.json (Velocity Template)
#set($inputRoot = $input.path('$'))
{
"data": $input.json('$.data'),
"meta": {
"request_id": "$context.requestId",
"timestamp": "$context.requestTime"
}
}# API Gatewayの設定
IntegrationResponse:
StatusCode: "200"
ResponseTemplates:
application/json: |
#set($inputRoot = $input.path('$'))
{
"data": $input.json('$.data'),
"request_id": "$context.requestId"
}サクラの成果
「これでパートナーからの苦情が減った」
レスポンスを統一したことで、パートナー企業のエンジニアが書くクライアントコードがシンプルになった。
// クライアント側のコード(Before)
if (response.user) {
name = response.user.name || response.user.Name || response.data?.name
}
// クライアント側のコード(After)
const { data } = response
name = data.attributes.name一貫性はドキュメントよりも雄弁だ。
次章では、このAPIに認証と認可を追加する。誰がAPIを使えるか、を制御する設計に進む。