This article describes how to build API only Rails application using new Rails 5 --api option. Further, I'll explain how to follow JSON:API specification in your code and how to test your API's. Also, I'll cover token authentication using some of the new Rails 5 features. All code from this article is available on GitHub.

Rails 5 API

Rails 5 have new --api flag that you can use when creating new application. That will create lightweight Rails application suitable for serving only API data. This was originally started as separated gem called rails-api but now is part of Rails 5.

JSON:API

JSON:API is specification that defines how server had to deliver JSON response, how client should format request, how to implement filtering, sorting, pagination, handle errors, describe relationship between data, HTTP status codes that server need to return and other things. Specification is originally extracted from ember-data library, but you can find JSON:API libraries for different languages and frameworks.

Application

In order to show most of the features described in this article, I'm going to build simple "blog" style application where all registered users can post articles. To keep everything simple, this is not going to be full application. I'll implement users, authentication and articles, but not commenting on articles. Also, I'm not going to implement any "blog" features that have little or nothing to do with the topic of this article, for instance password resetting, user authorization based on roles, etc…

Installing Rails 5 and generating API only application

Install Rails 5:

gem install rails

After that, you can generate new API only application using:

rails new rails5_json_api_demo --api

Please, check other options that rails provides with rails -h. For example you can use -C to skip generation of ActionCable (web sockets) files, -M to skip ActionMailer, etc…

Enabling CORS

CORS is a mechanism that allows restricted resources on a web page to be requested from another domain outside the domain from which the resource originated. Application generated with --api will generate CORS initializer, but rack-cors gem is still disabled in generated Gemfile. Enable it there and then run bundle to install it.

After that you need to enable CORS in config/initializers/cors.rb file and edit it for your needs. You can find some examples on rack-cors GitHub repo. For this example I'll use defaults from generated file, with exception of origins. I'll remove example.com and place * instead. In this case I don't care where request came from.

Alternative for CORS is JSON-P, however I'll not cover it here. CORS is endorsed by Rails and W3C recommendation and I'll like to stick with Rails defaults. Also, JSON-P supports only GET request method and that is not enough for this application.

Serialization

Even Rails provides JSON serialization, I'll use active_model_serializers gem. "JsonApi Adapter" provided by this gem will save me a lot of time.

Add following to Gemfile:

gem 'active_model_serializers', '~> 0.10.0'

Run bundle to install this gem. After that create config/initializers/active_model_serializers.rb file and write following in there:

ActiveModel::Serializer.config.adapter = :json_api

We will also use Rails URL helpers for link generation and have to specify default URL options in environment files. Here is what I added in development.rb and test.rb files in config/environments/ directory:

Rails.application.routes.default_url_options = {
  host: 'localhost',
  port: 3000
}

Creating users

One of important aspects when creating users is authentication. In demo application only authenticated users can create or edit posts. I'll use token authentication with has_secure_token method in Rails 5. As alternative, you can use Devise gem with build in token authentication or use it with JWT. Personally, I usually use Devise, but in this case I need something simple and want to explore this new has_secure_token method.

Let start with a migration. Run rails g migration CreateUsers and edit new file like this:

class CreateUsers < ActiveRecord::Migration[5.0]
  def change
    create_table :users do |t|
      t.timestamps
      t.string     :full_name
      t.string     :password_digest
      t.string     :token
      t.text       :description
    end
    add_index :users, :token, unique: true
  end
end

One of changes in Rails 5 is that migrations are now versioned. Other change is that you don't run migrations with rake any more. Just replace rake with rails when trying to run any task. To create this table in database run rails db:migrate.

Create model app/models/user.rb:

class User < ApplicationRecord
  has_secure_token
  has_secure_password

  validates :full_name, presence: true
end

Add new route:

Rails.application.routes.draw do
  resources :users
end

When adding resources into routes of Rails 5 application created with --api, routes for new and edit are not created. Exactly what we want.

