├── .rspec ├── .gitignore ├── Gemfile ├── lib ├── rspec_api_docs │ ├── version.rb │ ├── formatter │ │ ├── renderer │ │ │ ├── raddocs_renderer │ │ │ │ ├── link.rb │ │ │ │ ├── index_serializer.rb │ │ │ │ └── resource_serializer.rb │ │ │ ├── json_renderer │ │ │ │ ├── name.rb │ │ │ │ ├── resource_serializer.rb │ │ │ │ └── example_serializer.rb │ │ │ ├── README.md │ │ │ ├── slate_renderer.rb │ │ │ ├── slate_renderer │ │ │ │ └── slate_index.html.md.erb │ │ │ ├── raddocs_renderer.rb │ │ │ └── json_renderer.rb │ │ ├── resource │ │ │ ├── response_field.rb │ │ │ ├── parameter.rb │ │ │ ├── example │ │ │ │ ├── request_headers.rb │ │ │ │ └── deep_hash_set.rb │ │ │ └── example.rb │ │ └── resource.rb │ ├── config.rb │ ├── resource_collection.rb │ ├── after.rb │ ├── dsl │ │ ├── request_store.rb │ │ └── doc_proxy.rb │ ├── after │ │ └── type_checker.rb │ ├── dsl.rb │ ├── formatter.rb │ └── rake_task.rb └── rspec_api_docs.rb ├── bin ├── setup └── generate_integration_docs ├── .travis.yml ├── spec ├── spec_helper.rb ├── integration │ ├── json_helper.rb │ ├── slate_helper.rb │ ├── raddocs_helper.rb │ ├── output │ │ ├── raddocs │ │ │ ├── characters │ │ │ │ ├── characters_head.json │ │ │ │ ├── when_a_character_cannot_be_found.json │ │ │ │ ├── deleting_a_character.json │ │ │ │ ├── listing_all_characters.json │ │ │ │ └── fetching_a_character.json │ │ │ ├── places │ │ │ │ ├── listing_all_places.json │ │ │ │ ├── listing_all_places_with_a_modified_response_bod_.json │ │ │ │ └── fetching_all_places_and_page__.json │ │ │ └── index.json │ │ ├── slate │ │ │ └── index.html.md │ │ └── json │ │ │ └── index.json │ └── rspec_api_docs_spec.rb └── rspec_api_docs │ ├── formatter │ ├── renderer │ │ ├── raddocs_renderer │ │ │ ├── link_spec.rb │ │ │ └── index_serializer_spec.rb │ │ └── json_renderer │ │ │ ├── name_spec.rb │ │ │ ├── example_serializer_spec.rb │ │ │ └── resource_serializer_spec.rb │ ├── resource │ │ ├── example │ │ │ ├── request_headers_spec.rb │ │ │ └── deep_hash_set_spec.rb │ │ └── example_spec.rb │ └── resource_spec.rb │ ├── resource_collection_spec.rb │ └── after │ └── type_checker_spec.rb ├── Rakefile ├── LICENSE.txt ├── .rubocop.yml ├── rspec-api-docs.gemspec ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /Gemfile.lock 2 | /pkg 3 | /.yardoc 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/version.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | VERSION = '1.1.0.2' 3 | end 4 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | IFS=$'\n\t' 5 | set -vx 6 | 7 | bundle install 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.3.1 5 | before_install: gem install bundler -v 1.13.5 6 | -------------------------------------------------------------------------------- /lib/rspec_api_docs.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/config' 2 | require 'rspec_api_docs/version' 3 | 4 | # The base module of the gem. 5 | module RspecApiDocs 6 | METADATA_NAMESPACE = :rspec_api_docs 7 | 8 | BaseError = Class.new(StandardError) 9 | end 10 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | 3 | require 'rspec_api_docs' 4 | require 'rspec_api_docs/after' 5 | require 'pry' 6 | 7 | RSpec.configure do |config| 8 | config.after &RspecApiDocs::After::Hook 9 | end 10 | -------------------------------------------------------------------------------- /spec/integration/json_helper.rb: -------------------------------------------------------------------------------- 1 | RspecApiDocs.configure do |config| 2 | config.output_dir = File.expand_path('../output/json', __FILE__) 3 | config.renderer = :json 4 | end 5 | 6 | RspecApiDocs.configure do |config| 7 | config.exclude_request_headers = %w[Authorization] 8 | end 9 | -------------------------------------------------------------------------------- /spec/integration/slate_helper.rb: -------------------------------------------------------------------------------- 1 | RspecApiDocs.configure do |config| 2 | config.output_dir = File.expand_path('../output/slate', __FILE__) 3 | config.renderer = :slate 4 | end 5 | 6 | RspecApiDocs.configure do |config| 7 | config.exclude_request_headers = %w[Authorization] 8 | end 9 | -------------------------------------------------------------------------------- /spec/integration/raddocs_helper.rb: -------------------------------------------------------------------------------- 1 | RspecApiDocs.configure do |config| 2 | config.output_dir = File.expand_path('../output/raddocs', __FILE__) 3 | config.renderer = :raddocs 4 | end 5 | 6 | RspecApiDocs.configure do |config| 7 | config.exclude_request_headers = %w[Authorization] 8 | end 9 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/renderer/raddocs_renderer/link.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | module Renderer 3 | class RaddocsRenderer 4 | class Link 5 | def self.call(resource_name, example_name) 6 | "#{resource_name.downcase.gsub(/[^a-z]/, '_')}/#{example_name.downcase.gsub(/[^a-z]/, '_')}.json" 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/renderer/json_renderer/name.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | module Renderer 3 | class JSONRenderer 4 | class Name 5 | def self.call(name:, scope:) 6 | scope = Array(scope) 7 | if scope.empty? 8 | name 9 | else 10 | scope.each_with_index.inject('') do |str, (part, index)| 11 | str << (index == 0 ? part : "[#{part}]").to_s 12 | end + "[#{name}]" 13 | end 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/rspec_api_docs/formatter/renderer/raddocs_renderer/link_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/renderer/raddocs_renderer/link' 2 | 3 | module RspecApiDocs 4 | module Renderer 5 | class RaddocsRenderer 6 | RSpec.describe Link do 7 | describe '.call' do 8 | it 'returns a cleaned link' do 9 | expect(Link.('Other Characters', 'Returns a Character')) 10 | .to eq 'other_characters/returns_a_character.json' 11 | end 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /bin/generate_integration_docs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -ex 4 | 5 | rm -rf spec/integration/output 6 | 7 | bundle exec rspec spec/integration/*_spec.rb --format RspecApiDocs::Formatter --require ./spec/integration/json_helper.rb 8 | bundle exec rspec spec/integration/*_spec.rb --format RspecApiDocs::Formatter --require ./spec/integration/raddocs_helper.rb 9 | bundle exec rspec spec/integration/*_spec.rb --format RspecApiDocs::Formatter --require ./spec/integration/slate_helper.rb 10 | 11 | { set +x; } 2>/dev/null 12 | 13 | if [[ -n "$(git status -z --porcelain spec/integration/output)" ]]; then 14 | git diff spec/integration/output 15 | exit 1 16 | fi 17 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/renderer/README.md: -------------------------------------------------------------------------------- 1 | # Renderers 2 | 3 | Included renders: 4 | 5 | - JSON renderer for https://github.com/twe4ked/api-docs 6 | - Raddocs renderer for https://github.com/smartlogic/raddocs 7 | - Slate renderer for https://github.com/lord/slate 8 | 9 | ## Protocol 10 | 11 | A renderer gets initialized with an array of `Resource`s and then the `render` 12 | instance method is called. 13 | 14 | ## Example 15 | 16 | ``` ruby 17 | class ExampleRenderer 18 | def initialize(resources) 19 | @resources = resources 20 | end 21 | 22 | def render 23 | puts @resources.map(&:name).join("\n") 24 | end 25 | end 26 | 27 | RspecApiDocs.configure do |config| 28 | config.renderer = ExampleRenderer 29 | end 30 | ``` 31 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/config.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | class << self 3 | attr_accessor :configuration 4 | 5 | def configuration 6 | @configuration ||= Config.new 7 | end 8 | end 9 | 10 | def self.configure 11 | self.configuration ||= Config.new 12 | yield configuration 13 | end 14 | 15 | # Used to control the behaviour of the gem. 16 | class Config 17 | attr_accessor \ 18 | :output_dir, 19 | :renderer, 20 | :validate_params, 21 | :exclude_request_headers, 22 | :exclude_response_headers 23 | 24 | def initialize 25 | @output_dir = 'docs' 26 | @renderer = :json 27 | @validate_params = true 28 | @exclude_request_headers = [] 29 | @exclude_response_headers = [] 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/renderer/slate_renderer.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | module Renderer 3 | class SlateRenderer 4 | attr_reader :resources 5 | 6 | def initialize(resources) 7 | @resources = resources 8 | end 9 | 10 | def render 11 | FileUtils.mkdir_p output_file.dirname 12 | 13 | File.open(output_file, 'w') do |f| 14 | f.write ERB.new(File.read(template), nil, '-').result(binding) 15 | end 16 | end 17 | 18 | private 19 | 20 | def output_file 21 | Pathname.new(RspecApiDocs.configuration.output_dir) + 'index.html.md' 22 | end 23 | 24 | def template 25 | File.expand_path('../slate_renderer/slate_index.html.md.erb', __FILE__) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/resource_collection.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | class ResourceCollection 3 | def initialize(resources = {}) 4 | @resources = resources 5 | end 6 | 7 | def all 8 | @resources.values.sort_by { |resource| [resource.precedence, resource.name] } 9 | end 10 | 11 | def add_example(rspec_example) 12 | resource = Resource.new(rspec_example) 13 | 14 | existing_resource = @resources[resource.name] 15 | if existing_resource 16 | existing_resource.precedence = [existing_resource.precedence, resource.precedence].min 17 | resource = existing_resource 18 | else 19 | @resources[resource.name] = resource 20 | end 21 | 22 | resource.add_example Resource::Example.new(rspec_example) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/renderer/json_renderer/resource_serializer.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/renderer/json_renderer/example_serializer' 2 | 3 | module RspecApiDocs 4 | module Renderer 5 | class JSONRenderer 6 | class ResourceSerializer 7 | attr_reader :resource 8 | 9 | def initialize(resource) 10 | @resource = resource 11 | end 12 | 13 | def to_h 14 | { 15 | name: resource.name, 16 | description: resource.description, 17 | examples: examples, 18 | } 19 | end 20 | 21 | private 22 | 23 | def examples 24 | resource.examples.map do |example| 25 | ExampleSerializer.new(example).to_h 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/rspec_api_docs/formatter/renderer/json_renderer/name_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/renderer/json_renderer/name' 2 | 3 | module RspecApiDocs 4 | module Renderer 5 | class JSONRenderer 6 | RSpec.describe Name do 7 | describe '.call' do 8 | context 'with the name "foo"' do 9 | subject(:name) { Name.call(name: 'foo', scope: scope) } 10 | 11 | context 'and no scope' do 12 | let(:scope) { } 13 | 14 | it { is_expected.to eq 'foo' } 15 | end 16 | 17 | context 'and a single scope' do 18 | let(:scope) { :bar } 19 | 20 | it { is_expected.to eq 'bar[foo]' } 21 | end 22 | 23 | context 'and multiple scopes' do 24 | let(:scope) { [:bar, :baz] } 25 | 26 | it { is_expected.to eq 'bar[baz][foo]' } 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/resource/response_field.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | class Resource 3 | class ResponseField 4 | attr_reader :name, :field 5 | 6 | def initialize(name, field) 7 | @name = name 8 | @field = field 9 | end 10 | 11 | # The scope of the response field 12 | # 13 | # @return [Array] 14 | def scope 15 | field[:scope] 16 | end 17 | 18 | # The type of the response field 19 | # 20 | # @return [String] 21 | def type 22 | field[:type] 23 | end 24 | 25 | # The description of the response field 26 | # 27 | # @return [String] 28 | def description 29 | field[:description] 30 | end 31 | 32 | # Example value 33 | def example 34 | field[:example] 35 | end 36 | 37 | # @return [true, false] 38 | def ==(other) 39 | name == other.name && 40 | field == other.field 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/resource/parameter.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | class Resource 3 | class Parameter 4 | attr_reader :name, :parameter 5 | 6 | def initialize(name, parameter) 7 | @name = name 8 | @parameter = parameter 9 | end 10 | 11 | # The scope of the parameter 12 | # 13 | # @return [Array] 14 | def scope 15 | parameter[:scope] 16 | end 17 | 18 | # If the parameter is required 19 | # 20 | # @return [String] 21 | def required 22 | !!parameter[:required] 23 | end 24 | 25 | # The description of the parameter 26 | # 27 | # @return [String] 28 | def description 29 | parameter[:description] 30 | end 31 | 32 | # @return [true, false] 33 | def ==(other) 34 | name == other.name && 35 | parameter == other.parameter 36 | end 37 | 38 | # @return [String, nil] 39 | def type 40 | parameter[:type] 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/integration/output/raddocs/characters/characters_head.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": "Characters", 3 | "resource_explanation": null, 4 | "http_method": "HEAD", 5 | "route": "/characters", 6 | "description": "Characters head", 7 | "explanation": null, 8 | "parameters": [], 9 | "response_fields": [], 10 | "requests": [ 11 | { 12 | "request_method": "HEAD", 13 | "request_path": "/characters", 14 | "request_body": null, 15 | "request_headers": { 16 | "Cookie": "", 17 | "Host": "example.org" 18 | }, 19 | "request_query_parameters": {}, 20 | "request_content_type": "application/x-www-form-urlencoded", 21 | "response_status": 200, 22 | "response_status_text": "OK", 23 | "response_body": null, 24 | "response_headers": { 25 | "content-type": "application/json", 26 | "content-length": "74", 27 | "x-content-type-options": "nosniff" 28 | }, 29 | "response_content_type": "application/json", 30 | "curl": null 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/resource/example/request_headers.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | class Resource 3 | class Example 4 | class RequestHeaders 5 | attr_reader :env 6 | 7 | def self.call(*args) 8 | new(*args).call 9 | end 10 | 11 | def initialize(env) 12 | @env = env 13 | end 14 | 15 | def call 16 | headers.reject do |k, v| 17 | excluded_headers.include?(k) 18 | end 19 | end 20 | 21 | private 22 | 23 | # http://stackoverflow.com/a/33235714/826820 24 | def headers 25 | Hash[ 26 | *env.select { |k, v| k.start_with? 'HTTP_' } 27 | .collect { |k, v| [k.sub(/^HTTP_/, ''), v] } 28 | .collect { |k, v| [k.split('_').collect(&:capitalize).join('-'), v] } 29 | .sort.flatten, 30 | ] 31 | end 32 | 33 | def excluded_headers 34 | RspecApiDocs.configuration.exclude_request_headers 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | require 'rubocop/rake_task' 4 | require 'rspec_api_docs/rake_task' 5 | 6 | RSpec::Core::RakeTask.new :rspec do |task| 7 | task.verbose = false 8 | end 9 | 10 | RuboCop::RakeTask.new :rubocop do |task| 11 | task.verbose = false 12 | end 13 | 14 | task :generate_integration_docs do 15 | system './bin/generate_integration_docs' 16 | exit $?.exitstatus 17 | end 18 | 19 | RspecApiDocs::RakeTask.new do |task| 20 | task.verbose = false 21 | task.rspec_opts = [ 22 | '--require ./spec/integration/json_helper.rb', 23 | '--format progress', 24 | ] 25 | task.pattern = 'spec/integration/rspec_api_docs_spec.rb' 26 | task.existing_file = 'spec/integration/output/json/index.json' 27 | task.verify = true 28 | end 29 | 30 | RspecApiDocs::RakeTask.new do |task| 31 | task.verbose = false 32 | task.rspec_opts = [ 33 | '--format progress', 34 | ] 35 | task.pattern = 'spec/integration/rspec_api_docs_spec.rb' 36 | end 37 | 38 | task default: [:rspec, :rubocop, :generate_integration_docs] 39 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/after.rb: -------------------------------------------------------------------------------- 1 | # TODO: Move Resource out of formatter dir 2 | require 'rspec_api_docs/formatter/resource' 3 | require 'rspec_api_docs/after/type_checker' 4 | 5 | module RspecApiDocs 6 | UndocumentedParameter = Class.new(BaseError) 7 | 8 | module After 9 | Hook = -> (example) do 10 | metadata = example.metadata[METADATA_NAMESPACE] 11 | return unless metadata 12 | 13 | metadata[:requests] ||= [] 14 | metadata[:requests] << [last_request, last_response] 15 | 16 | return unless RspecApiDocs.configuration.validate_params 17 | 18 | metadata[:requests].each do |request, response| 19 | request.params.each do |key, value| 20 | parameter = RspecApiDocs::Resource::Example.new(example).parameters 21 | .select { |parameter| parameter.name == key.to_sym }.first 22 | 23 | if parameter 24 | After::TypeChecker.call(type: parameter.type, value: value) 25 | else 26 | raise UndocumentedParameter, "undocumented parameter included in request #{key.inspect}" 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Odin Dutton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/rspec_api_docs/formatter/resource/example/request_headers_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/resource/example/request_headers' 2 | 3 | module RspecApiDocs 4 | class Resource 5 | class Example 6 | RSpec.describe RequestHeaders do 7 | describe '.call' do 8 | let(:env) do 9 | { 10 | 'HTTP_AUTHORIZATION' => 'Basic foo', 11 | 'HTTP_ACCEPT' => 'application/json', 12 | } 13 | end 14 | 15 | it 'returns the formatted headers' do 16 | expect(RequestHeaders.call(env)).to eq( 17 | 'Authorization' => 'Basic foo', 18 | 'Accept' => 'application/json', 19 | ) 20 | end 21 | 22 | context 'with an excluded header' do 23 | before do 24 | allow(RspecApiDocs).to receive(:configuration) 25 | .and_return(double(exclude_request_headers: %w[Authorization])) 26 | end 27 | 28 | it 'excludes the header' do 29 | expect(RequestHeaders.call(env)).to eq('Accept' => 'application/json') 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-rake 3 | - rubocop-rspec 4 | 5 | AllCops: 6 | TargetRubyVersion: 3.4 7 | DisplayCopNames: true 8 | DisabledByDefault: true 9 | 10 | Layout/LineLength: 11 | Max: 120 12 | 13 | Layout/DotPosition: 14 | EnforcedStyle: leading 15 | 16 | Layout/HashAlignment: 17 | Enabled: true 18 | 19 | Layout/ExtraSpacing: 20 | AllowForAlignment: false 21 | 22 | Style/HashSyntax: 23 | EnforcedStyle: ruby19 24 | 25 | Style/PercentLiteralDelimiters: 26 | PreferredDelimiters: 27 | '%': '{}' 28 | '%i': '[]' 29 | '%q': '{}' 30 | '%Q': '{}' 31 | '%r': '{}' 32 | '%s': '[]' 33 | '%w': '[]' 34 | '%W': '[]' 35 | '%x': '[]' 36 | 37 | Layout/SpaceInsideHashLiteralBraces: 38 | EnforcedStyle: no_space 39 | 40 | Layout/SpaceInsideBlockBraces: 41 | EnforcedStyleForEmptyBraces: space 42 | 43 | Style/StringLiterals: 44 | EnforcedStyle: single_quotes 45 | 46 | Style/TrailingCommaInArrayLiteral: 47 | EnforcedStyleForMultiline: comma 48 | 49 | Style/TrailingCommaInHashLiteral: 50 | EnforcedStyleForMultiline: comma 51 | 52 | Style/TrailingCommaInArguments: 53 | EnforcedStyleForMultiline: comma 54 | 55 | Style/ClassAndModuleChildren: 56 | EnforcedStyle: nested 57 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/dsl/request_store.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | module Dsl 3 | # Used to store request/response pairs. 4 | class RequestStore 5 | attr_reader :metadata 6 | 7 | def initialize(example) 8 | @metadata = example.metadata 9 | end 10 | 11 | # Only needed if you need to store multiple requests for a single example. 12 | # 13 | # Usage: 14 | # 15 | # it 'stores the requests a character' do 16 | # doc do 17 | # explanation 'Creating and requesting a character' 18 | # end 19 | # 20 | # post '/characters', {name: 'Finn The Human'} 21 | # 22 | # doc << [last_request, last_response] 23 | # 24 | # get '/characters/1' 25 | # 26 | # # The last request/response pair is stored automatically 27 | # end 28 | # 29 | # @param value [Array] an array of a request and response object 30 | # @return [void] 31 | def <<(value) 32 | metadata[METADATA_NAMESPACE][:requests] ||= [] 33 | metadata[METADATA_NAMESPACE][:requests] << value.sort_by { |v| v.class.name }.reverse 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/rspec_api_docs/formatter/renderer/json_renderer/example_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/renderer/json_renderer/example_serializer' 2 | require 'rspec_api_docs/formatter/resource/example' 3 | 4 | module RspecApiDocs 5 | module Renderer 6 | class JSONRenderer 7 | RSpec.describe ExampleSerializer do 8 | describe '#to_h' do 9 | let(:example) do 10 | instance_double Resource::Example, 11 | description: 'Example description', 12 | name: 'Example name', 13 | http_method: 'GET', 14 | parameters: [], 15 | path: '/characters', 16 | requests: [], 17 | response_fields: [], 18 | notes: {} 19 | end 20 | 21 | it 'returns a hash' do 22 | expect(ExampleSerializer.new(example).to_h).to eq( 23 | description: 'Example description', 24 | name: 'Example name', 25 | http_method: 'GET', 26 | parameters: [], 27 | path: '/characters', 28 | requests: [], 29 | response_fields: [], 30 | notes: {}, 31 | ) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/integration/output/raddocs/characters/when_a_character_cannot_be_found.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": "Characters", 3 | "resource_explanation": null, 4 | "http_method": "GET", 5 | "route": "/characters/:id", 6 | "description": "When a Character cannot be found", 7 | "explanation": "Returns an error", 8 | "parameters": [], 9 | "response_fields": [ 10 | { 11 | "scope": "errors", 12 | "Type": "string", 13 | "name": "message", 14 | "description": "Error message" 15 | } 16 | ], 17 | "requests": [ 18 | { 19 | "request_method": "GET", 20 | "request_path": "/characters/404", 21 | "request_body": null, 22 | "request_headers": { 23 | "Cookie": "", 24 | "Host": "example.org" 25 | }, 26 | "request_query_parameters": {}, 27 | "request_content_type": null, 28 | "response_status": 404, 29 | "response_status_text": "Not Found", 30 | "response_body": "{\"errors\":{\"message\":\"Character not found.\"}}", 31 | "response_headers": { 32 | "content-type": "application/json", 33 | "content-length": "45", 34 | "x-content-type-options": "nosniff" 35 | }, 36 | "response_content_type": "application/json", 37 | "curl": null 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /spec/rspec_api_docs/formatter/renderer/raddocs_renderer/index_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/renderer/raddocs_renderer/index_serializer' 2 | 3 | module RspecApiDocs 4 | module Renderer 5 | class RaddocsRenderer 6 | RSpec.describe IndexSerializer do 7 | describe '#to_h' do 8 | let(:rspec_example) do 9 | double :rspec_example, 10 | metadata: { 11 | METADATA_NAMESPACE => { 12 | requests: [ 13 | [double(request_method: 'GET', path: '/characters/:id')], 14 | ], 15 | }, 16 | example_group: {description: 'Character'}, 17 | }, 18 | description: 'Viewing a character' 19 | end 20 | let(:resource) { Resource.new(rspec_example) } 21 | let(:index_serializer) { IndexSerializer.new([resource]) } 22 | 23 | before do 24 | resource.add_example Resource::Example.new(rspec_example) 25 | end 26 | 27 | it 'includes a link' do 28 | expect(index_serializer.to_h[:resources].first[:examples].first[:link]) 29 | .to eq 'character/viewing_a_character.json' 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/renderer/slate_renderer/slate_index.html.md.erb: -------------------------------------------------------------------------------- 1 | --- 2 | title: API Reference 3 | search: true 4 | --- 5 | 6 | <% resources.each do |resource| %> 7 | # <%= resource.name %> 8 | 9 | <% resource.examples.each do |example| %> 10 | ## <%= example.name %> 11 | 12 | <%= example.description %> 13 | 14 | <% example.requests.each do |request| -%> 15 | ```json 16 | <% response_body = JSON.parse(request[:response_body]) if request[:response_body] %> 17 | <%= JSON.pretty_generate(response_body) if response_body %> 18 | ``` 19 | 20 | ### HTTP Request 21 | 22 | `<%= request[:request_method] %> http://example.com<%= request[:request_path] %>` 23 | 24 | <% end -%> 25 | <% unless example.parameters.empty? -%> 26 | ### Query Parameters 27 | 28 | Parameter | Required | Description 29 | --------- | ------- | ----------- 30 | <% example.parameters.each do |parameter| -%> 31 | <%= parameter.name %> | <%= !!parameter.required %> | <%= parameter.description %> 32 | <% end -%> 33 | <% end -%> 34 | 35 | <% unless example.response_fields.empty? -%> 36 | ### Response Fields 37 | 38 | Parameter | Type | Description 39 | --------- | ------- | ----------- 40 | <% example.response_fields.each do |parameter| -%> 41 | <%= parameter.name %> | <%= parameter.type %> | <%= parameter.description %> 42 | <% end -%> 43 | <% end -%> 44 | <% end -%> 45 | <% end -%> 46 | -------------------------------------------------------------------------------- /spec/rspec_api_docs/formatter/renderer/json_renderer/resource_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/renderer/json_renderer/resource_serializer' 2 | 3 | module RspecApiDocs 4 | module Renderer 5 | class JSONRenderer 6 | RSpec.describe ResourceSerializer do 7 | describe '#to_h' do 8 | let(:resource) do 9 | double(:resource, 10 | name: 'Characters', 11 | description: 'All about characters', 12 | examples: [example], 13 | ) 14 | end 15 | let(:example) { double :example } 16 | let(:serialized_example) { double :serialized_example } 17 | let(:example_serializer) { double :example_serializer } 18 | 19 | before do 20 | allow(ExampleSerializer).to receive(:new).with(example) 21 | .and_return example_serializer 22 | allow(example_serializer).to receive(:to_h) 23 | .and_return serialized_example 24 | end 25 | 26 | it 'returns a hash' do 27 | expect(ResourceSerializer.new(resource).to_h) 28 | .to eq( 29 | name: 'Characters', 30 | description: 'All about characters', 31 | examples: [serialized_example], 32 | ) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/integration/output/raddocs/places/listing_all_places.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": "Places", 3 | "resource_explanation": null, 4 | "http_method": "GET", 5 | "route": "/places", 6 | "description": "Listing all places", 7 | "explanation": null, 8 | "parameters": [], 9 | "response_fields": [ 10 | { 11 | "scope": "data", 12 | "Type": "integer", 13 | "name": "id", 14 | "description": "The id of the place" 15 | }, 16 | { 17 | "scope": "data", 18 | "Type": "string", 19 | "name": "name", 20 | "description": "The place's name" 21 | } 22 | ], 23 | "requests": [ 24 | { 25 | "request_method": "GET", 26 | "request_path": "/places", 27 | "request_body": null, 28 | "request_headers": { 29 | "Cookie": "", 30 | "Host": "example.org" 31 | }, 32 | "request_query_parameters": {}, 33 | "request_content_type": null, 34 | "response_status": 200, 35 | "response_status_text": "OK", 36 | "response_body": "{\"data\":[{\"id\":1,\"name\":\"Candy Kingdom\"},{\"id\":2,\"name\":\"Tree Fort\"}]}", 37 | "response_headers": { 38 | "content-type": "application/json", 39 | "content-length": "70", 40 | "x-content-type-options": "nosniff" 41 | }, 42 | "response_content_type": "application/json", 43 | "curl": null 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/after/type_checker.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | module After 3 | class TypeChecker 4 | UnknownType = Class.new(BaseError) 5 | TypeError = Class.new(BaseError) 6 | 7 | attr_reader :type, :value 8 | 9 | def self.call(...) 10 | new(...).check 11 | end 12 | 13 | def initialize(type:, value:) 14 | @type = type 15 | @value = value 16 | end 17 | 18 | def check 19 | case type 20 | when /integer/i 21 | is_integer?(value) or raise_type_error 22 | when /float/i 23 | is_float?(value) or raise_type_error 24 | when /boolean/i 25 | is_bool?(value) or raise_type_error 26 | when /string/i 27 | # NO OP 28 | else 29 | raise UnknownType, "unknown type #{type.inspect}" 30 | end 31 | end 32 | 33 | private 34 | 35 | def is_integer?(str) 36 | Integer(str) && true 37 | rescue ArgumentError 38 | false 39 | end 40 | 41 | def is_float?(str) 42 | Float(str) && true 43 | rescue ArgumentError 44 | false 45 | end 46 | 47 | def is_bool?(str) 48 | %w[true false].include?(str) 49 | end 50 | 51 | def raise_type_error 52 | raise TypeError, "wrong type #{value.inspect}, expected #{type.inspect}" 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/integration/output/raddocs/places/listing_all_places_with_a_modified_response_bod_.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": "Places", 3 | "resource_explanation": null, 4 | "http_method": "GET", 5 | "route": "/places", 6 | "description": "Listing all places with a modified response bod,", 7 | "explanation": null, 8 | "parameters": [], 9 | "response_fields": [ 10 | { 11 | "scope": "data", 12 | "Type": "integer", 13 | "name": "id", 14 | "description": "The id of the place" 15 | }, 16 | { 17 | "scope": "data", 18 | "Type": "string", 19 | "name": "name", 20 | "description": "The place's name" 21 | } 22 | ], 23 | "requests": [ 24 | { 25 | "request_method": "GET", 26 | "request_path": "/places", 27 | "request_body": null, 28 | "request_headers": { 29 | "Cookie": "", 30 | "Host": "example.org" 31 | }, 32 | "request_query_parameters": {}, 33 | "request_content_type": null, 34 | "response_status": 200, 35 | "response_status_text": "OK", 36 | "response_body": "{\"data\":[{\"id\":2,\"name\":\"Tree Fort\"}]}", 37 | "response_headers": { 38 | "content-type": "application/json", 39 | "content-length": "70", 40 | "x-content-type-options": "nosniff" 41 | }, 42 | "response_content_type": "application/json", 43 | "curl": null 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/renderer/json_renderer/example_serializer.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | module Renderer 3 | class JSONRenderer 4 | class ExampleSerializer 5 | attr_reader :example 6 | 7 | def initialize(example) 8 | @example = example 9 | end 10 | 11 | def to_h 12 | { 13 | description: example.description, 14 | name: example.name, 15 | http_method: example.http_method, 16 | parameters: parameters, 17 | path: example.path, 18 | requests: example.requests, 19 | response_fields: response_fields, 20 | notes: example.notes, 21 | } 22 | end 23 | 24 | private 25 | 26 | def parameters 27 | example.parameters.map do |parameter| 28 | { 29 | name: Name.(name: parameter.name, scope: parameter.scope), 30 | description: parameter.description, 31 | required: parameter.required, 32 | } 33 | end 34 | end 35 | 36 | def response_fields 37 | example.response_fields.map do |field| 38 | { 39 | name: Name.(name: field.name, scope: field.scope), 40 | description: field.description, 41 | type: field.type, 42 | } 43 | end 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/integration/output/raddocs/characters/deleting_a_character.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": "Characters", 3 | "resource_explanation": null, 4 | "http_method": "DELETE", 5 | "route": "/characters/:id", 6 | "description": "Deleting a Character", 7 | "explanation": "For getting information about a Character.", 8 | "parameters": [ 9 | { 10 | "required": true, 11 | "scope": "", 12 | "name": "id", 13 | "description": "The id of a character" 14 | } 15 | ], 16 | "response_fields": [ 17 | { 18 | "scope": "", 19 | "Type": "string", 20 | "name": "message", 21 | "description": "Success message" 22 | } 23 | ], 24 | "requests": [ 25 | { 26 | "request_method": "DELETE", 27 | "request_path": "/characters/1", 28 | "request_body": null, 29 | "request_headers": { 30 | "Cookie": "", 31 | "Host": "example.org" 32 | }, 33 | "request_query_parameters": {}, 34 | "request_content_type": "application/x-www-form-urlencoded", 35 | "response_status": 200, 36 | "response_status_text": "OK", 37 | "response_body": "{\"message\":\"Character not found.\"}", 38 | "response_headers": { 39 | "content-type": "application/json", 40 | "content-length": "34", 41 | "x-content-type-options": "nosniff" 42 | }, 43 | "response_content_type": "application/json", 44 | "curl": null 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /spec/integration/output/raddocs/characters/listing_all_characters.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": "Characters", 3 | "resource_explanation": null, 4 | "http_method": "GET", 5 | "route": "/characters", 6 | "description": "Listing all characters", 7 | "explanation": "Getting all the characters.\n\nFor when you need everything!\n", 8 | "parameters": [], 9 | "response_fields": [ 10 | { 11 | "scope": "data", 12 | "Type": "integer", 13 | "name": "id", 14 | "description": "The id of a character" 15 | }, 16 | { 17 | "scope": "data", 18 | "Type": "string", 19 | "name": "name", 20 | "description": "The character's name" 21 | } 22 | ], 23 | "requests": [ 24 | { 25 | "request_method": "GET", 26 | "request_path": "/characters", 27 | "request_body": null, 28 | "request_headers": { 29 | "Cookie": "", 30 | "Host": "example.org" 31 | }, 32 | "request_query_parameters": {}, 33 | "request_content_type": null, 34 | "response_status": 200, 35 | "response_status_text": "OK", 36 | "response_body": "{\"data\":[{\"id\":1,\"name\":\"Finn the Human\"},{\"id\":2,\"name\":\"Jake the Dog\"}]}", 37 | "response_headers": { 38 | "content-type": "application/json", 39 | "content-length": "74", 40 | "x-content-type-options": "nosniff" 41 | }, 42 | "response_content_type": "application/json", 43 | "curl": null 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /rspec-api-docs.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'rspec_api_docs/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'rspec-api-docs' 7 | spec.version = RspecApiDocs::VERSION 8 | spec.authors = ['Odin Dutton'] 9 | spec.email = ['odindutton@gmail.com'] 10 | 11 | spec.summary = 'Generate API documentation using RSpec' 12 | spec.homepage = 'https://github.com/twe4ked' 13 | spec.license = 'MIT' 14 | 15 | spec.metadata = { 16 | 'allowed_push_host' => 'https://rubygems.pkg.github.com/envato', 17 | 'github_repo' => 'ssh://github.com/envato/rspec_api_docs', 18 | } 19 | 20 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) } 21 | spec.bindir = 'bin' 22 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 23 | spec.require_paths = ['lib'] 24 | 25 | spec.add_development_dependency 'base64' 26 | spec.add_development_dependency 'bundler', '~> 2.6.9' 27 | spec.add_development_dependency 'rake', '~> 13.3' 28 | spec.add_development_dependency 'rspec', '~> 3.13' 29 | spec.add_development_dependency 'logger' 30 | spec.add_development_dependency 'ostruct' 31 | spec.add_development_dependency 'pry' 32 | spec.add_development_dependency 'rack-test' 33 | spec.add_development_dependency 'sinatra', '~> 4.1' 34 | spec.add_development_dependency 'rubocop' 35 | spec.add_development_dependency 'rubocop-rake' 36 | spec.add_development_dependency 'rubocop-rspec' 37 | end 38 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/resource/example/deep_hash_set.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | class Resource 3 | class Example 4 | class DeepHashSet 5 | attr_reader :hash, :keys, :value, :node 6 | 7 | def self.call(*args) 8 | new(*args).call 9 | end 10 | 11 | def initialize(hash, keys, value) 12 | @hash = hash 13 | @keys = keys 14 | @value = value 15 | @node = [] 16 | end 17 | 18 | def call 19 | keys.each_with_index do |key, index| 20 | case 21 | when key.nil? 22 | deep_set_value_at_array(index) 23 | break 24 | when index == keys.size - 1 25 | set_value_at(key) 26 | else 27 | node << key 28 | end 29 | end 30 | 31 | hash 32 | end 33 | 34 | private 35 | 36 | attr_reader :node 37 | 38 | def deep_set_value_at_array(index) 39 | array = deep_find(hash, node) 40 | array && array.each do |inner_hash| 41 | DeepHashSet.call(inner_hash, keys[index+1..-1], value) 42 | end 43 | end 44 | 45 | def set_value_at(key) 46 | part = deep_find(hash, node) 47 | if part.is_a?(Hash) && !part[key].nil? 48 | part[key] = value 49 | end 50 | end 51 | 52 | def deep_find(hash, keys) 53 | keys.inject(hash) { |h, k| h && h[k] } 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/integration/output/raddocs/characters/fetching_a_character.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": "Characters", 3 | "resource_explanation": null, 4 | "http_method": "GET", 5 | "route": "/characters/:id", 6 | "description": "Fetching a Character", 7 | "explanation": "For getting information about a Character.", 8 | "parameters": [ 9 | { 10 | "required": true, 11 | "scope": "", 12 | "name": "id", 13 | "description": "The id of a character" 14 | } 15 | ], 16 | "response_fields": [ 17 | { 18 | "scope": "character", 19 | "Type": "integer", 20 | "name": "id", 21 | "description": "The id of a character" 22 | }, 23 | { 24 | "scope": "character", 25 | "Type": "string", 26 | "name": "name", 27 | "description": "The character's name" 28 | } 29 | ], 30 | "requests": [ 31 | { 32 | "request_method": "GET", 33 | "request_path": "/characters/1", 34 | "request_body": null, 35 | "request_headers": { 36 | "Cookie": "", 37 | "Host": "example.org" 38 | }, 39 | "request_query_parameters": {}, 40 | "request_content_type": null, 41 | "response_status": 200, 42 | "response_status_text": "OK", 43 | "response_body": "{\"character\":{\"id\":42,\"name\":\"Finn the Human\"}}", 44 | "response_headers": { 45 | "content-type": "application/json", 46 | "content-length": "47", 47 | "x-content-type-options": "nosniff" 48 | }, 49 | "response_content_type": "application/json", 50 | "curl": null 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/renderer/raddocs_renderer/index_serializer.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/renderer/raddocs_renderer/link' 2 | require 'rspec_api_docs/formatter/resource' 3 | 4 | module RspecApiDocs 5 | module Renderer 6 | class RaddocsRenderer 7 | class IndexSerializer 8 | class ExampleSerializer 9 | attr_reader :example, :resource_name 10 | 11 | def initialize(example, resource_name) 12 | @example = example 13 | @resource_name = resource_name 14 | end 15 | 16 | def to_h 17 | { 18 | description: example.name, 19 | link: link, 20 | groups: groups, 21 | route: example.path, 22 | method: example.http_method.downcase, 23 | } 24 | end 25 | 26 | private 27 | 28 | def link 29 | Link.(resource_name, example.name) 30 | end 31 | 32 | def groups 33 | 'all' 34 | end 35 | end 36 | 37 | attr_reader :resources 38 | 39 | def initialize(resources) 40 | @resources = resources 41 | end 42 | 43 | def to_h 44 | { 45 | resources: resources.map do |resource| 46 | { 47 | name: resource.name, 48 | explanation: nil, 49 | examples: resource.examples.map { |example| ExampleSerializer.new(example, resource.name).to_h }, 50 | } 51 | end, 52 | } 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/dsl.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs' 2 | require 'rspec_api_docs/dsl/request_store' 3 | require 'rspec_api_docs/dsl/doc_proxy' 4 | 5 | module RspecApiDocs 6 | # This module is intended to be included in your RSpec specs to expose the 7 | # {#doc} method. 8 | module Dsl 9 | # DSL method for use in your RSpec examples. 10 | # 11 | # Usage: 12 | # 13 | # it 'returns a character' do 14 | # doc do 15 | # title 'Returns a Character' 16 | # description 'Allows you to return a single character.' 17 | # path '/characters/:id' 18 | # 19 | # param :id, 'The id of a character', required: true 20 | # 21 | # field :id, 'The id of a character', scope: :character 22 | # field :name, "The character's name", scope: :character 23 | # end 24 | # 25 | # get '/characters/1' 26 | # end 27 | # 28 | # For more info on the methods available in the block, see {DocProxy}. 29 | # 30 | # @param should_document [true, false] clear documentation metadata for the example 31 | # @return [RequestStore, nil] an object to store request/response pairs 32 | def doc(should_document = true, &block) 33 | if should_document 34 | example.metadata[METADATA_NAMESPACE] ||= {} 35 | 36 | if block 37 | DocProxy.new(example).instance_eval(&block) 38 | end 39 | 40 | RequestStore.new(example) 41 | else 42 | example.metadata[METADATA_NAMESPACE] = nil 43 | end 44 | end 45 | 46 | private 47 | 48 | def example 49 | RSpec.current_example 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/renderer/raddocs_renderer.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | require 'rspec_api_docs/formatter/renderer/raddocs_renderer/index_serializer' 4 | require 'rspec_api_docs/formatter/renderer/raddocs_renderer/link' 5 | require 'rspec_api_docs/formatter/renderer/raddocs_renderer/resource_serializer' 6 | 7 | module RspecApiDocs 8 | module Renderer 9 | class RaddocsRenderer 10 | attr_reader :resources 11 | 12 | def initialize(resources) 13 | @resources = resources 14 | end 15 | 16 | def render 17 | write_index 18 | write_examples 19 | end 20 | 21 | private 22 | 23 | def write_index 24 | FileUtils.mkdir_p output_dir 25 | 26 | File.open(output_dir + 'index.json', 'w') do |f| 27 | f.write JSON.pretty_generate(IndexSerializer.new(resources).to_h) + "\n" 28 | end 29 | end 30 | 31 | def write_examples 32 | resources.each do |resource| 33 | resource.examples.each do |example| 34 | write_example(resource, example) 35 | end 36 | end 37 | end 38 | 39 | def write_example(resource, example) 40 | FileUtils.mkdir_p file(resource, example).dirname 41 | 42 | File.open(file(resource, example), 'w') do |f| 43 | f.write JSON.pretty_generate(ResourceSerializer.new(resource, example).to_h) + "\n" 44 | end 45 | end 46 | 47 | def output_dir 48 | Pathname.new RspecApiDocs.configuration.output_dir 49 | end 50 | 51 | def file(resource, example) 52 | output_dir + Link.(resource.name, example.name) 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.1.0.2] - 2025-06-03 8 | 9 | ### Changed 10 | - Made it work with Ruby 3.4 and latest `rack` gem ([PR](https://github.com/envato/rspec-api-docs/pull/1)) 11 | 12 | ## [1.1.0.1] - 2022-03-10 13 | 14 | ### Changed 15 | - Envato forked this gem 16 | - Replaced `*args` with `...` to support Ruby 3.x ([commit](https://github.com/envato/rspec-api-docs/commit/e412c6b3cc577e72b8c137c3622b44fe0da0bc5b)) 17 | 18 | ## [1.1.0] - 2018-11-02 19 | 20 | ### Changed 21 | - Relax JSON Content-Type check to work with custom MIME types [#13] ([@2potatocakes]) 22 | 23 | ## [1.0.0] - 2018-09-24 24 | 25 | ### Changed 26 | - The JSON response body will only be documented when the content type is `application/json` [#12] ([@2potatocakes]) 27 | 28 | ## [0.14.0] - 2018-07-21 29 | 30 | ### Added 31 | - This CHANGELOG file. 32 | 33 | ### Changed 34 | - Include the request body in the generated JSON [#9] ([@2potatocakes]) 35 | 36 | [Unreleased]: https://github.com/twe4ked/rspec-api-docs/compare/v1.1.0...HEAD 37 | [1.1.0]: https://github.com/twe4ked/rspec-api-docs/compare/v1.0.0...v1.1.0 38 | [1.0.0]: https://github.com/twe4ked/rspec-api-docs/compare/v0.14.0...v1.0.0 39 | [0.14.0]: https://github.com/twe4ked/rspec-api-docs/compare/v0.13.0...v0.14.0 40 | 41 | [#9]: https://github.com/twe4ked/rspec-api-docs/pull/9 42 | [#12]: https://github.com/twe4ked/rspec-api-docs/pull/12 43 | [@2potatocakes]: https://github.com/2potatocakes 44 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/renderer/json_renderer.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'rspec_api_docs/formatter/renderer/json_renderer/name' 3 | require 'rspec_api_docs/formatter/renderer/json_renderer/resource_serializer' 4 | 5 | module RspecApiDocs 6 | module Renderer 7 | class JSONRenderer 8 | attr_reader :resources 9 | 10 | def initialize(resources) 11 | @resources = resources 12 | end 13 | 14 | def render 15 | FileUtils.mkdir_p output_file.dirname 16 | 17 | File.open(output_file, 'w') do |f| 18 | f.write JSON.pretty_generate(output) + "\n" 19 | end 20 | end 21 | 22 | private 23 | 24 | def output 25 | resources.map do |resource| 26 | recursive_format_hash ResourceSerializer.new(resource).to_h 27 | end 28 | end 29 | 30 | def recursive_format_hash(hash) 31 | case hash 32 | when Hash 33 | Hash[ 34 | hash.map do |key, v| 35 | [ 36 | key.is_a?(Symbol) && key =~ /\A[a-z]/ ? lower_camel_case(key.to_s).to_sym : key, 37 | recursive_format_hash(v), 38 | ] 39 | end, 40 | ] 41 | when Enumerable 42 | hash.map { |value| recursive_format_hash(value) } 43 | else 44 | hash 45 | end 46 | end 47 | 48 | def lower_camel_case(string) 49 | string = string.split('_').collect(&:capitalize).join 50 | string[0].downcase + string[1..-1] 51 | end 52 | 53 | def output_file 54 | Pathname.new(RspecApiDocs.configuration.output_dir) + 'index.json' 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/resource.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/resource/example' 2 | require 'rspec_api_docs/formatter/resource/parameter' 3 | require 'rspec_api_docs/formatter/resource/response_field' 4 | 5 | module RspecApiDocs 6 | class Resource 7 | MAX_PRECEDENCE = 9_999 8 | 9 | attr_reader :rspec_example 10 | 11 | def initialize(rspec_example) 12 | @rspec_example = rspec_example 13 | @examples = [] 14 | end 15 | 16 | # The name of the resource. 17 | # 18 | # E.g. "Characters" 19 | # 20 | # @return [String] 21 | def name 22 | metadata.fetch(:resource_name) { rspec_example.metadata[:example_group][:description] } 23 | end 24 | 25 | # The description of the resource. 26 | # 27 | # E.g. "Orders can be created, viewed, and deleted" 28 | # 29 | # @return [String] 30 | def description 31 | metadata[:resource_description] 32 | end 33 | 34 | # Returns an array of {Example}s 35 | # 36 | # @return [Array] 37 | def examples 38 | @examples.sort_by { |example| [example.precedence, example.name] } 39 | end 40 | 41 | # Add an example 42 | # 43 | # @return [void] 44 | def add_example(example) 45 | @examples << example 46 | end 47 | 48 | # @return [Integer] 49 | def precedence 50 | @precedence ||= metadata.fetch(:resource_precedence, MAX_PRECEDENCE) 51 | end 52 | 53 | # Set the resource precedence 54 | # 55 | # @return [void] 56 | def precedence=(value) 57 | @precedence = value 58 | end 59 | 60 | def inspect 61 | "#" 62 | end 63 | 64 | private 65 | 66 | def metadata 67 | rspec_example.metadata[METADATA_NAMESPACE] 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/renderer/raddocs_renderer/resource_serializer.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | module Renderer 3 | class RaddocsRenderer 4 | class ResourceSerializer 5 | attr_reader :resource, :example 6 | 7 | def initialize(resource, example) 8 | @resource = resource 9 | @example = example 10 | end 11 | 12 | def to_h # rubocop:disable Metrics/AbcSize 13 | { 14 | resource: resource.name, 15 | resource_explanation: nil, 16 | http_method: example.http_method, 17 | route: example.path, 18 | description: example.name, 19 | explanation: example.description, 20 | parameters: parameters(example.parameters), 21 | response_fields: response_fields(example.response_fields), 22 | requests: requests, 23 | } 24 | end 25 | 26 | private 27 | 28 | def requests 29 | example.requests.map { |request| request.merge(curl: nil) } 30 | end 31 | 32 | def parameters(parameters) 33 | parameters.map do |parameter| 34 | result = {} 35 | result[:required] = true if parameter.required 36 | result[:scope] = parameter.scope.join 37 | result = result.merge( 38 | name: parameter.name, 39 | description: parameter.description, 40 | ) 41 | result 42 | end 43 | end 44 | 45 | def response_fields(fields) 46 | fields.map do |field| 47 | { 48 | scope: field.scope.join, 49 | Type: field.type, 50 | name: field.name, 51 | description: field.description, 52 | } 53 | end 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/core/formatters/base_formatter' 2 | 3 | require 'rspec_api_docs' 4 | require 'rspec_api_docs/resource_collection' 5 | require 'rspec_api_docs/formatter/resource' 6 | require 'rspec_api_docs/formatter/renderer/json_renderer' 7 | require 'rspec_api_docs/formatter/renderer/raddocs_renderer' 8 | require 'rspec_api_docs/formatter/renderer/slate_renderer' 9 | 10 | module RspecApiDocs 11 | # Unknown renderer configured. 12 | UnknownRenderer = Class.new(BaseError) 13 | 14 | # The RSpec formatter. 15 | # 16 | # Usage: 17 | # 18 | # rspec --format=RspecApiDocs::Formatter 19 | class Formatter < RSpec::Core::Formatters::BaseFormatter 20 | RSpec::Core::Formatters.register self, :example_passed, :close 21 | 22 | attr_reader :renderer 23 | 24 | def initialize(*args, renderer: default_renderer) 25 | @renderer = renderer 26 | super args 27 | end 28 | 29 | # Initializes and stores {Resource}s. 30 | # 31 | # @return [void] 32 | def example_passed(example_notification) 33 | rspec_example = example_notification.example 34 | return unless rspec_example.metadata[METADATA_NAMESPACE] 35 | resource_collection.add_example(rspec_example) 36 | end 37 | 38 | # Calls the configured renderer with the stored {Resource}s. 39 | # 40 | # @return [void] 41 | def close(null_notification) 42 | renderer.new(resource_collection.all).render 43 | end 44 | 45 | private 46 | 47 | def resource_collection 48 | @resource_collection ||= ResourceCollection.new 49 | end 50 | 51 | def default_renderer 52 | value = RspecApiDocs.configuration.renderer 53 | 54 | case value 55 | when :json 56 | Renderer::JSONRenderer 57 | when :raddocs 58 | Renderer::RaddocsRenderer 59 | when :slate 60 | Renderer::SlateRenderer 61 | when Class 62 | value 63 | else 64 | raise UnknownRenderer, "unknown renderer #{value.inspect}" 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/rspec_api_docs/formatter/resource_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/resource' 2 | require 'json' 3 | 4 | module RspecApiDocs 5 | RSpec.describe Resource do 6 | let(:example_metadata) { {METADATA_NAMESPACE => _metadata} } 7 | let(:_example) { double :example, metadata: example_metadata } 8 | let(:_metadata) { {} } 9 | let(:resource) { Resource.new(_example) } 10 | 11 | describe '#name' do 12 | let(:_example) { double :example, metadata: example_metadata.merge(example_group: {description: 'Character'}) } 13 | 14 | it 'returns the example group description' do 15 | expect(resource.name).to eq 'Character' 16 | end 17 | 18 | context 'when a custom name is set' do 19 | let(:_metadata) { {resource_name: 'The Characters'} } 20 | 21 | it 'returns the custom name' do 22 | expect(resource.name).to eq 'The Characters' 23 | end 24 | end 25 | end 26 | 27 | describe '#examples' do 28 | let(:empty_metadata) { {METADATA_NAMESPACE => {}} } 29 | 30 | it 'returns examples ordered by precedence then name' do 31 | [ 32 | example_1 = Resource::Example.new( 33 | double(metadata: empty_metadata, description: 'Xyz'), 34 | ), 35 | example_2 = Resource::Example.new( 36 | double(metadata: { 37 | METADATA_NAMESPACE => {example_precedence: 1}, 38 | }, description: 'Xyz'), 39 | ), 40 | example_3 = Resource::Example.new( 41 | double(metadata: empty_metadata, description: 'Abc'), 42 | ), 43 | example_4 = Resource::Example.new( 44 | double(metadata: { 45 | METADATA_NAMESPACE => {example_precedence: 10}, 46 | }, description: 'Abc'), 47 | ), 48 | ].each do |_example| 49 | resource.add_example _example 50 | end 51 | 52 | expect(resource.examples).to eq [ 53 | example_2, 54 | example_4, 55 | example_3, 56 | example_1, 57 | ] 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/integration/output/raddocs/places/fetching_all_places_and_page__.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": "Places", 3 | "resource_explanation": null, 4 | "http_method": "GET", 5 | "route": "/places", 6 | "description": "Fetching all places and page 2", 7 | "explanation": null, 8 | "parameters": [ 9 | { 10 | "scope": "", 11 | "name": "page", 12 | "description": "The page" 13 | } 14 | ], 15 | "response_fields": [ 16 | { 17 | "scope": "data", 18 | "Type": "integer", 19 | "name": "id", 20 | "description": "The id of the place" 21 | }, 22 | { 23 | "scope": "data", 24 | "Type": "string", 25 | "name": "name", 26 | "description": "The place's name" 27 | } 28 | ], 29 | "requests": [ 30 | { 31 | "request_method": "GET", 32 | "request_path": "/places", 33 | "request_body": null, 34 | "request_headers": { 35 | "Cookie": "", 36 | "Host": "example.org" 37 | }, 38 | "request_query_parameters": {}, 39 | "request_content_type": null, 40 | "response_status": 200, 41 | "response_status_text": "OK", 42 | "response_body": "{\"data\":[{\"id\":1,\"name\":\"Candy Kingdom\"},{\"id\":2,\"name\":\"Tree Fort\"}]}", 43 | "response_headers": { 44 | "content-type": "application/json", 45 | "content-length": "70", 46 | "x-content-type-options": "nosniff" 47 | }, 48 | "response_content_type": "application/json", 49 | "curl": null 50 | }, 51 | { 52 | "request_method": "GET", 53 | "request_path": "/places?page=2", 54 | "request_body": null, 55 | "request_headers": { 56 | "Cookie": "", 57 | "Host": "example.org" 58 | }, 59 | "request_query_parameters": { 60 | "page": "2" 61 | }, 62 | "request_content_type": null, 63 | "response_status": 200, 64 | "response_status_text": "OK", 65 | "response_body": "{\"data\":[]}", 66 | "response_headers": { 67 | "content-type": "application/json", 68 | "content-length": "11", 69 | "x-content-type-options": "nosniff" 70 | }, 71 | "response_content_type": "application/json", 72 | "curl": null 73 | } 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /spec/integration/output/raddocs/index.json: -------------------------------------------------------------------------------- 1 | { 2 | "resources": [ 3 | { 4 | "name": "Characters", 5 | "explanation": null, 6 | "examples": [ 7 | { 8 | "description": "Listing all characters", 9 | "link": "characters/listing_all_characters.json", 10 | "groups": "all", 11 | "route": "/characters", 12 | "method": "get" 13 | }, 14 | { 15 | "description": "Characters head", 16 | "link": "characters/characters_head.json", 17 | "groups": "all", 18 | "route": "/characters", 19 | "method": "head" 20 | }, 21 | { 22 | "description": "Deleting a Character", 23 | "link": "characters/deleting_a_character.json", 24 | "groups": "all", 25 | "route": "/characters/:id", 26 | "method": "delete" 27 | }, 28 | { 29 | "description": "Fetching a Character", 30 | "link": "characters/fetching_a_character.json", 31 | "groups": "all", 32 | "route": "/characters/:id", 33 | "method": "get" 34 | }, 35 | { 36 | "description": "When a Character cannot be found", 37 | "link": "characters/when_a_character_cannot_be_found.json", 38 | "groups": "all", 39 | "route": "/characters/:id", 40 | "method": "get" 41 | } 42 | ] 43 | }, 44 | { 45 | "name": "Places", 46 | "explanation": null, 47 | "examples": [ 48 | { 49 | "description": "Fetching all places and page 2", 50 | "link": "places/fetching_all_places_and_page__.json", 51 | "groups": "all", 52 | "route": "/places", 53 | "method": "get" 54 | }, 55 | { 56 | "description": "Listing all places", 57 | "link": "places/listing_all_places.json", 58 | "groups": "all", 59 | "route": "/places", 60 | "method": "get" 61 | }, 62 | { 63 | "description": "Listing all places with a modified response bod,", 64 | "link": "places/listing_all_places_with_a_modified_response_bod_.json", 65 | "groups": "all", 66 | "route": "/places", 67 | "method": "get" 68 | } 69 | ] 70 | } 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /spec/rspec_api_docs/resource_collection_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter' 2 | 3 | module RspecApiDocs 4 | RSpec.describe ResourceCollection do 5 | describe '#all' do 6 | let(:resource_1) { new_resource(resource_name: 'Xyz') } 7 | let(:resource_2) { new_resource(resource_name: 'Xyz', resource_precedence: 1) } 8 | let(:resource_3) { new_resource(resource_name: 'Abc') } 9 | let(:resource_4) { new_resource(resource_name: 'Abc', resource_precedence: 10) } 10 | 11 | it 'returns resources ordered by precedence then name' do 12 | collection = ResourceCollection.new( 13 | resource_1: resource_1, 14 | resource_2: resource_2, 15 | resource_3: resource_3, 16 | resource_4: resource_4, 17 | ) 18 | 19 | expect(collection.all).to eq [ 20 | resource_2, 21 | resource_4, 22 | resource_3, 23 | resource_1, 24 | ] 25 | end 26 | 27 | def new_resource(args) 28 | Resource.new( 29 | double(metadata: { 30 | METADATA_NAMESPACE => args, 31 | }), 32 | ) 33 | end 34 | end 35 | 36 | describe '#add_example' do 37 | it 'stores the resource and example' do 38 | collection = ResourceCollection.new 39 | 40 | rspec_example = double :rspec_example 41 | 42 | resource = instance_double Resource, name: 'foo' 43 | expect(Resource).to receive(:new).with(rspec_example).and_return(resource) 44 | 45 | _example = instance_double Resource::Example 46 | expect(Resource::Example).to receive(:new).with(rspec_example).and_return(_example) 47 | 48 | expect(resource).to receive(:add_example).with(_example) 49 | 50 | collection.add_example(rspec_example) 51 | end 52 | 53 | it 'maintains the lowest precedence' do 54 | collection = ResourceCollection.new 55 | 56 | rspec_example_1 = double metadata: { 57 | METADATA_NAMESPACE => { 58 | resource_name: 'foo', 59 | }, 60 | } 61 | collection.add_example(rspec_example_1) 62 | 63 | rspec_example_1 = double metadata: { 64 | METADATA_NAMESPACE => { 65 | resource_name: 'foo', 66 | resource_precedence: 10, 67 | }, 68 | } 69 | collection.add_example(rspec_example_1) 70 | 71 | expect(collection.all.map(&:name)).to eq ['foo'] 72 | expect(collection.all.map(&:precedence)).to eq [10] 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/rspec_api_docs/after/type_checker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/after/type_checker' 2 | 3 | module RspecApiDocs 4 | module After 5 | RSpec.describe TypeChecker do 6 | describe '.call' do 7 | def call(value) 8 | TypeChecker.call type: type, value: value 9 | end 10 | 11 | context 'with an "integer" type' do 12 | let(:type) { 'integer' } 13 | 14 | it 'raises an error if the value is not an integer' do 15 | expect { call 'foo' } 16 | .to raise_error TypeChecker::TypeError, 'wrong type "foo", expected "integer"' 17 | end 18 | 19 | it 'does not raise an error if the value is an integer' do 20 | expect { call '42' }.to_not raise_error 21 | end 22 | end 23 | 24 | context 'with a "float" type' do 25 | let(:type) { 'float' } 26 | 27 | it 'raises an error if the value is not an float' do 28 | expect { call 'foo' } 29 | .to raise_error TypeChecker::TypeError, 'wrong type "foo", expected "float"' 30 | end 31 | 32 | it 'does not raise an error if the value is a float' do 33 | expect { call '4.2' }.to_not raise_error 34 | end 35 | end 36 | 37 | context 'with a "boolean" type' do 38 | let(:type) { 'boolean' } 39 | 40 | it 'raises an error if the value is not an boolean' do 41 | expect { call 'foo' } 42 | .to raise_error TypeChecker::TypeError, 'wrong type "foo", expected "boolean"' 43 | end 44 | 45 | it 'does not raise an error if the value is a boolean' do 46 | expect { call 'true' }.to_not raise_error 47 | expect { call 'false' }.to_not raise_error 48 | end 49 | end 50 | 51 | context 'with a "string" type' do 52 | let(:type) { 'string' } 53 | 54 | it 'does not raise an error if the value is a string' do 55 | expect { call '42' }.to_not raise_error 56 | expect { call '4.2' }.to_not raise_error 57 | expect { call 'foo' }.to_not raise_error 58 | expect { call 'false' }.to_not raise_error 59 | end 60 | end 61 | 62 | context 'with an unknown type' do 63 | let(:type) { 'foo' } 64 | 65 | it 'raises an error if the value is not an boolean' do 66 | expect { call 'bar' } 67 | .to raise_error TypeChecker::UnknownType, 'unknown type "foo"' 68 | end 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/rake_task.rb: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rspec' 3 | require 'json' 4 | require 'rspec_api_docs/config' 5 | 6 | module RspecApiDocs 7 | class RakeTask < ::Rake::TaskLib 8 | module RSpecMatchers 9 | extend RSpec::Matchers 10 | end 11 | 12 | attr_accessor \ 13 | :verbose, 14 | :pattern, 15 | :rspec_opts, 16 | :existing_file, 17 | :dir, 18 | :verify 19 | 20 | def initialize(name = nil, &block) 21 | @name = name 22 | @verbose = true 23 | @pattern = 'spec/requests/**/*_spec.rb' 24 | @rspec_opts = [] 25 | @existing_file = 'docs/index.json' 26 | @verify = false 27 | 28 | block.call(self) if block 29 | 30 | define 31 | end 32 | 33 | private 34 | 35 | def define 36 | desc default_desc 37 | task name do 38 | @dir = Dir.mktmpdir if verify 39 | 40 | rspec_task.run_task(verbose) 41 | 42 | verify! if verify 43 | end 44 | end 45 | 46 | def generated 47 | JSON.parse(File.read(Pathname.new(dir) + 'index.json')) 48 | end 49 | 50 | def existing 51 | JSON.parse(File.read(existing_file)) 52 | end 53 | 54 | def rspec_task 55 | RSpec::Core::RakeTask.new.tap do |task| 56 | task.pattern = pattern 57 | task.rspec_opts = task_rspec_opts 58 | end 59 | end 60 | 61 | def spec_helper 62 | tempfile = Tempfile.new(['shoebox', '.rb']) 63 | tempfile.write <<-EOF 64 | RspecApiDocs.configure do |config| 65 | config.output_dir = '#{dir}' 66 | end 67 | EOF 68 | tempfile.close 69 | tempfile 70 | end 71 | 72 | def configure_rspec 73 | RSpec.configure do |config| 74 | config.color = true 75 | end 76 | end 77 | 78 | def remove_dir 79 | FileUtils.remove_entry dir 80 | end 81 | 82 | def verify! 83 | configure_rspec 84 | 85 | RSpecMatchers.expect(generated).to RSpecMatchers.eq(existing) 86 | 87 | remove_dir 88 | end 89 | 90 | def task_rspec_opts 91 | arr = rspec_opts + [ 92 | '--format RspecApiDocs::Formatter', 93 | '--order defined', 94 | ] 95 | arr += ["--require #{spec_helper.path}"] if verify 96 | arr 97 | end 98 | 99 | def name 100 | @name || 101 | verify ? :'docs:ensure_updated' : :'docs:generate' 102 | end 103 | 104 | def default_desc 105 | verify ? 'Ensure API docs are up to date' : 'Generate API docs' 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at odindutton@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /spec/rspec_api_docs/formatter/resource/example/deep_hash_set_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/resource/example/deep_hash_set' 2 | 3 | module RspecApiDocs 4 | class Resource 5 | class Example 6 | RSpec.describe DeepHashSet do 7 | describe '.call' do 8 | let(:hash) do 9 | { 10 | foo: { 11 | bar: { 12 | baz: 1, 13 | }, 14 | qux: [ 15 | {foo: 1}, 16 | {bar: 1}, 17 | ], 18 | }, 19 | } 20 | end 21 | let(:value) { 42 } 22 | 23 | subject(:call) { DeepHashSet.call(hash, keys, value) } 24 | 25 | context 'pointing at a deeply nested hash' do 26 | let(:keys) { [:foo, :bar, :baz] } 27 | 28 | it 'changes the value' do 29 | expect(call).to eq( 30 | foo: { 31 | bar: { 32 | baz: 42, 33 | }, 34 | qux: [ 35 | {foo: 1}, 36 | {bar: 1}, 37 | ], 38 | }, 39 | ) 40 | end 41 | end 42 | 43 | context 'pointing at a hash within an array' do 44 | let(:keys) { [:foo, :qux, nil, :foo] } 45 | 46 | it 'changes the value' do 47 | expect(call).to eq( 48 | foo: { 49 | bar: { 50 | baz: 1, 51 | }, 52 | qux: [ 53 | {foo: 42}, 54 | {bar: 1}, 55 | ], 56 | }, 57 | ) 58 | end 59 | end 60 | 61 | context 'more complex' do 62 | let(:hash) do 63 | { 64 | foo: { 65 | bar: { 66 | baz: 1, 67 | }, 68 | qux: [ 69 | { 70 | foo: { 71 | bar: 1, 72 | }, 73 | }, 74 | { 75 | bar: [ 76 | {qux: 1}, 77 | ], 78 | }, 79 | ], 80 | }, 81 | } 82 | end 83 | 84 | context 'changing a nested hash inside an array' do 85 | let(:keys) { [:foo, :qux, nil, :foo, :bar] } 86 | 87 | it 'changes the value' do 88 | expect(call).to eq( 89 | { 90 | foo: { 91 | bar: { 92 | baz: 1, 93 | }, 94 | qux: [ 95 | { 96 | foo: { 97 | bar: 42, 98 | }, 99 | }, 100 | { 101 | bar: [ 102 | {qux: 1}, 103 | ], 104 | }, 105 | ], 106 | }, 107 | }, 108 | ) 109 | end 110 | end 111 | 112 | context 'changing a hash inside an array inside a hash inside an array' do 113 | let(:keys) { [:foo, :qux, nil, :bar, nil, :qux] } 114 | 115 | it 'changes the value' do 116 | expect(call).to eq( 117 | { 118 | foo: { 119 | bar: { 120 | baz: 1, 121 | }, 122 | qux: [ 123 | { 124 | foo: { 125 | bar: 1, 126 | }, 127 | }, 128 | { 129 | bar: [ 130 | {qux: 42}, 131 | ], 132 | }, 133 | ], 134 | }, 135 | }, 136 | ) 137 | end 138 | end 139 | end 140 | end 141 | end 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /spec/integration/output/slate/index.html.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API Reference 3 | search: true 4 | --- 5 | 6 | 7 | # Characters 8 | 9 | 10 | ## Listing all characters 11 | 12 | Getting all the characters. 13 | 14 | For when you need everything! 15 | 16 | 17 | ```json 18 | 19 | { 20 | "data": [ 21 | { 22 | "id": 1, 23 | "name": "Finn the Human" 24 | }, 25 | { 26 | "id": 2, 27 | "name": "Jake the Dog" 28 | } 29 | ] 30 | } 31 | ``` 32 | 33 | ### HTTP Request 34 | 35 | `GET http://example.com/characters` 36 | 37 | 38 | ### Response Fields 39 | 40 | Parameter | Type | Description 41 | --------- | ------- | ----------- 42 | id | integer | The id of a character 43 | name | string | The character's name 44 | 45 | ## Characters head 46 | 47 | 48 | 49 | ```json 50 | 51 | 52 | ``` 53 | 54 | ### HTTP Request 55 | 56 | `HEAD http://example.com/characters` 57 | 58 | 59 | 60 | ## Deleting a Character 61 | 62 | For getting information about a Character. 63 | 64 | ```json 65 | 66 | { 67 | "message": "Character not found." 68 | } 69 | ``` 70 | 71 | ### HTTP Request 72 | 73 | `DELETE http://example.com/characters/1` 74 | 75 | ### Query Parameters 76 | 77 | Parameter | Required | Description 78 | --------- | ------- | ----------- 79 | id | true | The id of a character 80 | 81 | ### Response Fields 82 | 83 | Parameter | Type | Description 84 | --------- | ------- | ----------- 85 | message | string | Success message 86 | 87 | ## Fetching a Character 88 | 89 | For getting information about a Character. 90 | 91 | ```json 92 | 93 | { 94 | "character": { 95 | "id": 42, 96 | "name": "Finn the Human" 97 | } 98 | } 99 | ``` 100 | 101 | ### HTTP Request 102 | 103 | `GET http://example.com/characters/1` 104 | 105 | ### Query Parameters 106 | 107 | Parameter | Required | Description 108 | --------- | ------- | ----------- 109 | id | true | The id of a character 110 | 111 | ### Response Fields 112 | 113 | Parameter | Type | Description 114 | --------- | ------- | ----------- 115 | id | integer | The id of a character 116 | name | string | The character's name 117 | 118 | ## When a Character cannot be found 119 | 120 | Returns an error 121 | 122 | ```json 123 | 124 | { 125 | "errors": { 126 | "message": "Character not found." 127 | } 128 | } 129 | ``` 130 | 131 | ### HTTP Request 132 | 133 | `GET http://example.com/characters/404` 134 | 135 | 136 | ### Response Fields 137 | 138 | Parameter | Type | Description 139 | --------- | ------- | ----------- 140 | message | string | Error message 141 | 142 | # Places 143 | 144 | 145 | ## Fetching all places and page 2 146 | 147 | 148 | 149 | ```json 150 | 151 | { 152 | "data": [ 153 | { 154 | "id": 1, 155 | "name": "Candy Kingdom" 156 | }, 157 | { 158 | "id": 2, 159 | "name": "Tree Fort" 160 | } 161 | ] 162 | } 163 | ``` 164 | 165 | ### HTTP Request 166 | 167 | `GET http://example.com/places` 168 | 169 | ```json 170 | 171 | { 172 | "data": [] 173 | } 174 | ``` 175 | 176 | ### HTTP Request 177 | 178 | `GET http://example.com/places?page=2` 179 | 180 | ### Query Parameters 181 | 182 | Parameter | Required | Description 183 | --------- | ------- | ----------- 184 | page | false | The page 185 | 186 | ### Response Fields 187 | 188 | Parameter | Type | Description 189 | --------- | ------- | ----------- 190 | id | integer | The id of the place 191 | name | string | The place's name 192 | 193 | ## Listing all places 194 | 195 | 196 | 197 | ```json 198 | 199 | { 200 | "data": [ 201 | { 202 | "id": 1, 203 | "name": "Candy Kingdom" 204 | }, 205 | { 206 | "id": 2, 207 | "name": "Tree Fort" 208 | } 209 | ] 210 | } 211 | ``` 212 | 213 | ### HTTP Request 214 | 215 | `GET http://example.com/places` 216 | 217 | 218 | ### Response Fields 219 | 220 | Parameter | Type | Description 221 | --------- | ------- | ----------- 222 | id | integer | The id of the place 223 | name | string | The place's name 224 | 225 | ## Listing all places with a modified response bod, 226 | 227 | 228 | 229 | ```json 230 | 231 | { 232 | "data": [ 233 | { 234 | "id": 2, 235 | "name": "Tree Fort" 236 | } 237 | ] 238 | } 239 | ``` 240 | 241 | ### HTTP Request 242 | 243 | `GET http://example.com/places` 244 | 245 | 246 | ### Response Fields 247 | 248 | Parameter | Type | Description 249 | --------- | ------- | ----------- 250 | id | integer | The id of the place 251 | name | string | The place's name 252 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/formatter/resource/example.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/resource/example/request_headers' 2 | require 'rspec_api_docs/formatter/resource/example/deep_hash_set' 3 | 4 | module RspecApiDocs 5 | class Resource 6 | class Example 7 | MAX_PRECEDENCE = 9_999 8 | 9 | attr_reader :example 10 | 11 | def initialize(example) 12 | @example = example 13 | end 14 | 15 | # The name of the example. 16 | # 17 | # E.g. "Returns a Character" 18 | # 19 | # @return [String] 20 | def name 21 | metadata.fetch(:example_name, example.description) 22 | end 23 | 24 | # The description of the example. 25 | # 26 | # E.g. "For getting information about a Character." 27 | # 28 | # @return [String] 29 | def description 30 | metadata[:description] 31 | end 32 | 33 | # Parameters for the example. 34 | # 35 | # @return [Array] 36 | def parameters 37 | metadata.fetch(:parameters, []).map do |name_hash, parameter| 38 | Parameter.new(name_hash[:name], parameter) 39 | end 40 | end 41 | 42 | # Response fields for the example. 43 | # 44 | # @return [Array] 45 | def response_fields 46 | metadata.fetch(:fields, []).map do |name_hash, field| 47 | ResponseField.new(name_hash[:name], field) 48 | end 49 | end 50 | 51 | # Requests stored for the example. 52 | # 53 | # @return [Array] 54 | def requests # rubocop:disable Metrics/AbcSize 55 | request_response_pairs.map do |request, response| 56 | { 57 | request_method: request.request_method, 58 | request_path: request_path(request), 59 | request_body: request_body(request.body), 60 | request_headers: request_headers(request.env), 61 | request_query_parameters: request.params, 62 | request_content_type: request.content_type, 63 | response_status: response.status, 64 | response_status_text: response_status_text(response.status), 65 | response_body: response_body(response.body, content_type: response.content_type), 66 | response_headers: response_headers(response.headers), 67 | response_content_type: response.content_type, 68 | } 69 | end 70 | end 71 | 72 | # Path stored on the example OR the path of first route requested. 73 | # 74 | # @return [String, nil] 75 | def path 76 | metadata.fetch(:path) do 77 | return if request_response_pairs.empty? 78 | request_response_pairs.first.first.path 79 | end 80 | end 81 | 82 | # The HTTP method of first route requested. 83 | # 84 | # @return [String, nil] 85 | def http_method 86 | return if request_response_pairs.empty? 87 | request_response_pairs.first.first.request_method 88 | end 89 | 90 | # @return [Hash, nil] 91 | def notes 92 | metadata.fetch(:note, {}) 93 | end 94 | 95 | # @return [Integer] 96 | def precedence 97 | metadata.fetch(:example_precedence, MAX_PRECEDENCE) 98 | end 99 | 100 | def inspect 101 | "#" 102 | end 103 | 104 | private 105 | 106 | def request_response_pairs 107 | metadata.fetch(:requests, []).reject { |pair| pair.any?(&:nil?) } 108 | end 109 | 110 | def request_headers(env) 111 | RequestHeaders.call(env) 112 | end 113 | 114 | def response_headers(headers) 115 | excluded_headers = RspecApiDocs.configuration.exclude_response_headers 116 | 117 | headers.reject do |k, v| 118 | excluded_headers.include?(k) 119 | end 120 | end 121 | 122 | def request_path(request) 123 | URI(request.path).tap do |uri| 124 | uri.query = request.query_string unless request.query_string.empty? 125 | end.to_s 126 | end 127 | 128 | def request_body(body) 129 | return nil if body.nil? 130 | 131 | body.rewind if body.respond_to?(:rewind) 132 | body_content = body.read 133 | body.rewind if body.respond_to?(:rewind) 134 | body_content.empty? ? nil : body_content 135 | end 136 | 137 | def response_body(body, content_type:) 138 | unless body.empty? || !content_type.to_s.include?('json') 139 | parsed_body = JSON.parse(body, symbolize_names: true) 140 | response_fields.each do |f| 141 | unless f.example.nil? 142 | DeepHashSet.call(parsed_body, f.scope + [f.name], f.example) 143 | end 144 | end 145 | if metadata[:response_body_after_hook] 146 | parsed_body = metadata[:response_body_after_hook].call(parsed_body) 147 | end 148 | JSON.dump(parsed_body) 149 | end 150 | end 151 | 152 | def response_status_text(status) 153 | Rack::Utils::HTTP_STATUS_CODES[status] 154 | end 155 | 156 | def metadata 157 | example.metadata[METADATA_NAMESPACE] 158 | end 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /lib/rspec_api_docs/dsl/doc_proxy.rb: -------------------------------------------------------------------------------- 1 | module RspecApiDocs 2 | module Dsl 3 | class DocProxy 4 | UnknownNoteLevel = Class.new(BaseError) 5 | 6 | attr_reader :metadata 7 | 8 | def initialize(example) 9 | @metadata = example.metadata 10 | end 11 | 12 | # For setting the name of the example. 13 | # 14 | # E.g. "Returns a Character" 15 | # 16 | # @return [void] 17 | def name(value) 18 | metadata[METADATA_NAMESPACE][:example_name] = value 19 | end 20 | 21 | # For setting the name of the resource. 22 | # 23 | # E.g. "Characters" 24 | # 25 | # @return [void] 26 | def resource_name(value) 27 | metadata[METADATA_NAMESPACE][:resource_name] = value 28 | end 29 | 30 | # For setting the description of the resource. 31 | # 32 | # E.g. "Orders can be created, viewed, and deleted" 33 | # 34 | # @return [void] 35 | def resource_description(value) 36 | metadata[METADATA_NAMESPACE][:resource_description] = value 37 | end 38 | 39 | # For setting the precedence of the resource 40 | # 41 | # Lower numbers will be ordered higher 42 | # 43 | # @param value [Integer] the precedence 44 | def resource_precedence(value) 45 | metadata[METADATA_NAMESPACE][:resource_precedence] = value 46 | end 47 | 48 | # For setting a description of the example. 49 | # 50 | # E.g. "Allows you to return a single character." 51 | # 52 | # @return [void] 53 | def description(value) 54 | metadata[METADATA_NAMESPACE][:description] = value 55 | end 56 | 57 | # For setting the request path of the example. 58 | # 59 | # E.g. "/characters/:id" 60 | # 61 | # @return [void] 62 | def path(value) 63 | metadata[METADATA_NAMESPACE][:path] = value 64 | end 65 | 66 | # For setting response fields of a request. 67 | # 68 | # Usage: 69 | # 70 | # doc do 71 | # field :id, 'The id of a character', scope: :character 72 | # field :name, "The character's name", scope: :character 73 | # end 74 | # 75 | # For a response body of: 76 | # 77 | # { 78 | # character: { 79 | # id: 1, 80 | # name: 'Finn The Human' 81 | # } 82 | # } 83 | # 84 | # @param name [Symbol] the name of the response field 85 | # @param description [String] a description of the response field 86 | # @param scope [Symbol, Array] how the field is scoped 87 | # @param type [String] 88 | # @param example an example value 89 | # @return [void] 90 | def field(name, description, scope: [], type: nil, example: nil) 91 | metadata[METADATA_NAMESPACE][:fields] ||= {} 92 | metadata[METADATA_NAMESPACE][:fields][{name: name, scope: scope}] = { 93 | description: description, 94 | scope: Array(scope), 95 | type: type, 96 | example: example, 97 | } 98 | end 99 | 100 | # For setting params of a request. 101 | # 102 | # Usage: 103 | # 104 | # doc do 105 | # param :id, 'The id of a character', required: true 106 | # param :name, 'A tag on a character', scope: :tag 107 | # end 108 | # 109 | # For a path of: 110 | # 111 | # /characters/:id?tag[name]=:name 112 | # 113 | # @param name [Symbol] the name of the parameter 114 | # @param description [String] a description of the parameter 115 | # @param scope [Symbol, Array] how the parameter is scoped 116 | # @param type [String] 117 | # @param required [true, false] if the field is required 118 | # @return [void] 119 | def param(name, description, scope: [], type: nil, required: false) 120 | metadata[METADATA_NAMESPACE][:parameters] ||= {} 121 | metadata[METADATA_NAMESPACE][:parameters][{name: name, scope: scope}] = { 122 | description: description, 123 | scope: Array(scope), 124 | type: type, 125 | required: required, 126 | } 127 | end 128 | 129 | # For setting notes on an example 130 | # 131 | # @param level [Symbol] the level of the note 132 | # @param value [String] the note, +:success+, +:info+, +:warning+, or +:danger+ 133 | # @return [void] 134 | def note(level = :info, value) 135 | %i[success info warning danger].include?(level) or 136 | raise UnknownNoteLevel, "unknown note level #{level.inspect}" 137 | metadata[METADATA_NAMESPACE][:note] ||= {} 138 | metadata[METADATA_NAMESPACE][:note][level] = value 139 | end 140 | 141 | # For setting the precedence of an example 142 | # 143 | # Lower numbers will be ordered higher 144 | # 145 | # @param value [Integer] the precedence 146 | def precedence(value) 147 | metadata[METADATA_NAMESPACE][:example_precedence] = value 148 | end 149 | 150 | # For passing a lambda to modify the response body 151 | # 152 | # This is useful if the entire body of the response isn't relevant to the 153 | # documentation example. 154 | # 155 | # With a response body of: 156 | # 157 | # { 158 | # characters: [ 159 | # { 160 | # id: 1, 161 | # name: 'Finn The Human', 162 | # }, 163 | # { 164 | # id: 2, 165 | # name: 'Jake The Dog', 166 | # }, 167 | # ], 168 | # } 169 | # 170 | # Usage: 171 | # 172 | # doc do 173 | # response_body_after_hook -> (parsed_response_body) { 174 | # parsed_response_body[:characters].delete_if { |character| character[:id] != 1 } 175 | # parsed_response_body 176 | # } 177 | # end 178 | # 179 | # @param value [Lambda] after hook lambda 180 | # @return [void] 181 | def response_body_after_hook(value) 182 | metadata[METADATA_NAMESPACE][:response_body_after_hook] = value 183 | end 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /spec/integration/rspec_api_docs_spec.rb: -------------------------------------------------------------------------------- 1 | require 'base64' 2 | require 'rack/test' 3 | require 'sinatra' 4 | require 'rspec_api_docs/dsl' 5 | 6 | RSpec.describe RspecApiDocs do 7 | include Rack::Test::Methods 8 | include RspecApiDocs::Dsl 9 | 10 | class TestApp < Sinatra::Base 11 | # Disable host authorization for tests since we don't need this security in test environment 12 | set :host_authorization, {permitted_hosts: []} 13 | 14 | CHARACTERS = { 15 | 1 => {name: 'Finn the Human'}, 16 | 2 => {name: 'Jake the Dog'}, 17 | } 18 | 19 | PLACES = { 20 | 1 => {name: 'Candy Kingdom'}, 21 | 2 => {name: 'Tree Fort'}, 22 | } 23 | 24 | before do 25 | content_type 'application/json' 26 | end 27 | 28 | get '/characters' do 29 | characters = CHARACTERS.map { |id, character| {id: id}.merge(character) } 30 | 31 | JSON.dump(data: characters) 32 | end 33 | 34 | get '/characters/:id' do 35 | id = params[:id].to_i 36 | character = CHARACTERS[id] 37 | 38 | if character 39 | JSON.dump(character: {id: (10..99).to_a.sample}.merge(character)) 40 | else 41 | status 404 42 | JSON.dump( 43 | errors: { 44 | message: 'Character not found.', 45 | }, 46 | ) 47 | end 48 | end 49 | 50 | delete '/characters/:id' do 51 | JSON.dump( 52 | message: 'Character not found.', 53 | ) 54 | end 55 | 56 | get '/places' do 57 | if params[:page] 58 | JSON.dump(data: []) 59 | else 60 | places = PLACES.map { |id, place| {id: id}.merge(place) } 61 | 62 | JSON.dump(data: places) 63 | end 64 | end 65 | end 66 | 67 | def app 68 | TestApp 69 | end 70 | 71 | describe 'Characters' do 72 | before do 73 | doc do 74 | resource_name 'Characters' 75 | resource_description <<-EOF.gsub(/^ {10}/, '') 76 | Characters inhabit the Land of Ooo. 77 | 78 | Use the following endpoints to fetch information and modify them. 79 | EOF 80 | end 81 | end 82 | 83 | it 'returns all characters' do 84 | doc do 85 | name 'Listing all characters' 86 | description <<-EOF.gsub(/^ {10}/, '') 87 | Getting all the characters. 88 | 89 | For when you need everything! 90 | EOF 91 | precedence 1 92 | 93 | field :id, 'The id of a character', scope: [:data, nil], type: 'integer' 94 | field :name, "The character's name", scope: [:data, nil], type: 'string' 95 | end 96 | 97 | get '/characters', {}, {'HTTP_AUTHORIZATION' => "Basic #{Base64.encode64('finn:hunter2')}"} 98 | end 99 | 100 | it 'returns a character' do 101 | doc do 102 | name 'Fetching a Character' 103 | description 'For getting information about a Character.' 104 | path '/characters/:id' 105 | 106 | note 'You need to supply an id!' 107 | note :warning, "An error will be thrown if you don't supply an id!" 108 | 109 | param :id, 'The id of a character', type: 'integer', required: true 110 | 111 | field :id, 'The id of a character', scope: :character, type: 'integer', example: 42 112 | field :name, "The character's name", scope: :character, type: 'string' 113 | end 114 | 115 | get '/characters/1' 116 | end 117 | 118 | it 'returns 404' do 119 | doc do 120 | name 'When a Character cannot be found' 121 | description 'Returns an error' 122 | path '/characters/:id' 123 | 124 | note :danger, 'This is an error case' 125 | 126 | field :message, 'Error message', scope: :errors, type: 'string' 127 | end 128 | 129 | get '/characters/404' 130 | end 131 | 132 | it 'Deleting a Character' do 133 | doc do 134 | # NOTE: name defaults to the description of the RSpec example 135 | description 'For getting information about a Character.' 136 | path '/characters/:id' 137 | 138 | param :id, 'The id of a character', type: 'integer', required: true 139 | 140 | field :message, 'Success message', type: 'string' 141 | end 142 | 143 | delete '/characters/1' 144 | end 145 | 146 | it 'is unrelated' do 147 | doc false 148 | end 149 | end 150 | 151 | describe 'Places' do 152 | before do 153 | doc do 154 | resource_name 'Places' 155 | resource_description <<-EOF.gsub(/^ {10}/, '') 156 | This category consists of locations in the Land of Ooo. 157 | 158 | These are all great places! 159 | 160 | The Characters that live here are great too. 161 | EOF 162 | end 163 | end 164 | 165 | describe 'GET /places' do 166 | before do 167 | doc do 168 | field :id, 'The id of the place', scope: [:data, nil], type: 'integer' 169 | field :name, "The place's name", scope: [:data, nil], type: 'string' 170 | end 171 | end 172 | 173 | it 'returns all places' do 174 | doc do 175 | name 'Listing all places' 176 | end 177 | 178 | get '/places' 179 | end 180 | 181 | context 'when only a part of the response is relevant' do 182 | it 'returns all places but only shows one' do 183 | doc do 184 | name 'Listing all places with a modified response bod,' 185 | response_body_after_hook -> (parsed_response_body) { 186 | parsed_response_body[:data].delete_if { |i| i[:id] == 1 } 187 | parsed_response_body 188 | } 189 | end 190 | 191 | get '/places' 192 | end 193 | end 194 | 195 | it 'can store two requests' do 196 | doc do 197 | name 'Fetching all places and page 2' 198 | 199 | note :success, 'You can store multiple requests in a single example.' 200 | 201 | param :page, 'The page', type: 'integer' 202 | end 203 | 204 | get '/places' 205 | doc << [last_response, last_request] # NOTE: Wrong order 206 | 207 | get '/places?page=2' 208 | end 209 | end 210 | 211 | it 'is part of another resource' do 212 | doc do 213 | resource_name 'Characters' 214 | name 'Characters head' 215 | 216 | note 'This example has overridden the resource name set in the `before` block.' 217 | end 218 | 219 | head '/characters' 220 | end 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /spec/rspec_api_docs/formatter/resource/example_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec_api_docs/formatter/resource/example' 2 | require 'json' 3 | 4 | module RspecApiDocs 5 | class Resource 6 | RSpec.describe Example do 7 | let(:example_metadata) { {METADATA_NAMESPACE => _metadata} } 8 | let(:_example) { double :example, metadata: example_metadata } 9 | let(:_metadata) { {} } 10 | subject { Example.new(_example) } 11 | 12 | describe '#name' do 13 | let(:_example) { double :example, description: 'Viewing a character', metadata: example_metadata } 14 | 15 | it 'returns the example description' do 16 | expect(subject.name).to eq 'Viewing a character' 17 | end 18 | 19 | context 'when a custom name is set' do 20 | let(:_metadata) { {example_name: 'Viewing characters'} } 21 | 22 | it 'returns the custom name' do 23 | expect(subject.name).to eq 'Viewing characters' 24 | end 25 | end 26 | end 27 | 28 | describe '#description' do 29 | let(:_metadata) { {description: 'Characters from the Land of Ooo'} } 30 | 31 | it 'returns the description' do 32 | expect(subject.description).to eq 'Characters from the Land of Ooo' 33 | end 34 | end 35 | 36 | describe '#parameters' do 37 | it 'returns an empty array' do 38 | expect(subject.parameters).to eq [] 39 | end 40 | 41 | context 'with parameters' do 42 | let(:_metadata) do 43 | { 44 | parameters: { 45 | {name: :id} => { 46 | description: 'The character id', 47 | scope: ['character'], 48 | required: true, 49 | }, 50 | {name: :name} => { 51 | description: 'The name of character', 52 | scope: ['character'], 53 | }, 54 | }, 55 | } 56 | end 57 | 58 | it 'returns the parameters' do 59 | expect(subject.parameters).to eq [ 60 | Resource::Parameter.new(:id, { 61 | description: 'The character id', 62 | scope: ['character'], 63 | required: true, 64 | }), 65 | Resource::Parameter.new(:name, { 66 | description: 'The name of character', 67 | scope: ['character'], 68 | }), 69 | ] 70 | end 71 | end 72 | end 73 | 74 | describe '#response_fields' do 75 | it 'returns an empty array' do 76 | expect(subject.response_fields).to eq [] 77 | end 78 | 79 | context 'with response_fields' do 80 | let(:_metadata) do 81 | { 82 | fields: { 83 | {name: :id} => { 84 | description: 'The character id', 85 | scope: ['character'], 86 | type: 'integer', 87 | }, 88 | {name: :name} => { 89 | description: 'The name of character', 90 | scope: ['character'], 91 | type: 'string', 92 | }, 93 | }, 94 | } 95 | end 96 | 97 | it 'returns the response fields' do 98 | expect(subject.response_fields).to eq [ 99 | Resource::ResponseField.new(:id, { 100 | description: 'The character id', 101 | scope: ['character'], 102 | type: 'integer', 103 | }), 104 | Resource::ResponseField.new(:name, { 105 | description: 'The name of character', 106 | scope: ['character'], 107 | type: 'string', 108 | }), 109 | ] 110 | end 111 | end 112 | end 113 | 114 | describe '#requests' do 115 | let(:context) { double :context } 116 | let(:_metadata) do 117 | { 118 | requests: [ 119 | [last_request_1, last_response_1], 120 | [last_request_2, last_response_2], 121 | ], 122 | } 123 | end 124 | 125 | let(:request_1_body) do 126 | body = JSON.dump(character: {name: 'Earl of Lemongrab'}) 127 | StringIO.new(body).tap do |io| 128 | io.read 129 | end 130 | end 131 | let(:last_request_1) do 132 | double(:last_request, 133 | request_method: 'POST', 134 | path: '/characters', 135 | body: request_1_body, 136 | query_string: '', 137 | env: {}, 138 | params: {}, 139 | content_type: 'application/json', 140 | ) 141 | end 142 | let(:last_response_1_headers) { {} } 143 | let(:last_response_1) do 144 | double(:last_response, 145 | status: 201, 146 | body: JSON.dump(character: {id: 1, name: 'Earl of Lemongrab'}), 147 | headers: last_response_1_headers, 148 | content_type: 'application/vnd.api+json; charset=utf-8', 149 | ) 150 | end 151 | 152 | let(:last_request_2) do 153 | double(:last_request, 154 | request_method: 'GET', 155 | path: '/characters/1', 156 | body: StringIO.new, 157 | query_string: '', 158 | env: {}, 159 | params: {}, 160 | content_type: 'application/json', 161 | ) 162 | end 163 | let(:last_response_2) do 164 | double(:last_response, 165 | status: 200, 166 | body: JSON.dump(character: {id: 1, name: 'Princess Bubblegum'}), 167 | headers: {}, 168 | content_type: 'application/json', 169 | ) 170 | end 171 | 172 | it 'returns requests' do 173 | expect(subject.requests).to eq [ 174 | { 175 | request_method: 'POST', 176 | request_path: '/characters', 177 | request_body: '{"character":{"name":"Earl of Lemongrab"}}', 178 | request_headers: {}, 179 | request_query_parameters: {}, 180 | request_content_type: 'application/json', 181 | response_status: 201, 182 | response_status_text: 'Created', 183 | response_body: '{"character":{"id":1,"name":"Earl of Lemongrab"}}', 184 | response_headers: {}, 185 | response_content_type: 'application/vnd.api+json; charset=utf-8', 186 | }, 187 | { 188 | request_method: 'GET', 189 | request_path: '/characters/1', 190 | request_body: nil, 191 | request_headers: {}, 192 | request_query_parameters: {}, 193 | request_content_type: 'application/json', 194 | response_status: 200, 195 | response_status_text: 'OK', 196 | response_body: '{"character":{"id":1,"name":"Princess Bubblegum"}}', 197 | response_headers: {}, 198 | response_content_type: 'application/json', 199 | }, 200 | ] 201 | end 202 | 203 | it 'rewinds the request body' do 204 | subject.requests 205 | 206 | expect(request_1_body.pos).to eq 0 207 | end 208 | 209 | context 'when the response does not contain JSON' do 210 | let(:last_response_2) do 211 | double(:last_response, 212 | status: 200, 213 | body: 'BINARY PDF DATA', 214 | headers: {}, 215 | content_type: 'application/pdf', 216 | ) 217 | end 218 | 219 | it 'returns requests but excludes the PDF body' do 220 | expect(subject.requests).to include( 221 | hash_including( 222 | response_body: nil, 223 | response_content_type: 'application/pdf', 224 | ), 225 | ) 226 | end 227 | end 228 | 229 | context 'with excluded response headers' do 230 | let(:_metadata) do 231 | { 232 | requests: [ 233 | [last_request_1, last_response_1], 234 | ], 235 | } 236 | end 237 | let(:last_response_1_headers) do 238 | { 239 | 'Authorization' => 'Basic foo', 240 | 'Accept' => 'application/json', 241 | } 242 | end 243 | 244 | it 'excludes the specified response headers' do 245 | allow(RspecApiDocs).to receive(:configuration) 246 | .and_return(double(exclude_response_headers: %w[Authorization])) 247 | 248 | expect(subject.requests.first[:response_headers]).to eq( 249 | 'Accept' => 'application/json', 250 | ) 251 | end 252 | end 253 | end 254 | end 255 | end 256 | end 257 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

