JSON API in rails
Here's a couple of ideas about how to implement the JSON API schema in a ruby on rails project
Create a sample rails app
For this example we are going to create an empty RoR app:
rails new
Add JSON API gems
In this case we are going to use Nestflix's restful JSON API gem
- To serialize JSON API responses: Fast JSON API "A lightning fast JSON:API serializer for Ruby Objects" https://github.com/Netflix/fast_jsonapi
Gemfile
....
# for JSON API support
gem 'active_model_serializers', '~>0.9.4'
gem 'fast_jsonapi'
....
Project description
We are going to create a basic bookstore with following models:
- Authors that publishes articles
- Articles that belongs to an author
Set up scaffold for articles
Now we can set up a classic RoR scaffold for Article model
rails generate scaffold Article title:string body:text user:references
Then migrate
rails db:migrate
Now you can start the rails app with rails s
, create basic articles and associate them to a specific user from http://localhost:3000/articles
API Description
We are going to publish our API under /api/v1 path, so our routing file will be something like this:
config/routes.rb
namespace :api do
namespace :v1, defaults: { format: :json } do
resources :users, only: %i[index show] do
resources :articles, only: %i[index show]
end
end
end
Now our API will expose the following endpoints:
# list user articles
GET /api/v1/users/:user_id/articles
# get an user article
GET /api/v1/users/:user_id/articles/:id
# get all users
GET /api/v1/users
# get an user
GET /api/v1/users/:id
Set up an API controller
Lets set up a base API controller for our JSON API endpoints
# app/controllers/api/v1/api_controller.rb
module Api
module V1
class ApiController
def index
resources = resource_class.all
jsonapi_response(resources, 200, serializer_options)
end
def show
resource = resource_class.find(params[:id])
jsonapi_response(resource, 200, serializer_options)
end
private
def serializer_options
{}
end
end
end
end
Index and show methods will define common behavior to show certain resource list or detail, now the question is how to create DRY methods to infer the resource name, get the right serializer and render it in JSON API format, so let's introduce a controller concern for that:
# app/controllers/concerns/api/v1/api_response.rb
module Api
module V1
module ApiResponse
extend ActiveSupport::Concern
def json_response(object, status = 200)
render json: object, status: status
end
def jsonapi_response(resource, status = 200, options = {})
serializer = "Api::V1::#{resource.class}Serializer".constantize
serializer_object = serializer.new(resource, options)
json_response serializer_object.serialized_json, status
end
end
end
end
By implementing this concern we are going to get serializer name by the resource class and look for it in app/serializers
folder, then using the json_response
method we render it using the classic JSON rendering with a certain HTTP status. Also options parameter will encapsulate fast JSON API gem customizable options
Create the user controller
Now lets create the API/V1 controller for the User resource and inherit it from the ApiController:
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApiController
def resource_class
User
end
end
end
end
As you can see, using the ApiController, controller looks super skinny and easy to read
Create the Articles controller
Articles controller behaves differently from User controller because Article model is nested to publisher so we are gong to overwrite index and show actions:
# app/controllers/api/v1/articles_controller.rb
module Api
module V1
class ArticlesController < ApiController
def index
resources = resource_class.where(:user_id: params[:user_id])
jsonapi_response(resources, 200, serializer_options)
end
def show
resource = resource_class.find_by!(:user_id: params[:user_id], id:params[:id])
jsonapi_response(resource, 200, serializer_options)
end
def resource_class
Article
end
end
end
end
Adding Serializers
Now is time to create serializers for users and articles (for more info check fast JSON API documentation about serializer definition), lets namespace it too for future proof versions of API:
# app/serializers/api/v1/user_serializer.rb
module Api
module V1
class UserSerializer
include FastJsonapi::ObjectSerializer
attributes :email, :created_at, :updated_at
end
end
end
# app/serializers/api/v1/article_serializer.rb
module Api
module V1
class ArticleSerializer
include FastJsonapi::ObjectSerializer
attributes :id, :title, :body
has_one :user
end
end
end
Try it!
At this point we can test our endpoints:
- Getting all the users:
curl --location --request GET 'http://localhost:3000/api/v1/users/'
{
"data": [{
"id": "1",
"type": "user",
"attributes": {
"email": "[email protected]",
"created_at": "2020-07-07T01:47:34.551Z",
"updated_at": "2020-07-07T01:47:34.551Z"
}
}]
}
- Get a specific user:
curl --location --request GET 'http://localhost:3000/api/v1/users/1'
{
"data": {
"id": "1",
"type": "user",
"attributes": {
"email": "[email protected]",
"created_at": "2020-07-07T01:47:34.551Z",
"updated_at": "2020-07-07T01:47:34.551Z"
}
}
}
- Get user's articles:
curl --location --request GET 'http://localhost:3000/api/v1/users/1/articles'
{
"data": [
{
"id": "2",
"type": "article",
"attributes": {
"id": 2,
"title": "JSON:API paints my bikeshed!",
"body": "The shortest article. Ever."
},
"relationships": {
"user": {
"data": {
"id": "1",
"type": "user"
}
}
}
}
...
]
}
- Get an article:
curl --location --request GET 'http://localhost:3000/api/v1/users/1/articles/2'
{
"data": {
"id": "2",
"type": "article",
"attributes": {
"id": 2,
"title": "JSON:API paints my bikeshed!",
"body": "The shortest article. Ever."
},
"relationships": {
"user": {
"data": {
"id": "1",
"type": "user"
}
}
}
}
}
Handling HTTP errors
At this point if we try to get an article that does not exists the application cannot handle the ActiveRecord::RecordNotFound
in an user-friendly way, lo lets introduce some common behavior in API controllers:
# app/controllers/concerns/api/v1/api_response.rb
module Api
module V1
module ApiResponse
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordNotFound, with: :jsonapi_404
end
...
def jsonapi_404
jsonapi_error(404, 'not_found')
end
def jsonapi_error(status, text)
json_response(
{
errors:[
{
links: {},
status: status,
title: text,
meta: {}
}]
},
status)
end
end
end
end
With this small piece of code now users and articles can handle 404 HTTP errors in a JSON API way:
curl --location --request GET 'http://localhost:3000/api/v1/users/1/articles/200'
{
"errors": [
{
"links": {},
"status": 404,
"title": "not_found",
"meta": {}
}
]
}
Next steps
-
Add to deserialize: restful-jsonapi "A temporary monkeypatch for JSONAPI support" https://github.com/Netflix/restful-jsonapi in order to handle POST and PATCH HTTP methods to create/update users and articles
-
Add behavior to handle and forward model validation errors when create/update a resource
-
Go deep in serializer options to hide relationships, fields, etc.
-
Handle N+1 queries getting a resource, relationships and included data