diff --git a/features/slate_documentation.feature b/features/slate_documentation.feature new file mode 100644 index 00000000..c4be5499 --- /dev/null +++ b/features/slate_documentation.feature @@ -0,0 +1,290 @@ +Feature: Generate Slate 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 + 201 + end + + put '/orders/:id' do + 200 + end + + delete '/orders/:id' do + 200 + end + + get '/help' do + [200, 'Welcome Henry !'] + 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 = :slate + config.curl_host = 'http://localhost:3000' + config.request_headers_to_include = %w[Content-Type Host] + config.response_headers_to_include = %w[Content-Type Content-Length] + end + + resource 'Orders' do + get '/orders' do + response_field :page, "Current page" + + example_request 'Getting a list of orders' do + status.should eq(200) + response_body.should eq('{"page":1,"orders":[{"name":"Order 1","amount":9.99,"description":null},{"name":"Order 2","amount":100.0,"description":"A great order"}]}') + end + end + + get '/orders/:id' do + let(:id) { 1 } + + example_request 'Getting a specific order' do + status.should eq(200) + response_body.should == '{"order":{"name":"Order 1","amount":100.0,"description":"A great order"}}' + end + end + + post '/orders' do + parameter :name, 'Name of order', :required => true + parameter :amount, 'Amount paid', :required => true + parameter :description, 'Some comments on the order' + + let(:name) { "Order 3" } + let(:amount) { 33.0 } + + example_request 'Creating an order' do + status.should == 201 + end + end + + put '/orders/:id' do + parameter :name, 'Name of order', :required => true + parameter :amount, 'Amount paid', :required => true + parameter :description, 'Some comments on the order' + + let(:id) { 2 } + let(:name) { "Updated name" } + + example_request 'Updating an order' do + status.should == 200 + end + end + + delete "/orders/:id" do + let(:id) { 1 } + + example_request "Deleting an order" do + status.should == 200 + end + end + end + + resource 'Help' do + get '/help' do + example_request 'Getting welcome message' do + status.should eq(200) + response_body.should == 'Welcome Henry !' + 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 + GET /orders + * Getting a list of orders + GET /orders/:id + * Getting a specific order + POST /orders + * Creating an order + PUT /orders/:id + * Updating an order + DELETE /orders/:id + * Deleting an order + Help + GET /help + * Getting welcome message + """ + And the output should contain "6 examples, 0 failures" + And the exit status should be 0 + + Scenario: Example 'Getting a list of orders' docs should look like we expect + Then the file "doc/api/_generated_examples.markdown" should contain: + """ + ## Getting a list of orders + + ### Request + + #### Endpoint + + ``` + GET /orders + Host: example.org + ``` + + `GET /orders` + + + #### Parameters + + + None known. + + + ### Response + + ``` + Content-Type: application/json + Content-Length: 137 + 200 OK + ``` + + + ```json + { + "page": 1, + "orders": [ + { + "name": "Order 1", + "amount": 9.99, + "description": null + }, + { + "name": "Order 2", + "amount": 100.0, + "description": "A great order" + } + ] + } + ``` + + + + #### Fields + + | Name | Description | + |:-----------|:--------------------| + | page | Current page | + + + + ### cURL + + curl "http://localhost:3000/orders" -X GET \
  -H "Host: example.org" \
  -H "Cookie: "
+ """ + + Scenario: Example 'Creating an order' docs should look like we expect + Then the file "doc/api/_generated_examples.markdown" should contain: + """ + ## Creating an order + + ### Request + + #### Endpoint + + ``` + POST /orders + Host: example.org + Content-Type: application/x-www-form-urlencoded + ``` + + `POST /orders` + + + #### Parameters + + + ```json + name=Order+3&amount=33.0 + ``` + + + | Name | Description | + |:-----|:------------| + | name *required* | Name of order | + | amount *required* | Amount paid | + | description | Some comments on the order | + + + + ### Response + + ``` + Content-Type: text/html;charset=utf-8 + Content-Length: 0 + 201 Created + ``` + + + + + + ### cURL + + curl "http://localhost:3000/orders" -d 'name=Order+3&amount=33.0' -X POST \
  -H "Host: example.org" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "Cookie: "
+ """ + + Scenario: Example 'Deleting an order' docs should be created + Then the file "doc/api/_generated_examples.markdown" should contain: + """ + ## Deleting an order + """ + + Scenario: Example 'Getting a list of orders' docs should be created + Then the file "doc/api/_generated_examples.markdown" should contain: + """ + ## Getting a list of orders + """ + + Scenario: Example 'Getting a specific order' docs should be created + Then the file "doc/api/_generated_examples.markdown" should contain: + """ + ## Getting a specific order + """ + + Scenario: Example 'Updating an order' docs should be created + Then the file "doc/api/_generated_examples.markdown" should contain: + """ + ## Updating an order + """ + + Scenario: Example 'Getting welcome message' docs should be created + Then the file "doc/api/_generated_examples.markdown" should contain: + """ + ## Getting welcome message + """ diff --git a/lib/rspec_api_documentation.rb b/lib/rspec_api_documentation.rb index 3e07da93..569ff90d 100644 --- a/lib/rspec_api_documentation.rb +++ b/lib/rspec_api_documentation.rb @@ -43,6 +43,7 @@ module Writers autoload :IndexHelper autoload :CombinedTextWriter autoload :CombinedJsonWriter + autoload :SlateWriter end module Views @@ -56,6 +57,7 @@ module Views autoload :TextileExample autoload :MarkdownIndex autoload :MarkdownExample + autoload :SlateExample end def self.configuration diff --git a/lib/rspec_api_documentation/views/slate_example.rb b/lib/rspec_api_documentation/views/slate_example.rb new file mode 100644 index 00000000..1d23ef69 --- /dev/null +++ b/lib/rspec_api_documentation/views/slate_example.rb @@ -0,0 +1,32 @@ +module RspecApiDocumentation + module Views + class SlateExample < MarkdownExample + def initialize(example, configuration) + super + self.template_name = "rspec_api_documentation/slate_example" + end + + def curl_with_linebreaks + requests.map {|request| request[:curl].lines }.flatten.map do |line| + line.rstrip.gsub("\t", ' ').gsub(' ', ' ').gsub('\\', '\') + end.join "
" + end + + def explanation_with_linebreaks + explanation.gsub "\n", "
\n" + end + + def write + File.open(configuration.docs_dir.join("#{FILENAME}.#{extension}"), 'w+') do |file| + file.write "# #{configuration.api_name}\n\n" + index.examples.sort_by!(&:description) unless configuration.keep_source_order + + index.examples.each do |example| + markup_example = markup_example_class.new(example, configuration) + file.write markup_example.render + end + end + end + end + end +end diff --git a/lib/rspec_api_documentation/writers/slate_writer.rb b/lib/rspec_api_documentation/writers/slate_writer.rb new file mode 100644 index 00000000..758a6b37 --- /dev/null +++ b/lib/rspec_api_documentation/writers/slate_writer.rb @@ -0,0 +1,29 @@ +module RspecApiDocumentation + module Writers + + class SlateWriter < MarkdownWriter + FILENAME = '_generated_examples' + + def self.clear_docs(docs_dir) + FileUtils.mkdir_p(docs_dir) + FileUtils.rm Dir[File.join docs_dir, "#{FILENAME}.*"] + end + + def markup_example_class + RspecApiDocumentation::Views::SlateExample + end + + def write + File.open(configuration.docs_dir.join("#{FILENAME}.#{extension}"), 'w+') do |file| + file.write "# #{configuration.api_name}\n\n" + index.examples.sort_by!(&:description) unless configuration.keep_source_order + + index.examples.each do |example| + markup_example = markup_example_class.new(example, configuration) + file.write markup_example.render + end + end + end + end + end +end diff --git a/spec/views/slate_example_spec.rb b/spec/views/slate_example_spec.rb new file mode 100644 index 00000000..0a1b47c5 --- /dev/null +++ b/spec/views/slate_example_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe RspecApiDocumentation::Views::SlateExample 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(:slate_example) { described_class.new(rad_example, configuration) } + + describe '#curl_with_linebreaks' do + subject { slate_example.curl_with_linebreaks } + + before(:each) { allow(slate_example).to receive(:requests).and_return requests } + + context 'marshaling' do + let(:requests) { [{curl: 'One'}, {curl: "Two \nThree" }, {curl: 'Four '}] } + + it 'joins all the Curl requests with linebreaks, stripping trailing whitespace' do + expect(subject).to be == [ + 'One', 'Two', 'Three', 'Four' + ].join('
') + end + end + + context 'escaping' do + let(:requests) { [{curl: string}] } + + context 'spaces' do + let(:string) { 'a b' } + + it 'replaces them with nonbreaking spaces' do + expect(subject).to be == 'a b' + end + end + + context 'tabs' do + let(:string) { "a\tb" } + + it 'replaces them with two nonbreaking spaces' do + expect(subject).to be == 'a  b' + end + end + + context 'backslashes' do + let(:string) { 'a\\b'} + + it 'replaces them with an HTML entity' do + expect(subject).to be == 'a\b' + end + end + end + end + + describe '#explanation_with_linebreaks' do + it 'returns the explanation with HTML linebreaks' do + explanation = "Line 1\nLine 2\nLine 3\Line 4" + allow(slate_example).to receive(:explanation).and_return explanation + expect(slate_example.explanation_with_linebreaks).to be == explanation.gsub("\n", "
\n") + end + end +end diff --git a/spec/writers/slate_writer_spec.rb b/spec/writers/slate_writer_spec.rb new file mode 100644 index 00000000..9e6e33b0 --- /dev/null +++ b/spec/writers/slate_writer_spec.rb @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +require 'spec_helper' + +describe RspecApiDocumentation::Writers::SlateWriter do + let(:index) { RspecApiDocumentation::Index.new } + let(:configuration) { RspecApiDocumentation::Configuration.new } + + describe ".write" do + let(:writer) { double(:writer) } + + it "should build a new writer and write the docs" do + allow(described_class).to receive(:new).with(index, configuration).and_return(writer) + expect(writer).to receive(:write) + described_class.write(index, configuration) + end + end + + context 'instance methods' do + let(:writer) { described_class.new(index, configuration) } + + describe '#markup_example_class' do + subject { writer.markup_example_class } + it { is_expected.to be == RspecApiDocumentation::Views::SlateExample } + end + end +end diff --git a/templates/rspec_api_documentation/slate_example.mustache b/templates/rspec_api_documentation/slate_example.mustache new file mode 100644 index 00000000..66262d62 --- /dev/null +++ b/templates/rspec_api_documentation/slate_example.mustache @@ -0,0 +1,86 @@ +## {{ description }} + +### Request + +#### Endpoint + +{{# requests}} +``` +{{ request_method }} {{ request_path }} +{{ request_headers_text }} +``` +{{/ requests}} + +`{{ http_method }} {{ route }}` + +{{# explanation }} + +{{{ explanation_with_linebreaks }}} +{{/ explanation }} + +#### Parameters + +{{# requests}} +{{# request_query_parameters_text }} + +```json +{{ request_query_parameters_text }} +``` +{{/ request_query_parameters_text }} +{{# request_body }} + +```json +{{{ request_body }}} +``` +{{/ request_body }} + +{{# has_parameters? }} + +| Name | Description | +|:-----|:------------| +{{# parameters }} +| {{#scope}}{{scope}}[{{/scope}}{{ name }}{{#scope}}]{{/scope}} {{# required }}*required*{{/ required }} | {{{ description }}} | +{{/ parameters }} + +{{/ has_parameters? }} +{{^ has_parameters? }} +None known. +{{/ has_parameters? }} + +{{# response_status}} + +### Response + +``` +{{ response_headers_text }} +{{ response_status }} {{ response_status_text}} +``` + +{{# response_body}} + +```json +{{{ response_body }}} +``` +{{/response_body}} + +{{/ response_status}} + +{{# has_response_fields? }} + +#### Fields + +| Name | Description | +|:-----------|:--------------------| +{{# response_fields }} +| {{#scope}}{{scope}}[{{/scope}}{{ name }}{{#scope}}]{{/scope}} | {{{ description }}} | +{{/ response_fields }} + +{{/ has_response_fields? }} + +{{# curl }} + +### cURL + +{{{ curl_with_linebreaks }}} +{{/ curl }} +{{/ requests}}