As you've probably guessed by the title of my article, I still consider Ruby on Rails as a relevant technology that offers a lot of value, especially when combined with ReactJS as it's frontend counterpart. Here's how I approach the topic.
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
endclass 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
endclass 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
endclass 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
endAfter 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 }
endclass 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
endAPI 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
endAs 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
endclass 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
endmodule 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
endmodule 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
endmodule 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
endAs 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)
)
endNow 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
endBy 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
endThis code allows requests from the localhost.
Rack::Attack.blocklist('block bad UA logins') do |req|
req.path == '/' && req.user_agent == 'SomeScraper'
endThis 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
endThis 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
endWhat 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
endAnd 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
...
endThe 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
endWe 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
endFirst 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
endAs 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
endWhen 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
endIn 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
endAs 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
endAnd 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.




