yohan morales notes

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

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_responsemethod 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