Next step is to create serializer for our model. Create file app/serializers/user_serializer.rb and add following:

class UserSerializer < ActiveModel::Serializer
  attributes :id, :full_name, :description, :created_at
end

This basically say's that when requesting user data, we'll get only id, full_name, created_at and description.

Last step is creating users controller. Create file app/controllers/users_controller.rb and add following in that file:

class UsersController < ApplicationController
  def index
    users = User.all
    render json: users
  end
end

As you see, there is nothing special in this controller. ActiveModel::Serializers integrates fully into Rails controllers.

Add new user in Rails console (rails c):

User.create(full_name: "Sasa J", password: "Test")

Start server with rails s and check http://localhost:3000/users. You'll see something like this (indentation added by me):

{"data":[
  {"id":"1",
    "type":"users",
    "attributes":{
      "full-name":"Sasa J",
      "description":null,
      "created-at":"2016-06-16T09:55:37.856Z"
    }
  }
]}

This is exactly what JSON:API output needs to be. Not only that active_model_serializers gem did wrap everything into data, add type, made separation between id and the rest of attributes, but also replaced underscores with dashes, as JSON:API requires.

Media type

If you open development tools in your browser and check "Content-Type" response from server, you'll notice application/json. JSON:API requires that we use application/vnd.api+json. Lets change it! Open config/initializers/mime_types.rb and add following line at the end:

Mime::Type.register "application/vnd.api+json", :json

That will solve this problem. If you are using Firefox you may get dialog that prompts you to download file users. You can fix this by adding this media type in Firefox or simply use browser that understands application/vnd.api+json, for example Chrome. Even better, you can install browser extension that allows you to make API requests (Postman for Chrome or RESTED for Firefox).

Adding basis for posts

Let's create migration for posts with rails g migration CreatePosts and edit newly created file:

class CreatePosts < ActiveRecord::Migration[5.0]
  def change
    create_table :posts do |t|
      t.timestamps
      t.string     :title
      t.text       :content
      t.integer    :user_id
      t.string     :category
      t.integer    :rating
    end
  end
end

Run this migration with rails db:migrate and then create model for posts app/models/post.rb:

class Post < ApplicationRecord
  belongs_to :user
end

Don't forget to add has_many :posts, dependent: :destroy to user.rb model!

Add resources :posts in config/routes.rb file.

Next step is to create serializer for posts. Create file app/serializers/post_serializer.rb with following code:

class PostSerializer < ActiveModel::Serializer
  attributes :id, :title, :content, :category,
             :rating, :created_at, :updated_at
  belongs_to :user
end

As you see, ActiveModel::Serializer provides exactly the same way to describe relationships as ActiveRecord. Don't forget to add has_many :posts in UserSerializer!

If you create associated post record in database and check /users/ URL again, you'll see something like this:

{"data":[{
  "id":"1",
  "type":"users",
    "attributes":{
      "full-name":"Sasa J",
      "description":null,
      "created-at":"2016-06-16T09:55:37.856Z"
    },
    "relationships":{
      "posts":{
        "data":[{
          "id":"1",
          "type":"posts"
        }]
      }
    }
  }]
}

As you can see, relationships are part of JSON:API and ActiveModel::Serializer does great job here.

JSON:API allows us to provide links to resources in separated links JSON object. Client can use those to fetch more data. Add links into UserSerializer:

class UserSerializer < ActiveModel::Serializer
  attributes :id, :full_name, :description, :created_at
  has_many :posts
  link(:self) { user_url(object) }
end

If you check /users/ URL in browser, you'll notice links block.

Fixtures

In most cases you write tests first, but here I decided to make everything working a bit before creating full implementation with tests. It's also better to explain a few concepts of Rails 5 and JSON:API before going into TDD.

I'm going to use Minitest in this case. It's included in Rails for some time, fast and I like it more than RSpec. Also Rails 5 brings some Minitest goodies that I'll like to try.

First, lets create fixtures:

# users.yml
<% 6.times do |i| %>
user_<%= i %>:
  full_name: <%= "User Nr#{i}" %>
  password_digest: <%= BCrypt::Password.create('password') %>
  token: <%= SecureRandom.base58(24) %>
<% end %>
# posts.yml
<% 6.times do |i| %>
<% 25.times do |n| %>
article_<%= i %>_<%= n %>:
  title: <%= "Example title #{i}/#{n}" %>
  content: <%= "Example content #{i}/#{n}" %>
  user: <%= "user_#{i}" %>
  rating: <%= 1 + i + rand(3) %>
  category: <%= i == 0 ? 'First' : 'Example' %>
<% end %>
<% end %>

This will create 6 users and 150 posts, 25 for each user. I added some variations in rating and category fields in order to test filtering and sorting.

Adding tests for index and show action on users controller

Ok, lets write some controller tests in test/controllers/users_controller_test.rb:

require 'test_helper'
require 'json'

class UsersControllerTest < ActionController::TestCase

  test "Should get valid list of users" do
    get :index
    assert_response :success
    assert_equal response.content_type, 'application/vnd.api+json'
    jdata = JSON.parse response.body
    assert_equal 6, jdata['data'].length
    assert_equal jdata['data'][0]['type'], 'users'
  end

  test "Should get valid user data" do
    user = users('user_1')
    get :show, params: { id: user.id }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal user.id.to_s, jdata['data']['id']
    assert_equal user.full_name, jdata['data']['attributes']['full-name']
    assert_equal user_url(user, { host: "localhost", port: 3000 }),
                 jdata['data']['links']['self']
  end

  test "Should get JSON:API error block when requesting user data with invalid ID" do
    get :show, params: { id: "z" }
    assert_response 404
    jdata = JSON.parse response.body
    assert_equal "Wrong ID provided", jdata['errors'][0]['detail']
    assert_equal '/data/attributes/id', jdata['errors'][0]['source']['pointer']
  end
end

When writing tests, most important part is to know what to test.

  • Header Content-Type in response is set globally for all responses and that means that I need to test that just once in complete suite.
  • When getting users list I only care that there are 6 users and that any (in this case first) of those 6 user data blocks have type set to users. I don't need to test data for any of those users. Same serializer that creates data for those 6 users is also used when requesting single user.
  • When getting single user data, I'll check if data is correct. If id and full_name are correct, the rest is correct too.
  • Link is something that I have added separately and that is something that needs to become tested. For some reason providing default_url_options in config/environments/test.rb works for HTTP requests in tests but not in URL generation. That is the reason that I had to specify host and port directly in the test.
  • I also had to create test for JSON:API errors. In this case I'm sending error about wrong ID and pointing to id attribute.

This is how users controller end up:

class UsersController < ApplicationController
  before_action :set_user, only: [:show, :update, :destroy]

  def index
    users = User.all
    render json: users
  end

  def show
    render json: @user
  end

  private
  def set_user
    begin
      @user = User.find params[:id]
    rescue ActiveRecord::RecordNotFound
      user = User.new
      user.errors.add(:id, "Wrong ID provided")
      render_error(user, 404) and return
    end
  end
end

I placed error rendering in ApplicationController:

class ApplicationController < ActionController::API

  private
  def render_error(resource, status)
    render json: resource, status: status, adapter: :json_api,
           serializer: ActiveModel::Serializer::ErrorSerializer
  end
end

Errors are described in more details on active_model_serializer JSON:API errors document and errors part of JSON:API spec.

Creating new user

  • When creating or updating users we need authentication. As said before, we'll use token authentication.
  • When sending JSON data to server we need to specify Content-Type header. When using HTTP methods that do not send any JSON data (GET and DELETE) we don't need to set Content-Type header.
  • type must be present and correct in JSON data.

