Rails 5+ goodies for APIs

When the Web goes Javascript
and you want nothing to do with it

rails-api

Rails 5+

rails new --api rails-api-demo

Getting started

API controllers inherit from

ActionController::API

instead of

ActionController::Base

class API::ApplicationController < ActionController::API
  # ...
end

Batteries included

  • Rendering (all formats);
  • Redirection;
  • URL generation;
  • Conditional GETs;
  • Strong parameters;
  • Data streaming;
  • Default headers;
  • Callbacks;
  • Exception handling;
  • Instrumentation;
  • more

Bloat free

  • No implicit template rendering;
  • No translations;
  • No asset stuff;
  • No view pipeline;
  • Caching;
  • No cookies;
  • No authentication;
  • yet everything is opt-in

Hello world!

Routing

  • No special care needed;
  • Recommended:
    • Namespaced routes;
    • Default format!
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    get "/hello-world", to: "hello#world"
  end
end

Controller

  • Implicitly responds with
    204 No Content
  • Can render templates;
  • Recommended:
    • Specify valid format.
      (if not using templates)
# /app/controllers/api/hello_controller.rb

class Api::HelloController < Api::ApplicationController
  def world
    render json: { hello: "world" }
  end
end

Authentication

Token auth

You don't have cookies, so

each request is completely

stateless.

 

Authentication proof has

be sent in the request.

JWT

Json Web Token is the standard. It encrypts whatever information the system needs for authentication.

gem install jwt

Get an access token

# config/routes.rb

Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    get "/hello-world", to: "hello#world"

    # NEW:
    post "/authenticate", to: "access_tokens#create"
  end
end

Get an access token

# app/controllers/api/access_tokens_controller.rb

class Api::AccessTokensController < Api::ApplicationController
  def create
    params.require(%i[email password])

    user = User.find_by(email: params["email"])
    unless user&.authenticate(params["password"])
      raise StandardError, "Either email or password is incorrect"
    end

    render json: { jwt: AccessToken.encode(user) }, status: :created
  end
end

Get an access token

# app/models/access_token.rb

class AccessToken
  def self.encode_user(user)
    payload = {
      user_id: user.id,
      exp: 10.minutes.from_now.to_i,
    }

    JWT.encode(payload, jwt_password)
  end

  def self.jwt_password
    Rails.application.secret_key_base
  end
end

Authenticate the user

# /app/controllers/api/hello_controller.rb

class Api::HelloController < Api::ApplicationController
  include ActionController::HttpAuthentication::Token::ControllerMethods

  before_action :authenticate_user!

  # def world...

  private

  def authenticate_user!
    authenticate_or_request_with_http_token do |token|
      @current_user = AccessToken.decode_user!(token)
    end
  end
end

Authenticate the user

# app/models/access_token.rb

class AccessToken
  # def self.encode_user...

  def self.decode_user!(token)
    payload = JWT.decode(token, jwt_password).first

    User.find(payload["user_id"])
  end

  # def self.jwt_password...
end

Error Handling

Status codes

HTTP interfaces use status codes to classify the result of a request.

 

APIs must leverage these

codes, and complement them if possible, to provide context in case of errors.

Exceptions

The Rails API approach is focused on the happy path.

 

When errors occur, exceptions will interrupt the normal flow.

 

Handling these exceptions allows us to customize the responses.

Bad request

# app/controllers/api/application_controller.rb

class API::ApplicationController < ActionController::API
  rescue_from ActionController::ParameterMissing do |error|
    render_error(error&.message, :bad_request)
  end

  private

  def render_error(reason, status)
    render json: { reason: reason }, status: status
  end
end

Conflict

# app/controllers/api/application_controller.rb

class API::ApplicationController < ActionController::API
  class AuthenticationError < StandardError end

  # rescue_from ActionController::ParameterMissing...

  rescue_from AuthenticationError do |error|
    render_error(error&.message, :bad_request)
  end

  # private def render_error...
end

Conflict

# app/controllers/api/access_tokens_controller.rb

class Api::AccessTokensController < Api::ApplicationController
  def create
    # params.require...

    # user = User.find_by...
    # unless user&.authenticate...
      raise AuthenticationError, "Either email or password is incorrect"
    end

    # render json: ...
  end
end

Unauthorized

# app/controllers/api/application_controller.rb

class API::ApplicationController < ActionController::API
  # class AuthenticationError ...

  # rescue_from ActionController::ParameterMissing...
  # rescue_from AuthenticationError...

  rescue_from AccessToken::ValidationError do |error|
    render_error(error&.message, :unauthorized)
  end

  # private def render_error...
end

Unauthorized

# app/models/access_token.rb

class AccessToken
  class ValidationError < StandardError; end

  # def self.encode_user...

  def self.decode_user!(token)
    # payload = JWT.decode(token, jwt_password).first

    # User.find(payload["user_id"])
  rescue ActiveRecord::RecordNotFound, JWT::ExpiredSignature, JWT::DecodeError
    raise ValidationError, "Provided access token is not valid"
  end

  # def self.jwt_password...
end

BAAM!

Made with Slides.com