Skip to content

Latest commit

 

History

History
421 lines (309 loc) · 11.2 KB

BE102b.md

File metadata and controls

421 lines (309 loc) · 11.2 KB

Rails Advanced

Building an API

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 JSON

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"
  }
]

Serializing JSON

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

Authentication

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.

⚠️ Important security matters

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:

  1. Provide the User a login page
  2. User sends his credentials (email, password)
  3. If correct, we attribute the user a Token and send it back to him
  4. 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.

Rails' has_secure_password

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

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.