rspec-api-docs

2 | 3 |

Generate API documentation using RSpec

4 | 5 |

6 | 7 | 8 | 9 |

10 | 11 | **rspec-api-docs** provides a way to generate documentation from your request 12 | specs. It does this by providing a simple DSL and a custom formatter. 13 | 14 | The default renderer produces a [single JSON file] which can be used by 15 | [api-docs] to [display your documentation]. 16 | 17 | [single JSON file]: ./spec/integration/output/json/index.json 18 | [display your documentation]: https://twe4ked.github.io/api-docs/ 19 | 20 | ## Installation 21 | 22 | Add this line to your application's Gemfile: 23 | 24 | ```ruby 25 | gem 'rspec-api-docs' 26 | ``` 27 | 28 | And then execute: 29 | 30 | $ bundle 31 | 32 | Or install it yourself as: 33 | 34 | $ gem install rspec-api-docs 35 | 36 | ## Usage 37 | 38 | **rspec-api-docs** works in two stages. The first stage introduces a new DSL 39 | method, `doc`, to include in your RSpec specs. 40 | 41 | ``` ruby 42 | require 'rspec_api_docs/dsl' 43 | 44 | RSpec.describe 'Characters' do 45 | include RspecApiDocs::Dsl 46 | 47 | # ... 48 | end 49 | ``` 50 | 51 | The `doc` method stores data in a hash on the RSpec example metadata. 52 | 53 | The second stage is the formatter (`RspecApiDocs::Formatter`). The formatter 54 | parses the hash stored on each RSpec example and uses a 55 | [renderer](lib/rspec_api_docs/formatter/renderer/README.md) to write out your 56 | documentation. 57 | 58 | ``` 59 | $ rspec spec/requests/characters_spec.rb --formatter=RspecApiDocs::Formatter 60 | ``` 61 | 62 | ### DSL 63 | 64 | First, require the DSL and include the DSL module. 65 | 66 | You can do this in your `spec_helper.rb`: 67 | 68 | ``` ruby 69 | require 'rspec_api_docs/dsl' 70 | 71 | RSpec.configure do |config| 72 | config.include RspecApiDocs::Dsl, type: :request 73 | 74 | # ... 75 | end 76 | ``` 77 | 78 | Or in individual specs: 79 | 80 | 81 | ``` ruby 82 | require 'rspec_api_docs/dsl' 83 | 84 | RSpec.describe 'Characters' do 85 | include RspecApiDocs::Dsl 86 | 87 | # ... 88 | end 89 | ``` 90 | 91 | You also need to require a lambda that runs after each expectation: 92 | 93 | ``` ruby 94 | require 'rspec_api_docs/after' 95 | 96 | RSpec.configure do |config| 97 | config.after &RspecApiDocs::After::Hook 98 | end 99 | ``` 100 | 101 | This automatically stores the `last_request` and `last_response` objects for 102 | use by the formatter. 103 | 104 | **rspec-api-docs** doesn't touch any of the built-in RSpec DSL methods. 105 | Everything is contained in the `doc` block. 106 | 107 | You can use RSpec `before` blocks to share setup between multiple examples. 108 | 109 | ``` ruby 110 | require 'rspec_api_docs/dsl' 111 | 112 | RSpec.describe 'Characters' do 113 | include RspecApiDocs::Dsl 114 | 115 | before do 116 | doc do 117 | resource_name 'Characters' 118 | resource_description <<-EOF 119 | Characters inhabit the Land of Ooo. 120 | EOF 121 | end 122 | end 123 | 124 | describe 'GET /characters/:id' do 125 | it 'returns a character' do 126 | doc do 127 | name 'Fetching a Character' 128 | description 'For getting information about a Character.' 129 | path '/characters/:id' 130 | 131 | field :id, 'The id of a character', scope: :character, type: 'integer' 132 | field :name, "The character's name", scope: :character, type: 'string' 133 | end 134 | 135 | get '/characters/1' 136 | 137 | # normal expectations ... 138 | end 139 | 140 | # ... 141 | end 142 | end 143 | ``` 144 | 145 | #### `resource_name` 146 | 147 | Accepts a string of the name of the resource. 148 | 149 | > Characters 150 | 151 | #### `resource_description` 152 | 153 | Accepts a string that describes the resource. 154 | 155 | > Characters inhabit the Land of Ooo. 156 | 157 | #### `resource_precedence` 158 | 159 | Accepts an optional integer. 160 | 161 | Lower numbers are ordered first. 162 | 163 | #### `name` 164 | 165 | Accepts a string of the name of the resource. 166 | 167 | > Fetching a character 168 | 169 | Note: This defaults to the "description" of the RSpec example. 170 | 171 | 172 | ``` ruby 173 | it 'Fetching a character' do 174 | # ... 175 | end 176 | ``` 177 | 178 | #### `description` 179 | 180 | Accepts a string that describes the example. 181 | 182 | > To find out information about a Character. 183 | 184 | #### `path` 185 | 186 | Accepts a string for the path requested in the example. 187 | 188 | > /characters/:id 189 | 190 | Note: This defaults to the path of the first route requested in the example. 191 | 192 | #### `field` 193 | 194 | Accepts a `name`, `description`, and optionally a `scope`, `type`, and `example`. 195 | 196 | - `name` [`Symbol`] the name of the response field 197 | - `description` [`String`] a description of the response field 198 | - `scope` [`Symbol`, `Array`] _(optional)_ how the field is scoped 199 | - `type` [`String`] _(optional)_ the type of the returned field 200 | - `example` _(optional)_ an example value 201 | 202 | This can be called multiple times for each response field. 203 | 204 | ``` ruby 205 | field :id, 'The id of a character', scope: :character, type: 'integer', example: 42 206 | field :name, "The character's name", scope: :character, type: 'string' 207 | ``` 208 | 209 | The `example` is useful if the data might change (i.e. a database ID column). 210 | The value will be substituted in the resulting JSON. 211 | 212 | #### `param` 213 | 214 | Accepts a `name`, `description`, and optionally a `scope`, `type`, and `required` flag. 215 | 216 | - `name` [`Symbol`] the name of the parameter 217 | - `description` [`Symbol`] a description of the parameter 218 | - `scope` [`Symbol`, `Array`] _(optional)_ how the parameter is scoped 219 | - `type` [`String`] _(optional)_ the type of the parameter 220 | - `required` [`Boolean`] _(optional)_ if the parameter is required 221 | 222 | This can be called multiple times for each parameter. 223 | 224 | ``` ruby 225 | param :id, 'The id of a character', scope: :character, type: 'integer', required: true 226 | param :name, "The character's name", scope: :character, type: 'string' 227 | ``` 228 | 229 | #### `note` 230 | 231 | Accepts a `note` and optional `level`. 232 | 233 | - `level` [`Symbol`] one of `:success`, `:info`, `:warning`, or `:danger`. Defaults to `:info` 234 | - `note` [`String`] the note 235 | 236 | ``` ruby 237 | note 'You need to supply an id!' 238 | note :warning, "An error will be thrown if you don't supply an id!" 239 | ``` 240 | 241 | #### `precedence` 242 | 243 | Accepts an optional integer. 244 | 245 | Lower numbers are ordered first. 246 | 247 | See the integration specs for more examples of the DSL in use. 248 | 249 | ### Formatter 250 | 251 | The formatter can be configured in your `spec_helper.rb`: 252 | 253 | ``` ruby 254 | # Defaults are shown 255 | 256 | RspecApiDocs.configure do |config| 257 | # The output directory for file(s) created by the renderer. 258 | config.output_dir = 'docs' 259 | 260 | # One of :json, :raddocs, or :slate. 261 | # This can also be a class that quacks like a renderer. 262 | # A renderer is initialized with an array of `Resource`s and a `#render` 263 | # method is called. 264 | config.renderer = :json 265 | 266 | # Set to false if you don't want to validate params are documented and their 267 | # types in the after block. 268 | config.validate_params = true 269 | end 270 | ``` 271 | 272 | See [the documentation](http://www.rubydoc.info/github/twe4ked/rspec-api-docs/master). 273 | 274 | ## Rake tasks 275 | 276 | ``` ruby 277 | require 'rspec_api_docs/rake_task' 278 | 279 | RspecApiDocs::Rake.new do |task| # docs:generate 280 | # Pattern for where to find the specs 281 | task.pattern = 'spec/requests/**/*_spec.rb' 282 | 283 | # Extra RSpec options 284 | task.rspec_opts = ['--format progress'] 285 | end 286 | 287 | RspecApiDocs::Rake.new do |task| # docs:ensure_updated 288 | # Same as options above with some extras for when verify is true 289 | 290 | # Raise an error if the generated docs don't match the existing docs 291 | # The verify option only works with the :json renderer. 292 | task.verify = true 293 | 294 | # The existing (committed) output file 295 | task.existing_file = 'docs/index.json' 296 | end 297 | 298 | # Non-verify task with custom name 299 | RspecApiDocs::Rake.new :custom_task_name 300 | ``` 301 | 302 | ## Development 303 | 304 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 305 | `rake` to run the tests. 306 | 307 | Regenerate this project's integration spec docs locally: 308 | 309 | ```bash 310 | ./bin/generate_integration_docs 311 | ``` 312 | 313 | To install this gem onto your local machine, run `bundle exec rake install`. 314 | 315 | To release a new version, update the version number in `version.rb`, and then 316 | run `bundle exec rake release`, which will create a git tag for the version, 317 | push git commits and tags, and push the `.gem` file to [rubygems.org]. 318 | 319 | ## Creating a release 320 | 321 | 1. Bump the version number in `lib/rspec_api_docs/version.rb` 322 | 323 | 2. Authenticate with GitHub Packages using [these instructions](https://docs.envato.net/infrastructure/guides/github/gem-packages.html#pushing-gems) 324 | 325 | 3. Run the release Rake task: 326 | 327 | ``` 328 | BUNDLE_GEM__PUSH_KEY=github bundle exec rake release 329 | ``` 330 | 331 | This will create a git tag for the version, push tags up to GitHub, and 332 | package the code in a `gem` file and upload it to GitHub packages. 333 | 334 | 4. Create a [new release](https://github.com/envato/rspec_api_docs/releases) 335 | 336 | ## Contributing 337 | 338 | Bug reports and pull requests are welcome on GitHub at 339 | https://github.com/twe4ked/rspec-api-docs. This project is intended to be a 340 | safe, welcoming space for collaboration, and contributors are expected to 341 | adhere to the [Contributor Covenant] code of 342 | conduct. 343 | 344 | ## License 345 | 346 | The gem is available as open source under the terms of the [MIT License]. 347 | 348 | [MIT License]: http://opensource.org/licenses/MIT 349 | [Contributor Covenant]: http://contributor-covenant.org 350 | [rubygems.org]: https://rubygems.org 351 | [api-docs]: https://github.com/twe4ked/api-docs 352 | -------------------------------------------------------------------------------- /spec/integration/output/json/index.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Characters", 4 | "description": "Characters inhabit the Land of Ooo.\n\nUse the following endpoints to fetch information and modify them.\n", 5 | "examples": [ 6 | { 7 | "description": "Getting all the characters.\n\nFor when you need everything!\n", 8 | "name": "Listing all characters", 9 | "httpMethod": "GET", 10 | "parameters": [], 11 | "path": "/characters", 12 | "requests": [ 13 | { 14 | "requestMethod": "GET", 15 | "requestPath": "/characters", 16 | "requestBody": null, 17 | "requestHeaders": { 18 | "Cookie": "", 19 | "Host": "example.org" 20 | }, 21 | "requestQueryParameters": {}, 22 | "requestContentType": null, 23 | "responseStatus": 200, 24 | "responseStatusText": "OK", 25 | "responseBody": "{\"data\":[{\"id\":1,\"name\":\"Finn the Human\"},{\"id\":2,\"name\":\"Jake the Dog\"}]}", 26 | "responseHeaders": { 27 | "content-type": "application/json", 28 | "content-length": "74", 29 | "x-content-type-options": "nosniff" 30 | }, 31 | "responseContentType": "application/json" 32 | } 33 | ], 34 | "responseFields": [ 35 | { 36 | "name": "data[][id]", 37 | "description": "The id of a character", 38 | "type": "integer" 39 | }, 40 | { 41 | "name": "data[][name]", 42 | "description": "The character's name", 43 | "type": "string" 44 | } 45 | ], 46 | "notes": {} 47 | }, 48 | { 49 | "description": null, 50 | "name": "Characters head", 51 | "httpMethod": "HEAD", 52 | "parameters": [], 53 | "path": "/characters", 54 | "requests": [ 55 | { 56 | "requestMethod": "HEAD", 57 | "requestPath": "/characters", 58 | "requestBody": null, 59 | "requestHeaders": { 60 | "Cookie": "", 61 | "Host": "example.org" 62 | }, 63 | "requestQueryParameters": {}, 64 | "requestContentType": "application/x-www-form-urlencoded", 65 | "responseStatus": 200, 66 | "responseStatusText": "OK", 67 | "responseBody": null, 68 | "responseHeaders": { 69 | "content-type": "application/json", 70 | "content-length": "74", 71 | "x-content-type-options": "nosniff" 72 | }, 73 | "responseContentType": "application/json" 74 | } 75 | ], 76 | "responseFields": [], 77 | "notes": { 78 | "info": "This example has overridden the resource name set in the `before` block." 79 | } 80 | }, 81 | { 82 | "description": "For getting information about a Character.", 83 | "name": "Deleting a Character", 84 | "httpMethod": "DELETE", 85 | "parameters": [ 86 | { 87 | "name": "id", 88 | "description": "The id of a character", 89 | "required": true 90 | } 91 | ], 92 | "path": "/characters/:id", 93 | "requests": [ 94 | { 95 | "requestMethod": "DELETE", 96 | "requestPath": "/characters/1", 97 | "requestBody": null, 98 | "requestHeaders": { 99 | "Cookie": "", 100 | "Host": "example.org" 101 | }, 102 | "requestQueryParameters": {}, 103 | "requestContentType": "application/x-www-form-urlencoded", 104 | "responseStatus": 200, 105 | "responseStatusText": "OK", 106 | "responseBody": "{\"message\":\"Character not found.\"}", 107 | "responseHeaders": { 108 | "content-type": "application/json", 109 | "content-length": "34", 110 | "x-content-type-options": "nosniff" 111 | }, 112 | "responseContentType": "application/json" 113 | } 114 | ], 115 | "responseFields": [ 116 | { 117 | "name": "message", 118 | "description": "Success message", 119 | "type": "string" 120 | } 121 | ], 122 | "notes": {} 123 | }, 124 | { 125 | "description": "For getting information about a Character.", 126 | "name": "Fetching a Character", 127 | "httpMethod": "GET", 128 | "parameters": [ 129 | { 130 | "name": "id", 131 | "description": "The id of a character", 132 | "required": true 133 | } 134 | ], 135 | "path": "/characters/:id", 136 | "requests": [ 137 | { 138 | "requestMethod": "GET", 139 | "requestPath": "/characters/1", 140 | "requestBody": null, 141 | "requestHeaders": { 142 | "Cookie": "", 143 | "Host": "example.org" 144 | }, 145 | "requestQueryParameters": {}, 146 | "requestContentType": null, 147 | "responseStatus": 200, 148 | "responseStatusText": "OK", 149 | "responseBody": "{\"character\":{\"id\":42,\"name\":\"Finn the Human\"}}", 150 | "responseHeaders": { 151 | "content-type": "application/json", 152 | "content-length": "47", 153 | "x-content-type-options": "nosniff" 154 | }, 155 | "responseContentType": "application/json" 156 | } 157 | ], 158 | "responseFields": [ 159 | { 160 | "name": "character[id]", 161 | "description": "The id of a character", 162 | "type": "integer" 163 | }, 164 | { 165 | "name": "character[name]", 166 | "description": "The character's name", 167 | "type": "string" 168 | } 169 | ], 170 | "notes": { 171 | "info": "You need to supply an id!", 172 | "warning": "An error will be thrown if you don't supply an id!" 173 | } 174 | }, 175 | { 176 | "description": "Returns an error", 177 | "name": "When a Character cannot be found", 178 | "httpMethod": "GET", 179 | "parameters": [], 180 | "path": "/characters/:id", 181 | "requests": [ 182 | { 183 | "requestMethod": "GET", 184 | "requestPath": "/characters/404", 185 | "requestBody": null, 186 | "requestHeaders": { 187 | "Cookie": "", 188 | "Host": "example.org" 189 | }, 190 | "requestQueryParameters": {}, 191 | "requestContentType": null, 192 | "responseStatus": 404, 193 | "responseStatusText": "Not Found", 194 | "responseBody": "{\"errors\":{\"message\":\"Character not found.\"}}", 195 | "responseHeaders": { 196 | "content-type": "application/json", 197 | "content-length": "45", 198 | "x-content-type-options": "nosniff" 199 | }, 200 | "responseContentType": "application/json" 201 | } 202 | ], 203 | "responseFields": [ 204 | { 205 | "name": "errors[message]", 206 | "description": "Error message", 207 | "type": "string" 208 | } 209 | ], 210 | "notes": { 211 | "danger": "This is an error case" 212 | } 213 | } 214 | ] 215 | }, 216 | { 217 | "name": "Places", 218 | "description": "This category consists of locations in the Land of Ooo.\n\nThese are all great places!\n\nThe Characters that live here are great too.\n", 219 | "examples": [ 220 | { 221 | "description": null, 222 | "name": "Fetching all places and page 2", 223 | "httpMethod": "GET", 224 | "parameters": [ 225 | { 226 | "name": "page", 227 | "description": "The page", 228 | "required": false 229 | } 230 | ], 231 | "path": "/places", 232 | "requests": [ 233 | { 234 | "requestMethod": "GET", 235 | "requestPath": "/places", 236 | "requestBody": null, 237 | "requestHeaders": { 238 | "Cookie": "", 239 | "Host": "example.org" 240 | }, 241 | "requestQueryParameters": {}, 242 | "requestContentType": null, 243 | "responseStatus": 200, 244 | "responseStatusText": "OK", 245 | "responseBody": "{\"data\":[{\"id\":1,\"name\":\"Candy Kingdom\"},{\"id\":2,\"name\":\"Tree Fort\"}]}", 246 | "responseHeaders": { 247 | "content-type": "application/json", 248 | "content-length": "70", 249 | "x-content-type-options": "nosniff" 250 | }, 251 | "responseContentType": "application/json" 252 | }, 253 | { 254 | "requestMethod": "GET", 255 | "requestPath": "/places?page=2", 256 | "requestBody": null, 257 | "requestHeaders": { 258 | "Cookie": "", 259 | "Host": "example.org" 260 | }, 261 | "requestQueryParameters": { 262 | "page": "2" 263 | }, 264 | "requestContentType": null, 265 | "responseStatus": 200, 266 | "responseStatusText": "OK", 267 | "responseBody": "{\"data\":[]}", 268 | "responseHeaders": { 269 | "content-type": "application/json", 270 | "content-length": "11", 271 | "x-content-type-options": "nosniff" 272 | }, 273 | "responseContentType": "application/json" 274 | } 275 | ], 276 | "responseFields": [ 277 | { 278 | "name": "data[][id]", 279 | "description": "The id of the place", 280 | "type": "integer" 281 | }, 282 | { 283 | "name": "data[][name]", 284 | "description": "The place's name", 285 | "type": "string" 286 | } 287 | ], 288 | "notes": { 289 | "success": "You can store multiple requests in a single example." 290 | } 291 | }, 292 | { 293 | "description": null, 294 | "name": "Listing all places", 295 | "httpMethod": "GET", 296 | "parameters": [], 297 | "path": "/places", 298 | "requests": [ 299 | { 300 | "requestMethod": "GET", 301 | "requestPath": "/places", 302 | "requestBody": null, 303 | "requestHeaders": { 304 | "Cookie": "", 305 | "Host": "example.org" 306 | }, 307 | "requestQueryParameters": {}, 308 | "requestContentType": null, 309 | "responseStatus": 200, 310 | "responseStatusText": "OK", 311 | "responseBody": "{\"data\":[{\"id\":1,\"name\":\"Candy Kingdom\"},{\"id\":2,\"name\":\"Tree Fort\"}]}", 312 | "responseHeaders": { 313 | "content-type": "application/json", 314 | "content-length": "70", 315 | "x-content-type-options": "nosniff" 316 | }, 317 | "responseContentType": "application/json" 318 | } 319 | ], 320 | "responseFields": [ 321 | { 322 | "name": "data[][id]", 323 | "description": "The id of the place", 324 | "type": "integer" 325 | }, 326 | { 327 | "name": "data[][name]", 328 | "description": "The place's name", 329 | "type": "string" 330 | } 331 | ], 332 | "notes": {} 333 | }, 334 | { 335 | "description": null, 336 | "name": "Listing all places with a modified response bod,", 337 | "httpMethod": "GET", 338 | "parameters": [], 339 | "path": "/places", 340 | "requests": [ 341 | { 342 | "requestMethod": "GET", 343 | "requestPath": "/places", 344 | "requestBody": null, 345 | "requestHeaders": { 346 | "Cookie": "", 347 | "Host": "example.org" 348 | }, 349 | "requestQueryParameters": {}, 350 | "requestContentType": null, 351 | "responseStatus": 200, 352 | "responseStatusText": "OK", 353 | "responseBody": "{\"data\":[{\"id\":2,\"name\":\"Tree Fort\"}]}", 354 | "responseHeaders": { 355 | "content-type": "application/json", 356 | "content-length": "70", 357 | "x-content-type-options": "nosniff" 358 | }, 359 | "responseContentType": "application/json" 360 | } 361 | ], 362 | "responseFields": [ 363 | { 364 | "name": "data[][id]", 365 | "description": "The id of the place", 366 | "type": "integer" 367 | }, 368 | { 369 | "name": "data[][name]", 370 | "description": "The place's name", 371 | "type": "string" 372 | } 373 | ], 374 | "notes": {} 375 | } 376 | ] 377 | } 378 | ] 379 | --------------------------------------------------------------------------------