Duetcode

Make API payloads generic

25 July 2020

Welcome to the sixth chapter of the API-only ruby on rails course. This time, we will improve the renderer module, which we introduced in the previous chapter. If you didn’t read the previous sections yet, you can check the content list from the overview page.

Last time we have built the following renderer module and used it in the users controller.

app/controllers/concerns/renderer.rb
# frozen_string_literal: true

module Renderer
  def render_object(resource, status = :ok)
    render json: resource, status: status
  end

  def render_errors(errors, status = :unprocessable_entity)
    render json: errors, status: status
  end
end

This module provides a single place where we can modify the payload structure for all API endpoints. As a first step of the improvement, let’s talk about the payload structure we want to build. For the API responses that expose data, I would suggest using the following format with the resource and its metadata information.

{
  "data": {
    "id": 5,
    "email": "sample@mail.com",
    "created_at": "2020-07-07T13:46:41.496Z",
    "updated_at": "2020-07-07T13:46:41.496Z"
  },
  "meta": {
    "resource": "User",
    "count": 1
  }
}
  • data: will provide resource information. If it’s not a single resource, we will have an array instead of an object as a value of the data key.
  • meta: will provide metadata information about the resource. It will give the resource name as well as the total number of returned objects.

Also, for the API responses that expose errors, we will use a structure that looks like the following.

{
  "errors": {
    "email": [
      "can't be blank"
    ],
    "password": [
      "can't be blank"
    ]
  }
}

We provided the field names and corresponding error explanations as an array. (One field might have more than one error.) I don’t think having the metadata information gives us so much advantage for the errors, so I excluded it from the error payloads.

Improve renderer module

Let’s start improving the render_object method of the renderer module to have the structure we mentioned.

app/controllers/concerns/renderer.rb
# frozen_string_literal: true

module Renderer
  def render_object(resource, **options)
    options.merge!(json: resource, root: :data)
    options.merge!(status: :ok) unless options.key?(:status)

    render options
  end

  def render_errors(errors, status = :unprocessable_entity)
    render json: errors, status: status
  end
end

Instead of getting status as a second parameter, we got all keyword arguments after resource argument as options since we want to pass all of them to the active model’s serialization process. Those options might include serializer, root key, etc. in addition to the status.

After getting options, we merged the resource with the JSON key and added data as a root key. Status logic is the same as previous behavior. If the status code is provided, we will use it. Otherwise, it’s going to be 200 (ok) as the default.

Let’s run the renderer module specs to see if everything still works as expected.

~/code/bookmarker (master) $ bundle exec rspec spec/concerns/renderer_spec.rb
F.

Failures:

  1) Renderer GET show renders resource with render_object method
     Failure/Error: expect(load_body(response)['dummy']).to include(resource_fields)
       expected nil to include {"id" => 1, "name" => "sample"}, but it does not respond to `include?`
     # ./spec/concerns/renderer_spec.rb:61:in `block (3 levels) in <top (required)>'

