Apiary Documentation with RSpec API Doc Generator

A solid and maintainable Rails Backend project, that plays an API role, should be based on a good quality code that implements a business logic. Automatic tests and documentation also play an important role. Building an API, covered by tests, makes it easy to expand, detect errors and eliminate future problems. In a RoR application, well-described tests implemented with rspec-rails gem create some kind of an API specification. Inside each test case, there is a scenario showing interaction with the backend.

Backed API is not created for an end user to understand it since the user doesn’t have access to it. Usually, direct clients of the API are mobile applications, web applications or other external services. That’s why a readable documentation that clearly and comprehensively describes API requests is vital. Those requests are basically a realization of the API’s specification. So we have RSpec tests and the API documentation, both driven by the specification. Wouldn’t it be nice to have a tool that allows writing RSpec API requests/acceptance tests and simultaneously constructs the API documentation for them? We would achieve a constant consistency between tests and the documentation while avoiding the boring process of writing the documentation. Fortunately, there is a gem that implements this idea - it’s rspec_api_documentation.

Why use Apiary?

Nowadays, Apiary is widely used as a standard documentation platform for the backend APIs. Apiary is an independent backend platform, so it can be used not only for Rails APIs. It’s based on an open source API Blueprint - a powerful high-level API description language for web APIs. Apiary allows to not only publish written down documentation, but it also provides mocking of documented requests and tracking of its usage. Unfortunately, Apiary doesn’t fully cover an API Blueprint specification, it has some concessions. Writing an Apiary documentation and maintaining it is a tiresome task for a developer, rspec_api_documentation gem helps to speed up the process and this makes it more bearable.

Why write API Acceptance Tests?

There is no strict standard or guidelines for Rails API on how to build and write a test suite. You are not limited to RSpec, you can use others testing engines. About 95% of Rails APIs use RSpec gem as the test engine, but each project has a different approach to testing. RSpec allows testing of many aspects of Rails APIs. You can build specs for models, controllers, views, request, routes and even for custom services. A well-done test suite usually consists of:

  • low-level specs - Model specs
  • middle-level specs - Service specs
  • high-level specs - this is the most problematic. That’s where a debate begins.

Some Rails developers choose Controller specs, the others pick Feature specs (a.k.a. Acceptance) or Requests specs. So what to choose? In my opinion, the best are acceptance tests. In this case, the best means - has the least negative factors.

A Rails APIs request is generally processed in three steps:

  1. A Rails APIs customer calls HTTP method and selects a path to the desired resource, for example, GET /cars/orders.
  2. Rails Engine calls a proper controllers action based on an HTTP method and the path sent by the customer.
  3. After finishing processing a response in proper format is sent to the API customer.

A solid high-level API spec example should test all those three steps. However, controller specs don’t cover the first step. Those specs just call an appointed controller's action. A mapping between a request path and the controller’s action is not checked. In order to test this mapping, we have to create some Routing specs, but our goal is to have all three steps tested in a single test example. Next, we could try with Requests spec, in spite of the fact we use Acceptance API spec. So what’s wrong with Request spec? If you want to test many API requests or a requests flow in a single test scenario, you ought to choose Request specs. This type of RSpec was designed for classic Rails applications, with the HTML views rendered as a response or with redirections. In our case, as I mentioned before, we want to test only a single request per a single spec.

By default, Feature specs scenarios describe some operation on forms or views. API Acceptance specs work almost in the same manner, by making some requests, passing params and expecting to get some results in JSON format.

Using RSpec API Doc Generator in Rails Application

Let’s create a sample new project.

$ rails new car_api --api

Add gems to your application's Gemfile:

gem 'active_model_serializers'
gem 'will_paginate'
group :test, :development do
  gem 'rspec-rails'
  gem 'rspec_api_documentation'
end

Bundle it!

$ bundle install

Initialize RSpec:

$ bundle exec rails generate rspec:install

The project consists of the Car model. Let’s create it and run migration:

$ bundle exec rails g model Car name brand year:integer 
$ bundle exec rails db:create && bundle exec rails db:migrate

We also add some extra validation rule in a Car model:

# app/model/car.rb
class Car < ApplicationRecord
  validates :brand, presence: true
end  

Cars API will have three actions: index, create and destroy defined in Cars controller:

# app/controllers/api/v1/cars_controller.rb
class Api::V1::CarsController < ApplicationController
  def index
    @cars = Car.order('year DESC').paginate(:page => params[:page], :per_page => params[:per_page])

    render json: @cars
  end

  def create
    @car = Car.new(car_params)
    if @car.save
      render json: @car, status: :created
    else
      render json: @car.errors, status: 400
    end
  end

  def destroy
    @car = Car.find(params[:id])
    @car.destroy
  end

  private
    def car_params
      params.require(:car).permit(:name, :brand, :year)
    end
end  
# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :cars, only: %i{index create destroy}
    end
  end
end 