This is how new set of tests looks like:

  test "Creating new user without sending correct content-type should result in error" do
    post :create, params: {}
    assert_response 406
  end

  test "Creating new user without sending X-Api-Key should result in error" do
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    post :create, params: {}
    assert_response 403
  end

  test "Creating new user with incorrect X-Api-Key should result in error" do
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    @request.headers["X-Api-Key"] = '0000'
    post :create, params: {}
    assert_response 403
  end

  test "Creating new user with invalid type in JSON data should result in error" do
    user = users('user_1')
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    @request.headers["X-Api-Key"] = user.token
    post :create, params: { data: { type: 'posts' }}
    assert_response 409
  end

  test "Creating new user with invalid data should result in error" do
    user = users('user_1')
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    @request.headers["X-Api-Key"] = user.token
    post :create, params: {
                    data: {
                      type: 'users',
                      attributes: {
                        full_name: nil,
                        password: nil,
                        password_confirmation: nil }}}
    assert_response 422
    jdata = JSON.parse response.body
    pointers = jdata['errors'].collect { |e|
      e['source']['pointer'].split('/').last
    }.sort
    assert_equal ['full-name','password'], pointers
  end

  test "Creating new user with valid data should create new user" do
    user = users('user_1')
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    @request.headers["X-Api-Key"] = user.token
    post :create, params: {
                    data: {
                      type: 'users',
                      attributes: {
                        full_name: 'User Number7',
                        password: 'password',
                        password_confirmation: 'password' }}}
    assert_response 201
    jdata = JSON.parse response.body
    assert_equal 'User Number7',
                 jdata['data']['attributes']['full-name']
  end

New additions to UsersController:

  before_action :validate_user, only: [:create, :update, :destroy]
  before_action :validate_type, only: [:create, :update]

  def create
    user = User.new(user_params)
    if user.save
      render json: user, status: :created
    else
      render_error(user, :unprocessable_entity)
    end
  end

  private

  def user_params
    ActiveModelSerializers::Deserialization.jsonapi_parse(params)
  end
end

And this is how ApplicationController looks now:

class ApplicationController < ActionController::API
  before_action :check_header

  private
  def check_header
    if ['POST','PUT','PATCH'].include? request.method
      if request.content_type != "application/vnd.api+json"
        head 406 and return
      end
    end
  end

  def validate_type
    if params['data'] && params['data']['type']
      if params['data']['type'] == params[:controller]
        return true
      end
    end
    head 409 and return
  end

  def validate_user
    token = request.headers["X-Api-Key"]
    head 403 and return unless token
    user = User.find_by token: token
    head 403 and return unless user
  end

  def render_error(resource, status)
    render json: resource, status: status, adapter: :json_api, serializer: ActiveModel::Serializer::ErrorSerializer
  end
end

Updating user

Most of the rules about sending data when updating an user are same as when creating one. There is no point for writing extra tests for headers checks and validity of the data because before_action used for create are the same as for update. Validations are also the same. I'll just write test for successful update action:

  test "Updating an existing user with valid data should update that user" do
    user = users('user_1')
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    @request.headers["X-Api-Key"] = user.token
    patch :update, params: {
                     id: user.id,
                     data: {
                       id: user.id,
                       type: 'users',
                       attributes: { full_name: 'User Number1a' }}}
    assert_response 200
    jdata = JSON.parse response.body
    assert_equal 'User Number1a', jdata['data']['attributes']['full-name']
  end

And update action in UsersController:

  def update
    if @user.update_attributes(user_params)
      render json: @user, status: :ok
    else
      render_error(@user, :unprocessable_entity)
    end
  end

Deleting user

This one is simple, just delete record and return only headers with status 204 "No Content". Test is here:

  test "Should delete user" do
    user = users('user_1')
    ucount = User.count - 1
    @request.headers["X-Api-Key"] = user.token
    delete :destroy, params: { id: users('user_5').id }
    assert_response 204
    assert_equal ucount, User.count
  end

And destroy action in UsersController:

  def destroy
    @user.destroy
    head 204
  end

Posts

Ok, after implementing users it's time to do posts. I'm going to implement only index method in this article, the rest of the methods are similar to those implemented in UsersControllers. You can find them in this demo application on GitHub. The reason that I'm doing index method in this article is because that is the best place to implement sorting, filtering and ordering following JSON:API spec.

Lets start with test in test/controllers/posts_controller_test.rb:

require 'test_helper'
require 'json'

class PostsControllerTest < ActionController::TestCase

  test "Should get valid list of posts" do
    get :index
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal Post.count, jdata['data'].length
    assert_equal jdata['data'][0]['type'], 'posts'
  end
end

And app/controllers/posts_controller.rb:

class PostsController < ApplicationController

  def index
    posts = Post.all
    render json: posts
  end
end

Pagination

active_model_serializers gem provides pagination for us by using kaminari or will_paginate gems. I'll use will_paginate.

Add gem "will_paginate" in your Gemfile and run bundle.

Modify Post model:

class Post < ApplicationRecord
  belongs_to :user
  self.per_page = 50
end

After that add pagination in PostsController:

  def index
    posts = Post.page(params[:page] ? params[:page][:number] : 1)
    render json: posts
  end

This will cause test to fail:

Failure:
PostsControllerTest#test_Should_get_valid_list_of_posts [<filename-removed>:10]:
Expected: 150
  Actual: 50

We can fix this by changing our test:

  test "Should get valid list of posts" do
    get :index, params: { page: { number: 2 } }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal Post.per_page, jdata['data'].length
    assert_equal jdata['data'][0]['type'], 'posts'
  end

JSON:API also suggests that you may add links for other pages if you use pagination. In that case you are required to use names first, prev, next and last for those links. That is also generated for us, so lets test it:

  test "Should get valid list of posts" do
    get :index, params: { page: { number: 2 } }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal Post.per_page, jdata['data'].length
    assert_equal jdata['data'][0]['type'], 'posts'
    l = jdata['links']
    assert_equal l['first'], l['prev']
    assert_equal l['last'], l['next']
  end

In this case I used page parameter and JSON:API doesn't have any rules what to use for the name of this parameter. However, those 4 generated links will use page['number'] and page['size'], so we don't have much choice there…

Also, we can add extra meta object in JSON with more useful pagination data, like total number of pages or records:

class PostsController < ApplicationController
  def index
    posts = Post.page(params[:page] ? params[:page][:number] : 1)
    render json: posts, meta: pagination_meta(posts)
  end

  private
  def pagination_meta(object)
    {
      current_page: object.current_page,
      next_page: object.next_page,
      prev_page: object.previous_page,
      total_pages: object.total_pages,
      total_count: object.total_entries
    }
  end
end

And we can add extra assert in the test:

assert_equal Post.count, jdata['meta']['total-count']

More meta key goodies

You can add even more information to your JSON messages using meta key. For example API version, last API update, copyright information, etc… For example, you can add following private method in ApplicationController:

  def default_meta
    {
      licence: 'CC-0',
      authors: ['Saša']
    }
  end

Everywhere when you use render json: object you can change it to render json: object, meta: default_meta. When using pagination you can merge those hashes: pagination_meta(posts).merge(default_meta).

Sometimes you want to include related resources in JSON reply, that may be important. Por example, when requesting list of posts, we may need author's name to display it. Instead of making new request, we can include that data in JSON reply. We can do it easily when calling render method.

render json: posts, meta: pagination_meta(posts), include: ['user']

This will add included object in JSON, after data object.

Sorting

When implementing sorting with JSON:API we must use sort parameter. We can specify multiple fields for sorting, comma separated. Sorting is always in ascending order, unless you prefix sorting field with - character. For example /posts?sort=-rating will return Post records based on rating, highest first.

Lets write sorting test:

  test "Should get properly sorted list" do
    post = Post.order('rating DESC').first
    get :index, params: { sort: '-rating' }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal post.title, jdata['data'][0]['attributes']['title']
  end

