Nopio image

Rails API with Active Model Serializers – Part 1

   Back to list

In this tutorial, we’ll build an API, which helps to organize the workflow of a library. It allows us to borrow a book, give it back, or to create a user, a book or an author. What’s more, the administration part will be available only for admins – CRUD of books, authors, and users. Authentication will be handled via HTTP Tokens.

To build this, I’m using Rails API 5 with ActiveModelSerializers. In the next part of this series, I’ll show how to fully test API, which we’re gonna build.

Let’s start with creating a fresh Rails API application:

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

Please clean up our Gemfile and add three gems – active_model_serializers, faker and rack-cors:

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 is a library which helps to build an object, which is returned to create a JSON object. In this case, we don’t need to use any views, we just return a serializer which represents an object. It’s fully customizable and reusable – which is great!

RackCors is Rack middleware which allows the handling of Cross-Origin Resource Sharing – basically, it makes cross-origin AJAX requests possible.

Faker is a library which creates fake data – it’s really powerful and awesome!

Create models

Well, we need to design our database schema. Let’s keep it simple – we need 4 tables. Users, books, authors and book copies. Users are basically library customers who can borrow books.

Authors are books’ authors and books are books. Book copies are copies which can be borrowed by users. We won’t keep a history of each borrow – as I told before, let’s keep it simple.

Ok, let’s generate them:

$ 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

I also added indexes; please check the following migrations and add null:false to needed fields.

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

After you’re done with these files, create a database and run all migrations:

$ rake db:create
$ rake db:migrate

Our database schema is ready! Now we can focus on some methods which will be used in models.

Update models

Let’s update generated models – add all relationships and validations. We also validate the present of each needed field on the SQL level.

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

We should also remember these new routes. Please update our routes as below:

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 Versioning

One of the most important things, when you build new API, is versioning. You should remember to add a namespace (v1, v2) to your API. Why? The next version of your API will probably be different.

The problematic part is compatibility. Some of your customers will probably use an old version of your product. In this case, you can keep the old product under a v1 namespace while building a new one under a v2 namespace. For example:

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

In this case, all versions of the application will be supported – your customers will be happy!

Serializers

As I told before, we’ll use serializers to build JSONs. What’s cool about them is that they’re objects – they can be used in every part of the application. Views are not needed! What’s more, you can include or exclude any field you want!

Let’s create our first serializer:

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

In the attributes, we can define which fields will be included in an object.

Now, let’s create a 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

As you can see, we also define attributes; but what’s new is the overidden author method. In some cases, we’ll need a serialized author object and in some cases not. We can specify in the options which object we need (second parameter – options = {}). Why do we need it?

Check this case – we’ll create a book object. In this object we also include an author that includes books. Each book is serialized so it also will return an author. We would get an infinite loop – that’s why we need to specify if a serialized object is needed. What’s more, we can create a serializer, for each action (index, update etc.)

Please also add the author and book copy serializers:

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

Controllers

Now we’re missing controllers. They’ll also be versioned on our routes. These 4 controllers will be very similar – we need to add a basic CRUD for each table. Let’s do it.

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

As you can see, these return a basic object, for example: render Author.find(1). How does the application know that we want to render a serializer? By adding an adapter: :json options. From now on, by default the serializers will be used to render JSONs. You can read more about it here, in the official documentation.

Our application needs some fake data, let’s add it by filling our seeds files and using the 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

Now let’s add some data to our database:

$ rake db:seed

Rack-Cors

I mentioned previously that we’ll use Rack-CORS – an awesome tool that helps make cross-origin AJAX calls. Well, adding it to the Gemfile is not enough – we need to set up it in the application.rb file, too.

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

By adding these lines, we allow it to perform any request from any remote in the specified HTTP methods. It’s fully customizable – you can find more info here.

Rack-Attack

Another useful gem we can use is Rack-Attack. It can filter/throttle each request – block it, add it to a blacklist or track it. It has a lot of features; let’s check the following example.

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

This code allows requests from the localhost.

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

This code blocks requests at a root_path where the user agent is SomeScraper.

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

This code blocks requests if they’re from specified IPs.

Ok, let’s add it to our Gemfile:

gem 'rack-attack'

Then run bundle to install it:

$ bundle install

Now we need to tell our application that we want to use rack-attack. Add it to the application.rb:

config.middleware.use Rack::Attack

To add a filtering functionality, we need to add a new initializer to the config/initializers directory. Let’s call it 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 Errorn"]]
  end
end

What did we add here? Basically, we’re now allow to make 5 requests per IP address per 1 second. If someone hits one of our endpoints more that 5 times in 1 second, we return 503 HTTP status as a response with the Server Error message.

Tokens – API keys

