mybook

リクエストとレスポンスの設計 — 一貫性のある形を作る

バラバラなレスポンスの問題

「サクラさん、ちょっと見てほしいんだけど」

パートナー企業のエンジニアから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やドキュメントよりも重要かもしれない。開発者は一度パターンを学べば、残りは予測できる。

Loading diagram...

エンベロープパターン

成功・エラーを統一した形式でラップする「エンベロープ」パターンを採用する。

成功レスポンス

// 単一リソース
{
  "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_caseuser_nameRuby, Python系API
camelCaseuserNameJavaScript系API
PascalCaseUserNameC#系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を使用
end

jsonapi-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を使えるか、を制御する設計に進む。