Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: ActiveModelSerializersでAPIを作る--Part 1(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Rails: ActiveModelSerializersでAPIを作る--Part 1(翻訳)

本チュートリアルでは、図書館の業務フロー編成を助けるAPIを作成することにします。このAPIでは、本の貸出、返却、ユーザーの作成、本の作成、著者の作成を行います。さらに、本/著者/ユーザーの作成/読み出し/更新/削除(CRUD)を行う管理ページは管理者だけがアクセスできるようにします。認証はHTTPトークン経由で扱うことにします。

API作成のため、Rails API 5にActiveModelSerializersを併用します。次回は、ここで作成するAPIのフルテストを行う予定です。

それでは、まっさらのRails APIアプリを作るところから始めてみましょう。

$ rails new library --api --database=postgresql

終わったらGemfileを以下の内容に差し替えて、active_model_serializers、faker、rack-corsの3つのgemを追加します。

訳注: 原文のRailsバージョンは5.0.1です。Rails 5.1.4で動作を確認しました。

source 'https://rubygems.org'

git_source(:github) do |repo_name|
  repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
  "https://github.com/#{repo_name}.git"
end

gem 'rails', '~> 5.0.1'
gem 'pg', '~> 0.18'
gem 'puma', '~> 3.0'
gem 'active_model_serializers', '~> 0.10.0'
gem 'rack-cors'

group :development, :test do
  gem 'pry-rails'
  gem 'faker'
end

group :development do
  gem 'bullet'
  gem 'listen', '~> 3.0.5'
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end
$ bundle install
  • ActiveModelSerializers: JSONオブジェクトを作成するオブジェクトの作成を支援するライブラリです。今回の場合、Railsのビューはまったく使わないため、オブジェクトを表すシリアライザだけを返します。カスタマイズや再利用を自在に行える素晴らしいライブラリです。

  • RackCors: クロスオリジンリソース共有(CORS)を扱えるようにするRackミドルウェアであり、これを使ってクロスオリジンのAjaxリクエストを行えるようにします。

  • Faker: データのフェイクを作成する強力で素晴らしいライブラリです。

訳注: RackCorsは、現在のRailsではGemfileにコメントアウトの形で追加済みです。

モデルを作成する

ところで、データベーススキーマの設計が必要ですね。今回はusers、books、authors、book_copiesの4つのテーブルが必要です。スキーマはシンプルにしましょう。usersは基本的に、図書館で本を借りる人を表します。

authorsは本の著者、booksは本を表します。book_copiesは、貸出可能な本を表します。スキーマをシンプルにすると申し上げたとおり、ここでは貸出の履歴は保存しないことにします。

それではgenerateしましょう。

$ rails generate model author first_name last_name
$ rails g model book title author:references
$ rails g model user first_name last_name email
$ rails g model book_copy book:references isbn published:date format:integer user:references

ついでにインデックスも追加します。以下のマイグレーションのように、必要な箇所にはnull:falseも追加してください。

class CreateAuthors < ActiveRecord::Migration[5.0]
  def change
    create_table :authors do |t|
      t.string :first_name, null: false
      t.string :last_name, index: true, null: false

      t.timestamps
    end
  end
end
class CreateBooks < ActiveRecord::Migration[5.0]
  def change
    create_table :books do |t|
      t.references :author, foreign_key: true, null: false
      t.string :title, index: true, null: false

      t.timestamps
    end
  end
end
class CreateUsers < ActiveRecord::Migration[5.0]
  def change
    create_table :users do |t|
      t.string :first_name, null: false
      t.string :last_name, null: false
      t.string :email, null: false, index: true

      t.timestamps
    end
  end
end
class CreateBookCopies < ActiveRecord::Migration[5.0]
  def change
    create_table :book_copies do |t|
      t.references :book, foreign_key: true, null: false
      t.string :isbn, null: false, index: true
      t.date :published, null: false
      t.integer :format, null: false
      t.references :user, foreign_key: true

      t.timestamps
    end
  end
end

それぞれのファイルで変更作業が終わったら、データベースを作成してマイグレーションをすべて実行します。

$ rake db:create
$ rake db:migrate

データベーススキーマができましたので、ここからはモデルで使うメソッドを見ていきます。

モデルを更新する

生成したモデルを更新し、リレーションシップバリデーションをすべて追加します。必要な各フィールドがSQLレベルで存在するかどうかのバリデーションも行います。

class Author < ApplicationRecord
  has_many :books

  validates :first_name, :last_name, presence: true
end
class Book < ApplicationRecord
  has_many :book_copies
  belongs_to :author

  validates :title, :author, presence: true
end
class BookCopy < ApplicationRecord
  belongs_to :book
  belongs_to :user, optional: true

  validates :isbn, :published, :format, :book, presence: true

  HARDBACK = 1
  PAPERBACK = 2
  EBOOK = 3

  enum format: { hardback: HARDBACK, paperback: PAPERBACK, ebook: EBOOK }
end
class User < ApplicationRecord
  has_many :book_copies

  validates :first_name, :last_name, :email, presence: true
end

新しいルーティングの作成も忘れないようにしましょう。routes.rbを次のように更新します。

Rails.application.routes.draw do
  scope module: :v1 do
    resources :authors, only: [:index, :create, :update, :destroy, :show]
    resources :books, only: [:index, :create, :update, :destroy, :show]
    resources :book_copies, only: [:index, :create, :update, :destroy, :show]
    resources :users, only: [:index, :create, :update, :destroy, :show]
  end
end

API バージョニング

新しいAPIを作成する上で最も重要なのがバージョニングと言えます。APIに名前空間v1v2を追加するべきです。理由は、次バージョンのAPIはおそらく異なるものになるはずだからです。

バージョンが変わると、何かと互換性が問題になります。古いバージョンの製品を使いたがる顧客は必ずいるものです。今回の場合、古い製品はv1名前空間の下に置き、新しい方はv2の下に置きます。次の例をご覧ください。

my-company.com/my_product/v1/my_endpoint
my-company.com/my_product/v2/my_endpoint

本チュートリアルでは、アプリですべてのバージョンをサポートすることにします。きっと顧客も幸せになれるでしょう。

シリアライザ

前述のとおり、本チュートリアルではJSONのビルドにシリアライザを使います。こうするとオブジェクトになってくれるので、アプリのどこでも使えるようになるのがよい点です。JSONのためにビューを使う必要もなくなりますし、フィールドを自由に増やしたり減らしたりすることもできます。

それでは最初のシリアライザを作成しましょう。

$ rails g serializer user
class UserSerializer < ActiveModel::Serializer
  attributes :id, :first_name, :last_name, :email, :book_copies
end

オブジェクトに含めたいフィールドをattributesで定義できます。

続いてbook_serializerを作成します。

$ rails g serializer book
class BookSerializer < ActiveModel::Serializer
  attributes :id, :title, :author, :book_copies

  def author
    instance_options[:without_serializer] ? object.author : AuthorSerializer.new(object.author, without_serializer: true)
  end
end

上のコードでもattributesを定義していますが、先ほどと異なるのは#authorメソッドがオーバーライドされている点です。シリアライズされたauthorオブジェクトが必要になるときもありますが、必要ではないこともあります。呼び出しの第2パラメータでoptions = {}オプションを使って、どのオブジェクトが必要かを指定できます。ところで、なぜこれが必要なのでしょうか。

今回の場合をチェックしてみましょう。bookオブジェクトを1つ作成すると、その中には、1つ以上のbookを含むauthorも1つ含まれます。各bookはシリアライズ済みなので、authorも1つ返されます。このままだと無限ループに陥ってしまうかもしれません。シリアライズ済みオブジェクトが必要かどうかを指定しなければならない理由は、これです。なお、さらに#index#updateなどのアクションごとにシリアライザを作成することもできます。

同じ要領で、authorbook_copyにもシリアライザを追加しましょう。

class BookCopySerializer < ActiveModel::Serializer
  attributes :id, :book, :user, :isbn, :published, :format

  def book
    instance_options[:without_serializer] ? object.book : BookSerializer.new(object.book, without_serializer: true)
  end

  def user
    return unless object.user
    instance_options[:without_serializer] ? object.user : UserSerializer.new(object.user, without_serializer: true)
  end
end
class AuthorSerializer < ActiveModel::Serializer
  attributes :id, :first_name, :last_name, :books
end

コントローラ

ところで、このままではコントローラがありません。コントローラもルーティングでバージョニングされ、4つのコントローラはほとんど同じ内容です。テーブルごとに基本的なCRUDを追加する必要があります。それではやってみましょう。

module V1
  class AuthorsController < ApplicationController
    before_action :set_author, only: [:show, :destroy, :update]

    def index
      authors = Author.preload(:books).paginate(page: params[:page])
      render json: authors, meta: pagination(authors), adapter: :json
    end

    def show
      render json: @author, adapter: :json
    end

    def create
      author = Author.new(author_params)
      if author.save
        render json: author, adapter: :json, status: 201
      else
        render json: { error: author.errors }, status: 422
      end
    end

    def update
      if @author.update(author_params)
        render json: @author, adapter: :json, status: 200
      else
        render json: { error: @author.errors }, status: 422
      end
    end

    def destroy
      @author.destroy
      head 204
    end

    private

    def set_author
      @author = Author.find(params[:id])
    end

    def author_params
      params.require(:author).permit(:first_name, :last_name)
    end
  end
end
module V1
  class BookCopiesController < ApplicationController
    before_action :set_book_copy, only: [:show, :destroy, :update]

    def index
      book_copies = BookCopy.preload(:book, :user, book: [:author]).paginate(page: params[:page])
      render json: book_copies, meta: pagination(book_copies), adapter: :json
    end

    def show
      render json: @book_copy, adapter: :json
    end

    def create
      book_copy = BookCopy.new(book_copy_params)
      if book_copy.save
        render json: book_copy, adapter: :json, status: 201
      else
        render json: { error: book_copy.errors }, status: 422
      end
    end

    def update
      if @book_copy.update(book_copy_params)
        render json: @book_copy, adapter: :json, status: 200
      else
        render json: { error: @book_copy.errors }, status: 422
      end
    end

    def destroy
      @book_copy.destroy
      head 204
    end

    private

    def set_book_copy
      @book_copy = BookCopy.find(params[:id])
    end

    def book_copy_params
      params.require(:book_copy).permit(:book_id, :format, :isbn, :published, :user_id)
    end
  end
end
module V1
  class BooksController < ApplicationController
    before_action :set_book, only: [:show, :destroy, :update]

    def index
      books = Book.preload(:author, :book_copies).paginate(page: params[:page])
      render json: books, meta: pagination(books), adapter: :json
    end

    def show
      render json: @book, adapter: :json
    end

    def create
      book = Book.new(book_params)
      if book.save
        render json: book, adapter: :json, status: 201
      else
        render json: { error: book.errors }, status: 422
      end
    end

    def update
      if @book.update(book_params)
        render json: @book, adapter: :json, status: 200
      else
        render json: { error: @book.errors }, status: 422
      end
    end

    def destroy
      @book.destroy
      head 204
    end

    private

    def set_book
      @book = Book.find(params[:id])
    end

    def book_params
      params.require(:book).permit(:title, :author_id)
    end
  end
end
module V1
  class UsersController < ApplicationController
    before_action :set_user, only: [:show, :destroy, :update]

    def index
      users = User.preload(:book_copies).paginate(page: params[:page])
      render json: users, meta: pagination(users), adapter: :json
    end

    def show
      render json: @user, adapter: :json
    end

    def create
      user = User.new(user_params)
      if user.save
        render json: user, adapter: :json, status: 201
      else
        render json: { error: user.errors }, status: 422
      end
    end

    def update
      if @user.update(user_params)
        render json: @user, adapter: :json, status: 200
      else
        render json: { error: @user.errors }, status: 422
      end
    end

    def destroy
      @user.destroy
      head 204
    end

    private

    def set_user
      @user = User.find(params[:id])
    end

    def user_params
      params.require(:user).permit(:first_name, :last_name, :email)
    end
  end
end

コードからわかるように、これらのメソッドはrender Author.find(1)などの基本オブジェクトを返します。シリアライザをレンダリングしたいということをアプリにどうやって伝えればよいでしょうか。その答えは、adapter: :jsonを追加することです。こうすると、以後JSONのレンダリングにデフォルトでシリアライザが使われるようになります。詳しくは公式ドキュメントをご覧ください(訳注: 原文にリンクがありませんでした)。

アプリのフェイクデータが欲しいところなので、seeds.rbファイルでFaker gemを使ってデータを追加しましょう。

authors = (1..20).map do
  Author.create!(
    first_name: Faker::Name.first_name,
    last_name: Faker::Name.last_name
  )
end

books = (1..70).map do
  Book.create!(
    title: Faker::Book.title,
    author: authors.sample
  )
end

users = (1..10).map do
  User.create!(
    first_name: Faker::Name.first_name,
    last_name: Faker::Name.last_name,
    email: Faker::Internet.email
  )
end

(1..300).map do
  BookCopy.create!(
    format: rand(1..3),
    published: Faker::Date.between(10.years.ago, Date.today),
    book: books.sample,
    isbn: Faker::Number.number(13)
  )
end

後は以下を実行して、データベースにデータを追加します。

$ rake db:seed

Rack-Cors

前述のとおり、Rack-CORSも使います。これはクロスオリジンAjax呼び出しを実現できるうれしいツールです。なお、これはGemファイルに追加するだけではだめで、application.rbに設定を追加する必要もあります。

require_relative 'boot'

require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_view/railtie"
require "action_cable/engine"
# require "sprockets/railtie"
require "rails/test_unit/railtie"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module Library
  class Application < Rails::Application
    # Settings in config/environments/* take precedence over those specified here.
    # Application configuration should go into files in config/initializers
    # -- all .rb files in that directory are automatically loaded.

    # Only loads a smaller set of middleware suitable for API only apps.
    # Middleware like session, flash, cookies can be added back manually.
    # Skip views, helpers and assets when generating a new resource.
    config.api_only = true

    config.middleware.insert_before 0, Rack::Cors do
      allow do
        origins '*'
        resource '*', headers: :any, methods: [:get, :post, :put, :delete, :options]
      end
    end
  end
end

上のように設定を追加することで、Railsアプリが外部からの特定のHTTPリクエストを処理するようになります。設定は完全にカスタマイズ可能です。詳しくはこちらをご覧ください(訳注: 原文にリンクがありませんでした)。

Rack-Attack

もうひとつ便利なgemはRack-Attackです。このgemはリクエストをフィルタしたり絞り込んだりすることで、ブロックしたり、ブラックリストに追加したり、トラッキングしたりでき、他にも多くの機能があります。次の設定例をご覧ください。

  Rack::Attack.safelist('allow from localhost') do |req|
    '127.0.0.1' == req.ip || '::1' == req.ip
  end

上のコードは、localhostからのリクエストをすべて許可します。

  Rack::Attack.blocklist('block bad UA logins') do |req|
    req.path == '/' && req.user_agent == 'SomeScraper'
  end

上のコードは、user agentがSomeScraperであるroot_pathからのリクエストをブロックします。

  Rack::Attack.blocklist('block some IP addresses') do |req|
    '123.456.789' == req.ip || '1.9.02.2' == req.ip
  end

上のコードは、指定のIPアドレスからのリクエストをブロックします。

それではRack-attackをGemfileに追加しましょう。

gem 'rack-attack'

Bundlerでgemをインストールします。

$ bundle install

Rack-attackを使うための設定をアプリに追加する必要があります。以下をapplication.rbに追加します。

config.middleware.use Rack::Attack

フィルタ機能を追加するには、config/initializersディレクトリに以下の内容を含むrack_attack.rbというファイルを追加する必要があります。

class Rack::Attack
  Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new

  Rack::Attack.throttle('req/ip', limit: 5, period: 1.second) do |req|
    req.ip
  end

  Rack::Attack.throttled_response = lambda do |env|
    # Using 503 because it may make attacker think that they have successfully
    # DOSed the site. Rack::Attack returns 429 for throttling by default
    [ 503, {}, ["Server Error\n"]]
  end
end

この設定では、基本的にIPアドレスごとに1秒あたり5リクエストまでのアクセスを許可しています。誰かがサーバー側のエンドポイントに1秒あたり5回を超えるリクエストを送信すると、HTTP 503とサーバーエラーをレスポンスとして返します。

トークン – APIキー

最初に書いたように、APIのセキュリティ保護にはHTTPトークンを使います。ユーザーごとに独自のトークンを持ち、これを使ってユーザーをデータベースから検索し、current_userとして設定します。

通常のユーザーは本の貸出と返却のみを行えます。また、ユーザーは借りていない本を返却することはできず、既に借りている本を借りることもできません。usersテーブルにフィールドを1つ追加しましょう。

$ rails g migration add_api_key_to_users api_key:index

usersテーブルにadmimフィールドも追加します。

$ rails g migration add_admin_to_users admin:boolean

マイグレーションファイルを以下のようにカスタマイズします。

class AddAdminToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :admin, :boolean, default: false
  end
end

マイグレーションを実行します。

$ rake db:migrate

この時点ではAPIトークンのフィールドをユーザーに追加しただけなので、何らかの形でトークンを生成する必要があります。トークン生成は、ユーザー作成前かデータベースへのinsert前に行えます。Userクラスに#generate_api_keyメソッドを追加しましょう。

class User < ApplicationRecord
  ...

  before_create :generate_api_key

  private

  def generate_api_key
    loop do
      self.api_key = SecureRandom.base64(30)
      break unless User.exists?(api_key: self.api_key)
    end
  end

  ...
end

このままではAPIがセキュリティ保護されず、リクエストにAPIキーが含まれているかどうかがチェックされていませんので、変更が必要です。#authenticate_with_http_tokenメソッドを使うためには、ActionController::HttpAuthentication::Token::ControllerMethodsモジュールのインクルードが必要です。

それが終わったら、コードを追加します。認証はリクエストごとに行う必要があります。ユーザーまたはadminに、リクエストされたトークンが含まれていれば、トークンをインスタンス変数に保存して後で使えるようにします。トークンが含まれていない場合は、HTTP 401でJSONを返します。

さらに、レコードが見つからない場合(存在しない本をリクエストされた場合など)にはrescueするのがベストプラクティスです。こうした場合にも対応するため、アプリケーションエラーをthrowする代わりに、有効なHTTPステータスコードを返す必要があります。ApplicationControllerに以下のコードを追加してください。

class ApplicationController < ActionController::API
  include ActionController::HttpAuthentication::Token::ControllerMethods

  rescue_from ActiveRecord::RecordNotFound, with: :record_not_found

  protected

  def pagination(records)
    {
      pagination: {
        per_page: records.per_page,
        total_pages: records.total_pages,
        total_objects: records.total_entries
      }
    }
  end

  def current_user
    @user
  end

  def current_admin
    @admin
  end

  private

  def authenticate_admin
    authenticate_admin_with_token || render_unauthorized_request
  end

  def authenticate_user
    authenticate_user_with_token || render_unauthorized_request
  end

  def authenticate_admin_with_token
    authenticate_with_http_token do |token, options|
      @admin = User.find_by(api_key: token, admin: true)
    end
  end

  def authenticate_user_with_token
    authenticate_with_http_token do |token, options|
      @user = User.find_by(api_key: token)
    end
  end

  def render_unauthorized_request
    self.headers['WWW-Authenticate'] = 'Token realm="Application"'
    render json: { error: 'Bad credentials' }, status: 401
  end

  def record_not_found
    render json: { error: 'Record not found' }, status: 404
  end
end

APIキーをデータベースに設定する必要があります。Railsコンソールで以下を実行してください。

User.all.each { |u| u.send(:generate_api_key); u.save }

終わったら、システムをセキュリティ保護しましょう。一部のパーツはadminだけがアクセスできるようにする必要があります。ApplicationControllerに以下を追加します。

before_action :authenticate_admin

ここまでできたら、APIをテストします。まずはサーバーを起動しましょう。

$ rails s

それでは、books#showエンドポイントに無効なidでアクセスして、アプリが動作していることを確認します。有効なリクエストを確認するには、HTTPトークンを有効なものに置き換えてください。

$ curl -X GET -H "Authorization: Token
token=ULezVx1CFV5jUsN4TkutL2p/lVtDDDYBqllqf6pS" http://localhost:3000/books/121211

adminアクセスを確認する場合は、adminフラグをtrueに設定します。

idが121211の本がない場合は、以下が返ります。

{“error”:”Record not found.”}

無効なキーでリクエストすると、以下が返ります。

{“error”:”Bad credentials.”}

本を作成するには、以下を実行します。

$ curl -X POST -H "Authorization: Token token=TDBWEkpmV0EzJFI2KRo6F/VL/F15VXYi4r2wtUOo" -d "book[title]=Test&book[author_id]=1" http://localhost:3000/books

Pundit

これで、リクエストにトークンが含まれているかどうかをチェックできるようになりましたが、レコードの更新や作成を行えるユーザー(つまりadmin)かどうかをチェックできていません。これを行うには、Pundit gemを使ってフィルタを少し追加します。

Punditは、本を返却しようとしているユーザーが、実際に本を借りているかどうかをチェックするのに使われます。実のところ、1つのアクションのためだけならPunditは不要です。ここではPunditをカスタマイズしてPunditのスコープにさらに情報を追加できるところをお見せしたいと思います。この手順をご紹介するのは無駄ではないと思います。

Gemファイルに以下を追加してインストールを実行しましょう。

gem 'pundit'
$ bundle install
$ rails g pundit:install

Railsサーバーを再起動してください。

続いて、ApplicationControllerにフィルタとメソッドを追加します。

class ApplicationController < ActionController::API
  include Pundit
  include ActionController::HttpAuthentication::Token::ControllerMethods

  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from Pundit::NotAuthorizedError, with: :not_authorized

  before_action :authenticate_admin

  ...

  def current_user
    @user ||= admin_user
  end

  def admin_user
    return unless @admin && params[:user_id]

    User.find_by(id: params[:user_id])
  end

  def pundit_user
    Contexts::UserContext.new(current_user, current_admin)
  end

  def authenticate
    authenticate_admin_with_token || authenticate_user_with_token || render_unauthorized_request
  end

  ...

  def current_user_presence
    unless current_user
      render json: { error: 'Missing a user' }, status: 422
    end
  end

  ...

  def not_authorized
    render json: { error: 'Unauthorized' }, status: 403
  end
end

最初に、Punditをincludeし、403エラーからrescueするメソッドを追加する必要があります。また、リクエストが一般ユーザーかadminかを調べるauthorizeメソッドも追加します。

もうひとつ重要なのは、current_userをadminからのリクエストとして設定するメソッドです。たとえば、adminがユーザーを1人追加して、貸し出された本の情報を変更しようとしているとします。この場合、一般ユーザーからのリクエストと同様、user_idパラメータを渡してインスタンス変数に`current_userを設定する必要があります。

ここで重要なのが、Punditのカスタムコンテキスト(オーバーライドされたpundit_userメソッド)です。

最初に、app/policies/contexts.rbにUserContextクラスを追加しましょう。

module Contexts
  class UserContext
    attr_reader :user, :admin

    def initialize(user, admin)
      @user = user
      @admin = admin
    end
  end
end

見ての通り、ユーザーやadminを設定する、ごく普通のクラスです。

Punditは、デフォルトでApplicationPolicyクラスを生成します。ここで問題なのは、1つのコンテキストにはレコード1件とユーザー1人しか含まれていないということです。ユーザーとadminの両方を扱えるようにするにはどうしたらよいでしょうか。

この場合、user_contextを追加するのがよさそうです。ここではUserContextクラスのインスタンス全体を保存して、ポリシーのクラスにユーザーとadminの両方を設定します。

class ApplicationPolicy
  attr_reader :user_context, :record, :admin, :user

  def initialize(user_context, record)
    @user_context = user_context
    @record = record
    @admin = user_context.admin
    @user = user_context.user
  end

  def index?
    false
  end

  def show?
    scope.where(id: record.id).exists?
  end

  def create?
    false
  end

  def new?
    create?
  end

  def update?
    false
  end

  def edit?
    update?
  end

  def destroy?
    false
  end

  def scope
    Pundit.policy_scope!(user, record.class)
  end

  class Scope
    attr_reader :user_context, :scope, :user, :admin

    def initialize(user_context, scope)
      @user_context = user_context
      @scope = scope
      @admin = user_context.admin
      @user = user_context.user
    end

    def resolve
      scope
    end
  end
end

メインのポリシーを変更したら、app/policiesの下にbook_copy.rbポリシーを追加しましょう。

class BookCopyPolicy < ApplicationPolicy
  class Scope
    attr_reader :user_context, :scope, :user, :admin

    def initialize(user_context, scope)
      @user_context = user_context
      @admin = user_context.admin
      @user = user_context.user
      @scope = scope
    end

    def resolve
      if admin
        scope.all
      else
        scope.where(user: user)
      end
    end
  end

  def return_book?
    admin || record.user == user
  end
end

return_book?メソッドでは、ユーザーがadminか、本を借りた一般ユーザーかをチェックします。Punditは、全レコードを返す#policy_scopeメソッドも追加します。返されるレコードはすなわち、現在貸出可能な本に基づきます。これは#resolveメソッドで定義されます。これで、policy_scope(BookCopy)を実行すると、adminの場合はすべての本のリストが返され、一般ユーザーの場合は自分が借りている本だけが返されます。なかなかよいとは思いませんか?

#borrowメソッドと#return_bookメソッドがまだないので、BookCopiesControllerに追加しましょう。

module V1
  class BookCopiesController < ApplicationController
    skip_before_action :authenticate_admin, only: [:return_book, :borrow]
    before_action :authenticate, only: [:return_book, :borrow]
    before_action :current_user_presence, only: [:return_book, :borrow]
    before_action :set_book_copy, only: [:show, :destroy, :update, :borrow, :return_book]

    ...

    def borrow
      if @book_copy.borrow(current_user)
        render json: @book_copy, adapter: :json, status: 200
      else
        render json: { error: 'Cannot borrow this book.' }, status: 422
      end
    end

    def return_book
      authorize(@book_copy)

      if @book_copy.return_book(current_user)
        render json: @book_copy, adapter: :json, status: 200
      else
        render json: { error: 'Cannot return this book.' }, status: 422
      end
    end

    ...
  end
end

上のコードでは、以下のようにauthenticate_adminフィルタを更新したことで、#return_bookメソッドと#borrowメソッドを除くすべてのアクションでrequireされるようになりました。

skip_before_action :authenticate_admin, only: [:return_book, :borrow]

また、authenticateフィルタを追加して、現在のユーザー(adminまたは一般ユーザー)を設定しています。

before_action :authenticate, only: [:return_book, :borrow]

また、#current_user_presenceメソッドも追加しています。これは、adminがuser_idパラメータを渡したかどうかと、current_userが設定されているかどうかをチェックします。

今度はBookCopyクラスの更新が必要です。ここにも#return_bookメソッドと#borrowメソッドを追加します。

 class BookCopy < ApplicationRecord
   ...

  def borrow(borrower)
    return false if user.present?

    self.user = borrower
    save
  end

  def return_book(borrower)
    return false unless user.present?

    self.user = nil
    save
  end
end

ルーティングの更新もお忘れなく。

...
    resources :book_copies, only: [:index, :create, :update, :destroy, :show] do
      member do
        put :borrow
        put :return_book
      end
    end
...

最後に

チュートリアルのパートIでは、セキュアなAPIの作成法、HTTPトークンの利用法、Rack-attackやPunditの利用法を解説しました。次回はRSpecでのAPIテストについて解説します。

皆さまがこの記事を気に入って、お役に立てていただければと思います。

今回のソースコードはこちらで参照できます。

私たちのブログがお気に召しましたら、ぜひニュースレターを購読して今後の記事の更新情報をお受け取りください。質問がありましたら、元記事にいつでもご自由にコメントいただけます。

関連記事(Rubygem)

Rails: N+1クエリを「バッチング」で解決するBatchLoader gem(翻訳)

Rein: RailsのActiveRecordでDB制約やデータベースビューを使えるgem(README翻訳)

Rails: render_async gemでレンダリングを高速化(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。