Like I mentioned at the beginning, we secure our API with HTTP Tokens. Each user has a unique token. Through this token we find a user in our database and set it as current_user.

Normal users are able only to borrow or return a book. What’s more, if a requested book is not borrowed by us, we can’t return it. Also, we can’t borrow an already borrowed book. Let’s add a new field to the users table:

$ rails g migration add_api_key_to_users api_key:index

Also please add an admin field to the users table:

$ rails g migration add_admin_to_users admin:boolean

Please add a custom value to the last migration:

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

And run migrations:

$ rake db:migrate

We’ve just added an API token to users but we need to generate it somehow. We can do it before a user is created and inserted into a database. Let’s add the generate_api_key method to the user class:

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

The way things are now, we won’t secure our API. We don’t check to see if a request contains an API key. We need to change it. To use the authenticate_with_http_token method we need to include ActionController::HttpAuthentication::Token::ControllerMethods module.

Once we’re done with it, let’s write some code. We need to authorize each request – if a user/admin is found with a requested token, we set it in an instance variable for further purposes. If a token is not provided, we should return a JSON with 401 HTTP status code.

Furthermore, it’s best practices to rescue from a not found record – for example, if someone requests a non-existent book. We should handle it and return a valid HTTP status code, rather than throwing an application error. Please add the following code to the 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

We need to add API keys to the database. Please run it in rails console:

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

Ok, now let’s secure our system, some parts should only be only accessible for admins. To the ApplicationController, add the following line:

before_action :authenticate_admin

When you’re done with it, we can test our API. But first, please run a server:

$ rails s

Now let’s hit the books#show endpoint with an invalid id, to see if our application works. Remember to add a valid HTTP Token to your request:

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

Remember that an admin should has an admin flag set to true.

If you don’t have a book with 121211 id, your terminal should return:

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

If you send a request with an invalid key, it should return:

{“error”:”Bad credentials.”}

To create a book, run:

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

Pundit

For now, we can check if someone’s request includes a Token, but we can’t check if someone can update/create a record (is an admin in fact). To do it we will add some filters and Pundit.

Pundit will be used for checking if the person returning a book is the person who borrowed it. To be honest, using Pundit for only one action is not necessary. But I want to show how to customize it and add more info to Pundit’s scope. I think it could be useful for you.

Let’s add it to our Gemfile and install:

gem 'pundit'

$ bundle install
$ rails g pundit:install

Please restart rails server.

Let’s add more filters and method to the 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

First of all, we need to include Pundit and add a method which rescues from a 403 error. Also, we will add the authorize method, which checks if a request is from a user or an admin.

Another important piece is a method which sets current_user as an admin’s request. For example, if an admin wants to modify info about a borrowed book by adding a user. In this case, we need to pass the user_id parameter and set current_user in an instance variable – like in a request from an ordinary user.

And here’s one important thing – custom Pundit’s context (overridden pundit_user method).

Let’s first add the UserContext class (to app/policies/contexts):

module Contexts
  class UserContext
    attr_reader :user, :admin

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

As you can see it’s an ordinary, plain ruby class which sets a user and an admin.

Pundit by default generates the ApplicationPolicy class. But the main problem is that by default, it only includes a record and a user in a context. How can we deal with a situation where we want to keep an admin and a user?

Adding a user_context would be a good idea! In this case, we store the whole instance of the UserContext class and we can also set a user and an admin in our policy classes:

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

When we’re done with the main policy, let’s add a book copy policy (under app/policies).

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

In the return_book? method we check if a user is an admin or a user who borrowed a book. What’s more, Pundit adds a method called policy_scope that returns all records, which should be returned by current ability. It’s defined in the resolve method. So if you run policy_scope(BookCopy), it returns all books if you’re an admin, or only borrowed books if you’re a user. Pretty cool, yeah?

We’re missing the borrow and the return_book methods. Let’s add them to the 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

As you can see, we updated the authenticate_admin filter there. We require it in all actions except in return_book and borrow methods.

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

Also, we added the authenticate filter, which sets a current user – also for an admin and a user.

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

Also, there is something new, the current_user_presence method. It checks if an admin passed a user_id parameter in a request and if a current_user is set.

Now, we need to update the BookCopy class – add the borrow and the return_book methods:

 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

And one more thing, routes! We need to update our router:

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

Conclusion

In this tutorial I covered how to create a secure API, using HTTP tokens, Rack-attack and Pundit. In the next part of the series, I’ll show how our API should be tested using RSpec.

I hope that you liked it and that this part is be useful for you!

The source code can be found here.

If you like our blog, please subscribe to the newsletter to get notified about upcoming articles! If you have any questions, feel free to leave a comment below.

Send this to a friend