18 July 2020
Welcome to the fifth chapter of the API-only ruby on rails course. In this chapter, we will introduce the Renderer
module for the API controllers. If you haven’t read the previous chapters yet, you can check the content list from the course page.
As you remember from the previous chapters, we have a user creation endpoint as following.
# frozen_string_literal: true
class Api::V1::UsersController < Api::V1::BaseController
def create
@user = User.new(user_params)
if @user.save
return render json: @user, status: :created
end
render json: @user.errors, status: :unprocessable_entity
end
private
def user_params
params.require(:user).permit(:email, :password)
end
end
Even though we only have one action at the moment, we will have the same behavior for other endpoints. If the desired condition is done successfully, we would like to render the JSON representation of the resource along with the HTTP status code. Otherwise, we want to render the JSON representation of the errors for the resource. If the endpoint has only one possible path like getting the resource information, we will again use the first approach.
Instead of writing render json: @user, status: :created
and render json: @user.errors, status: :unprocessable_entity
every time in the controllers, we can extract those code blocks to the specific methods called render_object
and render_errors
. We can then place them to a separate module to be able to use from different API controllers and have a single place to structure all API payloads. It will also make it easy to add metadata information for the payloads, which we will cover in the next chapter.
We will build the renderer
module while refactoring the create
action of the users controller, but we will do it with small iterations and check the tests to feel confident enough about our changes.
Let’s start with creating a concern file (app/controllers/concerns/renderer.rb
) and implement render_object
method as a first step.
# frozen_string_literal: true
module Renderer
def render_object(resource, status = :ok)
render json: resource, status: status
end
end
With the render_object
method, we basically get the resource object and render them as we were doing in the users controller. We also used :ok
status as a default parameter, since most of the API responses will return HTTP 200 status code. Now let’s use the render_object
method from the users controller. (Do not forget to include renderer
module!)
# frozen_string_literal: true
class Api::V1::UsersController < Api::V1::BaseController
include Renderer
def create
@user = User.new(user_params)
if @user.save
return render_object(@user, :created)
end
render json: @user.errors, status: :unprocessable_entity
end
private
def user_params
params.require(:user).permit(:email, :password)
end
end
Now, we can run user request spec to see if everything still works as before.
➜ ~/code/bookmarker (master) $ bundle exec rspec spec/requests/api/v1/users_spec.rb
..
Finished in 0.07713 seconds (files took 0.76363 seconds to load)
2 examples, 0 failures
Since everything works as expected, we can do the same enhancement with the render_errors
method.
# frozen_string_literal: true
module Renderer
def render_object(object, status = :ok)
render json: object, status: status
end
def render_errors(errors, status = :unprocessable_entity)
render json: errors, status: status
end
end
Here we passed unprocessable_entity
as a default value of the status parameter, which is 422 HTTP status code. And again, let’s refactor the create
action of the users controller.
# frozen_string_literal: true
class Api::V1::UsersController < Api::V1::BaseController
include Renderer
def create
@user = User.new(user_params)
if @user.save
return render_object(@user, :created)
end
render_errors(@user.errors)
end
private
def user_params
params.require(:user).permit(:email, :password)
end
end
And again, let’s run user request spec to see tests are still green.
➜ ~/code/bookmarker (master) $ bundle exec rspec spec/requests/api/v1/users_spec.rb
..
Finished in 0.07368 seconds (files took 0.73671 seconds to load)
2 examples, 0 failures
All tests are passed, perfect! As you can see, we refactor our create
action confidently since we have proper request tests. As a general rule of thumb, it’s good to check whether you have corresponding specs before refactoring any place in the codebase.
We included the renderer
module inside of the users controller, but we need to move it to the base controller because we will use that functionality also from other API controllers, which are extended by the base controller.
# frozen_string_literal: true
class Api::V1::BaseController < ApplicationController
include Renderer
end
We already checked requests specs for the user controller but we also need to write necessary tests for the renderer module itself. Let’s create spec/concerns/renderer_spec.rb
file and start with writing spec for the render_object
method.
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Renderer, type: :controller do
controller(ApplicationController) do
include Renderer
def show
user = User.find(params[:id])
render_object(user)
end
end
describe 'GET show' do
let(:resource) { create(:user) }
it 'renders resource with render_object method' do
get :show, params: { id: resource.id }
resource_fields = { 'id' => resource.id, 'email' => resource.email }
expect(response.status).to eq(200)
expect(load_body(response)['user']).to include(resource_fields)
end
end
end
Here, we created an anonymous rails controller extended by ApplicationController
and included the Renderer
module. Then we built a show action that uses the render_object
method with the user resource. And later, we defined the controller spec for the anonymous controller we created, and we sent a request to check if the render_object
method returns the expected resource payload along with the correct HTTP status code.
Now we can extend same approach for the render_errors
method.
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Renderer, type: :controller do
controller(ApplicationController) do
include Renderer
# ...
def create
user = User.create(user_params)
render_errors(user.errors)
end
private
def user_params
params.require(:user).permit(:email, :password)
end
end
# ...
describe 'POST create' do
it 'renders resource errors with render_errors method' do
post :create, params: { user: { email: nil, password: nil } }
error_fields = {
'email' => ['can\'t be blank'],
'password' => ['can\'t be blank']
}
expect(response.status).to eq(422)
expect(load_body(response)).to eq(error_fields)
end
end
end
We tried to create a user record with invalid parameters with the create action, and then we returned validation errors with the render_errors
method. Similar to the render_object
test, we send a request to create
action and checked if it has the correct payload with the status code.
Let’s run renderer specs to see everything is working as intended.
➜ ~/code/bookmarker (master) $ bundle exec rspec spec/concerns/renderer_spec.rb
..
Finished in 0.07072 seconds (files took 0.80828 seconds to load)
2 examples, 0 failures
You might have realized that we have a Metrics/BlockLength
rubocop offense for renderer_spec.rb
file since rspec block has too many lines. But since it’s actually the DSL of the rspec, I want to exclude spec directory from this rule.
inherit_from: .rubocop_todo.yml
# ...
Metrics/BlockLength:
Exclude:
- 'spec/**/*'
Before finishing the chapter, let’s run all specs to make sure we didn’t break any existing behavior of the application.
➜ ~/code/bookmarker (master) $ bundle exec rspec
..........
Finished in 0.11616 seconds (files took 0.87333 seconds to load)
10 examples, 0 failures
In this chapter, we introduced the renderer
module and refactored the create action of users controller by using render_object
and render_errors
methods. In the next chapter, we will improve the renderer
module to have a better API payload structure, and we will add metadata information to the API responses. 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.