In our simple app, we are returning in JSON format responses only car’s: id, name, brand and year.

So it’s good to have some simple serializer:

# app/serializers/api/v1/car_serializer.rb
class Api::V1::CarSerializer < ActiveModel::Serializer
  attributes :id, :name, :brand, :year
end 

Before we start to write some tests, we need to make some configuration changes for rspec_api_documentation gem. A default documentation output format is :html, so we have to change it to :bluprint_api. Moreover, we want to add some API description and define which headers we want to include in our output doc file:

# spec/acceptance_helper.rb
require 'rails_helper'
require 'rspec_api_documentation'
require 'rspec_api_documentation/dsl'

RspecApiDocumentation.configure do |config|
  config.format = :api_blueprint
  config.request_body_formatter = :json
  config.request_headers_to_include = %w[Content-Type Accept]
  config.response_headers_to_include = %w[Content-Type]
end   

Finally, we will create an acceptance spec file:

# spec/acceptance/api/v1/cars_spec.rb.
require 'acceptance_helper'      # We require acceptance specs configuration from spec/acceptance_helper.rb.

resource 'Cars' do  	         # Documentation refers to the Car model
  # Headers that will be sent in every request. 
  header 'Accept', 'application/json'
  header 'Content-Type', 'application/json'

  # Describe URL and parameters for a cars list request.
  # As a second parameter we pass request description. 
  # REST APIs requests may have the same paths, but differ by HTTP method, 
  # we extract a path to a parent block, so descendant blocks can describe each method 
  route '/api/v1/cars?{page,per_page}', 'Cars Collection' do
    # List of optional parameters with description for request 
    parameter :page, 'Current page of cars'
    parameter :per_page, 'Number of cars on a single page'

    # Testing GET /api/v1/cars request. 
    get 'Returns all cars' do
      # Creation of some test data.
      let!(:volkswagen) { Car.create(:brand => 'Vokswagen', :name => 'Polo', :year => 2011) }
      let!(:subaru) { Car.create(:brand => 'Subaru', :name => 'Impeza', :year => 2015) }

      # Let’s test two cases: 
      context 'without page params' do
        # This block plays role of ‘it’ block from RSpec - the test scenario starts here, 
        # example_request makes request defined by ancestor blocks (GET /api/v1/cars) implicitly, so we don’t have to call do_request method 
        example_request 'Get a list of all cars ordered DESC by year' do
          expect(status).to eq(200)
          # response_body returns a response body in string format 
          expect(response_body).to eq(json_collection([subaru, volkswagen]))
        end
      end

      context 'with page params' do
        let(:page) { 1 }
        let(:per_page) { 1 }
        # Here we calling do_request method explicitly.
        # :document => false will match this example as a test only spec and not include it in the output doc.  
        example 'Getting a paged list of cars ordered DESC by year', :document => false do
           # We are passing extra pagination parameters to the request.
          do_request(page: page, per_page: per_page)
          expect(status).to eq(200)
          expect(response_body).to eq(json_collection([subaru]))
        end
      end
    end
  end

  route '/api/v1/cars', 'Creation of car' do
    # Attribute defines what attributes you can send in the request body.
    # Option :required makes the parameter mandatory. 
    attribute :name, "Car’s name"
    attribute :brand, "Car’s band", :required => true
    attribute :year, 'Year of production'

    post 'Add a car' do
      let(:name)  { 'Passat' }
      let(:year)  { 2010 }
      let(:request) { { car: { name: name, brand: brand, year: year } } }

      context 'with an invalid brand' do
        let(:brand) { nil }
        example 'Fails when missing params' do
          do_request(request)
          expect(Car.any?).to eq false
          expect(status).to eq(400)
        end
      end

      context 'with a valid brand' do
        let(:brand) { 'Vokswagen' }

        example 'Creating a car' do
          do_request(request)
          expect(response_body).to eq(json_item(Car.last))
          expect(status).to eq(201)
        end
      end
    end
  end

  # Requests on a single car.  
  route '/api/v1/cars/:id', "Single Car" do
    # Options :type and :example are self-explanatory.  
    parameter :id, 'Car id', required: true, type: 'string', :example => '1'

    delete 'Deletes a specific car' do
      let(:id) { Car.create(:brand => 'Renault', :name => 'Megane', :year => 2016).id }

      # Here again, we calling request in an implicit manner. 
      # The :id parameter is fetched from the let statement.
      example_request 'Deleting a car' do
        expect(status).to eq(204)
        expect(response_body).to eq('')
        expect(Car.any?).to eq false
      end
    end
  end

  protected

  def json_collection(collection)
    ActiveModel::Serializer::CollectionSerializer.new(collection, serializer: Api::V1::CarSerializer).to_json
  end

  def json_item(item)
    Api::V1::CarSerializer.new(item).to_json
  end
end

At the end, we can generate a Blueprint API documentation:

$ bundle exec rake docs:generate

A documentation file was generated:

# doc/api/index.apib
FORMAT: A1

# Group Cars

Cars operations.

## Creation of car [/api/v1/cars]
+ Attributes (object)
  + name - Car’s name
  + brand (required) - Car’s band
  + year - Year of production

### Add a car [POST]

+ Request Creating a car (application/json)
    + Headers
        Accept: application/json
        Content-Type: application/json

    + Body
        {
          "car": {
            "name": "Passat",
            "brand": "Vokswagen",
            "year": 2010
          }
        }

+ Response 201 (application/json)
    + Headers

        Content-Type: application/json

    + Body

        {
          "id": 1,
          "name": "Passat",
          "brand": "Vokswagen",
          "year": 2010
        }

+ Request Fails when missing params (application/json)

    + Headers

        Accept: application/json
        Content-Type: application/json

    + Body

        {
          "car": {
            "name": "Passat",
            "brand": null,
            "year": 2010
          }
        }

+ Response 400 (application/json)

    + Headers

        Content-Type: application/json

    + Body

        {
          "brand": [
            "can't be blank"
          ]
        }

## Single Car [/api/v1/cars/:id]

+ Parameters
  + id: 1 (required, string) - Car id

### Deletes a specific car [DELETE]

+ Request Deleting a car (application/json)

    + Headers

        Accept: application/json

        Content-Type: application/json

+ Response 204 ()

## Cars Collection [/api/v1/cars?{page,per_page}]

+ Parameters
  + page - Current page of cars
  + per_page - Numer of cars on single page

### Returns all cars [GET]

+ Request Get a list of all cars ordered DESC by year (application/json)

    + Headers

        Accept: application/json
        Content-Type: application/json

+ Response 200 (application/json)

    + Headers

        Content-Type: application/json

    + Body

        [
          {
            "id": 2,
            "name": "Impeza",
            "brand": "Subaru",
            "year": 2015
          },
          {
            "id": 1,
            "name": "Polo",
            "brand": "Vokswagen",
            "year": 2011
          }
        ]

Unfortunately, as I mentioned, there are some minor differences between Apiary and API Blueprint specification. We have to change three things in index.apib:

  1. Remove Content-Type: application/json from +Headers sections, otherwise, we’ll get a duplicated header warning from Apiary.
  2. Add an extra TAB indent to finally have 3 TAB indents for +Headers and +Body sections.
  3. Remove colon and add curly braces around obligatory parameters in URL - in our case, delete car request.

The doc file after modification should look like this:

# Group Cars

Cars operations.

## Creation of car [/api/v1/cars]

+ Attributes (object)

  + name - Car’s name
  + brand (required) - Car’s band
  + year - Year of production

### Add a car [POST]

+ Request Creating a car (application/json)

    + Headers

            Accept: application/json

    + Body

            {
              "car": {
                "name": "Passat",
                "brand": "Vokswagen",
                "year": 2010
              }
            }

+ Response 201 (application/json)

    + Headers

    + Body

            {
              "id": 1,
              "name": "Passat",
              "brand": "Vokswagen",
              "year": 2010
            }

+ Request Fails when missing params (application/json)

    + Headers

            Accept: application/json

    + Body

            {
              "car": {
                "name": "Passat",
                "brand": null,
                "year": 2010
              }
            }

+ Response 400 (application/json)

    + Headers

    + Body

            {
              "brand": [
                "can't be blank"
              ]
            }

## Single Car [/api/v1/cars/{id}]

+ Parameters

  + id: 1 (required, string) - Car id

### Deletes a specific car [DELETE]

+ Request Deleting a car (application/json)

    + Headers

            Accept: application/json

+ Response 204 ()

## Cars Collection [/api/v1/cars?{page,per_page}]

+ Parameters
  + page - Current page of cars
  + per_page - Number of cars on single page

### Returns all cars [GET]

+ Request Get a list of all cars ordered DESC by year (application/json)

    + Headers

            Accept: application/json

+ Response 200 (application/json)

    + Headers

    + Body

            [
              {
                "id": 2,
                "name": "Impeza",
                "brand": "Subaru",
                "year": 2015
              },
              {
                "id": 1,
                "name": "Polo",
                "brand": "Vokswagen",
                "year": 2011
              }
            ]

Now we can copy and paste our # Group Cars to the Project API Apiary editor, after required API description at the beginning of the Apiary document, like so:

FORMAT: 1A

HOST: http://polls.apiblueprint.org/

# Cars API

Simple API allowing consumers to manage cars.

# Group Cars 

….

We have a fully working Apiary documentation now for our Car API.

Summary

Writing Acceptance specs with rspec_api_documetation gem can significantly speed up the creation of the API documentation. However, we still have to make some modification on a generated documentation. In addition, rspec_api_documentation offers other output formats, like json, html or markdown. We are not tied to one documentation type.

Summarizing, generation of an API documentation on test suite is a great idea that supports Ruby on Rails agility, it will definitely be developed in the future.