And implement it in controller:

  def index
    posts = Post.all
    if params['sort']
      f = params['sort'].split(',').first
      field = f[0] == '-' ? f[1..-1] : f
      order = f[0] == '-' ? 'DESC' : 'ASC'
      if Post.new.has_attribute?(field)
        posts = posts.order("#{field} #{order}")
      end
    end
    posts = posts.page(params[:page] ? params[:page][:number] : 1)
    render json: posts, meta: pagination_meta(posts), include: ['user']
  end

Filtering

JSON:API suggests that we use filter parameter for filtering results, however doesn't care about filtering strategy used on server. We can implement it like we want. Lets build filtering based on category field.

New test (25 records with category First created in fixtures):

  test "Should get filtered list" do
    get :index, params: { filter: 'First' }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal Post.where(category: 'First').count, jdata['data'].length
  end

And new index method in PostsController:

  def index
    posts = Post.all
    if params[:filter]
      posts = posts.where(["category = ?", params[:filter]])
    end
    if params['sort']
      f = params['sort'].split(',').first
      field = f[0] == '-' ? f[1..-1] : f
      order = f[0] == '-' ? 'DESC' : 'ASC'
      if Post.new.has_attribute?(field)
        posts = posts.order("#{field} #{order}")
      end
    end
    posts = posts.page(params[:page] ? params[:page][:number] : 1)
    render json: posts, meta: pagination_meta(posts), include: ['user']
  end

I kept this simple by implementing only filtering on one field.

Login and logout

In all examples when user needs to update or create resource we use token from database as X-Api-Key. But how Client/SPA gets that token in the first place? Lets implement login and logout methods.

test/controllers/sessions_routes_test.rb:

require 'test_helper'

class SessionsRoutesTest < ActionController::TestCase
  test "should route to create session" do
    assert_routing({ method: 'post', path: '/sessions' },
                   { controller: "sessions", action: "create" })
  end
  test "should route to delete session" do
    assert_routing({ method: 'delete', path: '/sessions/something'},
                   { controller: "sessions", action: "destroy", id: "something" })
  end
end

In config/routes.rb:

  post    'sessions'     => 'sessions#create'
  delete  'sessions/:id' => 'sessions#destroy'

app/serializers/session_serializer.rb:

class SessionSerializer < ActiveModel::Serializer
  attributes :id, :full_name, :token
end

test/controllers/sessions_controller_test.rb:

require 'test_helper'
require 'json'

class SessionsControllerTest < ActionController::TestCase

  test "Creating new session with valid data should create new session" do
    user = users('user_0')
    @request.headers["Content-Type"] = 'application/vnd.api+json'
    post :create, params: {
                    data: {
                      type: 'sessions',
                      attributes: {
                        full_name: user.full_name,
                        password: 'password' }}}
    assert_response 201
    jdata = JSON.parse response.body
    refute_equal user.token, jdata['data']['attributes']['token']
  end

  test "Should delete session" do
    user = users('user_0')
    delete :destroy, params: { id: user.token }
    assert_response 204
  end
end

And app/controllers/sessions_controller.rb:

class SessionsController < ApplicationController
  def create
    data = ActiveModelSerializers::Deserialization.jsonapi_parse(params)
    Rails.logger.error params.to_yaml
    user = User.where(full_name: data[:full_name]).first
    head 406 and return unless user
    if user.authenticate(data[:password])
      user.regenerate_token
      render json: user, status: :created, meta: default_meta,
             serializer: ActiveModel::Serializer::SessionSerializer and return
    end
    head 403
  end

  def destroy
    user = User.where(token: params[:id]).first
    head 404 and return unless user
    user.regenerate_token
    head 204
  end
end
  • I created SessionSerializer in order to serialize and deserialize data needed for login.
  • To bring more security every time client requests login or logout I reset token. This regeneration is part of Rails 5.
  • When doing render json: user, client will get serialized JSON from UserSerializer. That is the reason that I explicitly use SessionSerializer.
  • There is also class named SessionSerializer in Rails and selecting that name was not a good thing. However, Session middleware is disabled in Rails for application created with --api.

