From df9ebeeaaafed0aebf3dcea0e4b57f45fe085a20 Mon Sep 17 00:00:00 2001 From: Alexandre de Oliveira Date: Wed, 16 Nov 2016 12:36:24 -0500 Subject: [PATCH 1/6] Adds API Blueprint This commit adds API Blueprint (APIB) to this gem. A few highlights: * APIB groups entities, resources, routes, HTTP verbs and requests differently than what the gem currently uses. Because of that I had to override more methods than usual in the `Markup` classes. APIB has the following structure: 1. resource (e.g "Group Orders") 2. route (e.g "Orders Collection [/orders]") 3. HTTP method (e.g "Loads all orders [GET]") 4. Requests. Here, we show all different requests which means that we don't have the repetition of GET for each example. All examples stay under one, unified HTTP method header. * APIB differentiates parameters (values used in the URLs) from attributes (values used in the actual request body). You can use `attributes` in your texts. * APIB has a `route` header, which means we had to add a new RSpec block called `route()` which wraps HTTP methods, like the following: ```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. * APIB is not navigable like HTML, so generating an index file makes no sense. Because of that, we are generating just one file, `index.apib`. * We are omitting some APIB features in this version so we can get up and running sooner. Examples are object grouping, arrays objects and a few description points. Unrelated to APIB: * FakeFS was being used _globally_ in test mode, which means that nothing would load file systems, not even a simple `~/.pry_history`, which made debugging impossible. I moved the usage of this gem to the places where it is used. Closes #235. --- README.md | 29 +- features/api_blueprint_documentation.feature | 481 ++++++++++++++++++ lib/rspec_api_documentation.rb | 3 + lib/rspec_api_documentation/dsl/endpoint.rb | 12 +- lib/rspec_api_documentation/dsl/resource.rb | 25 +- lib/rspec_api_documentation/example.rb | 4 + .../views/api_blueprint_example.rb | 50 ++ .../views/api_blueprint_index.rb | 47 ++ .../views/markup_example.rb | 20 + .../writers/api_blueprint_writer.rb | 29 ++ .../writers/general_markup_writer.rb | 27 +- spec/dsl_spec.rb | 47 ++ spec/example_spec.rb | 20 + spec/spec_helper.rb | 1 - spec/views/api_blueprint_index_spec.rb | 131 +++++ spec/writers/html_writer_spec.rb | 20 +- spec/writers/markdown_writer_spec.rb | 20 +- spec/writers/slate_writer_spec.rb | 20 +- spec/writers/textile_writer_spec.rb | 20 +- .../api_blueprint_index.mustache | 79 +++ 20 files changed, 1034 insertions(+), 51 deletions(-) create mode 100644 features/api_blueprint_documentation.feature create mode 100644 lib/rspec_api_documentation/views/api_blueprint_example.rb create mode 100644 lib/rspec_api_documentation/views/api_blueprint_index.rb create mode 100644 lib/rspec_api_documentation/writers/api_blueprint_writer.rb create mode 100644 spec/views/api_blueprint_index_spec.rb create mode 100644 templates/rspec_api_documentation/api_blueprint_index.mustache 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..4538a1b2 --- /dev/null +++ b/features/api_blueprint_documentation.feature @@ -0,0 +1,481 @@ +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 :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}', "Single Order" do + parameter :id, 'Order id', required: true, type: 'string', :example => '1' + + attribute :name, 'The order name', required: true, type: 'string', :example => '1' + + 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" + + 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} 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 + + + Body + + Content-Type: text/html;charset=utf-8 + Content-Length: 57 + + + 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 + + Content-Type: application/json + Content-Length: 73 + + + 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 + + + Body + + Content-Type: application/json + Content-Length: 137 + + + Response 200 (application/json) + + + Headers + + Content-Type: application/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}] + + + Parameters + + id: (required, string) - Order id + + + Attributes (object) + + name: (required, string) - The order name + + ### 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 + + + Body + + Content-Type: text/html;charset=utf-8 + Content-Length: 0 + + + 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 + + + Body + + Content-Type: application/json + Content-Length: 73 + + + 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) + + + Headers + + Content-Type: application/json + Host: example.org + + + Body + + Content-Type: application/json + Content-Length: 0 + + + Response 400 (application/json) + + + Headers + + Content-Type: application/json + Content-Length: 0 + + + Request Update an order (application/json) + + + Headers + + Content-Type: application/json + Host: example.org + + + Body + + Content-Type: application/json + Content-Length: 111 + + + 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..84d354c1 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,24 @@ 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] + 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 +94,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..288306b6 --- /dev/null +++ b/lib/rspec_api_documentation/views/api_blueprint_example.rb @@ -0,0 +1,50 @@ +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| + if request[:request_content_type] =~ /application\/json/ && request[:request_body] + request[:request_body] = JSON.pretty_generate(JSON.parse(request[:request_body])) + end + + request[:request_body] = indent(request[:request_body]) + request[:request_body] = indent(request[:request_headers_text]) + request[:request_body] = indent(request[:response_body]) + request[:request_body] = indent(request[:response_headers_text]) + request + end + end + + def extension + Writers::ApiBlueprintWriter::EXTENSION + end + + private + + def indent(string) + string.tap do |str| + if str + str.gsub!(/\n/, "\n" + (" " * TOTAL_SPACES_INDENTATION)) + end + end + 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..70ebf989 --- /dev/null +++ b/lib/rspec_api_documentation/views/api_blueprint_index.rb @@ -0,0 +1,47 @@ +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(&:route_uri).map do |route_uri, examples| + attrs = examples.map { |example| example.metadata[:attributes] }.flatten.compact.uniq { |attr| attr[:name] } + params = examples.map { |example| example.metadata[:parameters] }.flatten.compact.uniq { |param| param[:name] } + + 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_uri: route_uri, + 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 + 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..2f20dc06 100644 --- a/lib/rspec_api_documentation/views/markup_example.rb +++ b/lib/rspec_api_documentation/views/markup_example.rb @@ -47,9 +47,13 @@ def response_fields def requests super.map do |hash| + hash[:request_content_type] = content_type(hash[:request_headers]) hash[:request_headers_text] = format_hash(hash[:request_headers]) hash[:request_query_parameters_text] = format_hash(hash[:request_query_parameters]) + hash[:response_content_type] = content_type(hash[:response_headers]) hash[:response_headers_text] = format_hash(hash[:response_headers]) + hash[:has_request?] = has_request?(hash) + hash[:has_response?] = has_response?(hash) if @host if hash[:curl].is_a? RspecApiDocumentation::Curl hash[:curl] = hash[:curl].output(@host, @filter_headers) @@ -67,6 +71,18 @@ def extension 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 format_hash(hash = {}) return nil unless hash.present? hash.collect do |k, v| @@ -83,6 +99,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..5a7c27c9 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,35 @@ end end end + + route "/orders", "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 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_index_spec.rb b/spec/views/api_blueprint_index_spec.rb new file mode 100644 index 00000000..43596897 --- /dev/null +++ b/spec/views/api_blueprint_index_spec.rb @@ -0,0 +1,131 @@ +# -*- 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}", "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 + + 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 + get("/posts/{id}") do + example_request 'Deletes a post' do + do_request + end + end + end + end + + + let(:rspec_example_posts) do + post_group.route "/posts", "Posts Collection" do + attribute :description, "Order 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(:example1) { RspecApiDocumentation::Example.new(rspec_example_post_get, config) } + let(:example2) { RspecApiDocumentation::Example.new(rspec_example_post_delete, config) } + let(:example3) { RspecApiDocumentation::Example.new(rspec_example_posts, config) } + let(:example4) { RspecApiDocumentation::Example.new(rspec_example_comments, config) } + let(:index) do + RspecApiDocumentation::Index.new.tap do |index| + index.examples << example1 + index.examples << example2 + index.examples << example3 + index.examples << example4 + 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] + + comments_examples = comments_route[:http_methods].map { |http_method| http_method[:examples] }.flatten + expect(comments_examples.size).to eq 1 + expect(comments_route[:route_uri]).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_uri]).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, + example: "1", + type: "string", + name: "id", + description: "The id", + }] + expect(post_route[:has_attributes?]).to eq true + expect(post_route[:attributes]).to eq [{ + required: true, + name: "name", + description: "Order name 1", + }] + + posts_examples = posts_route[:http_methods].map { |http_method| http_method[:examples] }.flatten + expect(posts_examples.size).to eq 1 + expect(posts_route[:route_uri]).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: "Order description", + }] + 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..a2a597ce --- /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_uri }}] +{{# description }} + +description: {{ description }} +{{/ description }} +{{# explanation }} + +explanation: {{ explanation }} +{{/ explanation }} +{{# has_parameters? }} + ++ Parameters +{{# parameters }} + + {{ name }}: ({{# required }}required, {{/ required }}{{ type }}) - {{ description }} +{{/ parameters }} +{{/ has_parameters? }} +{{# has_attributes? }} + ++ Attributes (object) +{{# attributes }} + + {{ name }}: ({{# required }}required, {{/ required }}{{ type }}) - {{ description }} +{{/ attributes }} +{{/ has_attributes? }} +{{# http_methods }} + +### {{ description }} [{{ http_method }}] +{{# examples }} +{{# requests }} +{{# has_request? }} + ++ Request {{ description }} ({{ 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 }} From 6ff62bd8024789b94b78ba03fef686efdd1bf7fc Mon Sep 17 00:00:00 2001 From: Alexandre de Oliveira Date: Fri, 9 Dec 2016 12:35:48 -0200 Subject: [PATCH 2/6] Fixes APIB requests without explicit content-type Connects to https://github.com/zipmark/rspec_api_documentation/pull/313 --- features/api_blueprint_documentation.feature | 6 +++--- .../rspec_api_documentation/api_blueprint_index.mustache | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/features/api_blueprint_documentation.feature b/features/api_blueprint_documentation.feature index 4538a1b2..3aa7d119 100644 --- a/features/api_blueprint_documentation.feature +++ b/features/api_blueprint_documentation.feature @@ -254,7 +254,7 @@ Feature: Generate API Blueprint documentation from test examples ### Returns all instructions [GET] - + Request List all instructions () + + Request List all instructions + Headers @@ -315,7 +315,7 @@ Feature: Generate API Blueprint documentation from test examples ### Return all orders [GET] - + Request Getting a list of orders () + + Request Getting a list of orders + Headers @@ -382,7 +382,7 @@ Feature: Generate API Blueprint documentation from test examples ### Returns a single order [GET] - + Request Getting a specific order () + + Request Getting a specific order + Headers diff --git a/templates/rspec_api_documentation/api_blueprint_index.mustache b/templates/rspec_api_documentation/api_blueprint_index.mustache index a2a597ce..d5d33e8f 100644 --- a/templates/rspec_api_documentation/api_blueprint_index.mustache +++ b/templates/rspec_api_documentation/api_blueprint_index.mustache @@ -42,7 +42,7 @@ explanation: {{ explanation }} {{# requests }} {{# has_request? }} -+ Request {{ description }} ({{ request_content_type }}) ++ Request {{ description }}{{# request_content_type }} ({{ request_content_type }}){{/ request_content_type }} {{/ has_request? }} {{# request_headers_text }} From 1969e057eb608840dc176a08ae70335895b84799 Mon Sep 17 00:00:00 2001 From: Alexandre de Oliveira Date: Fri, 9 Dec 2016 16:05:44 -0200 Subject: [PATCH 3/6] Fixes parameters properties rendering inconsistently * `parameter :id, required: true` renders `id: (required, ) - id` * `parameter :id, required: false` renders `id: () - id` Connects to https://github.com/zipmark/rspec_api_documentation/pull/313 --- features/api_blueprint_documentation.feature | 10 ++-- .../views/api_blueprint_index.rb | 47 ++++++++++++++++++- spec/views/api_blueprint_index_spec.rb | 7 ++- .../api_blueprint_index.mustache | 4 +- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/features/api_blueprint_documentation.feature b/features/api_blueprint_documentation.feature index 3aa7d119..8475775f 100644 --- a/features/api_blueprint_documentation.feature +++ b/features/api_blueprint_documentation.feature @@ -110,7 +110,9 @@ Feature: Generate API Blueprint documentation from test examples route '/orders/{id}', "Single Order" do parameter :id, 'Order id', required: true, type: 'string', :example => '1' - attribute :name, 'The order name', required: true, type: 'string', :example => '1' + 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." @@ -354,10 +356,12 @@ Feature: Generate API Blueprint documentation from test examples ## Single Order [/orders/{id}] + Parameters - + id: (required, string) - Order id + + id: 1 (required, string) - Order id + Attributes (object) - + name: (required, string) - The order name + + name: a name (required) - The order name + + amount + + description: a description (string) - The order description ### Deletes a specific order [DELETE] diff --git a/lib/rspec_api_documentation/views/api_blueprint_index.rb b/lib/rspec_api_documentation/views/api_blueprint_index.rb index 70ebf989..b6f264f2 100644 --- a/lib/rspec_api_documentation/views/api_blueprint_index.rb +++ b/lib/rspec_api_documentation/views/api_blueprint_index.rb @@ -9,8 +9,8 @@ def initialize(index, configuration) def sections super.map do |section| routes = section[:examples].group_by(&:route_uri).map do |route_uri, examples| - attrs = examples.map { |example| example.metadata[:attributes] }.flatten.compact.uniq { |attr| attr[:name] } - params = examples.map { |example| example.metadata[:parameters] }.flatten.compact.uniq { |param| param[:name] } + attrs = fields(:attributes, examples) + params = fields(:parameters, examples) methods = examples.group_by(&:http_method).map do |http_method, examples| { @@ -42,6 +42,49 @@ def examples 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/spec/views/api_blueprint_index_spec.rb b/spec/views/api_blueprint_index_spec.rb index 43596897..6232f6c7 100644 --- a/spec/views/api_blueprint_index_spec.rb +++ b/spec/views/api_blueprint_index_spec.rb @@ -37,7 +37,7 @@ let(:rspec_example_posts) do post_group.route "/posts", "Posts Collection" do - attribute :description, "Order description", required: false + attribute :description, required: false get("/posts") do example_request 'Get all posts' do @@ -105,12 +105,14 @@ type: "string", 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" }] posts_examples = posts_route[:http_methods].map { |http_method| http_method[:examples] }.flatten @@ -123,7 +125,8 @@ expect(posts_route[:attributes]).to eq [{ required: false, name: "description", - description: "Order description", + description: nil, + properties_description: nil }] end end diff --git a/templates/rspec_api_documentation/api_blueprint_index.mustache b/templates/rspec_api_documentation/api_blueprint_index.mustache index d5d33e8f..18f95212 100644 --- a/templates/rspec_api_documentation/api_blueprint_index.mustache +++ b/templates/rspec_api_documentation/api_blueprint_index.mustache @@ -25,14 +25,14 @@ explanation: {{ explanation }} + Parameters {{# parameters }} - + {{ name }}: ({{# required }}required, {{/ required }}{{ type }}) - {{ description }} + + {{ name }}{{# example }}: {{ example }}{{/ example }}{{# properties_description }} ({{ properties_description }}){{/ properties_description }}{{# description }} - {{ description }}{{/ description }} {{/ parameters }} {{/ has_parameters? }} {{# has_attributes? }} + Attributes (object) {{# attributes }} - + {{ name }}: ({{# required }}required, {{/ required }}{{ type }}) - {{ description }} + + {{ name }}{{# example }}: {{ example }}{{/ example }}{{# properties_description }} ({{ properties_description }}){{/ properties_description }}{{# description }} - {{ description }}{{/ description }} {{/ attributes }} {{/ has_attributes? }} {{# http_methods }} From 5eaf2f5fc1b56317142f1a43ea8f41f4ffe4dce2 Mon Sep 17 00:00:00 2001 From: Alexandre de Oliveira Date: Fri, 9 Dec 2016 17:05:38 -0200 Subject: [PATCH 4/6] APIB: allows routes with optional query strings This allows for routes to be defined with optional querystrings, like: ```ruby route '/orders/:id{?optional=:optional}', "Single Order" do ``` Before this, the http methods (e.g `get`, `post`) were injecting the optional into the final URI. This moves optionals into another metadata key (`options[:route_optionals]`). --- features/api_blueprint_documentation.feature | 8 ++- lib/rspec_api_documentation/dsl/resource.rb | 3 +- .../views/api_blueprint_index.rb | 4 +- spec/dsl_spec.rb | 6 +- spec/views/api_blueprint_index_spec.rb | 71 ++++++++++++++----- .../api_blueprint_index.mustache | 2 +- 6 files changed, 67 insertions(+), 27 deletions(-) diff --git a/features/api_blueprint_documentation.feature b/features/api_blueprint_documentation.feature index 8475775f..ff91a920 100644 --- a/features/api_blueprint_documentation.feature +++ b/features/api_blueprint_documentation.feature @@ -107,8 +107,9 @@ Feature: Generate API Blueprint documentation from test examples end end - route '/orders/{id}', "Single Order" do + 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 @@ -225,7 +226,7 @@ Feature: Generate API Blueprint documentation from test examples * Getting a list of orders POST Creates an order * Creating an order - /orders/{id} Single Order + /orders/:id{?optional=:optional} Single Order GET Returns a single order * Getting a specific order PUT Updates a single order @@ -353,10 +354,11 @@ Feature: Generate API Blueprint documentation from test examples ] } - ## Single Order [/orders/{id}] + ## Single Order [/orders/:id{?optional=:optional}] + Parameters + id: 1 (required, string) - Order id + + optional + Attributes (object) + name: a name (required) - The order name diff --git a/lib/rspec_api_documentation/dsl/resource.rb b/lib/rspec_api_documentation/dsl/resource.rb index 84d354c1..1e45d4e2 100644 --- a/lib/rspec_api_documentation/dsl/resource.rb +++ b/lib/rspec_api_documentation/dsl/resource.rb @@ -47,7 +47,8 @@ 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] + 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) diff --git a/lib/rspec_api_documentation/views/api_blueprint_index.rb b/lib/rspec_api_documentation/views/api_blueprint_index.rb index b6f264f2..aa7a4d50 100644 --- a/lib/rspec_api_documentation/views/api_blueprint_index.rb +++ b/lib/rspec_api_documentation/views/api_blueprint_index.rb @@ -8,7 +8,7 @@ def initialize(index, configuration) def sections super.map do |section| - routes = section[:examples].group_by(&:route_uri).map do |route_uri, examples| + routes = section[:examples].group_by { |e| "#{e.route_uri}#{e.route_optionals}" }.map do |route, examples| attrs = fields(:attributes, examples) params = fields(:parameters, examples) @@ -23,7 +23,7 @@ def sections { "has_attributes?".to_sym => attrs.size > 0, "has_parameters?".to_sym => params.size > 0, - route_uri: route_uri, + route: route, route_name: examples[0][:route_name], attributes: attrs, parameters: params, diff --git a/spec/dsl_spec.rb b/spec/dsl_spec.rb index 5a7c27c9..ec6cff5f 100644 --- a/spec/dsl_spec.rb +++ b/spec/dsl_spec.rb @@ -605,13 +605,17 @@ end end - route "/orders", "Orders Collection" do + 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 diff --git a/spec/views/api_blueprint_index_spec.rb b/spec/views/api_blueprint_index_spec.rb index 6232f6c7..92b4e21c 100644 --- a/spec/views/api_blueprint_index_spec.rb +++ b/spec/views/api_blueprint_index_spec.rb @@ -7,10 +7,9 @@ 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}", "Single Post" do + post_group.route "/posts/:id{?option=:option}", "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 + parameter :option get("/posts/{id}") do example_request 'Gets a post' do @@ -25,8 +24,10 @@ end let(:rspec_example_post_delete) do - post_group.route "/posts/{id}", "Single Post" do - get("/posts/{id}") 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 @@ -34,6 +35,20 @@ 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 @@ -54,16 +69,13 @@ end end end - let(:example1) { RspecApiDocumentation::Example.new(rspec_example_post_get, config) } - let(:example2) { RspecApiDocumentation::Example.new(rspec_example_post_delete, config) } - let(:example3) { RspecApiDocumentation::Example.new(rspec_example_posts, config) } - let(:example4) { RspecApiDocumentation::Example.new(rspec_example_comments, config) } let(:index) do RspecApiDocumentation::Index.new.tap do |index| - index.examples << example1 - index.examples << example2 - index.examples << example3 - index.examples << example4 + 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 } @@ -82,12 +94,13 @@ it "returns routes grouped" do comments_route = sections[0][:routes][0] - posts_route = sections[1][:routes][0] - post_route = sections[1][:routes][1] + 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_uri]).to eq "/comments" + 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 [] @@ -96,13 +109,13 @@ post_examples = post_route[:http_methods].map { |http_method| http_method[:examples] }.flatten expect(post_examples.size).to eq 2 - expect(post_route[:route_uri]).to eq "/posts/{id}" + 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, - example: "1", type: "string", + example: "1", name: "id", description: "The id", properties_description: "required, string" @@ -115,9 +128,29 @@ 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_uri]).to eq "/posts" + 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 [] diff --git a/templates/rspec_api_documentation/api_blueprint_index.mustache b/templates/rspec_api_documentation/api_blueprint_index.mustache index 18f95212..2955a22d 100644 --- a/templates/rspec_api_documentation/api_blueprint_index.mustache +++ b/templates/rspec_api_documentation/api_blueprint_index.mustache @@ -12,7 +12,7 @@ FORMAT: A1 {{/ description }} {{# routes }} -## {{ route_name }} [{{ route_uri }}] +## {{ route_name }} [{{ route }}] {{# description }} description: {{ description }} From 2f894bb91fa5ecf85076d20f1ccfefa4f5bfb40d Mon Sep 17 00:00:00 2001 From: Alexandre de Oliveira Date: Mon, 12 Dec 2016 13:18:49 -0200 Subject: [PATCH 5/6] Fixes aplication/vnd.api+json content not treated as JSON Connects to #235 --- features/api_blueprint_documentation.feature | 54 ++++++++----------- .../views/api_blueprint_example.rb | 25 ++++++--- 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/features/api_blueprint_documentation.feature b/features/api_blueprint_documentation.feature index ff91a920..f8bc58d4 100644 --- a/features/api_blueprint_documentation.feature +++ b/features/api_blueprint_documentation.feature @@ -7,7 +7,7 @@ Feature: Generate API Blueprint documentation from test examples class App < Sinatra::Base get '/orders' do - content_type :json + content_type "application/vnd.api+json" [200, { :page => 1, @@ -263,11 +263,6 @@ Feature: Generate API Blueprint documentation from test examples Host: example.org - + Body - - Content-Type: text/html;charset=utf-8 - Content-Length: 57 - + Response 200 (text/html;charset=utf-8) + Headers @@ -296,8 +291,16 @@ Feature: Generate API Blueprint documentation from test examples + Body - Content-Type: application/json - Content-Length: 73 + { + "data": { + "type": "order", + "attributes": { + "name": "Order 1", + "amount": 100.0, + "description": "A description" + } + } + } + Response 201 (application/json) @@ -324,16 +327,11 @@ Feature: Generate API Blueprint documentation from test examples Host: example.org - + Body - - Content-Type: application/json - Content-Length: 137 - - + Response 200 (application/json) + + Response 200 (application/vnd.api+json) + Headers - Content-Type: application/json + Content-Type: application/vnd.api+json Content-Length: 137 + Body @@ -374,11 +372,6 @@ Feature: Generate API Blueprint documentation from test examples Host: example.org Content-Type: application/x-www-form-urlencoded - + Body - - Content-Type: text/html;charset=utf-8 - Content-Length: 0 - + Response 200 (text/html;charset=utf-8) + Headers @@ -394,11 +387,6 @@ Feature: Generate API Blueprint documentation from test examples Host: example.org - + Body - - Content-Type: application/json - Content-Length: 73 - + Response 200 (application/json) + Headers @@ -425,11 +413,6 @@ Feature: Generate API Blueprint documentation from test examples Content-Type: application/json Host: example.org - + Body - - Content-Type: application/json - Content-Length: 0 - + Response 400 (application/json) + Headers @@ -446,8 +429,15 @@ Feature: Generate API Blueprint documentation from test examples + Body - Content-Type: application/json - Content-Length: 111 + { + "data": { + "id": "1", + "type": "order", + "attributes": { + "name": "Order 1" + } + } + } + Response 200 (application/json) diff --git a/lib/rspec_api_documentation/views/api_blueprint_example.rb b/lib/rspec_api_documentation/views/api_blueprint_example.rb index 288306b6..afa9a224 100644 --- a/lib/rspec_api_documentation/views/api_blueprint_example.rb +++ b/lib/rspec_api_documentation/views/api_blueprint_example.rb @@ -20,14 +20,13 @@ def parameters def requests super.map do |request| - if request[:request_content_type] =~ /application\/json/ && request[:request_body] - request[:request_body] = JSON.pretty_generate(JSON.parse(request[:request_body])) - end + request[:request_body] = body_to_json(request, :request) + request[:response_body] = body_to_json(request, :response) request[:request_body] = indent(request[:request_body]) - request[:request_body] = indent(request[:request_headers_text]) - request[:request_body] = indent(request[:response_body]) - request[:request_body] = indent(request[:response_headers_text]) + request[:request_headers_text] = indent(request[:request_headers_text]) + request[:response_body] = indent(request[:response_body]) + request[:response_headers_text] = indent(request[:response_headers_text]) request end end @@ -45,6 +44,20 @@ def indent(string) end 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 content_type =~ /application\/.*json/ && body + body = JSON.pretty_generate(JSON.parse(body)) + end + + body + end end end end From eb8db637c1851636dd8eccb2a9c79ac639d6e551 Mon Sep 17 00:00:00 2001 From: Alexandre de Oliveira Date: Tue, 13 Dec 2016 15:12:11 -0200 Subject: [PATCH 6/6] Removes utf-8 from JSON requests because it's redundant 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 avoid redundancy. In the process, I moved code that was only used by APIB into the APIB classes, such as exposing `content_type` to the templates. If I changed the `content-type` for all templates it would break unrelated things. Connects to #235. --- features/api_blueprint_documentation.feature | 10 +- .../views/api_blueprint_example.rb | 59 ++++++++-- .../views/markup_example.rb | 16 --- spec/views/api_blueprint_example_spec.rb | 111 ++++++++++++++++++ 4 files changed, 166 insertions(+), 30 deletions(-) create mode 100644 spec/views/api_blueprint_example_spec.rb diff --git a/features/api_blueprint_documentation.feature b/features/api_blueprint_documentation.feature index f8bc58d4..a824e0cf 100644 --- a/features/api_blueprint_documentation.feature +++ b/features/api_blueprint_documentation.feature @@ -131,7 +131,7 @@ Feature: Generate API Blueprint documentation from test examples put 'Updates a single order' do explanation "This is used to update orders." - header "Content-Type", "application/json" + header "Content-Type", "application/json; charset=utf-16" context "with a valid id" do let(:id) { 1 } @@ -406,11 +406,11 @@ Feature: Generate API Blueprint documentation from test examples ### Updates a single order [PUT] - + Request Invalid request (application/json) + + Request Invalid request (application/json; charset=utf-16) + Headers - Content-Type: application/json + Content-Type: application/json; charset=utf-16 Host: example.org + Response 400 (application/json) @@ -420,11 +420,11 @@ Feature: Generate API Blueprint documentation from test examples Content-Type: application/json Content-Length: 0 - + Request Update an order (application/json) + + Request Update an order (application/json; charset=utf-16) + Headers - Content-Type: application/json + Content-Type: application/json; charset=utf-16 Host: example.org + Body diff --git a/lib/rspec_api_documentation/views/api_blueprint_example.rb b/lib/rspec_api_documentation/views/api_blueprint_example.rb index afa9a224..45f815a9 100644 --- a/lib/rspec_api_documentation/views/api_blueprint_example.rb +++ b/lib/rspec_api_documentation/views/api_blueprint_example.rb @@ -20,13 +20,22 @@ def parameters def requests super.map do |request| - request[:request_body] = body_to_json(request, :request) - request[:response_body] = body_to_json(request, :response) + 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[:request_body] = indent(request[:request_body]) - request[:request_headers_text] = indent(request[:request_headers_text]) - request[:response_body] = indent(request[:response_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 @@ -37,11 +46,21 @@ def extension 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| - if str - str.gsub!(/\n/, "\n" + (" " * TOTAL_SPACES_INDENTATION)) - end + str.gsub!(/\n/, "\n" + (" " * TOTAL_SPACES_INDENTATION)) if str end end @@ -52,12 +71,34 @@ 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 content_type =~ /application\/.*json/ && 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/markup_example.rb b/lib/rspec_api_documentation/views/markup_example.rb index 2f20dc06..df2c1fdd 100644 --- a/lib/rspec_api_documentation/views/markup_example.rb +++ b/lib/rspec_api_documentation/views/markup_example.rb @@ -47,13 +47,9 @@ def response_fields def requests super.map do |hash| - hash[:request_content_type] = content_type(hash[:request_headers]) hash[:request_headers_text] = format_hash(hash[:request_headers]) hash[:request_query_parameters_text] = format_hash(hash[:request_query_parameters]) - hash[:response_content_type] = content_type(hash[:response_headers]) hash[:response_headers_text] = format_hash(hash[:response_headers]) - hash[:has_request?] = has_request?(hash) - hash[:has_response?] = has_response?(hash) if @host if hash[:curl].is_a? RspecApiDocumentation::Curl hash[:curl] = hash[:curl].output(@host, @filter_headers) @@ -71,18 +67,6 @@ def extension 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 format_hash(hash = {}) return nil unless hash.present? hash.collect do |k, v| 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