├── .gitignore ├── Gemfile ├── README.md ├── lib ├── minitest │ ├── apidoc.rb │ ├── apidoc │ │ ├── endpoint.rb │ │ ├── example.rb │ │ ├── group.rb │ │ ├── methods.rb │ │ ├── reporter.rb │ │ ├── template.rb │ │ └── version.rb │ ├── apidoc_plugin.rb │ └── minitest_ext.rb └── template.mustache └── minitest-apidoc.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | Gemfile.lock 3 | .DS_Store 4 | TODO -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## minitest-apidoc 2 | 3 | This library allows you to generate API documentation for your Rack-based application from Minitest specs. 4 | 5 | It depends on [Minitest](https://github.com/seattlerb/minitest) and [Rack::Test](https://github.com/brynary/rack-test). 6 | 7 | See an [example of generated documentation](http://htmlpreview.github.io/?https://raw.githubusercontent.com/lauri/krack-apidoc-example/master/doc/index.html). 8 | 9 | See an [example of what the test code looks like](https://github.com/lauri/krack-apidoc-example/blob/master/spec/endpoints/albums/index_spec.rb). 10 | 11 | See an [entire example application](https://github.com/lauri/krack-apidoc-example). 12 | 13 | ### License 14 | This content is released under the [MIT License](http://opensource.org/licenses/MIT). 15 | -------------------------------------------------------------------------------- /lib/minitest/apidoc.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "minitest" # Requires apidoc_plugin.rb automatically 3 | require "minitest/minitest_ext" 4 | require "minitest/apidoc/version" 5 | require "minitest/apidoc/methods" 6 | require "minitest/apidoc/reporter" 7 | 8 | module Minitest 9 | module Apidoc 10 | def self.enable_plugin! 11 | Reporter.enable_plugin = true 12 | end 13 | end 14 | end -------------------------------------------------------------------------------- /lib/minitest/apidoc/endpoint.rb: -------------------------------------------------------------------------------- 1 | module Minitest 2 | module Apidoc 3 | class Endpoint 4 | attr_accessor :metadata, :params, :examples 5 | 6 | def initialize(test_class) 7 | @params = test_class.params 8 | @metadata = test_class.metadata 9 | @examples = [] 10 | end 11 | 12 | # If request method is specified explicitly in the metadata by the user, 13 | # prefer that. If not, grab the request method that was actually used by 14 | # rack-test (stored in the example). 15 | def request_method 16 | @metadata[:request_method] || @examples[0].request_method 17 | end 18 | 19 | # Ditto 20 | def request_path 21 | @metadata[:request_path] || @examples[0].request_path.split("?")[0] 22 | end 23 | 24 | def html_anchor 25 | "#{request_method}-#{request_path}" 26 | end 27 | 28 | def html_class 29 | request_method.downcase 30 | end 31 | 32 | # Used in the Mustache template to determine if the params block should 33 | # be rendered. 34 | def params? 35 | !params.empty? 36 | end 37 | 38 | # Sort order for endpoints: first sort by request method (HEAD, GET, ...) 39 | # and then by length of request path (shortest first). 40 | def sort_index 41 | 1000 * Methods::VERBS.index(request_method.downcase) + request_path.length 42 | end 43 | 44 | def method_missing(name, *args, &block) 45 | @metadata[name] 46 | end 47 | 48 | def respond_to_missing?(name, *) 49 | @metadata.include?(name) || super 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/minitest/apidoc/example.rb: -------------------------------------------------------------------------------- 1 | module Minitest 2 | module Apidoc 3 | class Example 4 | attr_reader :name 5 | 6 | def initialize(test_class) 7 | @name = test_class.metadata[:example_name] 8 | @session = test_class.metadata[:session] 9 | @request = @session.last_request 10 | @response = @session.last_response 11 | end 12 | 13 | def request_method 14 | @request.request_method 15 | end 16 | 17 | def request_path 18 | @request.fullpath 19 | end 20 | 21 | def request_headers 22 | format_headers(@session.instance_variable_get(:@headers)) 23 | end 24 | 25 | def request_body 26 | format_json(@request.body.read) unless @request.body.size.zero? 27 | end 28 | 29 | def response_status 30 | "#{@response.status} #{Rack::Utils::HTTP_STATUS_CODES[@response.status]}" 31 | end 32 | 33 | def response_headers 34 | format_headers(@response.headers) 35 | end 36 | 37 | def response_body 38 | format_json(@response.body) 39 | end 40 | 41 | private 42 | 43 | def format_json(string) 44 | JSON.pretty_generate(JSON.load(string)) 45 | rescue JSON::ParserError, JSON::GeneratorError 46 | string 47 | end 48 | 49 | def format_headers(hash) 50 | hash.to_a.map { |name, value| "#{name}: #{value} "}.join($/) 51 | end 52 | end 53 | end 54 | end -------------------------------------------------------------------------------- /lib/minitest/apidoc/group.rb: -------------------------------------------------------------------------------- 1 | module Minitest 2 | module Apidoc 3 | class Group 4 | attr_accessor :name, :endpoints 5 | 6 | def initialize(name, endpoints) 7 | @name = name || "" 8 | @endpoints = endpoints 9 | end 10 | 11 | # Takes a hash containing endpoint names as keys and endpoint objects as 12 | # values 13 | # 14 | # {"Widgets::Index" => #}, ...) 15 | # 16 | # and turns it into an array containing group objects so that the 17 | # endpoints whose group attribute is the same are put in the same group. 18 | # 19 | # [#, ...] 20 | # 21 | # Sorts groups by name and endpoints within each group as specified by 22 | # Endpoint#sort_index. The template works with this array to display the 23 | # documentation. 24 | def self.from(endpoint_hash) 25 | endpoint_hash 26 | .values 27 | .sort_by(&:sort_index) 28 | .group_by { |endpoint| endpoint.group } 29 | .map { |name, endpoints| Group.new(name, endpoints) } 30 | .sort_by(&:name) 31 | end 32 | end 33 | end 34 | end -------------------------------------------------------------------------------- /lib/minitest/apidoc/methods.rb: -------------------------------------------------------------------------------- 1 | require "rack/test" 2 | 3 | module Minitest 4 | module Apidoc 5 | module Methods 6 | include Rack::Test::Methods 7 | 8 | VERBS = %w[head get post put patch delete options] 9 | 10 | # Takes over rack-test's `get`, `post`, etc. methods (first aliasing the 11 | # originals so that they can still be used). This way we can call the 12 | # methods normally in our tests but they perform all the documentation 13 | # goodness. 14 | VERBS.each do |verb| 15 | alias_method "rack_test_#{verb}", verb 16 | define_method(verb) do |uri, params={}, env={}, &block| 17 | _request(verb, uri, params, env, &block) 18 | end 19 | end 20 | 21 | # Performs a rack-test request while also saving the metadata necessary 22 | # for documentation. Detects if the response is JSON (naively by just 23 | # trying to parse it as JSON). If it is, formats the response nicely and 24 | # also yields the data as parsed JSON object instead of raw text. 25 | def _request(verb, uri, params={}, env={}) 26 | send("rack_test_#{verb}", uri, params, env) 27 | self.class.metadata[:session] = current_session 28 | 29 | response_data = begin 30 | JSON.load(last_response.body) 31 | rescue JSON::ParserError 32 | last_response.body 33 | end 34 | 35 | yield response_data if block_given? 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/minitest/apidoc/reporter.rb: -------------------------------------------------------------------------------- 1 | require "minitest/apidoc/group" 2 | require "minitest/apidoc/example" 3 | require "minitest/apidoc/endpoint" 4 | require "minitest/apidoc/template" 5 | 6 | module Minitest 7 | module Apidoc 8 | class Reporter < Minitest::Reporter 9 | class << self 10 | attr_accessor :enable_plugin 11 | end 12 | 13 | def initialize 14 | super 15 | @endpoints = {} 16 | end 17 | 18 | def record(result) 19 | @endpoints[result.test_class] ||= Endpoint.new(result.test_class) 20 | 21 | if result.passed? 22 | @endpoints[result.test_class].examples << Example.new(result.test_class) 23 | end 24 | end 25 | 26 | def passed? 27 | groups = Group.from(@endpoints) 28 | Template.new(groups).write 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/minitest/apidoc/template.rb: -------------------------------------------------------------------------------- 1 | require "mustache" 2 | 3 | module Minitest 4 | module Apidoc 5 | class Template < Mustache 6 | class << self 7 | attr_accessor :template_file, :output_file, :blurb 8 | end 9 | 10 | attr_reader :groups 11 | 12 | self.template_file = File.expand_path("../../../template.mustache", __FILE__) 13 | self.output_file = "apidoc.html" 14 | 15 | def initialize(groups) 16 | @groups = groups 17 | end 18 | 19 | def blurb 20 | self.class.blurb 21 | end 22 | 23 | def write 24 | File.write(self.class.output_file, render) 25 | end 26 | end 27 | end 28 | end -------------------------------------------------------------------------------- /lib/minitest/apidoc/version.rb: -------------------------------------------------------------------------------- 1 | module Minitest 2 | module Apidoc 3 | VERSION = "0.5.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/minitest/apidoc_plugin.rb: -------------------------------------------------------------------------------- 1 | module Minitest 2 | def self.plugin_apidoc_init(options) 3 | self.reporter << Apidoc::Reporter.new if Apidoc::Reporter.enable_plugin 4 | end 5 | end -------------------------------------------------------------------------------- /lib/minitest/minitest_ext.rb: -------------------------------------------------------------------------------- 1 | module Minitest 2 | class Test < Runnable 3 | class << self 4 | def metadata 5 | @metadata ||= {} 6 | end 7 | 8 | def params 9 | @params ||= [] 10 | end 11 | 12 | def example(desc=nil, &block) 13 | it desc do 14 | self.class.metadata[:example_name] = desc 15 | self.instance_eval(&block) 16 | end 17 | end 18 | 19 | def meta(key, value) 20 | metadata[key] = value 21 | end 22 | 23 | def param(name, description, options={}) 24 | params << {name: name, description: description}.merge(options) 25 | end 26 | end 27 | end 28 | 29 | class Result < Runnable 30 | attr_accessor :test_class 31 | 32 | class << self 33 | alias :__from__ :from 34 | 35 | def from(runnable) 36 | __from__(runnable).tap { |r| r.test_class = runnable.class } 37 | end 38 | end 39 | end 40 | end 41 | 42 | module Kernel 43 | # `document` is an alias for `describe` but it also declares the tests 44 | # order-dependent. This is because we would like to have the documentation 45 | # always in the same order. 46 | def document(desc, additional_desc=nil, &block) 47 | describe desc, additional_desc do 48 | i_suck_and_my_tests_are_order_dependent! 49 | self.instance_eval(&block) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/template.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 172 | 191 | 192 | 193 | 208 |
209 | {{{blurb}}} 210 | {{#groups}} 211 |

{{name}}

212 | {{#endpoints}} 213 |
214 |

215 | {{request_method}} 216 | {{request_path}} 217 |

218 |

{{{description}}}

219 | {{#params?}} 220 |

Parameters

221 | 222 | {{#params}} 223 | 224 | 227 | 232 | 233 | {{/params}} 234 |
225 | {{name}} 226 | 228 | {{{description}}} 229 | {{#default}}Default: {{default}}{{/default}} 230 | {{#required}}Required{{/required}} 231 |
235 | {{/params?}} 236 | {{#examples}} 237 |
238 |

Example

239 | {{#name}}

{{name}}

{{/name}} 240 |
241 | Toggle headers 242 |
{{request_method}} {{request_path}}
243 |
{{request_headers}}
244 |
{{request_body}}
245 |
246 |
247 |
{{response_status}}
{{response_headers}}
248 |
{{response_body}}
249 |
250 |
251 | {{/examples}} 252 |
253 | {{/endpoints}} 254 | {{/groups}} 255 |
256 | 257 | 258 | -------------------------------------------------------------------------------- /minitest-apidoc.gemspec: -------------------------------------------------------------------------------- 1 | $: << File.expand_path("../lib", __FILE__) 2 | require "minitest/apidoc/version" 3 | 4 | Gem::Specification.new do |gem| 5 | gem.name = "minitest-apidoc" 6 | gem.version = Minitest::Apidoc::VERSION 7 | gem.email = ["git@lap.fi"] 8 | gem.authors = gem.email 9 | gem.description = "Generate API documentation from Minitest specs" 10 | gem.summary = gem.description 11 | gem.homepage = "https://github.com/lauri/minitest-apidoc" 12 | 13 | gem.files = `git ls-files`.split($/) 14 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 15 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 16 | gem.require_paths = ["lib"] 17 | 18 | gem.add_dependency "minitest", ">= 5.11.2" 19 | gem.add_dependency "rack-test", ">= 0.6.0" 20 | gem.add_dependency "mustache", ">= 1.0.0" 21 | end 22 | --------------------------------------------------------------------------------