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.