Generating documentation from specs

Jonah Williams ·

On one of our rails projects I am creating an api to allow mobile clients to access a web service. I need to provide documentation of this API to the developers of several different clients during its development. Normally I would prefer to let my API tests act as documentation of the expected API behavior but in this case I cannot give all of the client developers access to the rails project’s git repository and even the cleanest rspec specs might not be legible to developers without ruby experience.

I also don’t want to spend time manually updating documentation every time I change the API and don’t want to risk wasting other developers’ time by providing out of date or inaccurate documentation.

Instead I hacked together a rake task to covert my API controller specs into documentation using a custom rspec formatter. Now I can automatically update the documentation as part of our CI build process.

This example is available as https://gist.github.com/934902

First I wrote a rake task to generate my API documentation. This runs my API controller specs after adding some additional files to the ruby load path and requiring them to patch ActionController::TestCase::Behavior and load a custom rspec formatter.

require 'rubygems'
require 'rspec/core/rake_task'

namespace :project_name do
  namespace :api do
    desc 'Generate API request documentation from API specs'
    RSpec::Core::RakeTask.new('generate_docs') do |t|
      t.pattern = 'spec/controllers/api/**/*_spec.rb'
      t.rspec_opts = ["-Ilib/tasks/api_docs", "-rinstrument_specs", "-rapi_formatter", "--format APIFormatter"]
    end
  end
end

I patched ActionController::TestCase::Behavior to capture the requests and responses from process (called by get, put, post, and delete) and store them in rspec’s example metadata.

require 'action_dispatch'
require 'action_controller'

module InstrumentSpecs
  module ::ActionController::TestCase::Behavior
    alias_method :process_without_logging, :process

    def process(action, parameters = nil, session = nil, flash = nil, http_method = 'GET')
      version, route = described_class.to_s.match(/::(Vd)::(S+)Controller/)[1..2].collect(&:downcase)
      curl = "curl "
      parameters.each {|key, value| curl << "-d "#{key}=#{CGI.escape(value)}" " } if parameters.present?
      curl << "-X #{http_method} "
      curl << "https://api.host.example/#{version}/#{route} "
      example.metadata[:curl] = curl

      response = process_without_logging(action, parameters, session, flash, http_method)
      example.metadata[:response] = "#{response.status} #{response.body}"

      response
    end
  end
end

Finally I added a custom rspec formatter to output the requests and responses as markdown.

require 'rspec/core/formatters/base_formatter'

class APIFormatter < RSpec::Core::Formatters::BaseFormatter
  def initialize(output)
    super(output)
  end

  def example_group_started(example_group)
    indented_message("#{example_group.description}n", example_group)
  end

  def example_group_finished(example_group)

  end

  def example_started(example)
    indented_message("#{example.description}n", example)
  end

  def example_passed(example)
    indented_message("#{example.metadata[:curl]}n")
    indented_message("#{example.metadata[:response]}nn")
  end

  def example_failed(example)

  end

  def example_pending(example)

  end

  private

  def indented_message(message, context = nil)
    if context.present?
      if context.respond_to?(:example_group)
        (context.example_group.ancestors.count + 1).times do
          output << '#'
        end
      else
        context.ancestors.count.times do
          output << '#'
        end
      end
    else
      output << '    '
    end
    output << message
  end
end

#Api::V1::AccountsController
###index
###without an API key
####behaves like an unauthenticated user
#####responds with success status
curl -X GET https://api.host.example/v1/accounts
200 {"error":{"type":"Unauthorized","message":"Authentication is required to access this resource"}}
...

Now I can generate a description of my API based on my specs complete with examples of hitting the API using curl and the expected response status and body. As long as I name my rspec assertions and contexts well this should be legible and always up to date since I can regenerate the docs and push them to a github wiki page every time my specs pass on a continuous integration server.