Building a Ruby on Rails (RoR) API Part IV: Versioning, Request tests, Controllers

Supporting specific versions

  ·   7 min read

So far we’ve covered the initial configuration, the models and their tests and factories. We have to deal now with controllers and their tests. I also will show how to deal with versioning. This is particularly essential, because it will allow us to offer support for an specific version, tell what versions are supported and tell what versions are deprecated. Hands on work!

Request Tests and Controllers

Instead of writing the tests for controllers, I’ve written tests for requests, why?

Request specs provide a thin wrapper around Rails’ integration tests, and are designed to drive behavior through the full stack, including routing (provided by Rails) and without stubbing (that’s up to you).

API Versioning

The only constant in software development is the change. Thus, it’s always a good idea to break the API into different versions such as v1, v2, and so on. This gives your consumers the possibility to keep using certain version of the software while you keep improving it till you reach stability.

To reach this goal the folder structure should be like this:

 1.
 2├── app
 3│   ├── controllers
 4│   │   ├── api
 5│   │   │   ├── sessions_controller.rb
 6│   │   │   └── v1
 7│   │   │       ├── containers_controller.rb
 8│   │   │       ├── crop_logs_controller.rb
 9│   │   │       ├── crops_controller.rb
10│   │   │       ├── packages_controller.rb
11│   │   │       ├── places_controller.rb
12│   │   │       ├── producers_controller.rb
13│   │   │       ├── products_controller.rb
14│   │   │       ├── route_logs_controller.rb
15│   │   │       ├── routes_controller.rb
16│   │   │       ├── tracers_controller.rb
17│   │   │       └── warehouses_controller.rb

Then a typical controller will start like this:

1# /app/controllers/api/v1/producers_controller.rb
2module Api::V1
3  class ProducersController < ApplicationController
4    ...
5  end
6end

Then in the routing file the routes can keep this configuration using a set of scopes like this:

 1# /config/routes.rb
 2Rails.application.routes.draw do
 3  scope module: 'api' do
 4    namespace :v1 do
 5      concern :localizable do
 6        resources :places
 7      end
 8
 9      concern :user do
10        resources :containers, only: [:index, :show]
11        resources :products, only: [:index, :show]
12        resources :packages
13        resources :routes do
14          resources :details, controller: 'route_logs'
15        end
16      end
17
18      concern :user_crops do
19        resources :crops do
20          resources :logs, controller: 'crop_logs'
21        end
22      end
23
24      resources :containers, only: [:index, :show]
25      resources :products, only: [:index, :show]
26
27      resource :producer, concerns: [:localizable, :user, :user_crops]
28      resource :warehouse, concerns: [:localizable, :user]
29    end
30  end
31end

The Producer request and controller

