diff --git a/.gitignore b/.gitignore index a0190963..06664625 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ example/public/docs *.gem *.swp /html/ +.idea \ No newline at end of file diff --git a/README.md b/README.md index 332f3bd6..49168c0a 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ 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, - # :api_blueprint + # :api_blueprint, :postman config.format = [:html] # Location of templates @@ -172,6 +172,7 @@ end * **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. +* **postman**: (In Alpha) Generates a Postman collection JSON file that you can import ### append_json diff --git a/features/postman.feature b/features/postman.feature new file mode 100644 index 00000000..0e360a27 --- /dev/null +++ b/features/postman.feature @@ -0,0 +1,162 @@ +Feature: Postman + + Background: + Given a file named "app.rb" with: + """ + class App + def self.call(env) + request = Rack::Request.new(env) + response = Rack::Response.new + response["Content-Type"] = "text/plain" + response.write("Hello, #{request.params["target"]}!") + response.finish + 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 = "app" + config.api_explanation = "desc" + config.format = :postman + config.io_docs_protocol = "https" + end + + resource "Greetings" do + explanation "Greetings API methods" + header "Content-Type", "application/json" + + get "/greetings" do + parameter :target, "The thing you want to greet" + parameter :type, "foo" + + example "Greeting your favorite gem" do + do_request({:target => "rspec_api_documentation", :type => "foo"}) + + expect(response_headers["Content-Type"]).to eq("text/plain") + expect(status).to eq(200) + expect(response_body).to eq('Hello, rspec_api_documentation!') + end + + example "Greeting your favorite developers of your favorite gem" do + do_request :target => "Sam & Eric" + + expect(response_headers["Content-Type"]).to eq("text/plain") + expect(status).to eq(200) + expect(response_body).to eq('Hello, Sam & Eric!') + 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 + Greetings + GET /greetings + * Greeting your favorite gem + * Greeting your favorite developers of your favorite gem + """ + And the output should contain "2 examples, 0 failures" + And the exit status should be 0 + + Scenario: File should look like we expect + Then the file "doc/api/app.postman_collection.json" should contain JSON exactly like: + """ + { + "info": { + "name": "app", + "description": "desc", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Greetings", + "description": "Greetings API methods", + "item": [ + { + "name": "Greeting your favorite developers of your favorite gem", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": {}, + "url": { + "host": [ + "{{application_url}}" + ], + "path": [ + "greetings" + ], + "query" : [ + { + "key": "target", + "value": "", + "equals": true, + "description": "The thing you want to greet", + "disabled": true + } + ], + "variable": [] + }, + "description": "\n * `target`: The thing you want to greet\n * `type`: foo" + }, + "response": [] + }, + { + "name": "Greeting your favorite gem", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": {}, + "url": { + "host": [ + "{{application_url}}" + ], + "path": [ + "greetings" + ], + "query" : [ + { + "key": "target", + "value": "", + "equals": true, + "description": "The thing you want to greet", + "disabled": true + }, + { + "key": "type", + "value": "", + "equals": true, + "description": "foo", + "disabled": true + } + ], + "variable": [] + }, + "description": "\n * `target`: The thing you want to greet\n * `type`: foo" + }, + "response": [] + } + ] + } + ] + } + """ + + diff --git a/lib/rspec_api_documentation.rb b/lib/rspec_api_documentation.rb index 6d6afdc2..f1b4412b 100644 --- a/lib/rspec_api_documentation.rb +++ b/lib/rspec_api_documentation.rb @@ -45,6 +45,7 @@ module Writers autoload :CombinedJsonWriter autoload :SlateWriter autoload :ApiBlueprintWriter + autoload :PostmanWriter end module Views @@ -62,6 +63,9 @@ module Views autoload :SlateExample autoload :ApiBlueprintIndex autoload :ApiBlueprintExample + autoload :PostmanIndex + autoload :PostmanRequestExample + autoload :PostmanRequestMetadata end def self.configuration diff --git a/lib/rspec_api_documentation/views/postman_index.rb b/lib/rspec_api_documentation/views/postman_index.rb new file mode 100644 index 00000000..e5b571b3 --- /dev/null +++ b/lib/rspec_api_documentation/views/postman_index.rb @@ -0,0 +1,42 @@ +module RspecApiDocumentation + module Views + class PostmanIndex + POSTMAN_SCHEMA = 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'.freeze + + def initialize(index, configuration) + @index = index + @configuration = configuration + end + + def sections + Writers::IndexHelper.sections(examples, @configuration) + end + + def examples + @index.examples.map do |example| + Views::PostmanRequestExample.new(example) + end + end + + def as_json(opts = nil) + collections = { info: { name: @configuration.api_name, + description: @configuration.api_explanation, + schema: POSTMAN_SCHEMA }, + item: [] + } + + sections.each do |section| + folder = { name: section[:resource_name], + description: section[:resource_explanation], + item: section[:examples].map do |example| + example.as_json(opts) + end + } + collections[:item] << folder + end + + collections + end + end + end +end \ No newline at end of file diff --git a/lib/rspec_api_documentation/views/postman_request_example.rb b/lib/rspec_api_documentation/views/postman_request_example.rb new file mode 100644 index 00000000..1fdf1077 --- /dev/null +++ b/lib/rspec_api_documentation/views/postman_request_example.rb @@ -0,0 +1,35 @@ +module RspecApiDocumentation + module Views + class PostmanRequestExample + attr_reader :example, :metadata + + def initialize(example) + @example = example + @metadata = Views::PostmanRequestMetadata.new(example) + end + + def method_missing(method, *args, &block) + example.send(method, *args, &block) + end + + def as_json(options = nil) + { + name: description, + request: { + method: http_method, + header: [metadata.content_type], + body: metadata.body, + url: { + host: ['{{application_url}}'], + path: metadata.tokenized_path, + query: metadata.query_in_url, + variable: metadata.variables_for_url + }, + description: metadata.request_description + }, + response: [] + } + end + end + end +end \ No newline at end of file diff --git a/lib/rspec_api_documentation/views/postman_request_metadata.rb b/lib/rspec_api_documentation/views/postman_request_metadata.rb new file mode 100644 index 00000000..a93022a2 --- /dev/null +++ b/lib/rspec_api_documentation/views/postman_request_metadata.rb @@ -0,0 +1,117 @@ +module RspecApiDocumentation + module Views + class PostmanRequestMetadata + attr_reader :example, :metadata + + def initialize(example) + @example = example + @metadata = requests.first + end + + def method_missing(method, *args, &block) + example.send(method, *args, &block) + end + + def query_in_url + query_params = [] + + if metadata[:request_query_parameters] && example.respond_to?(:parameters) + metadata[:request_query_parameters].map do |k, v| + documented_param = example.parameters.select { |p| p[:name] == k.to_s }.try(:first) + if documented_param + query_params << { key: k.to_s, + value: '', + equals: true, + description: format_description(documented_param[:description], + documented_param[:required]), + disabled: !documented_param[:required] + } + end + end + end + + query_params + end + + def content_type + { key: 'Content-Type', value: metadata[:request_headers]['Content-Type'] } + end + + def body + return {} unless metadata[:request_body] + + if content_type[:value] == 'application/w-www-form-urlencoded' + { mode: 'urlencoded', urlencoded: build_urlencoded_body } + elsif content_type[:value] == 'application/octet-stream' + { mode: 'file', file: {} } + else + { mode: 'raw', raw: metadata[:request_body] } + end + end + + def variables_for_url + route_variables = tokenized_path.select { |r| r.start_with?(':') } + return [] unless route_variables + + variables = [] + route_variables.each do |rv| + param_name = rv.split(':')[1] + param_from_example = example.parameters.select { |e| e[:name] == param_name }.try(:first) + if param_from_example + variable = { + key: param_name, + value: '', + description: format_description(param_from_example[:description], + param_from_example[:required]), + disabled: !param_from_example[:required] + + } + variables << variable + end + end + + variables + end + + def request_description + text = explanation ? explanation : "" + if example.respond_to?(:parameters) + example.parameters.each do |param| + text = text + "\n * `#{param[:name]}`: #{param[:description]}" + end + end + text + end + + def tokenized_path + route.split('/').reject { |p| p.empty? } + end + + private + + def build_urlencoded_body + urlencoded_params = [] + params = CGI::parse(metadata[:request_body]) + params.each do |p| + param_from_example = example.parameters.select{ |e| e[:name] == p.first }.try(:first) + if param_from_example + urlencoded_param = { + key: p.first, + value: '', + description: format_description(param_from_example[:description], + param_from_example[:required]), + type: 'text', + disabled: !param_from_example[:required] + } + urlencoded_params << urlencoded_param + end + end + urlencoded_params + end + + def format_description(description, required = false) + required ? "Required. #{description}" : description + end + end + end +end diff --git a/lib/rspec_api_documentation/writers/postman_writer.rb b/lib/rspec_api_documentation/writers/postman_writer.rb new file mode 100644 index 00000000..6caac416 --- /dev/null +++ b/lib/rspec_api_documentation/writers/postman_writer.rb @@ -0,0 +1,21 @@ +require 'rspec_api_documentation/writers/formatter' + +module RspecApiDocumentation + module Writers + class PostmanWriter < Writer + attr_accessor :api_name + delegate :docs_dir, :to => :configuration + + def initialize(index, configuration) + super + self.api_name = configuration.api_name.parameterize + end + + def write + File.open(docs_dir.join("#{api_name}.postman_collection.json"), "w+") do |file| + file.write Formatter.to_json(Views::PostmanIndex.new(index, configuration)) + end + end + end + end +end \ No newline at end of file diff --git a/spec/views/postman_example_spec.rb b/spec/views/postman_example_spec.rb new file mode 100644 index 00000000..6ed54cd9 --- /dev/null +++ b/spec/views/postman_example_spec.rb @@ -0,0 +1,109 @@ +require 'spec_helper' + +describe RspecApiDocumentation::Views::PostmanRequestExample do + let(:metadata) do + { resource_name: 'Orders', + parameters: + [ + { name: 'type', required: false, description: 'decaf or regular' }, + { name: 'size', required: true, description: 'cup size' } + ], + route: '/orders', + method: 'get' + } + end + let(:group) { RSpec::Core::ExampleGroup.describe('', metadata) } + let(:description) { 'Ordering a cup of coffee' } + let(:rspec_example) { group.example(description) {} } + let(:rad_example) do + RspecApiDocumentation::Example.new(rspec_example, configuration) + end + let(:configuration) { RspecApiDocumentation::Configuration.new } + let(:postman_example) { described_class.new(rad_example) } + + let(:content_type) { 'application/json' } + let(:body_content) { '{}' } + let(:requests) do + [{ + request_body: body_content, + request_headers: { + "Content-Type" => content_type + }, + request_content_type: "", + request_query_parameters: { type: 'decaf', size: 'tall' } + }] + end + + before do + rspec_example.metadata[:requests] = requests + end + + subject(:view) { postman_example } + + describe '#as_json' do + context 'when the example is for POST' do + let(:metadata) do + { resource_name: 'Orders', + route: '/orders', + method: 'post' + } + end + let(:body_content) { "{ \"customer_name\": \"FooBar\" }" } + + it 'returns expected hash with correct data' do + expected_hash = { + name: description, + request: { + method: 'POST', + header: [{ key: 'Content-Type', value: content_type }], + body: { mode: 'raw', raw: body_content }, + url: { + host: ['{{application_url}}'], + path: ['orders'], + query: [], + variable: [] + }, + description: "", + }, + response: [] + } + expect(subject.as_json).to eq expected_hash + end + end + + context 'when the example is for GET' do + it 'returns expected hash with correct data' do + expected_hash = { + name: description, + request: { + method: 'GET', + header: [{ key: 'Content-Type', value: content_type }], + body: { mode: 'raw', raw: body_content }, + url: { + host: ['{{application_url}}'], + path: ['orders'], + query: [ + { key: 'type', + value: '', + equals: true, + description: 'decaf or regular', + disabled: true + }, + { + key: 'size', + value: '', + equals: true, + description: 'Required. cup size', + disabled: false + }], + variable: [] + }, + description: "\n * `type`: decaf or regular\n * `size`: cup size", + }, + response: [] + } + expect(subject.as_json).to eq expected_hash + end + end + end +end \ No newline at end of file diff --git a/spec/views/postman_request_metadata_spec.rb b/spec/views/postman_request_metadata_spec.rb new file mode 100644 index 00000000..d196a0a0 --- /dev/null +++ b/spec/views/postman_request_metadata_spec.rb @@ -0,0 +1,126 @@ +require 'spec_helper' + +describe RspecApiDocumentation::Views::PostmanRequestMetadata do + let(:metadata) do + { resource_name: 'Orders', + parameters: + [ + { name: 'type', required: false, description: 'decaf or regular' }, + { name: 'size', required: true, description: 'cup size' } + ], + route: '/orders', + method: 'get' + } + end + let(:group) { RSpec::Core::ExampleGroup.describe('', metadata) } + let(:description) { 'Ordering a cup of coffee' } + let(:rspec_example) { group.example(description) {} } + let(:rad_example) do + RspecApiDocumentation::Example.new(rspec_example, configuration) + end + let(:configuration) { RspecApiDocumentation::Configuration.new } + let(:postman_metadata) { described_class.new(rad_example) } + + let(:content_type) { 'application/json' } + let(:body_content) { '{}' } + let(:requests) do + [{ + request_body: body_content, + request_headers: { + "Content-Type" => content_type + }, + request_content_type: "", + request_query_parameters: { type: 'decaf', size: 'tall' } + }] + end + + before do + rspec_example.metadata[:requests] = requests + end + + subject(:view_helper) { postman_metadata } + + describe '#query_in_url' do + it 'populates parameters' do + expect(subject.query_in_url).to eq [{ + key: 'type', + value: '', + equals: true, + description: 'decaf or regular', + disabled: true + }, + { + key: 'size', + value: '', + equals: true, + description: 'Required. cup size', + disabled: false + }] + end + end + + describe '#content_type' do + it 'parses content type' do + expect(subject.content_type).to eq({ key: 'Content-Type', value: 'application/json' }) + end + end + + describe '#body' do + context 'when content type includes application/json' do + let(:body_content) { "{ \"customer_name\": \"FooBar\" }" } + + it 'returns raw mode hash' do + expect(subject.body).to eq({ mode: 'raw', raw: body_content }) + end + end + + context 'when content type is w-www-form-urlencoded' do + let(:content_type) { 'application/w-www-form-urlencoded' } + let(:body_content) { "type=decaf&size=regular"} + + it 'returns urlencoded hash' do + expect(subject.body).to eq({ mode: 'urlencoded', + urlencoded: [ + { + key: 'type', + value: '', + description: 'decaf or regular', + type: 'text', + disabled: true + }, + { + key: 'size', + value: '', + description: 'Required. cup size', + type: 'text', + disabled: false + }] + }) + end + end + end + + describe '#variables_for_url' do + context 'when route has a variable' do + let(:metadata) do + { resource_name: 'Orders', + parameters: + [ + { name: 'id', required: true, description: 'Order ID' }, + { name: 'type', required: false, description: 'decaf or regular' }, + { name: 'size', required: true, description: 'cup size' } + ], + route: '/orders/:id', + method: 'get' + } + end + + it 'can populate variable for url' do + expect(subject.variables_for_url).to eq([{ key: 'id', + value: '', + description: 'Required. Order ID', + disabled: false }]) + end + end + end +end diff --git a/spec/writers/postman_writer_spec.rb b/spec/writers/postman_writer_spec.rb new file mode 100644 index 00000000..22c368ba --- /dev/null +++ b/spec/writers/postman_writer_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe RspecApiDocumentation::Writers::PostmanWriter 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 + + describe "#write" do + let(:writer) { described_class.new(index, configuration) } + + before do + allow(configuration.api_name).to receive(:parameterize).and_return("Name") + FileUtils.mkdir_p(configuration.docs_dir) + end + + it "should write the collection" do + writer.write + collection_file = File.join(configuration.docs_dir, "Name.postman_collection.json") + expect(File.exists?(collection_file)).to be_truthy + end + end +end \ No newline at end of file