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 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.