Token validity

Let's add a bit more security with timeouts. If client does not access any API on server for 15 minutes, we'll not accept any future API calls from that client that change anything. Also, I'm going to use meta key to give information to client about logged-in status.

First thing is to change updated_at field for one user in fixture:

<% 6.times do |i| %>
user_<%= i %>:
  full_name: <%= "User Nr#{i}" %>
  password_digest: <%= BCrypt::Password.create('password') %>
  token: <%= SecureRandom.base58(24) %>
  updated_at: <%= i == 5 ? 1.hour.ago : Time.now %>
<% end %>

Then we need change ApplicationController:

  before_action :validate_login

  def validate_login
    token = request.headers["X-Api-Key"]
    return unless token
    user = User.find_by token: token
    return unless user
    if 15.minutes.ago < user.updated_at
      user.touch
      @current_user = user
    end
  end

  def validate_user
    head 403 and return unless @current_user
  end

  def default_meta
    {
      licence: 'CC-0',
      authors: ['Saša'],
      logged_in: (@current_user ? true : false)
    }
  end
  • New before_action as called on every request and it checks of user have valid token and have access API server in last 15 minutes.
  • It also sets @current_user globally.
  • Old validate_user is changed a lot.
  • default_meta gives information about client logged-in status.

Here is integration test for this:

require 'test_helper'
require 'json'

class SessionFlowTestTest < ActionDispatch::IntegrationTest
  test "login timeout and meta/logged-in key test" do
    user = users('user_5')
    # Not logged in, because of timeout
    get '/users', params: nil,
        headers: { 'X-Api-Key' => user.token }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal false, jdata['meta']['logged-in']
    # Log in
    post '/sessions',
         params: {
           data: {
             type: 'sessions',
             attributes: {
               full_name: user.full_name,
               password: 'password' }}}.to_json,
         headers: { 'Content-Type' => 'application/vnd.api+json' }
    assert_response 201
    jdata = JSON.parse response.body
    token = jdata['data']['attributes']['token']
    refute_equal user.token, token
    # Logged in
    get '/users', params: nil,
        headers: { 'X-Api-Key' => token }
    assert_response :success
    jdata = JSON.parse response.body
    assert_equal true, jdata['meta']['logged-in']
  end
end

Conclusion

Rails 5 --api option is just great. It really creates lightweight application and scaffolding works great (see notes below). It's a bit strange that when you use --api option, Rails will first create full application and then remove newly generated files that are not needed for API only application. Even more files get deleted if you use -C (without ActionCable).

Rails default uses their own simple JSON format. There is nothing wrong with that, but I prefer something more standard, with clearly defined message format, HTTP status codes, etc… JSON:API is currently the best option, and that is where 'active_model_serializers' gem shines.

Full source of this application is available on GitHub.

Notes

  • Scaffolding works great with applications created with --api. I created everything by hand in this tutorial, method by method, because that way is easier to explain everything. Code generated with scaffolding is 90% done, you just need to change a little.
  • I used X-Api-Key header for authentication token. You can use ActionController::HttpAuthentication::Token that is build in Rails or something else. I used simplest solution, just to illustrate how to secure some methods in controllers and how to call those methods from tests.
  • You may also want to use JWT.
  • In this demo is no authorization implemented. Every logged in user can edit any other user or article. Normally, that is a problem, but this is just a demo and implementing that feature is more controller logic and have little to with this article. If somebody tries to edit resource that doesn't belong to him, you can just reply with head 403.
  • I didn't cover updating relationships from JSON:API spec. It's not hard to do and often faster than updating whole record.
  • I didn't cover any framework/gem/tool for API documentation. I may come back to this point in the future articles.

Updates

  • 2016-06-23: Updated for Rails 5.0.0.rc2
  • 2016-07-03: Updated for Rails 5.0.0
Share on: