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.
# 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.
Let’s start improving the render_object
method of the renderer
module to have the structure we mentioned.
# 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.
# 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.
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.
# 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.
# 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.)
# 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.
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.
# 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
.
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.
# 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.
One last improvement to do is adding the errors
root field for the render_errors
method. Let’s modify the renderer
module.
# 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.
# 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
.
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.
# 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.
# 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
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.