diff --git a/README.md b/README.md index 8ccc86cd..408f92aa 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,8 @@ RspecApiDocumentation.configure do |config| # An array of output format(s). # Possible values are :json, :html, :combined_text, :combined_json, - # :json_iodocs, :textile, :markdown, :append_json, :slate + # :json_iodocs, :textile, :markdown, :append_json, :slate, + # :api_blueprint config.format = [:html] # Location of templates @@ -170,6 +171,7 @@ end * **json_iodocs**: Generates [I/O Docs](http://www.mashery.com/product/io-docs) style documentation. * **textile**: Generates an index file and example files in Textile. * **markdown**: Generates an index file and example files in Markdown. +* **api_blueprint**: Generates an index file and example files in [APIBlueprint](https://apiblueprint.org). * **append_json**: Lets you selectively run specs without destroying current documentation. See section below. ### append_json @@ -204,7 +206,32 @@ rake docs:generate:append[spec/acceptance/orders_spec.rb] This will update the current index's examples to include any in the `orders_spec.rb` file. Any examples inside will be rewritten. +### api_blueprint + +This [format](https://apiblueprint.org) (APIB) has additional functions: + +* `route`: APIB groups URLs together and then below them are HTTP verbs. + + ```ruby + route "/orders", "Orders Collection" do + get "Returns all orders" do + # ... + end + + delete "Deletes all orders" do + # ... + end + end + ``` + + If you don't use `route`, then param in `get(param)` should be an URL as + states in the rest of this documentation. + +* `attribute`: APIB has attributes besides parameters. Use attributes exactly + like you'd use `parameter` (see documentation below). + ## Filtering and Exclusion + rspec_api_documentation lets you determine which examples get outputted into the final documentation. All filtering is done via the `:document` metadata key. You tag examples with either a single symbol or an array of symbols. diff --git a/features/api_blueprint_documentation.feature b/features/api_blueprint_documentation.feature new file mode 100644 index 00000000..a824e0cf --- /dev/null +++ b/features/api_blueprint_documentation.feature @@ -0,0 +1,477 @@ +Feature: Generate API Blueprint documentation from test examples + + Background: + Given a file named "app.rb" with: + """ + require 'sinatra' + + class App < Sinatra::Base + get '/orders' do + content_type "application/vnd.api+json" + + [200, { + :page => 1, + :orders => [ + { name: 'Order 1', amount: 9.99, description: nil }, + { name: 'Order 2', amount: 100.0, description: 'A great order' } + ] + }.to_json] + end + + get '/orders/:id' do + content_type :json + + [200, { order: { name: 'Order 1', amount: 100.0, description: 'A great order' } }.to_json] + end + + post '/orders' do + content_type :json + + [201, { order: { name: 'Order 1', amount: 100.0, description: 'A great order' } }.to_json] + end + + put '/orders/:id' do + content_type :json + + if params[:id].to_i > 0 + [200, { data: { id: "1", type: "order", attributes: { name: "Order 1", amount: 100.0, description: "A description" } } }.to_json] + else + [400, ""] + end + end + + delete '/orders/:id' do + 200 + end + + get '/instructions' do + response_body = { + data: { + id: "1", + type: "instructions", + attributes: {} + } + } + [200, response_body.to_json] + end + end + """ + And a file named "app_spec.rb" with: + """ + require "rspec_api_documentation" + require "rspec_api_documentation/dsl" + + RspecApiDocumentation.configure do |config| + config.app = App + config.api_name = "Example API" + config.format = :api_blueprint + config.request_body_formatter = :json + config.request_headers_to_include = %w[Content-Type Host] + config.response_headers_to_include = %w[Content-Type Content-Length] + end + + resource 'Orders' do + explanation "Orders resource" + + route '/orders', 'Orders Collection' do + explanation "This URL allows users to interact with all orders." + + get 'Return all orders' do + explanation "This is used to return all orders." + + example_request 'Getting a list of orders' do + expect(status).to eq(200) + expect(response_body).to eq('{"page":1,"orders":[{"name":"Order 1","amount":9.99,"description":null},{"name":"Order 2","amount":100.0,"description":"A great order"}]}') + end + end + + post 'Creates an order' do + explanation "This is used to create orders." + + header "Content-Type", "application/json" + + example 'Creating an order' do + request = { + data: { + type: "order", + attributes: { + name: "Order 1", + amount: 100.0, + description: "A description" + } + } + } + do_request(request) + expect(status).to eq(201) + end + end + end + + route '/orders/:id{?optional=:optional}', "Single Order" do + parameter :id, 'Order id', required: true, type: 'string', :example => '1' + parameter :optional + + attribute :name, 'The order name', required: true, :example => 'a name' + attribute :amount, required: false + attribute :description, 'The order description', type: 'string', required: false, example: "a description" + + get 'Returns a single order' do + explanation "This is used to return orders." + + let(:id) { 1 } + + example_request 'Getting a specific order' do + explanation 'Returns a specific order.' + + expect(status).to eq(200) + expect(response_body).to eq('{"order":{"name":"Order 1","amount":100.0,"description":"A great order"}}') + end + end + + put 'Updates a single order' do + explanation "This is used to update orders." + + header "Content-Type", "application/json; charset=utf-16" + + context "with a valid id" do + let(:id) { 1 } + + example 'Update an order' do + request = { + data: { + id: "1", + type: "order", + attributes: { + name: "Order 1", + } + } + } + do_request(request) + expected_response = { + data: { + id: "1", + type: "order", + attributes: { + name: "Order 1", + amount: 100.0, + description: "A description", + } + } + } + expect(status).to eq(200) + expect(response_body).to eq(expected_response.to_json) + end + end + + context "with an invalid id" do + let(:id) { "a" } + + example_request 'Invalid request' do + expect(status).to eq(400) + expect(response_body).to eq("") + end + end + end + + delete "Deletes a specific order" do + explanation "This is used to delete orders." + + let(:id) { 1 } + + example_request "Deleting an order" do + explanation 'Deletes the requested order.' + + expect(status).to eq(200) + expect(response_body).to eq('') + end + end + end + end + + resource 'Instructions' do + explanation 'Instructions help the users use the app.' + + route '/instructions', 'Instructions Collection' do + explanation 'This endpoint allows users to interact with all instructions.' + + get 'Returns all instructions' do + explanation 'This should be used to get all instructions.' + + example_request 'List all instructions' do + explanation 'Returns all instructions.' + + expected_response = { + data: { + id: "1", + type: "instructions", + attributes: {} + } + } + expect(status).to eq(200) + expect(response_body).to eq(expected_response.to_json) + end + end + end + end + """ + When I run `rspec app_spec.rb --require ./app.rb --format RspecApiDocumentation::ApiFormatter` + + Scenario: Output helpful progress to the console + Then the output should contain: + """ + Generating API Docs + Orders + /orders Orders Collection + GET Return all orders + * Getting a list of orders + POST Creates an order + * Creating an order + /orders/:id{?optional=:optional} Single Order + GET Returns a single order + * Getting a specific order + PUT Updates a single order + with a valid id + * Update an order + with an invalid id + * Invalid request + DELETE Deletes a specific order + * Deleting an order + Instructions + /instructions Instructions Collection + GET Returns all instructions + * List all instructions + """ + And the output should contain "7 examples, 0 failures" + And the exit status should be 0 + + Scenario: Index file should look like we expect + Then the file "doc/api/index.apib" should contain exactly: + """ + FORMAT: A1 + + # Group Instructions + + Instructions help the users use the app. + + ## Instructions Collection [/instructions] + + ### Returns all instructions [GET] + + + Request List all instructions + + + Headers + + Host: example.org + + + Response 200 (text/html;charset=utf-8) + + + Headers + + Content-Type: text/html;charset=utf-8 + Content-Length: 57 + + + Body + + {"data":{"id":"1","type":"instructions","attributes":{}}} + + # Group Orders + + Orders resource + + ## Orders Collection [/orders] + + ### Creates an order [POST] + + + Request Creating an order (application/json) + + + Headers + + Content-Type: application/json + Host: example.org + + + Body + + { + "data": { + "type": "order", + "attributes": { + "name": "Order 1", + "amount": 100.0, + "description": "A description" + } + } + } + + + Response 201 (application/json) + + + Headers + + Content-Type: application/json + Content-Length: 73 + + + Body + + { + "order": { + "name": "Order 1", + "amount": 100.0, + "description": "A great order" + } + } + + ### Return all orders [GET] + + + Request Getting a list of orders + + + Headers + + Host: example.org + + + Response 200 (application/vnd.api+json) + + + Headers + + Content-Type: application/vnd.api+json + Content-Length: 137 + + + Body + + { + "page": 1, + "orders": [ + { + "name": "Order 1", + "amount": 9.99, + "description": null + }, + { + "name": "Order 2", + "amount": 100.0, + "description": "A great order" + } + ] + } + + ## Single Order [/orders/:id{?optional=:optional}] + + + Parameters + + id: 1 (required, string) - Order id + + optional + + + Attributes (object) + + name: a name (required) - The order name + + amount + + description: a description (string) - The order description + + ### Deletes a specific order [DELETE] + + + Request Deleting an order (application/x-www-form-urlencoded) + + + Headers + + Host: example.org + Content-Type: application/x-www-form-urlencoded + + + Response 200 (text/html;charset=utf-8) + + + Headers + + Content-Type: text/html;charset=utf-8 + Content-Length: 0 + + ### Returns a single order [GET] + + + Request Getting a specific order + + + Headers + + Host: example.org + + + Response 200 (application/json) + + + Headers + + Content-Type: application/json + Content-Length: 73 + + + Body + + { + "order": { + "name": "Order 1", + "amount": 100.0, + "description": "A great order" + } + } + + ### Updates a single order [PUT] + + + Request Invalid request (application/json; charset=utf-16) + + + Headers + + Content-Type: application/json; charset=utf-16 + Host: example.org + + + Response 400 (application/json) + + + Headers + + Content-Type: application/json + Content-Length: 0 + + + Request Update an order (application/json; charset=utf-16) + + + Headers + + Content-Type: application/json; charset=utf-16 + Host: example.org + + + Body + + { + "data": { + "id": "1", + "type": "order", + "attributes": { + "name": "Order 1" + } + } + } + + + Response 200 (application/json) + + + Headers + + Content-Type: application/json + Content-Length: 111 + + + Body + + { + "data": { + "id": "1", + "type": "order", + "attributes": { + "name": "Order 1", + "amount": 100.0, + "description": "A description" + } + } + } + """ + + Scenario: Example 'Deleting an order' file should not be created + Then a file named "doc/api/orders/deleting_an_order.apib" should not exist + + Scenario: Example 'Getting a list of orders' file should be created + Then a file named "doc/api/orders/getting_a_list_of_orders.apib" should not exist + + Scenario: Example 'Getting a specific order' file should be created + Then a file named "doc/api/orders/getting_a_specific_order.apib" should not exist + + Scenario: Example 'Updating an order' file should be created + Then a file named "doc/api/orders/updating_an_order.apib" should not exist + + Scenario: Example 'Getting welcome message' file should be created + Then a file named "doc/api/help/getting_welcome_message.apib" should not exist diff --git a/lib/rspec_api_documentation.rb b/lib/rspec_api_documentation.rb index e50d672d..6d6afdc2 100644 --- a/lib/rspec_api_documentation.rb +++ b/lib/rspec_api_documentation.rb @@ -44,6 +44,7 @@ module Writers autoload :CombinedTextWriter autoload :CombinedJsonWriter autoload :SlateWriter + autoload :ApiBlueprintWriter end module Views @@ -59,6 +60,8 @@ module Views autoload :MarkdownExample autoload :SlateIndex autoload :SlateExample + autoload :ApiBlueprintIndex + autoload :ApiBlueprintExample end def self.configuration diff --git a/lib/rspec_api_documentation/dsl/endpoint.rb b/lib/rspec_api_documentation/dsl/endpoint.rb index 8a06e4ad..dcfc4888 100644 --- a/lib/rspec_api_documentation/dsl/endpoint.rb +++ b/lib/rspec_api_documentation/dsl/endpoint.rb @@ -9,6 +9,8 @@ module Endpoint extend ActiveSupport::Concern include Rack::Test::Utils + URL_PARAMS_REGEX = /[:\{](\w+)\}?/.freeze + delegate :response_headers, :response_status, :response_body, :to => :rspec_api_documentation_client module ClassMethods @@ -96,8 +98,16 @@ def status rspec_api_documentation_client.status end + def in_path?(param) + path_params.include?(param) + end + + def path_params + example.metadata[:route].scan(URL_PARAMS_REGEX).flatten + end + def path - example.metadata[:route].gsub(/:(\w+)/) do |match| + example.metadata[:route].gsub(URL_PARAMS_REGEX) do |match| if extra_params.keys.include?($1) delete_extra_param($1) elsif respond_to?($1) diff --git a/lib/rspec_api_documentation/dsl/resource.rb b/lib/rspec_api_documentation/dsl/resource.rb index c6854717..1e45d4e2 100644 --- a/lib/rspec_api_documentation/dsl/resource.rb +++ b/lib/rspec_api_documentation/dsl/resource.rb @@ -8,7 +8,12 @@ def self.define_action(method) define_method method do |*args, &block| options = args.extract_options! options[:method] = method - options[:route] = args.first + if metadata[:route_uri] + options[:route] = metadata[:route_uri] + options[:action_name] = args.first + else + options[:route] = args.first + end options[:api_doc_dsl] = :endpoint args.push(options) args[0] = "#{method.to_s.upcase} #{args[0]}" @@ -38,10 +43,25 @@ def callback(*args, &block) context(*args, &block) end + def route(*args, &block) + raise "You must define the route URI" if args[0].blank? + raise "You must define the route name" if args[1].blank? + options = args.extract_options! + options[:route_uri] = args[0].gsub(/\{.*\}/, "") + options[:route_optionals] = (optionals = args[0].match(/(\{.*\})/) and optionals[-1]) + options[:route_name] = args[1] + args.push(options) + context(*args, &block) + end + def parameter(name, *args) parameters.push(field_specification(name, *args)) end + def attribute(name, *args) + attributes.push(field_specification(name, *args)) + end + def response_field(name, *args) response_fields.push(field_specification(name, *args)) end @@ -75,6 +95,10 @@ def parameters safe_metadata(:parameters, []) end + def attributes + safe_metadata(:attributes, []) + end + def response_fields safe_metadata(:response_fields, []) end diff --git a/lib/rspec_api_documentation/example.rb b/lib/rspec_api_documentation/example.rb index 2ef5a53b..ba8f0ad7 100644 --- a/lib/rspec_api_documentation/example.rb +++ b/lib/rspec_api_documentation/example.rb @@ -38,6 +38,10 @@ def has_parameters? respond_to?(:parameters) && parameters.present? end + def has_attributes? + respond_to?(:attributes) && attributes.present? + end + def has_response_fields? respond_to?(:response_fields) && response_fields.present? end diff --git a/lib/rspec_api_documentation/views/api_blueprint_example.rb b/lib/rspec_api_documentation/views/api_blueprint_example.rb new file mode 100644 index 00000000..45f815a9 --- /dev/null +++ b/lib/rspec_api_documentation/views/api_blueprint_example.rb @@ -0,0 +1,104 @@ +module RspecApiDocumentation + module Views + class ApiBlueprintExample < MarkupExample + TOTAL_SPACES_INDENTATION = 8.freeze + + def initialize(example, configuration) + super + self.template_name = "rspec_api_documentation/api_blueprint_example" + end + + def parameters + super.map do |parameter| + parameter.merge({ + :required => !!parameter[:required], + :has_example => !!parameter[:example], + :has_type => !!parameter[:type] + }) + end + end + + def requests + super.map do |request| + request[:request_headers_text] = remove_utf8_for_json(request[:request_headers_text]) + request[:request_headers_text] = indent(request[:request_headers_text]) + request[:request_content_type] = content_type(request[:request_headers]) + request[:request_content_type] = remove_utf8_for_json(request[:request_content_type]) + request[:request_body] = body_to_json(request, :request) + request[:request_body] = indent(request[:request_body]) + + request[:response_headers_text] = remove_utf8_for_json(request[:response_headers_text]) + request[:response_headers_text] = indent(request[:response_headers_text]) + request[:response_content_type] = content_type(request[:response_headers]) + request[:response_content_type] = remove_utf8_for_json(request[:response_content_type]) + request[:response_body] = body_to_json(request, :response) + request[:response_body] = indent(request[:response_body]) + + request[:has_request?] = has_request?(request) + request[:has_response?] = has_response?(request) + request + end + end + + def extension + Writers::ApiBlueprintWriter::EXTENSION + end + + private + + def has_request?(metadata) + metadata.any? do |key, value| + [:request_body, :request_headers, :request_content_type].include?(key) && value + end + end + + def has_response?(metadata) + metadata.any? do |key, value| + [:response_status, :response_body, :response_headers, :response_content_type].include?(key) && value + end + end + + def indent(string) + string.tap do |str| + str.gsub!(/\n/, "\n" + (" " * TOTAL_SPACES_INDENTATION)) if str + end + end + + # http_call: the hash that contains all information about the HTTP + # request and response. + # message_direction: either `request` or `response`. + def body_to_json(http_call, message_direction) + content_type = http_call["#{message_direction}_content_type".to_sym] + body = http_call["#{message_direction}_body".to_sym] # e.g request_body + + if json?(content_type) && body + body = JSON.pretty_generate(JSON.parse(body)) + end + + body + end + + # JSON requests should use UTF-8 by default according to + # http://www.ietf.org/rfc/rfc4627.txt, so we will remove `charset=utf-8` + # when we find it to remove noise. + def remove_utf8_for_json(headers) + return unless headers + headers + .split("\n") + .map { |header| + header.gsub!(/; *charset=utf-8/, "") if json?(header) + header + } + .join("\n") + end + + def content_type(headers) + headers && headers.fetch("Content-Type", nil) + end + + def json?(string) + string =~ /application\/.*json/ + end + end + end +end diff --git a/lib/rspec_api_documentation/views/api_blueprint_index.rb b/lib/rspec_api_documentation/views/api_blueprint_index.rb new file mode 100644 index 00000000..aa7a4d50 --- /dev/null +++ b/lib/rspec_api_documentation/views/api_blueprint_index.rb @@ -0,0 +1,90 @@ +module RspecApiDocumentation + module Views + class ApiBlueprintIndex < MarkupIndex + def initialize(index, configuration) + super + self.template_name = "rspec_api_documentation/api_blueprint_index" + end + + def sections + super.map do |section| + routes = section[:examples].group_by { |e| "#{e.route_uri}#{e.route_optionals}" }.map do |route, examples| + attrs = fields(:attributes, examples) + params = fields(:parameters, examples) + + methods = examples.group_by(&:http_method).map do |http_method, examples| + { + http_method: http_method, + description: examples.first.respond_to?(:action_name) && examples.first.action_name, + examples: examples + } + end + + { + "has_attributes?".to_sym => attrs.size > 0, + "has_parameters?".to_sym => params.size > 0, + route: route, + route_name: examples[0][:route_name], + attributes: attrs, + parameters: params, + http_methods: methods + } + end + + section.merge({ + routes: routes + }) + end + end + + def examples + @index.examples.map do |example| + ApiBlueprintExample.new(example, @configuration) + end + end + + private + + # APIB has both `parameters` and `attributes`. This generates a hash + # with all of its properties, like name, description, required. + # { + # required: true, + # example: "1", + # type: "string", + # name: "id", + # description: "The id", + # properties_description: "required, string" + # } + def fields(property_name, examples) + examples + .map { |example| example.metadata[property_name] } + .flatten + .compact + .uniq { |property| property[:name] } + .map do |property| + properties = [] + properties << "required" if property[:required] + properties << property[:type] if property[:type] + if properties.count > 0 + property[:properties_description] = properties.join(", ") + else + property[:properties_description] = nil + end + + property[:description] = nil if description_blank?(property) + property + end + end + + # When no `description` was specified for a parameter, the DSL class + # is making `description = "#{scope} #{name}"`, which is bad because it + # assumes that all formats want this behavior. To avoid changing there + # and breaking everything, I do my own check here and if description + # equals the name, I assume it is blank. + def description_blank?(property) + !property[:description] || + property[:description].to_s.strip == property[:name].to_s.strip + end + end + end +end diff --git a/lib/rspec_api_documentation/views/markup_example.rb b/lib/rspec_api_documentation/views/markup_example.rb index f3cd769c..df2c1fdd 100644 --- a/lib/rspec_api_documentation/views/markup_example.rb +++ b/lib/rspec_api_documentation/views/markup_example.rb @@ -83,6 +83,10 @@ def format_scope(unformatted_scope) end end.join end + + def content_type(headers) + headers && headers.fetch("Content-Type", nil) + end end end end diff --git a/lib/rspec_api_documentation/writers/api_blueprint_writer.rb b/lib/rspec_api_documentation/writers/api_blueprint_writer.rb new file mode 100644 index 00000000..c785db26 --- /dev/null +++ b/lib/rspec_api_documentation/writers/api_blueprint_writer.rb @@ -0,0 +1,29 @@ +module RspecApiDocumentation + module Writers + class ApiBlueprintWriter < GeneralMarkupWriter + EXTENSION = 'apib' + + def markup_index_class + RspecApiDocumentation::Views::ApiBlueprintIndex + end + + def markup_example_class + RspecApiDocumentation::Views::ApiBlueprintExample + end + + def extension + EXTENSION + end + + private + + # API Blueprint is a spec, not navigable like HTML, therefore we generate + # only one file with all resources. + def render_options + super.merge({ + examples: false + }) + end + end + end +end diff --git a/lib/rspec_api_documentation/writers/general_markup_writer.rb b/lib/rspec_api_documentation/writers/general_markup_writer.rb index 6686a499..70e723c4 100644 --- a/lib/rspec_api_documentation/writers/general_markup_writer.rb +++ b/lib/rspec_api_documentation/writers/general_markup_writer.rb @@ -6,16 +6,20 @@ class GeneralMarkupWriter < Writer # Write out the generated documentation def write - File.open(configuration.docs_dir.join(index_file_name + '.' + extension), "w+") do |f| - f.write markup_index_class.new(index, configuration).render + if render_options.fetch(:index, true) + File.open(configuration.docs_dir.join(index_file_name + '.' + extension), "w+") do |f| + f.write markup_index_class.new(index, configuration).render + end end - index.examples.each do |example| - markup_example = markup_example_class.new(example, configuration) - FileUtils.mkdir_p(configuration.docs_dir.join(markup_example.dirname)) + if render_options.fetch(:examples, true) + index.examples.each do |example| + markup_example = markup_example_class.new(example, configuration) + FileUtils.mkdir_p(configuration.docs_dir.join(markup_example.dirname)) - File.open(configuration.docs_dir.join(markup_example.dirname, markup_example.filename), "w+") do |f| - f.write markup_example.render + File.open(configuration.docs_dir.join(markup_example.dirname, markup_example.filename), "w+") do |f| + f.write markup_example.render + end end end end @@ -27,6 +31,15 @@ def index_file_name def extension raise 'Parent class. This method should not be called.' end + + private + + def render_options + { + index: true, + examples: true + } + end end end end diff --git a/spec/dsl_spec.rb b/spec/dsl_spec.rb index 83fbce74..ec6cff5f 100644 --- a/spec/dsl_spec.rb +++ b/spec/dsl_spec.rb @@ -183,6 +183,15 @@ end end + put "/orders/{id}" do + describe "url params with curly braces" do + it "should overwrite path variables" do + expect(client).to receive(method).with("/orders/2", params, nil) + do_request(:id => 2) + end + end + end + get "/orders/:order_id/line_items/:id" do parameter :type, "The type document you want" @@ -194,6 +203,15 @@ end end + get "/orders/{order_id}/line_items/{id}" do + describe "url params with curly braces" do + it "should overwrite path variables and other parameters" do + expect(client).to receive(method).with("/orders/3/line_items/2?type=short", nil, nil) + do_request(:id => 2, :order_id => 3, :type => 'short') + end + end + end + get "/orders/:order_id" do let(:order) { double(:id => 1) } @@ -586,6 +604,39 @@ end end end + + route "/orders{?application_id=:some_id}", "Orders Collection" do + attribute :description, "Order description" + + it "saves the route URI" do |example| + expect(example.metadata[:route_uri]).to eq "/orders" + end + + it "saves the route optionals" do |example| + expect(example.metadata[:route_optionals]).to eq "{?application_id=:some_id}" + end + + it "saves the route name" do |example| + expect(example.metadata[:route_name]).to eq "Orders Collection" + end + + it "has 1 attribute" do |example| + expect(example.metadata[:attributes]).to eq [{ + name: "description", + description: "Order description" + }] + end + + get("Returns all orders") do + it "uses the route URI" do + expect(example.metadata[:route]).to eq "/orders" + end + + it "bubbles down the parent group metadata" do + expect(example.metadata[:method]).to eq :get + end + end + end end resource "top level parameters" do diff --git a/spec/example_spec.rb b/spec/example_spec.rb index e229e8e5..1aa94610 100644 --- a/spec/example_spec.rb +++ b/spec/example_spec.rb @@ -149,6 +149,26 @@ end end + describe "has_attributes?" do + subject { example.has_attributes? } + + context "when attributes are defined" do + before { allow(example).to receive(:attributes).and_return([double]) } + + it { should eq true } + end + + context "when attributes are empty" do + before { allow(example).to receive(:attributes).and_return([]) } + + it { should eq false } + end + + context "when attributes are not defined" do + it { should be_falsey } + end + end + describe "has_response_fields?" do subject { example.has_response_fields? } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8cb67272..918dd620 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,5 +4,4 @@ require 'pry' RSpec.configure do |config| - config.include FakeFS::SpecHelpers end diff --git a/spec/views/api_blueprint_example_spec.rb b/spec/views/api_blueprint_example_spec.rb new file mode 100644 index 00000000..427ef7d0 --- /dev/null +++ b/spec/views/api_blueprint_example_spec.rb @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +require 'spec_helper' + +describe RspecApiDocumentation::Views::ApiBlueprintExample do + let(:metadata) { { :resource_name => "Orders" } } + let(:group) { RSpec::Core::ExampleGroup.describe("Orders", metadata) } + let(:rspec_example) { group.example("Ordering a cup of coffee") {} } + let(:rad_example) do + RspecApiDocumentation::Example.new(rspec_example, configuration) + end + let(:configuration) { RspecApiDocumentation::Configuration.new } + let(:html_example) { described_class.new(rad_example, configuration) } + + let(:content_type) { "application/json; charset=utf-8" } + let(:requests) do + [{ + request_body: "{}", + request_headers: { + "Content-Type" => content_type, + "Another" => "header; charset=utf-8" + }, + request_content_type: "", + response_body: "{}", + response_headers: { + "Content-Type" => content_type, + "Another" => "header; charset=utf-8" + }, + response_content_type: "" + }] + end + + before do + rspec_example.metadata[:requests] = requests + end + + subject(:view) { described_class.new(rad_example, configuration) } + + describe '#requests' do + describe 'request_content_type' do + subject { view.requests[0][:request_content_type] } + + context 'when charset=utf-8 is present' do + it "just strips that because it's the default for json" do + expect(subject).to eq "application/json" + end + end + + context 'when charset=utf-16 is present' do + let(:content_type) { "application/json; charset=utf-16" } + + it "keeps that because it's NOT the default for json" do + expect(subject).to eq "application/json; charset=utf-16" + end + end + end + + describe 'request_headers_text' do + subject { view.requests[0][:request_headers_text] } + + context 'when charset=utf-8 is present' do + it "just strips that because it's the default for json" do + expect(subject).to eq "Content-Type: application/json\n Another: header; charset=utf-8" + end + end + + context 'when charset=utf-16 is present' do + let(:content_type) { "application/json; charset=utf-16" } + + it "keeps that because it's NOT the default for json" do + expect(subject).to eq "Content-Type: application/json; charset=utf-16\n Another: header; charset=utf-8" + end + end + end + + describe 'response_content_type' do + subject { view.requests[0][:response_content_type] } + + context 'when charset=utf-8 is present' do + it "just strips that because it's the default for json" do + expect(subject).to eq "application/json" + end + end + + context 'when charset=utf-16 is present' do + let(:content_type) { "application/json; charset=utf-16" } + + it "keeps that because it's NOT the default for json" do + expect(subject).to eq "application/json; charset=utf-16" + end + end + end + + describe 'response_headers_text' do + subject { view.requests[0][:response_headers_text] } + + context 'when charset=utf-8 is present' do + it "just strips that because it's the default for json" do + expect(subject).to eq "Content-Type: application/json\n Another: header; charset=utf-8" + end + end + + context 'when charset=utf-16 is present' do + let(:content_type) { "application/json; charset=utf-16" } + + it "keeps that because it's NOT the default for json" do + expect(subject).to eq "Content-Type: application/json; charset=utf-16\n Another: header; charset=utf-8" + end + end + end + end +end diff --git a/spec/views/api_blueprint_index_spec.rb b/spec/views/api_blueprint_index_spec.rb new file mode 100644 index 00000000..92b4e21c --- /dev/null +++ b/spec/views/api_blueprint_index_spec.rb @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +require 'spec_helper' +require 'rspec_api_documentation/dsl' + +describe RspecApiDocumentation::Views::ApiBlueprintIndex do + let(:reporter) { RSpec::Core::Reporter.new(RSpec::Core::Configuration.new) } + let(:post_group) { RSpec::Core::ExampleGroup.resource("Posts") } + let(:comment_group) { RSpec::Core::ExampleGroup.resource("Comments") } + let(:rspec_example_post_get) do + post_group.route "/posts/:id{?option=:option}", "Single Post" do + parameter :id, "The id", required: true, type: "string", example: "1" + parameter :option + + get("/posts/{id}") do + example_request 'Gets a post' do + explanation "Gets a post given an id" + end + + example_request 'Returns an error' do + explanation "You have to provide an id" + end + end + end + end + + let(:rspec_example_post_delete) do + post_group.route "/posts/:id", "Single Post" do + parameter :id, "The id", required: true, type: "string", example: "1" + + delete("/posts/:id") do + example_request 'Deletes a post' do + do_request + end + end + end + end + + let(:rspec_example_post_update) do + post_group.route "/posts/:id", "Single Post" do + parameter :id, "The id", required: true, type: "string", example: "1" + attribute :name, "Order name 1", required: true + attribute :name, "Order name 2", required: true + + put("/posts/:id") do + example_request 'Updates a post' do + do_request + end + end + end + end + + + let(:rspec_example_posts) do + post_group.route "/posts", "Posts Collection" do + attribute :description, required: false + + get("/posts") do + example_request 'Get all posts' do + end + end + end + end + + let(:rspec_example_comments) do + comment_group.route "/comments", "Comments Collection" do + get("/comments") do + example_request 'Get all comments' do + end + end + end + end + let(:index) do + RspecApiDocumentation::Index.new.tap do |index| + index.examples << RspecApiDocumentation::Example.new(rspec_example_post_get, config) + index.examples << RspecApiDocumentation::Example.new(rspec_example_post_delete, config) + index.examples << RspecApiDocumentation::Example.new(rspec_example_post_update, config) + index.examples << RspecApiDocumentation::Example.new(rspec_example_posts, config) + index.examples << RspecApiDocumentation::Example.new(rspec_example_comments, config) + end + end + let(:config) { RspecApiDocumentation::Configuration.new } + + subject { described_class.new(index, config) } + + describe '#sections' do + it 'returns sections grouped' do + expect(subject.sections.count).to eq 2 + expect(subject.sections[0][:resource_name]).to eq "Comments" + expect(subject.sections[1][:resource_name]).to eq "Posts" + end + + describe "#routes" do + let(:sections) { subject.sections } + + it "returns routes grouped" do + comments_route = sections[0][:routes][0] + posts_route = sections[1][:routes][0] + post_route = sections[1][:routes][1] + post_route_with_optionals = sections[1][:routes][2] + + comments_examples = comments_route[:http_methods].map { |http_method| http_method[:examples] }.flatten + expect(comments_examples.size).to eq 1 + expect(comments_route[:route]).to eq "/comments" + expect(comments_route[:route_name]).to eq "Comments Collection" + expect(comments_route[:has_parameters?]).to eq false + expect(comments_route[:parameters]).to eq [] + expect(comments_route[:has_attributes?]).to eq false + expect(comments_route[:attributes]).to eq [] + + post_examples = post_route[:http_methods].map { |http_method| http_method[:examples] }.flatten + expect(post_examples.size).to eq 2 + expect(post_route[:route]).to eq "/posts/:id" + expect(post_route[:route_name]).to eq "Single Post" + expect(post_route[:has_parameters?]).to eq true + expect(post_route[:parameters]).to eq [{ + required: true, + type: "string", + example: "1", + name: "id", + description: "The id", + properties_description: "required, string" + }] + expect(post_route[:has_attributes?]).to eq true + expect(post_route[:attributes]).to eq [{ + required: true, + name: "name", + description: "Order name 1", + properties_description: "required" + }] + + post_w_optionals_examples = post_route_with_optionals[:http_methods].map { |http_method| http_method[:examples] }.flatten + expect(post_w_optionals_examples.size).to eq 1 + expect(post_route_with_optionals[:route]).to eq "/posts/:id{?option=:option}" + expect(post_route_with_optionals[:route_name]).to eq "Single Post" + expect(post_route_with_optionals[:has_parameters?]).to eq true + expect(post_route_with_optionals[:parameters]).to eq [{ + required: true, + type: "string", + example: "1", + name: "id", + description: "The id", + properties_description: "required, string" + }, { + name: "option", + description: nil, + properties_description: nil + }] + expect(post_route_with_optionals[:has_attributes?]).to eq false + expect(post_route_with_optionals[:attributes]).to eq [] + + posts_examples = posts_route[:http_methods].map { |http_method| http_method[:examples] }.flatten + expect(posts_examples.size).to eq 1 + expect(posts_route[:route]).to eq "/posts" + expect(posts_route[:route_name]).to eq "Posts Collection" + expect(posts_route[:has_parameters?]).to eq false + expect(posts_route[:parameters]).to eq [] + expect(posts_route[:has_attributes?]).to eq true + expect(posts_route[:attributes]).to eq [{ + required: false, + name: "description", + description: nil, + properties_description: nil + }] + end + end + end +end diff --git a/spec/writers/html_writer_spec.rb b/spec/writers/html_writer_spec.rb index 7c30d0ac..72dc5615 100644 --- a/spec/writers/html_writer_spec.rb +++ b/spec/writers/html_writer_spec.rb @@ -18,17 +18,17 @@ describe "#write" do let(:writer) { described_class.new(index, configuration) } - before do - template_dir = File.join(configuration.template_path, "rspec_api_documentation") - FileUtils.mkdir_p(template_dir) - File.open(File.join(template_dir, "html_index.mustache"), "w+") { |f| f << "{{ mustache }}" } - FileUtils.mkdir_p(configuration.docs_dir) - end - it "should write the index" do - writer.write - index_file = File.join(configuration.docs_dir, "index.html") - expect(File.exists?(index_file)).to be_truthy + FakeFS do + template_dir = File.join(configuration.template_path, "rspec_api_documentation") + FileUtils.mkdir_p(template_dir) + File.open(File.join(template_dir, "html_index.mustache"), "w+") { |f| f << "{{ mustache }}" } + FileUtils.mkdir_p(configuration.docs_dir) + + writer.write + index_file = File.join(configuration.docs_dir, "index.html") + expect(File.exists?(index_file)).to be_truthy + end end end end diff --git a/spec/writers/markdown_writer_spec.rb b/spec/writers/markdown_writer_spec.rb index e7612c18..95950898 100644 --- a/spec/writers/markdown_writer_spec.rb +++ b/spec/writers/markdown_writer_spec.rb @@ -18,17 +18,17 @@ describe "#write" do let(:writer) { described_class.new(index, configuration) } - before do - template_dir = File.join(configuration.template_path, "rspec_api_documentation") - FileUtils.mkdir_p(template_dir) - File.open(File.join(template_dir, "markdown_index.mustache"), "w+") { |f| f << "{{ mustache }}" } - FileUtils.mkdir_p(configuration.docs_dir) - end - it "should write the index" do - writer.write - index_file = File.join(configuration.docs_dir, "index.markdown") - expect(File.exists?(index_file)).to be_truthy + FakeFS do + template_dir = File.join(configuration.template_path, "rspec_api_documentation") + FileUtils.mkdir_p(template_dir) + File.open(File.join(template_dir, "markdown_index.mustache"), "w+") { |f| f << "{{ mustache }}" } + FileUtils.mkdir_p(configuration.docs_dir) + + writer.write + index_file = File.join(configuration.docs_dir, "index.markdown") + expect(File.exists?(index_file)).to be_truthy + end end end end diff --git a/spec/writers/slate_writer_spec.rb b/spec/writers/slate_writer_spec.rb index 3f121b52..603be2ef 100644 --- a/spec/writers/slate_writer_spec.rb +++ b/spec/writers/slate_writer_spec.rb @@ -18,17 +18,17 @@ describe "#write" do let(:writer) { described_class.new(index, configuration) } - before do - template_dir = File.join(configuration.template_path, "rspec_api_documentation") - FileUtils.mkdir_p(template_dir) - File.open(File.join(template_dir, "markdown_index.mustache"), "w+") { |f| f << "{{ mustache }}" } - FileUtils.mkdir_p(configuration.docs_dir) - end - it "should write the index" do - writer.write - index_file = File.join(configuration.docs_dir, "index.html.md") - expect(File.exists?(index_file)).to be_truthy + FakeFS do + template_dir = File.join(configuration.template_path, "rspec_api_documentation") + FileUtils.mkdir_p(template_dir) + File.open(File.join(template_dir, "markdown_index.mustache"), "w+") { |f| f << "{{ mustache }}" } + FileUtils.mkdir_p(configuration.docs_dir) + + writer.write + index_file = File.join(configuration.docs_dir, "index.html.md") + expect(File.exists?(index_file)).to be_truthy + end end end diff --git a/spec/writers/textile_writer_spec.rb b/spec/writers/textile_writer_spec.rb index 1190695d..1531f7ad 100644 --- a/spec/writers/textile_writer_spec.rb +++ b/spec/writers/textile_writer_spec.rb @@ -18,17 +18,17 @@ describe "#write" do let(:writer) { described_class.new(index, configuration) } - before do - template_dir = File.join(configuration.template_path, "rspec_api_documentation") - FileUtils.mkdir_p(template_dir) - File.open(File.join(template_dir, "textile_index.mustache"), "w+") { |f| f << "{{ mustache }}" } - FileUtils.mkdir_p(configuration.docs_dir) - end - it "should write the index" do - writer.write - index_file = File.join(configuration.docs_dir, "index.textile") - expect(File.exists?(index_file)).to be_truthy + FakeFS do + template_dir = File.join(configuration.template_path, "rspec_api_documentation") + FileUtils.mkdir_p(template_dir) + File.open(File.join(template_dir, "textile_index.mustache"), "w+") { |f| f << "{{ mustache }}" } + FileUtils.mkdir_p(configuration.docs_dir) + + writer.write + index_file = File.join(configuration.docs_dir, "index.textile") + expect(File.exists?(index_file)).to be_truthy + end end end end diff --git a/templates/rspec_api_documentation/api_blueprint_index.mustache b/templates/rspec_api_documentation/api_blueprint_index.mustache new file mode 100644 index 00000000..2955a22d --- /dev/null +++ b/templates/rspec_api_documentation/api_blueprint_index.mustache @@ -0,0 +1,79 @@ +FORMAT: A1 +{{# sections }} + +# Group {{ resource_name }} +{{# resource_explanation }} + +{{{ resource_explanation }}} +{{/ resource_explanation }} +{{# description }} + +{{ description }} +{{/ description }} +{{# routes }} + +## {{ route_name }} [{{ route }}] +{{# description }} + +description: {{ description }} +{{/ description }} +{{# explanation }} + +explanation: {{ explanation }} +{{/ explanation }} +{{# has_parameters? }} + ++ Parameters +{{# parameters }} + + {{ name }}{{# example }}: {{ example }}{{/ example }}{{# properties_description }} ({{ properties_description }}){{/ properties_description }}{{# description }} - {{ description }}{{/ description }} +{{/ parameters }} +{{/ has_parameters? }} +{{# has_attributes? }} + ++ Attributes (object) +{{# attributes }} + + {{ name }}{{# example }}: {{ example }}{{/ example }}{{# properties_description }} ({{ properties_description }}){{/ properties_description }}{{# description }} - {{ description }}{{/ description }} +{{/ attributes }} +{{/ has_attributes? }} +{{# http_methods }} + +### {{ description }} [{{ http_method }}] +{{# examples }} +{{# requests }} +{{# has_request? }} + ++ Request {{ description }}{{# request_content_type }} ({{ request_content_type }}){{/ request_content_type }} +{{/ has_request? }} +{{# request_headers_text }} + + + Headers + + {{{ request_headers_text }}} +{{/ request_headers_text }} +{{# request_body }} + + + Body + + {{{ request_body }}} +{{/ request_body }} +{{# has_response? }} + ++ Response {{ response_status }} ({{ response_content_type }}) +{{/ has_response? }} +{{# response_headers_text }} + + + Headers + + {{{ response_headers_text }}} +{{/ response_headers_text }} +{{# response_body }} + + + Body + + {{{ response_body }}} +{{/ response_body }} +{{/ requests }} +{{/ examples }} +{{/ http_methods }} +{{/ routes }} +{{/ sections }}