Creating Rails 5 API only application following JSON:API specification
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.
Providing links in JSON data
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
andfull_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
inconfig/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)
.
Including related resources
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.
Related links
- Rails 5.0.0.rs1 API docs
- Edge Rails API only guide - More info about caching, rake middlewares and controller modules that may be useful to you
- JSON:API specification - Keep it open and consult it when in doubt
- active_model_serializers documentation and guides
Updates
- 2016-06-23: Updated for Rails 5.0.0.rc2
- 2016-07-03: Updated for Rails 5.0.0