diff --git a/.gitignore b/.gitignore index 94424fbc..8d585992 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ tmp example/docs example/public/docs *.gem +*.swp diff --git a/example/spec/spec_helper.rb b/example/spec/spec_helper.rb index 868d2fb1..f617c197 100644 --- a/example/spec/spec_helper.rb +++ b/example/spec/spec_helper.rb @@ -33,7 +33,7 @@ end RspecApiDocumentation.configure do |config| - config.format = [:json, :combined_text] + config.format = [:json, :combined_text, :html] config.curl_host = 'http://localhost:3000' config.api_name = "Example App API" end diff --git a/features/textile_documentation.feature b/features/textile_documentation.feature new file mode 100644 index 00000000..fa668d7d --- /dev/null +++ b/features/textile_documentation.feature @@ -0,0 +1,229 @@ +Feature: Generate Textile 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, [{ 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 = :textile + end + + resource 'Orders' do + get '/orders' do + + example_request 'Getting a list of orders' do + status.should eq(200) + response_body.should eq('[{"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: Index file should look like we expect + Then the file "doc/api/index.textile" should contain exactly: + """ + h1. Example API + + h2. Help + + * "Getting welcome message":help/getting_welcome_message.textile + + h2. Orders + + * "Creating an order":orders/creating_an_order.textile + * "Deleting an order":orders/deleting_an_order.textile + * "Getting a list of orders":orders/getting_a_list_of_orders.textile + * "Getting a specific order":orders/getting_a_specific_order.textile + * "Updating an order":orders/updating_an_order.textile + + + """ + + Scenario: Example 'Creating an order' file should look like we expect + Then the file "doc/api/orders/creating_an_order.textile" should contain exactly: + """ + h1. Orders API + + h2. Creating an order + + h3. POST /orders + + + h3. Parameters + + Name : name *- required -* + Description : Name of order + + Name : amount *- required -* + Description : Amount paid + + Name : description + Description : Some comments on the order + + h3. Request + + h4. Headers + +
Host: example.org
+    Content-Type: application/x-www-form-urlencoded
+    Cookie: 
+ + h4. Route + +
POST /orders
+ + + h4. Body + +
name=Order+3&amount=33.0
+ + + h3. Response + + h4. Headers + +
X-Frame-Options: sameorigin
+    X-XSS-Protection: 1; mode=block
+    Content-Type: text/html;charset=utf-8
+    Content-Length: 0
+ + h4. Status + +
201 Created
+ + + + + """ + + Scenario: Example 'Deleting an order' file should be created + Then a file named "doc/api/orders/deleting_an_order.textile" should exist + + Scenario: Example 'Getting a list of orders' file should be created + Then a file named "doc/api/orders/getting_a_list_of_orders.textile" should exist + + Scenario: Example 'Getting a specific order' file should be created + Then a file named "doc/api/orders/getting_a_specific_order.textile" should exist + + Scenario: Example 'Updating an order' file should be created + Then a file named "doc/api/orders/updating_an_order.textile" should exist + + Scenario: Example 'Getting welcome message' file should be created + Then a file named "doc/api/help/getting_welcome_message.textile" should exist + + diff --git a/lib/rspec_api_documentation.rb b/lib/rspec_api_documentation.rb index 23124a7b..a8bfcca4 100644 --- a/lib/rspec_api_documentation.rb +++ b/lib/rspec_api_documentation.rb @@ -28,7 +28,9 @@ module RspecApiDocumentation module Writers extend ActiveSupport::Autoload + autoload :GeneralMarkupWriter autoload :HtmlWriter + autoload :TextileWriter autoload :JsonWriter autoload :JsonIodocsWriter autoload :IndexWriter @@ -36,6 +38,17 @@ module Writers autoload :CombinedJsonWriter end + module Views + extend ActiveSupport::Autoload + + autoload :MarkupIndex + autoload :MarkupExample + autoload :HtmlIndex + autoload :HtmlExample + autoload :TextileIndex + autoload :TextileExample + end + def self.configuration @configuration ||= Configuration.new end diff --git a/lib/rspec_api_documentation/views/html_example.rb b/lib/rspec_api_documentation/views/html_example.rb new file mode 100644 index 00000000..6b0f6009 --- /dev/null +++ b/lib/rspec_api_documentation/views/html_example.rb @@ -0,0 +1,16 @@ +module RspecApiDocumentation + module Views + class HtmlExample < MarkupExample + EXTENSION = 'html' + + def initialize(example, configuration) + super + self.template_name = "rspec_api_documentation/html_example" + end + + def extension + EXTENSION + end + end + end +end diff --git a/lib/rspec_api_documentation/views/html_index.rb b/lib/rspec_api_documentation/views/html_index.rb new file mode 100644 index 00000000..3a56ccd4 --- /dev/null +++ b/lib/rspec_api_documentation/views/html_index.rb @@ -0,0 +1,14 @@ +module RspecApiDocumentation + module Views + class HtmlIndex < MarkupIndex + def initialize(index, configuration) + super + self.template_name = "rspec_api_documentation/html_index" + end + + def examples + @index.examples.map { |example| HtmlExample.new(example, @configuration) } + end + end + end +end diff --git a/lib/rspec_api_documentation/views/markup_example.rb b/lib/rspec_api_documentation/views/markup_example.rb new file mode 100644 index 00000000..6f9c72cb --- /dev/null +++ b/lib/rspec_api_documentation/views/markup_example.rb @@ -0,0 +1,58 @@ +require 'mustache' + +module RspecApiDocumentation + module Views + class MarkupExample < Mustache + def initialize(example, configuration) + @example = example + @host = configuration.curl_host + self.template_path = configuration.template_path + end + + def method_missing(method, *args, &block) + @example.send(method, *args, &block) + end + + def respond_to?(method, include_private = false) + super || @example.respond_to?(method, include_private) + end + + def dirname + resource_name.downcase.gsub(/\s+/, '_') + end + + def filename + basename = description.downcase.gsub(/\s+/, '_').gsub(/[^a-z_]/, '') + basename = Digest::MD5.new.update(description).to_s if basename.blank? + "#{basename}.#{extension}" + end + + def requests + super.map do |hash| + hash[:request_headers_text] = format_hash(hash[:request_headers]) + hash[:request_query_parameters_text] = format_hash(hash[:request_query_parameters]) + hash[:response_headers_text] = format_hash(hash[:response_headers]) + if @host + hash[:curl] = hash[:curl].output(@host) if hash[:curl].is_a? RspecApiDocumentation::Curl + else + hash[:curl] = nil + end + hash + end + end + + def extension + raise 'Parent class. This method should not be called.' + end + + private + + def format_hash(hash = {}) + return nil unless hash.present? + hash.collect do |k, v| + "#{k}: #{v}" + end.join("\n") + end + end + end +end diff --git a/lib/rspec_api_documentation/views/markup_index.rb b/lib/rspec_api_documentation/views/markup_index.rb new file mode 100644 index 00000000..f0bd6c3d --- /dev/null +++ b/lib/rspec_api_documentation/views/markup_index.rb @@ -0,0 +1,21 @@ +require 'mustache' + +module RspecApiDocumentation + module Views + class MarkupIndex < Mustache + def initialize(index, configuration) + @index = index + @configuration = configuration + self.template_path = configuration.template_path + end + + def api_name + @configuration.api_name + end + + def sections + RspecApiDocumentation::Writers::IndexWriter.sections(examples, @configuration) + end + end + end +end diff --git a/lib/rspec_api_documentation/views/textile_example.rb b/lib/rspec_api_documentation/views/textile_example.rb new file mode 100644 index 00000000..f872697b --- /dev/null +++ b/lib/rspec_api_documentation/views/textile_example.rb @@ -0,0 +1,16 @@ +module RspecApiDocumentation + module Views + class TextileExample < MarkupExample + EXTENSION = 'textile' + + def initialize(example, configuration) + super + self.template_name = "rspec_api_documentation/textile_example" + end + + def extension + EXTENSION + end + end + end +end diff --git a/lib/rspec_api_documentation/views/textile_index.rb b/lib/rspec_api_documentation/views/textile_index.rb new file mode 100644 index 00000000..d6aeb6ef --- /dev/null +++ b/lib/rspec_api_documentation/views/textile_index.rb @@ -0,0 +1,14 @@ +module RspecApiDocumentation + module Views + class TextileIndex < MarkupIndex + def initialize(index, configuration) + super + self.template_name = "rspec_api_documentation/textile_index" + end + + def examples + @index.examples.map { |example| TextileExample.new(example, @configuration) } + 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 new file mode 100644 index 00000000..93c94d05 --- /dev/null +++ b/lib/rspec_api_documentation/writers/general_markup_writer.rb @@ -0,0 +1,42 @@ +module RspecApiDocumentation + module Writers + class GeneralMarkupWriter + attr_accessor :index, :configuration + + INDEX_FILE_NAME = 'index' + + def initialize(index, configuration) + self.index = index + self.configuration = configuration + end + + def self.write(index, configuration) + writer = new(index, configuration) + writer.write + end + + def write + File.open(configuration.docs_dir.join(index_file_name + '.' + extension), "w+") do |f| + f.write markup_index_class.new(index, configuration).render + end + + 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 + end + end + end + + def index_file_name + INDEX_FILE_NAME + end + + def extension + raise 'Parent class. This method should not be called.' + end + end + end +end diff --git a/lib/rspec_api_documentation/writers/html_writer.rb b/lib/rspec_api_documentation/writers/html_writer.rb index 2728ddb1..32348a95 100644 --- a/lib/rspec_api_documentation/writers/html_writer.rb +++ b/lib/rspec_api_documentation/writers/html_writer.rb @@ -1,101 +1,20 @@ -require 'mustache' - module RspecApiDocumentation module Writers - class HtmlWriter + class HtmlWriter < GeneralMarkupWriter attr_accessor :index, :configuration - def initialize(index, configuration) - self.index = index - self.configuration = configuration - end - - def self.write(index, configuration) - writer = new(index, configuration) - writer.write - end - - def write - File.open(configuration.docs_dir.join("index.html"), "w+") do |f| - f.write HtmlIndex.new(index, configuration).render - end - index.examples.each do |example| - html_example = HtmlExample.new(example, configuration) - FileUtils.mkdir_p(configuration.docs_dir.join(html_example.dirname)) - File.open(configuration.docs_dir.join(html_example.dirname, html_example.filename), "w+") do |f| - f.write html_example.render - end - end - end - end - - class HtmlIndex < Mustache - def initialize(index, configuration) - @index = index - @configuration = configuration - self.template_path = configuration.template_path - self.template_name = "rspec_api_documentation/html_index" - end - - def api_name - @configuration.api_name - end - - def sections - IndexWriter.sections(examples, @configuration) - end - - def examples - @index.examples.map { |example| HtmlExample.new(example, @configuration) } - end - end - - class HtmlExample < Mustache - def initialize(example, configuration) - @example = example - @host = configuration.curl_host - self.template_path = configuration.template_path - self.template_name = "rspec_api_documentation/html_example" - end - - def method_missing(method, *args, &block) - @example.send(method, *args, &block) - end - - def respond_to?(method, include_private = false) - super || @example.respond_to?(method, include_private) - end - - def dirname - resource_name.downcase.gsub(/\s+/, '_') - end + EXTENSION = 'html' - def filename - basename = description.downcase.gsub(/\s+/, '_').gsub(/[^a-z_]/, '') - basename = Digest::MD5.new.update(description).to_s if basename.blank? - "#{basename}.html" + def markup_index_class + RspecApiDocumentation::Views::HtmlIndex end - def requests - super.map do |hash| - hash[:request_headers_text] = format_hash(hash[:request_headers]) - hash[:request_query_parameters_text] = format_hash(hash[:request_query_parameters]) - hash[:response_headers_text] = format_hash(hash[:response_headers]) - if @host - hash[:curl] = hash[:curl].output(@host) if hash[:curl].is_a? RspecApiDocumentation::Curl - else - hash[:curl] = nil - end - hash - end + def markup_example_class + RspecApiDocumentation::Views::HtmlExample end - private - def format_hash(hash = {}) - return nil unless hash.present? - hash.collect do |k, v| - "#{k}: #{v}" - end.join("\n") + def extension + EXTENSION end end end diff --git a/lib/rspec_api_documentation/writers/textile_writer.rb b/lib/rspec_api_documentation/writers/textile_writer.rb new file mode 100644 index 00000000..6b79b209 --- /dev/null +++ b/lib/rspec_api_documentation/writers/textile_writer.rb @@ -0,0 +1,21 @@ +module RspecApiDocumentation + module Writers + class TextileWriter < GeneralMarkupWriter + attr_accessor :index, :configuration + + EXTENSION = 'textile' + + def markup_index_class + RspecApiDocumentation::Views::TextileIndex + end + + def markup_example_class + RspecApiDocumentation::Views::TextileExample + end + + def extension + EXTENSION + end + end + end +end diff --git a/spec/api_documentation_spec.rb b/spec/api_documentation_spec.rb index 92869ecd..5119bc28 100644 --- a/spec/api_documentation_spec.rb +++ b/spec/api_documentation_spec.rb @@ -60,14 +60,16 @@ describe "#writers" do class RspecApiDocumentation::Writers::HtmlWriter; end class RspecApiDocumentation::Writers::JsonWriter; end + class RspecApiDocumentation::Writers::TextileWriter; end context "multiple" do before do - configuration.format = [:html, :json] + configuration.format = [:html, :json, :textile] end it "should return the classes from format" do - subject.writers.should == [RspecApiDocumentation::Writers::HtmlWriter, RspecApiDocumentation::Writers::JsonWriter] + subject.writers.should == [RspecApiDocumentation::Writers::HtmlWriter, RspecApiDocumentation::Writers::JsonWriter, + RspecApiDocumentation::Writers::TextileWriter] end end @@ -83,16 +85,18 @@ class RspecApiDocumentation::Writers::JsonWriter; end end describe "#write" do - let(:html_writer) { double(:html_writer) } - let(:json_writer) { double(:json_writer) } + let(:html_writer) { double(:html_writer) } + let(:json_writer) { double(:json_writer) } + let(:textile_writer) { double(:textile_writer) } before do - subject.stub(:writers => [html_writer, json_writer]) + subject.stub(:writers => [html_writer, json_writer, textile_writer]) end it "should write the docs in each format" do html_writer.should_receive(:write).with(subject.index, configuration) json_writer.should_receive(:write).with(subject.index, configuration) + textile_writer.should_receive(:write).with(subject.index, configuration) subject.write end end diff --git a/spec/views/html_example_spec.rb b/spec/views/html_example_spec.rb new file mode 100644 index 00000000..98c85d8c --- /dev/null +++ b/spec/views/html_example_spec.rb @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +require 'spec_helper' + +describe RspecApiDocumentation::Views::HtmlExample do + let(:metadata) { {} } + let(:group) { RSpec::Core::ExampleGroup.describe("Orders", metadata) } + let(:example) { group.example("Ordering a cup of coffee") {} } + let(:configuration) { RspecApiDocumentation::Configuration.new } + let(:html_example) { described_class.new(example, configuration) } + + it "should have downcased filename" do + html_example.filename.should == "ordering_a_cup_of_coffee.html" + end + + describe "multi charctor example name" do + let(:label) { "コーヒーが順番で並んでいること" } + let(:example) { group.example(label) {} } + + it "should have downcased filename" do + filename = Digest::MD5.new.update(label).to_s + html_example.filename.should == filename + ".html" + end + end +end diff --git a/spec/writers/html_writer_spec.rb b/spec/writers/html_writer_spec.rb index 66c9435d..a43c8ba2 100644 --- a/spec/writers/html_writer_spec.rb +++ b/spec/writers/html_writer_spec.rb @@ -33,24 +33,3 @@ end end -describe RspecApiDocumentation::Writers::HtmlExample do - let(:metadata) { {} } - let(:group) { RSpec::Core::ExampleGroup.describe("Orders", metadata) } - let(:example) { group.example("Ordering a cup of coffee") {} } - let(:configuration) { RspecApiDocumentation::Configuration.new } - let(:html_example) { described_class.new(example, configuration) } - - it "should have downcased filename" do - html_example.filename.should == "ordering_a_cup_of_coffee.html" - end - - describe "multi charctor example name" do - let(:label) { "コーヒーが順番で並んでいること" } - let(:example) { group.example(label) {} } - - it "should have downcased filename" do - filename = Digest::MD5.new.update(label).to_s - html_example.filename.should == filename + ".html" - end - end -end diff --git a/spec/writers/textile_writer_spec.rb b/spec/writers/textile_writer_spec.rb new file mode 100644 index 00000000..e5e5216d --- /dev/null +++ b/spec/writers/textile_writer_spec.rb @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +require 'spec_helper' + +describe RspecApiDocumentation::Writers::TextileWriter 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 + described_class.stub(:new).with(index, configuration).and_return(writer) + writer.should_receive(:write) + described_class.write(index, configuration) + end + end + + 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") + File.exists?(index_file).should be_true + end + end + +end diff --git a/templates/rspec_api_documentation/textile_example.mustache b/templates/rspec_api_documentation/textile_example.mustache new file mode 100644 index 00000000..9dc2e55c --- /dev/null +++ b/templates/rspec_api_documentation/textile_example.mustache @@ -0,0 +1,68 @@ +h1. {{ resource_name }} API + +h2. {{ description }} + +h3. {{ http_method }} {{ route }} + +{{# explanation }} +{{ explanation }} +{{/ explanation }} + +{{# has_parameters? }} +h3. Parameters +{{# parameters }} + +Name : {{ name }} {{# required }} *- required -*{{/ required }} +Description : {{ description }} +{{/ parameters }} +{{/ has_parameters? }} + +{{# requests }} +h3. Request + +h4. Headers + +
{{ request_headers_text }}
+ +h4. Route + +
{{ request_method }} {{ request_path }}
+ +{{# request_query_parameters_text }} +h4. Query Parameters + +
{{ request_query_parameters_text }}
+{{/ request_query_parameters_text }} + +{{# request_body }} +h4. Body + +
{{{ request_body }}}
+{{/ request_body }} + +{{# curl }} +h4. cURL + +
{{ curl }}
+{{/ curl }} + +{{# response_status }} +h3. Response + +h4. Headers + +
{{ response_headers_text }}
+ +h4. Status + +
{{ response_status }} {{ response_status_text}}
+ +{{# response_body }} +h4. Body + +
{{{ response_body }}}
+{{/ response_body }} +{{/ response_status }} + +{{/ requests }} + diff --git a/templates/rspec_api_documentation/textile_index.mustache b/templates/rspec_api_documentation/textile_index.mustache new file mode 100644 index 00000000..cbb93d57 --- /dev/null +++ b/templates/rspec_api_documentation/textile_index.mustache @@ -0,0 +1,10 @@ +h1. {{ api_name }} + +{{# sections }} +h2. {{ resource_name }} + +{{# examples }} +* "{{ description }}":{{ dirname }}/{{ filename }} +{{/ examples }} + +{{/ sections }}