Finished in 0.09045 seconds (files took 0.76959 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./spec/concerns/renderer_spec.rb:55 # Renderer GET show renders resource with render_object method

We have one error because inside the renderer module test, we were expecting to get data with dummy root key, but with the last improvement, we changed it to the data key. So we need to replace it with the data key.

spec/concerns/renderer_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Renderer, type: :controller do
  # ...

  describe 'GET show' do
    let(:resource) { create(:dummy, name: 'sample') }

    it 'renders resource with render_object method' do
      get :show, params: { id: resource.id }

      resource_fields = { 'id' => resource.id, 'name' => resource.name }

      expect(response.status).to eq(200)
      expect(load_body(response)['data']).to include(resource_fields)
    end
  end

  # ...
end

We can also create another helper method in spec/support/json_helpers.rb to get the data field of response body directly.

spec/support/json_helpers.rb
module JsonHelpers
  def load_body(response)
    JSON.parse(response.body)
  end

  def load_body_data(response)
    load_body(response)['data']
  end
end

And we will use load_body_data method for the renderer tests. It’s also better to name resource_fields as data_fields from now on.

spec/concerns/renderer_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Renderer, type: :controller do
  # ...

  describe 'GET show' do
    let(:resource) { create(:dummy, name: 'sample') }

    it 'renders resource with render_object method' do
      get :show, params: { id: resource.id }

      data_fields = { 'id' => resource.id, 'name' => resource.name }

      expect(response.status).to eq(200)
      expect(load_body_data(response)).to include(data_fields)
    end
  end

  # ...
end

And now, let’s rerun the tests.

~/code/bookmarker (master) $ bundle exec rspec spec/concerns/renderer_spec.rb
..

Finished in 0.06649 seconds (files took 0.83 seconds to load)
2 examples, 0 failures

It’s passing again. We also have to modify api/v1/users_spec.rb file because we passed status as a second argument previously, but we need to convert it to the keyword argument to get it through the options we defined in the renderer module.

app/controllers/api/v1/users_controller.rb
# frozen_string_literal: true

class Api::V1::UsersController < Api::V1::BaseController
  def create
    @user = User.new(user_params)

    if @user.save
      return render_object(@user, status: :created)
    end

    render_errors(@user.errors)
  end

  private

  def user_params
    params.require(:user).permit(:email, :password)
  end
end

We will also modify spec/requests/api/v1/users_spec.rb file because creates a new user case has the user root key that is supposed to be data with new changes. And also, it’s better to name it as expected_data rather than expected_body since it reflects the data part of the response body rather than the whole response body. (This is an unnoticed point from the previous chapter, I want to correct it here.)

spec/requests/api/v1/users_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Api::V1::Users', type: :request do
  describe 'POST /api/v1/users' do
    let(:user_params) do
      { email: 'user@duetcode.io', password: 'samplepassword' }
    end

    it 'creates a new user' do
      post api_v1_users_path, params: { user: user_params }
      expected_data = { 'email' => 'user@duetcode.io' }

      expect(response).to have_http_status(:created)
      expect(load_body_data(response)).to include(expected_data)
    end
  end

  # ...
end

Let’s run the api/v1/users_spec.rb to see whether it’s working or not.

~/code/bookmarker (master) $ bundle exec rspec spec/requests/api/v1/users_spec.rb
..

Finished in 0.06085 seconds (files took 0.9398 seconds to load)
2 examples, 0 failures

Perfect! Let’s move to the next topic.

Introduce metadata information

We added data as a root key for all API actions that are going to use the render_object method. But we also want to include metadata information of the resource. Now, we can add the assign_metadata method to the renderer module.

app/controllers/concerns/renderer.rb
# frozen_string_literal: true

module Renderer
  def render_object(resource, **options)
    options.merge!(json: resource, root: :data)
    options.merge!(status: :ok) unless options.key?(:status)
    options.merge!(meta: assign_metadata(resource))

    render options
  end

  def render_errors(errors, status = :unprocessable_entity)
    render json: errors, status: status
  end

  private

  def assign_metadata(resource)
    count = resource.respond_to?(:count) ? resource.count : 1
    resource_name = (resource.try(:first)&.class || resource.class).to_s

    { resource: resource_name, count: count }
  end
end

Now, we need to modify the renderer module test to cover the metadata part of the response. Before doing that, we can add load_body_meta method to the spec/support/json_helpers.rb file as we did similar to the load_body_data.

spec/support/json_helpers.rb
module JsonHelpers
  def load_body(response)
    JSON.parse(response.body)
  end

  def load_body_data(response)
    load_body(response)['data']
  end

  def load_body_meta(response)
    load_body(response)['meta']
  end
end

Now we can adjust renderer test to cover metadata information.

spec/concerns/renderer_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Renderer, type: :controller do
  # ...

  describe 'GET show' do
    let(:resource) { create(:dummy, name: 'sample') }

    it 'renders resource with render_object method' do
      get :show, params: { id: resource.id }

      data_fields = { 'id' => resource.id, 'name' => resource.name }
      meta_fields = { 'resource' => 'Dummy', 'count' => 1 }

      expect(response.status).to eq(200)
      expect(load_body_data(response)).to include(data_fields)
      expect(load_body_meta(response)).to include(meta_fields)
    end
  end
  # ...
end

With the last changes, we can run the renderer module tests.

~/code/bookmarker (master) $ bundle exec rspec spec/concerns/renderer_spec.rb
..

Finished in 0.06627 seconds (files took 0.77697 seconds to load)
2 examples, 0 failures

Yay! We have completed the improvements of the render_object method.

Add errors as a root key

One last improvement to do is adding the errors root field for the render_errors method. Let’s modify the renderer module.

app/controllers/concerns/renderer.rb
# frozen_string_literal: true

module Renderer
  # ...

  def render_errors(errors, status = :unprocessable_entity)
    render json: { errors: errors.messages }, status: status
  end

  # ...
end

We will also add errors root key to the renderer test.

spec/concerns/renderer_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Renderer, type: :controller do
  # ...

  describe 'POST create' do
    it 'renders resource errors with render_errors method' do
      post :create, params: { dummy: { name: nil } }

      error_fields = {
        'name' => ['can\'t be blank']
      }

      expect(response.status).to eq(422)
      expect(load_body(response)['errors']).to eq(error_fields)
    end
  end
end

Similar to the load_body_data and load_body_meta we can create another helper method for the errors in spec/support/json_helpers.rb.

spec/support/json_helpers.rb
module JsonHelpers
  def load_body(response)
    JSON.parse(response.body)
  end

  def load_body_data(response)
    load_body(response)['data']
  end

  def load_body_meta(response)
    load_body(response)['meta']
  end

  def load_body_errors(response)
    load_body(response)['errors']
  end
end

After adding load_body_errors method as descripted, let’s refactor the test by using load_body_errors method.

spec/concerns/renderer_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Renderer, type: :controller do
  # ...

  describe 'POST create' do
    it 'renders resource errors with render_errors method' do
      post :create, params: { dummy: { name: nil } }

      error_fields = {
        'name' => ['can\'t be blank']
      }

      expect(response.status).to eq(422)
      expect(load_body_errors(response)).to eq(error_fields)
    end
  end
end

We also need to do same modification for the spec/requests/api/v1/users_spec.rb file.

spec/requests/api/v1/users_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Api::V1::Users', type: :request do
  describe 'POST /api/v1/users' do
    # ...

    it 'returns unprocessable entity with errors' do
      user_params[:password] = nil
      post api_v1_users_path, params: { user: user_params }

      expected_error = { 'password' => ['can\'t be blank'] }

      expect(response).to have_http_status(:unprocessable_entity)
      expect(load_body_errors(response)).to eq(expected_error)
    end
  end
end

We completed the enhancement of the payload abstraction. Now, we can run all tests to see everything is still working.

~/code/bookmarker (master) $ bundle exec rspec
..........

Finished in 0.13188 seconds (files took 1.05 seconds to load)
10 examples, 0 failures

Summary

In this chapter, we structured the API response payloads by improving the renderer module. With the improment, API payloads will expose data and meta information of the resource. In the next chapter, we will start working on swagger integration for the API documentation. You can find the source code of bookmarker application on github. You can also find the previous chapter here.

Thank you for reading! If you want to be notified when I publish a new chapter, you can follow me on twitter.