mybook

RESTful API の設計原則

ナツミの最初のコードレビュー

APIの実装を始めたナツミ。まず物件(Property)のエンドポイントを作った。

# ナツミの最初の実装
Rails.application.routes.draw do
  get '/getProperties', to: 'properties#index'
  get '/getPropertyById/:id', to: 'properties#show'
  post '/createProperty', to: 'properties#create'
  post '/updateProperty/:id', to: 'properties#update'
  post '/deleteProperty/:id', to: 'properties#destroy'
end

レビューを依頼するとシニアエンジニアの田中さんから即座にコメントが来た。

「これ、RESTful じゃないね。全部直してほしい」

ナツミは頭を抱えた。「REST って何が正解なの?」


REST の6つの原則

REST(Representational State Transfer)はRoy Fieldingが2000年の論文で定義したアーキテクチャスタイル。6つの制約からなる。

Loading diagram...

APIエンジニアとして最重要なのは統一インターフェース——特に「リソース指向」と「HTTPメソッドの正しい使い方」だ。


リソース指向の命名規則

RESTの核心は「動詞ではなく名詞でURLを設計する」こと。

NG パターン(動詞URL)

GET  /getProperties
POST /createProperty
POST /deleteProperty/1

OK パターン(リソースURL)

GET    /properties       # 一覧
POST   /properties       # 作成
GET    /properties/1     # 詳細
PATCH  /properties/1     # 更新
DELETE /properties/1     # 削除

INFO

URLはリソース(名詞)を表す。操作はHTTPメソッドで表現する。/deleteProperty ではなく DELETE /properties/1 が正解。

ネストしたリソース

物件(property)に属する部屋(room)のような関係は、URLで階層を表現する。

GET  /properties/1/rooms       # 物件1の部屋一覧
POST /properties/1/rooms       # 物件1に部屋を追加
GET  /properties/1/rooms/5     # 物件1の部屋5の詳細

WARNING

ネストは2階層まで/properties/1/rooms/5/amenities/3/photos/2 のような深いネストはURLが複雑になりすぎる。3階層以上は設計を見直す。


HTTPメソッドの正しい使い方

メソッド意味冪等性安全性
GET取得
POST作成
PUT全体更新
PATCH部分更新
DELETE削除

冪等性:同じリクエストを何度送っても結果が変わらない。 安全性:サーバーの状態を変更しない。

PUT vs PATCH

// 既存リソース
{
  "id": 1,
  "name": "渋谷マンション",
  "price": 150000,
  "area": 35.5
}
 
// PUT(全体を置き換える)
// 省略したフィールドはnullになる
PUT /properties/1
{ "name": "渋谷マンション改", "price": 160000 }
// → area が null に!
 
// PATCH(指定したフィールドだけ更新)
PATCH /properties/1
{ "price": 160000 }
// → name, area は変わらず

Railsでのルーティング設計

ナツミは正しい実装に書き直した。

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :properties do
        resources :rooms, only: [:index, :create, :show, :update, :destroy]
        member do
          post :publish      # 非標準アクション
          delete :unpublish
        end
        collection do
          get :featured      # コレクションアクション
        end
      end
 
      resources :users, only: [:show, :update]
      resource :session, only: [:create, :destroy]  # 単数リソース
    end
  end
end

生成されるルートを確認する。

$ rails routes | grep properties
 
GET    /api/v1/properties          api/v1/properties#index
POST   /api/v1/properties          api/v1/properties#create
GET    /api/v1/properties/:id      api/v1/properties#show
PATCH  /api/v1/properties/:id      api/v1/properties#update
PUT    /api/v1/properties/:id      api/v1/properties#update
DELETE /api/v1/properties/:id      api/v1/properties#destroy
POST   /api/v1/properties/:id/publish    api/v1/properties#publish
GET    /api/v1/properties/featured       api/v1/properties#featured

APIコントローラの実装

