On an existing Rails application, it's possible to add an API by simply adding controllers that render JSON instead of HTML
It's also possible to use Rails as a pure API (that is, no HTML rendering) with the rails-api
gem:
$ gem install rails-api
$ rails-api new my-api-app
This will create an API-optimized Rails Application.
📝 Note rails-api
has been merged into Rails 5 (which is currently in release candidate version).
Rendering a single object or a collection of objects is pretty straightforward:
# app/controllers/posts_controller.rb
def index
@posts = Post.all
render json: @posts
end
Rails will serialize @posts
in JSON format and render it:
[
{
"id": 1,
"title": "Hello World",
"content": "First post",
"created_at": "2016-06-15T14:01:34.262Z",
"updated_at": "2016-06-15T14:01:34.262Z",
"author_name": "me"
},
{
"id": 3,
"title": "Second post",
"content": "Some interesting stuff",
"created_at": "2016-06-15T14:05:53.532Z",
"updated_at": "2016-06-15T14:05:53.532Z",
"author_name": "me again"
}
]
But we are pretty limited:
- What if we don't want to render all the attributes of an object (like user's admin status for example)?
- What if we want to render the post's comments when rendering a post?
There are several gems we can use to customize the serialization of our objects. We are going to use ActiveModel::Serializers (AMS).
Let's add it to our blog application's Gemfile:
# Gemfile
gem 'active_model_serializers', '~> 0.10.0'
And then run:
$ bundle install
📝 Note whenever we add a new gem to our application's Gemfile
, it's always clever to read the gem's documentation. Even if we know the gem, we might have forgotten some things and it might have evolved since the last time we used it.
Generate a serializer for an existing model:
$ rails generate serializer post
This will generate a new serializer:
# app/serializers/post_serializer.rb
class PostSerializer < ActiveModel::Serializer
attributes :id
end
We need to whitelist the attributes that will be serialized. AMS also supports has_many
, belongs_to
and has_one
:
# app/serializers/post_serializer.rb
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :content, :author_name
has_many :comments
end
Have a look at http://localhost:3000/posts:
- The post's comments are also rendered
- The posts don't include
:created_at
and:updated_at
anymore
By default, AMS looks for a serializer with the same name as the object (or collection of objects) rendered and uses it.
We can also add custom attributes:
# app/serializers/post_serializer.rb
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :content, :author_name, :time_of_creation
has_many :comments
def time_of_creation
object.created_at.strftime("%e %B %Y at %Hh%M")
end
end
It's also possible to pass some custom options to a serializer:
# app/controllers/posts_controller.rb
def show
render json: PostSerializer.new(@post, stars: 4)
end
# app/serializers/post_serializer.rb
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :content, :author_name, :time_of_creation, :review
has_many :comments
def time_of_creation
object.created_at.strftime("%e %B %Y at %Hh%M")
end
def review
"#{instance_options[:stars]} stars"
end
end
Visitors need to register and be logged in to perform certain actions.
To log in, they should provide a unique identifier (email, or username) and a password.
Visitors will trust us with their credentials:
- We don't want to store the password in the database in clear
- We don't want to make our own crypto
- We want to send passwords in http requests as little as possible
If we were building a standard Rails application, we could use the visitor's session and cookies so that he does not need to provide his password on every request.
But as we are building a Rails API that will communicate with other apps (and not with the human directly), we will use Tokens (a long string of random characters).
Here is the flow:
- Provide the User a login page
- User sends his credentials (email, password)
- If correct, we attribute the user a Token and send it back to him
- For all future requests the user uses his token to authenticate
That way, the password is sent only in one request. If there is an attack and a token is compromized, we can simply delete it. it's much less problematic than compromized passwords.
Let's implement this in our blog application with Rails' has_secure_password
's feature. As always, let's read the Documentation
We need a new User model that need to include a password_digest
attribute:
$ rails generate model User name:string email:string password_digest:string
$ rake db:migrate
We need to add bcrypt to our Gemfile:
# Gemfile
gem 'bcrypt', '~> 3.1.7'
$ bundle install
Make the User model authenticatable:
# app/models/user.rb
class User < ActiveRecord::Base
has_secure_password
validates :email, uniqueness: true
end
Now we need to add some new endpoints. For a complete authentication process, we should have a lot of different controllers for the different actions (change password, delete account, etc), but for the sake of simplicity let's just create 2 of them to be able to register, login and logout:
rails generate controller users::registrations new create
rails generate controller users::sessions new create destroy
# config/routes.rb
Rails.application.routes.draw do
namespace :users do
resources :registrations, only: [:new, :create]
resources :sessions, only: [:new, :create, :destroy]
end
# [...]
end
The User will provide a password and a password_confirmation. Both must match to be a valid user and save:
# app/controllers/users/registrations_controller.rb
class Users::RegistrationsController < ApplicationController
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
# We will need to log the user in here (create session)
else
render :new
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
end
Note that we don't save the password
, nor the password_confirmation
in the database, only a password_digest
, which is the encrypted password.
Let's implement the authentication process in login:
# app/controllers/users/sessions_controller.rb
class Users::SessionsController < ApplicationController
def new
@user = User.new
end
def create
@user = User.find_by(email: params[:user][:email])
if @user && @user.authenticate(params[:user][:password])
# Generate Token
else
@user ||= User.new(email: params[:user][:email])
render :new
end
end
def destroy
# Delete Token
end
end
We need to add a token
attribute to our User, that need to be unique:
$ rails generate migration AddTokenToUsers token:string
$ rake db:migrate
Let's add the validation on User and an instance method to generate a token:
# app/models/user.rb
class User < ActiveRecord::Base
has_secure_password
validates :email, uniqueness: true
validates :token, uniqueness: true
def generate_token!
loop do
self.token = SecureRandom.hex
break if valid?
end
save
self # return self for chaining
end
end
We can now complete our controllers:
# app/controllers/users/sessions_controller.rb
class Users::SessionsController < ApplicationController
def new
@user = User.new
end
def create
@user = User.find_by(email: params[:user][:email])
if @user && @user.authenticate(params[:user][:password])
@user.generate_token!
render json: @user # token must be passed here
else
@user ||= User.new(email: params[:user][:email])
render :new
end
end
def destroy
@user = User.find(:id)
@user.update(token: nil)
redirect_to root_path
end
end
# app/controllers/users/registrations_controller.rb
class Users::RegistrationsController < ApplicationController
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
# directly log the user in after registration
@user.generate_token!
render json: @user # token must be passed here
else
render :new
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
end
For all the future requests, a user will need to pass his token as a parameter in his requests to authenticate. Let's implement this token authentication method.
A user can send is token either as a parameter or in the header as Authorization: Token token=user_token_here
:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# [...]
# By default, all method need authentication
# Need to use skip_before_action for methods who won't need it. At least
# login (Sessions#new and Sessions#create) must not require authentication.
before_action :authenticate_by_token
private
def authenticate_by_token
authenticate_with_http_token do |token, options|
@current_user = User.find_by(token: token)
end
@current_user ||= User.find_by(token: params[:token])
unless @current_user
self.headers['WWW-Authenticate'] = 'Token realm="Application"'
render json: {error: "Bad credentials" }, status: :unauthorized
end
end
end
Test it with token as parameter. Try with a correct and incorrect user token after token=
:
http://localhost:3000/posts?token=069078747893eb4ad65cbee37a8cb6cc
Test it with token in the header. Try with a correct and incorrect user token after X-User-Token
:
$ curl -i -H "Authorization: Token token=069078747893eb4ad65cbee37a8cb6cc" -X GET http://localhost:3000/posts
Devise is a gem that handles authentication for you. Basically, it handles everything we just wrote (registration, login, logout) plus everything else a good authentication system needs: password forgotten, change password, cancel account, but also things like "log in with Facebook" (and other services).
Devise is thought for standard Rails applications (that render HTML), and if you build such an app with a standard user authentication flow, it will ease your life a lot. It's massively used and maintained, which makes it very secure to use.
It's also possible to use Devise with Rails APIs, but the Token part still needs to be implemented separately (either by hand or with another gem), and some conventions must be overriden.