概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Rails API with Active Model Serializers – Part 1
- 著者: Piotr Jaworski
- サイト: https://www.nopio.com/ -- RailsやWordPressの請負開発を行うポーランドの会社です。
- Part 1チュートリアルリポジトリ: nopio/rails_api_tutorial_part1
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に名前空間v1
とv2
を追加するべきです。理由は、次バージョンの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
などのアクションごとにシリアライザを作成することもできます。
同じ要領で、author
とbook_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テストについて解説します。
皆さまがこの記事を気に入って、お役に立てていただければと思います。
今回のソースコードはこちらで参照できます。
私たちのブログがお気に召しましたら、ぜひニュースレターを購読して今後の記事の更新情報をお受け取りください。質問がありましたら、元記事にいつでもご自由にコメントいただけます。