# app/controllers/api/v1/properties_controller.rb
module Api
  module V1
    class PropertiesController < Api::V1::BaseController
      before_action :set_property, only: [:show, :update, :destroy, :publish]
 
      def index
        @properties = Property
          .where(published: true)
          .order(created_at: :desc)
          .page(params[:page])
          .per(params[:per_page] || 20)
 
        render json: {
          data: @properties.map { |p| property_json(p) },
          meta: pagination_meta(@properties)
        }
      end
 
      def show
        render json: { data: property_json(@property) }
      end
 
      def create
        @property = current_user.properties.build(property_params)
 
        if @property.save
          render json: { data: property_json(@property) }, status: :created
        else
          render json: { errors: @property.errors.full_messages },
                 status: :unprocessable_entity
        end
      end
 
      def update
        if @property.update(property_params)
          render json: { data: property_json(@property) }
        else
          render json: { errors: @property.errors.full_messages },
                 status: :unprocessable_entity
        end
      end
 
      def destroy
        @property.destroy
        head :no_content
      end
 
      private
 
      def set_property
        @property = Property.find(params[:id])
      end
 
      def property_params
        params.require(:property).permit(:name, :description, :price, :area, :prefecture, :city)
      end
 
      def property_json(property)
        {
          id: property.id,
          name: property.name,
          price: property.price,
          area: property.area,
          location: "#{property.prefecture}#{property.city}",
          created_at: property.created_at.iso8601
        }
      end
    end
  end
end

HTTPステータスコードの使い方

適切なステータスコードを返すことで、クライアントはレスポンスの意味を正確に把握できる。

# よく使うステータスコード
# 2xx 成功
render json: data, status: :ok          # 200 GET成功
render json: data, status: :created     # 201 作成成功
head :no_content                        # 204 削除成功(本文なし)
 
# 4xx クライアントエラー
render json: errors, status: :bad_request           # 400 リクエスト不正
render json: errors, status: :unauthorized          # 401 認証失敗
render json: errors, status: :forbidden             # 403 権限なし
render json: errors, status: :not_found             # 404 リソース不存在
render json: errors, status: :unprocessable_entity  # 422 バリデーション失敗
 
# 5xx サーバーエラー
render json: errors, status: :internal_server_error # 500 サーバーエラー

WARNING

401 vs 403の違い:401は「誰なのか不明(未認証)」、403は「誰なのかはわかるが権限がない(認可失敗)」。ログインしていないユーザーには401、ログイン済みだが権限がない場合は403を返す。


クエリパラメータの設計

フィルタリング、ソート、ページネーションはクエリパラメータで実装する。

# GET /api/v1/properties?prefecture=東京都&min_price=100000&sort=price_asc&page=2
 
def index
  @properties = Property.where(published: true)
 
  # フィルタリング
  @properties = @properties.where(prefecture: params[:prefecture]) if params[:prefecture]
  @properties = @properties.where('price >= ?', params[:min_price]) if params[:min_price]
  @properties = @properties.where('price <= ?', params[:max_price]) if params[:max_price]
 
  # ソート
  case params[:sort]
  when 'price_asc'  then @properties = @properties.order(price: :asc)
  when 'price_desc' then @properties = @properties.order(price: :desc)
  when 'newest'     then @properties = @properties.order(created_at: :desc)
  else                   @properties = @properties.order(created_at: :desc)
  end
 
  # ページネーション
  @properties = @properties.page(params[:page]).per(20)
end

ベースコントローラの設定

API専用の基底コントローラを作り、共通処理をまとめる。

# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < ActionController::API
      include ActionController::HttpAuthentication::Token::ControllerMethods
 
      before_action :authenticate_user!
 
      rescue_from ActiveRecord::RecordNotFound do |e|
        render json: { error: 'Not found' }, status: :not_found
      end
 
      private
 
      def current_user
        @current_user ||= authenticate_token
      end
 
      def authenticate_user!
        render json: { error: 'Unauthorized' }, status: :unauthorized unless current_user
      end
 
      def pagination_meta(collection)
        {
          current_page: collection.current_page,
          total_pages: collection.total_pages,
          total_count: collection.total_count,
          per_page: collection.limit_value
        }
      end
    end
  end
end

まとめ

  • RESTはURLを**名詞(リソース)**で設計し、操作はHTTPメソッドで表現する
  • resources を使ったRailsルーティングで標準的なCRUDが簡潔に書ける
  • ネストは2階層まで、非標準アクションは member / collection で定義する
  • HTTPステータスコードを適切に使うことでクライアントが正確に状態を把握できる
  • APIコントローラは ActionController::API を継承し、共通処理はBaseControllerに集約する

次章では、コントローラが返すJSONの形を整えるシリアライゼーションを学ぶ。jbuilder、ActiveModelSerializers、JSON:APIの違いを理解しよう。