Now I’ll explain my request test from an initial version that didn’t include the authentication yet

  1# /spec/requests/api/v1/producers_spec.rb
  2require 'rails_helper'
  3
  4RSpec.describe Api::V1::ProducersController, type: :request do
  5  let!(:producers) { create_list(:producer, 10) }
  6  let(:producer_id) { producers.first.id }
  7
  8  describe 'GET /v1/producers' do
  9    before { get '/v1/producers' }
 10
 11    it 'should return producers' do
 12      expect(response).not_to be_empty
 13      expect(response.size).to eq(10)
 14    end
 15
 16    it 'should return status code 200' do
 17      expect(response).to have_http_status(200)
 18    end
 19  end
 20
 21  describe 'GET /v1/producers/:id' do
 22    before { get "/v1/producers/#{producer_id}" }
 23
 24    context 'when the record exists' do
 25      it 'should return the producer' do
 26        expect(response).not_to be_empty
 27        expect(response['id']).to eq(producer_id)
 28      end
 29
 30      it 'should return status code 200' do
 31        expect(response).to have_http_status(200)
 32      end
 33    end
 34
 35    context 'when the record does not exist' do
 36      let(:producer_id) { 100 }
 37
 38      it 'should return status code 404' do
 39        expect(response).to have_http_status(404)
 40      end
 41
 42      it 'should return a not found message' do
 43        expect(response.body).to match(/Couldn't find Producer/)
 44      end
 45    end
 46  end
 47
 48  describe 'POST /v1/producer' do
 49    let(:places) { create_list(:place, 1) }
 50    let(:place_id) { places.first.id }
 51    let(:valid_attributes) { {place_id: place_id, first_name: "John", last_name: "Doe", username: "jdoe", password: "1234567890"} }
 52
 53    context 'when the request is valid' do
 54      before { post '/v1/producers', params: valid_attributes }
 55
 56      it 'should create the producer' do
 57        pp(valid_attributes)
 58        expect(response['first_name']).to eq('John')
 59        expect(response['last_name']).to eq('Doe')
 60        expect(response['username']).to eq('jdoe')
 61        expect(response['password']).to eq('1234567890')
 62      end
 63
 64      it 'should return status code 201' do
 65        expect(response).to have_http_status(201)
 66      end
 67    end
 68
 69    context 'when the request is invalid' do
 70      before { post '/v1/producers', params: {name: "John"} }
 71
 72      it 'should return status code 422' do
 73        expect(response).to have_http_status(422)
 74      end
 75
 76      it 'should return a validation failure message' do
 77        expect(response.body).to match(/Place must exist, First name can't be blank, Last name can't be blank, Username can't be blank, Password can't be blank/)
 78      end
 79    end
 80  end
 81
 82  describe 'PUT /v1/producers/:id' do
 83    let(:valid_attributes) { {first_name: "Jane"} }
 84
 85    context 'when the record exists' do
 86      before { put "/v1/producers/#{producer_id}", params: valid_attributes }
 87
 88      it 'should update the record' do
 89        expect(response.body).to be_empty
 90      end
 91
 92      it 'should return status code 204' do
 93        expect(response).to have_http_status(204)
 94      end
 95    end
 96  end
 97
 98  describe 'DELETE /v1/producers/:id' do
 99    before { delete "/v1/producers/#{producer_id}" }
100
101    it 'should return status code 204' do
102      expect(response).to have_http_status(204)
103    end
104  end
105end

What we have here is the description for each of the REST verb exposed through the routes.rb file. I won’t walk through each one of them because it looks pretty straight forward. However, this RSpec slides should cover those doubts. But what’s worthy of mention is how the factories are used here for request testing, they start populating the test DB with the dummy data but there is a json helper that must be set up to get this working before lunching the tests.

1# /spec/support/request_spec_helper.rb
2module RequestSpecHelper
3  # Parse JSON response to ruby hash
4  def json
5    JSON.parse(response.body)
6  end
7end

Time to write the code for the Producer controller.

 1# /app/controllers/api/v1/producers_controller.rb
 2module Api::V1
 3  class ProducersController < ApplicationController
 4    before_action :set_producer, only: [:show, :update, :destroy]
 5
 6    # GET /producers
 7    def index
 8      @producers = Producer.all
 9      json_response(@producers)
10    end
11
12    # GET /producers/:id
13    def show
14      json_response(@producer)
15    end
16
17    # POST /producers
18    def create
19      @producer = Producer.create!(producer_params)
20      json_response(@producer, :created)
21    end
22
23    # PUT /producers/:id
24    def update
25      @producer.update(producer_params)
26      head :no_content
27    end
28
29    # DELETE /producers/:id
30    def destroy
31      @producer.destroy
32      head :no_content
33    end
34
35    private
36
37    def producer_params
38      params.permit(:place_id, :first_name, :last_name, :username, :password)
39    end
40
41    def set_producer
42      @producer = Producer.find(params[:id])
43    end
44  end
45end

As you can see this file has been written under the set of folders ../controllers/api/v1/. It was also defined as a module for the Api::V1 part and inside it the actual controller.

If the test for this controller is run, we get some errors, and that happens because we need to set the proper routes as well as two helpers that have to be included in the base ApplicationController file

1# /app/controllers/concerns/response.rb
2module Concerns
3  module Response
4    def json_response(object, status = :ok, include = '*')
5      render json: object, status: status, root: 'data', include: include
6    end
7  end
8end

This helper responds with a JSON and an OK HTTP status code by default, it also includes the default nesting level for data structures. I’ll talk about it later.

 1# /app/controllers/concerns/exception_handler.rb
 2module Concerns
 3  module ExceptionHandler
 4    # provides the more graceful `included` method
 5    extend ActiveSupport::Concern
 6
 7    included do
 8      rescue_from ActiveRecord::RecordNotFound do |e|
 9        json_response({message: e.message}, :not_found)
10      end
11
12      rescue_from ActiveRecord::RecordInvalid do |e|
13        json_response({message: e.message}, :unprocessable_entity)
14      end
15    end
16  end
17end

For errors, this helper shows the error message along with the proper HTTP status code.

1# /app/controllers/application_controller.rb
2class ApplicationController < ActionController::API
3  include Response
4  include ExceptionHandler
5end

Both helpers are included in the ApplicationController. Now the test should be passing.