Skip to content

Instantly share code, notes, and snippets.

@nikone
Created March 23, 2016 08:55
Show Gist options
  • Save nikone/de4fb72b50287354528a to your computer and use it in GitHub Desktop.
Save nikone/de4fb72b50287354528a to your computer and use it in GitHub Desktop.
Api Blueprint test docs
# lib/api_blueprint.rb
require 'rspec/core/formatters/base_formatter'
require 'pry'
class ApiBlueprintPrinter
def initialize(output)
@output = output
end
def print_meta_info
@output.puts("# Tiger Trade API Documentation")
end
def print_resource_group(resource_group_name, resource_group_resources)
@output.puts "# Group #{resource_group_name}"
resource_group_resources.each(&method(:print_resource))
end
def print_resource(resource_name, actions)
unless resource_name =~ /^[^\[\]]*\[\/[^\]]+\]/
raise "resource: '#{resource_name}' is invalid. :resource needs to be specified according to https://github.com/apiaryio/api-blueprint/blob/master/API%20Blueprint%20Specification.md#resource-section"
end
@output.puts "# #{resource_name}"
http_verbs = actions.keys.map {|action| action.scan(/\[([A-Z]+.*)\]/).flatten[0] }
unless http_verbs.length == http_verbs.uniq.length
raise "Action HTTP verbs are not unique #{actions.keys.inspect} for resource: '#{resource_name}'"
end
actions.each(&method(:print_action))
end
def print_action(action_name, action_meta_data)
@output.puts "## #{action_name}\n" \
"\n" \
"#{action_meta_data[:description]}\n" \
"\n" \
uri_params = action_meta_data[:action_parameters].map do |param|
" + #{param[:key]}: #{param[:value]} (#{param[:type]}, #{param[:required]}) - #{param[:description]}"
end
if uri_params.present?
@output.puts(["+ Parameters", uri_params].flatten.join("\n"))
end
action_meta_data[:examples].each(&method(:print_example))
end
def print_example(_example_description, example_metadata)
@output.puts "+ Request #{example_metadata[:request][:identifier]} (#{example_metadata[:request][:format]})"
if example_metadata[:request][:parameters].present?
@output.puts "\n" \
"#{indent_lines(8, pretty_json(example_metadata[:request][:parameters]))}\n" \
end
@output.puts "+ Response #{example_metadata[:response][:status]} (#{example_metadata[:request][:format]})"
if example_metadata[:response][:body].present?
@output.puts "\n" \
"#{indent_lines(8, pretty_json(safe_json_parse(example_metadata[:response][:body])))}\n" \
"\n"
end
end
def pretty_json(json_string)
if json_string.present?
JSON.pretty_generate(json_string)
else
''
end
end
def safe_json_parse(json_string)
json_string.length >= 2 ? JSON.parse(json_string) : nil
end
def indent_lines(number_of_spaces, string)
string
.split("\n")
.map { |a| a.prepend(' ' * number_of_spaces) }
.join("\n")
end
end
class ApiBlueprint < RSpec::Core::Formatters::BaseFormatter
RSpec::Core::Formatters.register self, :example_passed, :example_started, :stop
def initialize(output)
super
@passed_examples = {}
@group_level = 0
end
def example_started(notification)
@example_group_instance = notification.example.example_group_instance
end
def example_passed(passed)
metadata = passed.example.metadata
if should_document_example?(metadata)
move_example_to_passed(metadata)
end
@example_group_instance = nil
end
def stop(_notification)
printer.print_meta_info
@passed_examples.sort_by { |k, _v| k }.each do |resource_group_name, resource_group_resources|
printer.print_resource_group(resource_group_name, resource_group_resources)
end
end
private
def move_example_to_passed(metadata)
@passed_examples.deep_merge!({
metadata[:resource_group] => {
metadata[:resource] => {
metadata[:action] => {
description: metadata[:action_description],
action_parameters: metadata[:action_parameters] || [],
examples: {
metadata[:description] => {
request: {
parameters: request.parameters.except(*request.path_parameters.keys.map(&:to_s)).except(*request.query_parameters.keys.map(&:to_s)),
format: request.format,
identifier: fullpath
},
response: {
status: response.status,
body: response.body
}
}
}
}
}
}
})
end
def should_document_example?(metadata)
metadata[:apidoc] &&
metadata[:resource_group] &&
metadata[:resource] &&
metadata[:action] &&
metadata[:action_description] &&
!metadata[:nodoc]
end
def request
@example_group_instance.request
end
def response
@example_group_instance.response
end
def path
request.path.sub(/\.json[^\?]*/, '')
end
def fullpath
if request.query_parameters.present?
URI::HTTP.build(path: path, query: request.query_parameters.to_query)
else
path
end
end
def printer
@printer ||= ApiBlueprintPrinter.new(output)
end
end
# lib/tasks/api_docs.rake
task generate_api_docs: :environment do
`bundle exec rspec spec --tag apidoc -f ApiBlueprint --order defined --out spec/apispec.md`
`aglio -i spec/apispec.md -o public/api/docs/index.html`
`aglio --theme-variables slate -i spec/apispec.md -o public/api/docs/index-dark.html`
end
# lib/blueprint_api_doc.rb
require 'active_support/concern'
require 'active_support/inflector'
module BlueprintApiDoc
module DSL
module AttrProxy
def method_missing(name, value)
setter = "#{name.to_s}="
return super unless respond_to? setter
public_send setter, value
end
end
class Documentation
attr_accessor :subject
attr_accessor :_resource
attr_accessor :_action
def initialize(opts = {})
self.subject = opts.fetch :subject
end
def resource(name = nil, &block)
self._resource = Resource.new(name: name)
_resource.instance_eval(&block)
end
def action(name = nil, &block)
self._action = Action.new(name: name)
_action.instance_eval(&block)
end
def config
{}.merge(_resource ? _resource.config : {})
.merge(_action ? _action.config : {})
end
end
class Resource
include AttrProxy
attr_writer :name
attr_writer :group
def initialize(opts = {})
self.name = opts.fetch(:name, nil)
self.group = opts.fetch(:group, nil)
end
def config
{}.tap do |config|
config[:resource] = @name if @name
config[:resource_group] = @group if @group
config[:apidoc] = true
end
end
end
class Action
include AttrProxy
attr_writer :name
attr_writer :desc
attr_writer :params
def initialize(opts = {})
self.name = opts.fetch(:name, nil)
self.desc = opts.fetch(:desc, nil)
self.params = opts.fetch(:params, nil)
end
def param(signature)
params << signature
end
def config
Hash.new.tap do |config|
config[:action] = @name if @name
config[:action_description] = @desc if @desc
config[:action_parameters] = @params if @params
end
end
end
module Syntax
extend ActiveSupport::Concern
class_methods do
def document(subject, &block)
documentation = _subjects[subject] = Documentation.new(subject: subject)
documentation.instance_eval(&block)
end
def const_missing(name)
documentation = _subjects[infer_subject(name)]
return super unless documentation
Module.new do
define_singleton_method :included do |base|
base.metadata.merge! documentation.config
end
end
end
def infer_subject(name)
name.to_s.underscore.to_sym
end
def _subjects
@_subjects ||= {}
end
end
end
end
end
# spec/support/api_doc/v1/categories
module ApiDoc
module V1
module Categories
include BlueprintApiDoc::DSL::Syntax
category_params = [{ key: :id, type: :number, required: :required, value: 1, description: 'category id' }]
document :api do
resource do
name 'Categories [/categories]'
group 'Categories'
end
end
document :index do
action do
name 'Get categories [GET /categories]'
desc 'Returns list of categories'
end
end
document :show do
action do
name 'Get category [GET /categories/{id}]'
desc 'Returns category'
params category_params
end
end
document :create do
action do
name 'Post category [POST /categories]'
desc 'Creates category'
end
end
document :update do
action do
name 'Patch category [PATCH /categories/{id}]'
desc 'Updates category'
params category_params
end
end
document :destroy do
action do
name 'Delete category [DELETE /categories/{id}]'
desc 'Deletes category'
params category_params
end
end
end
end
end
# spec/controllers/api/v1/categories_controller_spec.rb
describe Api::V1::CategoriesController, type: :controller do
include ApiDoc::V1::Categories::Api
let(:category) { create(:category) }
describe 'GET #index' do
include ApiDoc::V1::Categories::Index
before { category }
it 'returns a list of categories' do
get :index
expect(response).to have_http_status(:ok)
expect(response_body).to be_jsonapi_collection_for('categories')
end
end
describe 'GET #show' do
include ApiDoc::V1::Categories::Show
it 'returns category' do
get :show, id: category.id
expect(response).to have_http_status(:ok)
expect(response_body).to be_jsonapi_resource_for('categories')
end
context 'invalid id' do
it 'returns not found error' do
get :show, id: 9808789
expect(response).to have_http_status(:not_found)
expect(response).to be_jsonapi_error
end
end
end
describe 'POST #create' do
include ApiDoc::V1::Categories::Create
context 'valid parameters' do
let(:params) { { name: 'Jewlery' } }
it 'creates category' do
expect {
post :create, data: { attributes: params }
}.to change(Category, :count).by(1)
expect(response).to have_http_status(:created)
expect(response_body).to be_jsonapi_resource_for('categories')
end
end
context 'invalid parameters' do
it 'returns bad request error' do
expect {
post :create, data: { attributes: {} }
}.to change(Category, :count).by(0)
expect(response).to have_http_status(:unprocessable_entity)
expect(response).to be_jsonapi_error
end
end
end
describe 'PATCH #update' do
include ApiDoc::V1::Categories::Update
context 'valid parameters' do
let(:params) { { name: 'Jewlery' } }
it 'updates category' do
patch :update, id: category.id, data: { attributes: params }
expect(response).to have_http_status(:ok)
expect(response_body).to be_jsonapi_resource_for('categories')
end
end
context 'invalid parameters' do
let(:params) { { name: '' } }
it 'returns bad request error' do
patch :update, id: category.id, data: { attributes: params }
expect(response).to have_http_status(:unprocessable_entity)
expect(response).to be_jsonapi_error
end
end
end
describe 'DELETE #destroy' do
include ApiDoc::V1::Categories::Destroy
it 'deletes category' do
delete :destroy, id: category.id
expect(response).to have_http_status(:no_content)
expect(response.body).to eq('')
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment