From b65559c0af2ad50d2847d16966aab4a560a934b0 Mon Sep 17 00:00:00 2001 From: djezzzl Date: Wed, 31 May 2017 15:12:49 +0200 Subject: [PATCH] Add support for OpenAPI Specification(2.0) --- .gitignore | 1 + README.md | 173 +++- example/Gemfile | 3 + example/Gemfile.lock | 22 +- .../app/controllers/application_controller.rb | 3 +- example/app/controllers/orders_controller.rb | 11 +- example/app/controllers/uploads_controller.rb | 2 + example/config/application.rb | 8 + example/config/open_api.yml | 23 + example/db/schema.rb | 2 +- example/spec/acceptance/orders_spec.rb | 52 +- example/spec/acceptance/uploads_spec.rb | 4 + example/spec/acceptance_helper.rb | 2 +- features/open_api.feature | 844 ++++++++++++++++++ lib/rspec_api_documentation.rb | 25 + lib/rspec_api_documentation/configuration.rb | 8 + lib/rspec_api_documentation/dsl/endpoint.rb | 34 +- .../dsl/endpoint/params.rb | 13 + .../dsl/endpoint/set_param.rb | 4 + lib/rspec_api_documentation/dsl/resource.rb | 23 + .../open_api/contact.rb | 9 + .../open_api/example.rb | 7 + .../open_api/header.rb | 12 + .../open_api/headers.rb | 7 + .../open_api/helper.rb | 29 + lib/rspec_api_documentation/open_api/info.rb | 12 + .../open_api/license.rb | 8 + lib/rspec_api_documentation/open_api/node.rb | 112 +++ .../open_api/operation.rb | 18 + .../open_api/parameter.rb | 33 + lib/rspec_api_documentation/open_api/path.rb | 13 + lib/rspec_api_documentation/open_api/paths.rb | 7 + .../open_api/response.rb | 10 + .../open_api/responses.rb | 9 + lib/rspec_api_documentation/open_api/root.rb | 21 + .../open_api/schema.rb | 15 + .../open_api/security_definitions.rb | 7 + .../open_api/security_schema.rb | 14 + lib/rspec_api_documentation/open_api/tag.rb | 9 + .../writers/open_api_writer.rb | 244 +++++ spec/dsl_spec.rb | 106 +++ spec/fixtures/open_api.yml | 296 ++++++ spec/open_api/contact_spec.rb | 12 + spec/open_api/info_spec.rb | 18 + spec/open_api/license_spec.rb | 11 + spec/open_api/node_spec.rb | 47 + spec/open_api/root_spec.rb | 38 + spec/writers/open_api_writer_spec.rb | 17 + 48 files changed, 2371 insertions(+), 27 deletions(-) create mode 100644 example/config/open_api.yml create mode 100644 features/open_api.feature create mode 100644 lib/rspec_api_documentation/open_api/contact.rb create mode 100644 lib/rspec_api_documentation/open_api/example.rb create mode 100644 lib/rspec_api_documentation/open_api/header.rb create mode 100644 lib/rspec_api_documentation/open_api/headers.rb create mode 100644 lib/rspec_api_documentation/open_api/helper.rb create mode 100644 lib/rspec_api_documentation/open_api/info.rb create mode 100644 lib/rspec_api_documentation/open_api/license.rb create mode 100644 lib/rspec_api_documentation/open_api/node.rb create mode 100644 lib/rspec_api_documentation/open_api/operation.rb create mode 100644 lib/rspec_api_documentation/open_api/parameter.rb create mode 100644 lib/rspec_api_documentation/open_api/path.rb create mode 100644 lib/rspec_api_documentation/open_api/paths.rb create mode 100644 lib/rspec_api_documentation/open_api/response.rb create mode 100644 lib/rspec_api_documentation/open_api/responses.rb create mode 100644 lib/rspec_api_documentation/open_api/root.rb create mode 100644 lib/rspec_api_documentation/open_api/schema.rb create mode 100644 lib/rspec_api_documentation/open_api/security_definitions.rb create mode 100644 lib/rspec_api_documentation/open_api/security_schema.rb create mode 100644 lib/rspec_api_documentation/open_api/tag.rb create mode 100644 lib/rspec_api_documentation/writers/open_api_writer.rb create mode 100644 spec/fixtures/open_api.yml create mode 100644 spec/open_api/contact_spec.rb create mode 100644 spec/open_api/info_spec.rb create mode 100644 spec/open_api/license_spec.rb create mode 100644 spec/open_api/node_spec.rb create mode 100644 spec/open_api/root_spec.rb create mode 100644 spec/writers/open_api_writer_spec.rb diff --git a/.gitignore b/.gitignore index 473211c6..1063635d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ example/public/docs *.gem *.swp /html/ +/.idea diff --git a/README.md b/README.md index 332f3bd6..d1e02947 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,16 @@ RspecApiDocumentation.configure do |config| # Set the application that Rack::Test uses config.app = Rails.application + # Used to provide a configuration for the specification (supported only by 'open_api' format for now) + config.configurations_dir = Rails.root.join("doc", "configurations", "api") + # Output folder config.docs_dir = Rails.root.join("doc", "api") # 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, :open_api config.format = [:html] # Location of templates @@ -172,6 +175,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. +* **open_api**: Generates [OpenAPI Specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md) (OAS) (Current supported version is 2.0). Can be used for [Swagger-UI](https://swagger.io/tools/swagger-ui/) ### append_json @@ -228,6 +232,173 @@ This [format](https://apiblueprint.org) (APIB) has additional functions: * `attribute`: APIB has attributes besides parameters. Use attributes exactly like you'd use `parameter` (see documentation below). + +### open_api + +This [format](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md) (OAS) has additional functions: + +* `authentication(type, value, opts = {})` ([Security schema object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#security-scheme-object)) + + The values will be passed through header of the request. Option `name` has to be provided for `apiKey`. + + * `authentication :basic, 'Basic Key'` + * `authentication :apiKey, 'Api Key', name: 'API_AUTH', description: 'Some description'` + + You could pass `Symbol` as value. In this case you need to define a `let` with the same name. + + ``` + authentication :apiKey, :api_key + let(:api_key) { some_value } + ``` + +* `route_summary(text)` and `route_description(text)`. ([Operation object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#operation-object)) + + These two simplest methods accept `String`. + It will be used for route's `summary` and `description`. + +* Several new options on `parameter` helper. + + - `with_example: true`. This option will adjust your description of the parameter with the passed value. + - `default: `. Will provide a default value for the parameter. + - `minimum: `. Will setup upper limit for your parameter. + - `maximum: `. Will setup lower limit for your parameter. + - `enum: [, , ..]`. Will provide a pre-defined list of possible values for your parameter. + - `type: [:file, :array, :object, :boolean, :integer, :number, :string]`. Will set a type for the parameter. Most of the type you don't need to provide this option manually. We extract types from values automatically. + + +You also can provide a configuration file in YAML or JSON format with some manual configs. +The file should be placed in `configurations_dir` folder with the name `open_api.yml` or `open_api.json`. +In this file you able to manually **hide** some endpoints/resources you want to hide from generated API specification but still want to test. +It's also possible to pass almost everything to the specification builder manually. + +#### Example of configuration file + +```yaml +swagger: '2.0' +info: + title: OpenAPI App + description: This is a sample server. + termsOfService: 'http://open-api.io/terms/' + contact: + name: API Support + url: 'http://www.open-api.io/support' + email: support@open-api.io + license: + name: Apache 2.0 + url: 'http://www.apache.org/licenses/LICENSE-2.0.html' + version: 1.0.0 +host: 'localhost:3000' +schemes: + - http + - https +consumes: + - application/json + - application/xml +produces: + - application/json + - application/xml +paths: + /orders: + hide: true + /instructions: + hide: false + get: + description: This description came from configuration file + hide: true +``` + +#### Example of spec file + +```ruby + resource 'Orders' do + explanation "Orders resource" + + authentication :apiKey, :api_key, description: 'Private key for API access', name: 'HEADER_KEY' + header "Content-Type", "application/json" + + let(:api_key) { generate_api_key } + + get '/orders' do + route_summary "This URL allows users to interact with all orders." + route_description "Long description." + + # This is manual way to describe complex parameters + parameter :one_level_array, type: :array, items: {type: :string, enum: ['string1', 'string2']}, default: ['string1'] + parameter :two_level_array, type: :array, items: {type: :array, items: {type: :string}} + + let(:one_level_array) { ['string1', 'string2'] } + let(:two_level_array) { [['123', '234'], ['111']] } + + # This is automatic way + # It's possible because we extract parameters definitions from the values + parameter :one_level_arr, with_example: true + parameter :two_level_arr, with_example: true + + let(:one_level_arr) { ['value1', 'value2'] } + let(:two_level_arr) { [[5.1, 3.0], [1.0, 4.5]] } + + context '200' do + example_request 'Getting a list of orders' do + expect(status).to eq(200) + expect(response_body).to eq() + end + end + end + + put '/orders/:id' do + route_summary "This is used to update orders." + + with_options scope: :data, with_example: true do + parameter :name, 'The order name', required: true + parameter :amount + parameter :description, 'The order description' + end + + context "200" do + let(:id) { 1 } + + example 'Update an order' do + request = { + data: { + name: 'order', + amount: 1, + description: 'fast order' + } + } + + # It's also possible to extract types of parameters when you pass data through `do_request` method. + do_request(request) + + expected_response = { + data: { + name: 'order', + amount: 1, + description: 'fast order' + } + } + expect(status).to eq(200) + expect(response_body).to eq() + end + end + + context "400" do + let(:id) { "a" } + + example_request 'Invalid request' do + expect(status).to eq(400) + end + end + + context "404" do + let(:id) { 0 } + + example_request 'Order is not found' do + expect(status).to eq(404) + end + end + end + end +``` ## Filtering and Exclusion diff --git a/example/Gemfile b/example/Gemfile index 7596b39b..53112786 100644 --- a/example/Gemfile +++ b/example/Gemfile @@ -2,12 +2,15 @@ source 'https://rubygems.org' ruby '2.3.3' +gem 'rack-cors', :require => 'rack/cors' gem 'rails', '4.2.5.1' gem 'sqlite3' gem 'spring', group: :development gem 'raddocs', :github => "smartlogic/raddocs" group :test, :development do + gem 'byebug' + gem 'awesome_print' gem 'rspec-rails' gem 'rspec_api_documentation', :path => "../" end diff --git a/example/Gemfile.lock b/example/Gemfile.lock index c1a4f808..32919b0f 100644 --- a/example/Gemfile.lock +++ b/example/Gemfile.lock @@ -8,13 +8,12 @@ GIT sinatra (~> 1.3, >= 1.3.0) PATH - remote: ../ + remote: .. specs: - rspec_api_documentation (4.7.0) + rspec_api_documentation (5.1.0) activesupport (>= 3.0.0) - json (~> 1.4, >= 1.4.6) - mustache (~> 0.99, >= 0.99.4) - rspec (>= 3.0.0) + mustache (~> 1.0, >= 0.99.4) + rspec (~> 3.0) GEM remote: https://rubygems.org/ @@ -55,7 +54,9 @@ GEM thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) arel (6.0.3) + awesome_print (1.7.0) builder (3.2.2) + byebug (9.0.6) concurrent-ruby (1.0.0) diff-lcs (1.2.5) erubis (2.7.0) @@ -72,10 +73,11 @@ GEM mime-types (2.99) mini_portile2 (2.0.0) minitest (5.8.4) - mustache (0.99.8) + mustache (1.0.5) nokogiri (1.6.7.2) mini_portile2 (~> 2.0.0.rc2) rack (1.6.4) + rack-cors (0.4.1) rack-protection (1.5.3) rack rack-test (0.6.3) @@ -148,6 +150,9 @@ PLATFORMS ruby DEPENDENCIES + awesome_print + byebug + rack-cors raddocs! rails (= 4.2.5.1) rspec-rails @@ -155,5 +160,8 @@ DEPENDENCIES spring sqlite3 +RUBY VERSION + ruby 2.3.3p222 + BUNDLED WITH - 1.11.2 + 1.16.2 diff --git a/example/app/controllers/application_controller.rb b/example/app/controllers/application_controller.rb index d83690e1..840f64a8 100644 --- a/example/app/controllers/application_controller.rb +++ b/example/app/controllers/application_controller.rb @@ -1,5 +1,6 @@ class ApplicationController < ActionController::Base # Prevent CSRF attacks by raising an exception. # For APIs, you may want to use :null_session instead. - protect_from_forgery with: :exception + # protect_from_forgery with: :exception + protect_from_forgery with: :null_session end diff --git a/example/app/controllers/orders_controller.rb b/example/app/controllers/orders_controller.rb index a91572cb..9ec2f703 100644 --- a/example/app/controllers/orders_controller.rb +++ b/example/app/controllers/orders_controller.rb @@ -1,10 +1,19 @@ class OrdersController < ApplicationController + before_action only: :index do + head :unauthorized unless request.headers['HTTP_AUTH_TOKEN'] =~ /\AAPI_TOKEN$/ + end + def index render :json => Order.all end def show - render :json => Order.find(params[:id]) + order = Order.find_by(id: params[:id]) + if order + render json: order + else + head :not_found + end end def create diff --git a/example/app/controllers/uploads_controller.rb b/example/app/controllers/uploads_controller.rb index 34b7f276..8855a415 100644 --- a/example/app/controllers/uploads_controller.rb +++ b/example/app/controllers/uploads_controller.rb @@ -1,4 +1,6 @@ class UploadsController < ApplicationController + http_basic_authenticate_with name: 'user', password: 'password' + def create head 201 end diff --git a/example/config/application.rb b/example/config/application.rb index ee6bc294..26647b9d 100644 --- a/example/config/application.rb +++ b/example/config/application.rb @@ -15,6 +15,14 @@ module Example class Application < Rails::Application + + config.middleware.insert_before 0, 'Rack::Cors' do + allow do + origins '*' + resource '*', :headers => :any, :methods => [:get, :post, :options, :put, :patch, :delete, :head] + end + end + # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. diff --git a/example/config/open_api.yml b/example/config/open_api.yml new file mode 100644 index 00000000..0be381d2 --- /dev/null +++ b/example/config/open_api.yml @@ -0,0 +1,23 @@ +swagger: '2.0' +info: + title: OpenAPI App + description: This is a sample server Petstore server. + termsOfService: 'http://open-api.io/terms/' + contact: + name: API Support + url: 'http://www.open-api.io/support' + email: support@open-api.io + license: + name: Apache 2.0 + url: 'http://www.apache.org/licenses/LICENSE-2.0.html' + version: 1.0.1 +host: 'localhost:3000' +schemes: + - http + - https +consumes: + - application/json + - application/xml +produces: + - application/json + - application/xml diff --git a/example/db/schema.rb b/example/db/schema.rb index 2cbe495b..a3b2ef4b 100644 --- a/example/db/schema.rb +++ b/example/db/schema.rb @@ -13,7 +13,7 @@ ActiveRecord::Schema.define(version: 20140616151047) do - create_table "orders", force: true do |t| + create_table "orders", force: :cascade do |t| t.string "name" t.boolean "paid" t.string "email" diff --git a/example/spec/acceptance/orders_spec.rb b/example/spec/acceptance/orders_spec.rb index a6596191..4505aabc 100644 --- a/example/spec/acceptance/orders_spec.rb +++ b/example/spec/acceptance/orders_spec.rb @@ -4,10 +4,13 @@ header "Accept", "application/json" header "Content-Type", "application/json" + explanation "Orders are top-level business objects" + let(:order) { Order.create(:name => "Old Name", :paid => true, :email => "email@example.com") } get "/orders" do - parameter :page, "Current page of orders" + authentication :apiKey, "API_TOKEN", :name => "AUTH_TOKEN" + parameter :page, "Current page of orders", with_example: true let(:page) { 1 } @@ -24,23 +27,31 @@ end head "/orders" do + authentication :apiKey, "API_TOKEN", :name => "AUTH_TOKEN" + example_request "Getting the headers" do expect(response_headers["Cache-Control"]).to eq("max-age=0, private, must-revalidate") end end post "/orders" do - parameter :name, "Name of order", :required => true, :scope => :order - parameter :paid, "If the order has been paid for", :required => true, :scope => :order - parameter :email, "Email of user that placed the order", :scope => :order + with_options :scope => :order, :with_example => true do + parameter :name, "Name of order", :required => true + parameter :paid, "If the order has been paid for", :required => true + parameter :email, "Email of user that placed the order" + parameter :data, "Array of string", :type => :array, :items => {:type => :string} + end - response_field :name, "Name of order", :scope => :order, "Type" => "String" - response_field :paid, "If the order has been paid for", :scope => :order, "Type" => "Boolean" - response_field :email, "Email of user that placed the order", :scope => :order, "Type" => "String" + with_options :scope => :order do + response_field :name, "Name of order", :type => :string + response_field :paid, "If the order has been paid for", :type => :boolean + response_field :email, "Email of user that placed the order", :type => :string + end let(:name) { "Order 1" } let(:paid) { true } let(:email) { "email@example.com" } + let(:data) { ["good", "bad"] } let(:raw_post) { params.to_json } @@ -61,18 +72,31 @@ end get "/orders/:id" do - let(:id) { order.id } + context "when id is valid" do + let(:id) { order.id } - example_request "Getting a specific order" do - expect(response_body).to eq(order.to_json) - expect(status).to eq(200) + example_request "Getting a specific order" do + expect(response_body).to eq(order.to_json) + expect(status).to eq(200) + end + end + + context "when id is invalid" do + let(:id) { "a" } + + example_request "Getting an error" do + expect(response_body).to eq "" + expect(status).to eq 404 + end end end put "/orders/:id" do - parameter :name, "Name of order", :scope => :order - parameter :paid, "If the order has been paid for", :scope => :order - parameter :email, "Email of user that placed the order", :scope => :order + with_options :scope => :order, with_example: true do + parameter :name, "Name of order" + parameter :paid, "If the order has been paid for" + parameter :email, "Email of user that placed the order" + end let(:id) { order.id } let(:name) { "Updated Name" } diff --git a/example/spec/acceptance/uploads_spec.rb b/example/spec/acceptance/uploads_spec.rb index 8c07d531..b242ca54 100644 --- a/example/spec/acceptance/uploads_spec.rb +++ b/example/spec/acceptance/uploads_spec.rb @@ -1,6 +1,10 @@ require 'acceptance_helper' resource "Uploads" do + authentication :basic, :api_key, :description => "Api Key description" + + let(:api_key) { "Basic #{Base64.encode64('user:password')}" } + post "/uploads" do parameter :file, "New file to upload" diff --git a/example/spec/acceptance_helper.rb b/example/spec/acceptance_helper.rb index 621342fe..af483744 100644 --- a/example/spec/acceptance_helper.rb +++ b/example/spec/acceptance_helper.rb @@ -3,7 +3,7 @@ require 'rspec_api_documentation/dsl' RspecApiDocumentation.configure do |config| - config.format = [:json, :combined_text, :html] + config.format = [:open_api] config.curl_host = 'http://localhost:3000' config.api_name = "Example App API" config.api_explanation = "API Example Description" diff --git a/features/open_api.feature b/features/open_api.feature new file mode 100644 index 00000000..b7ba07dd --- /dev/null +++ b/features/open_api.feature @@ -0,0 +1,844 @@ +Feature: Generate Open API Specification from test examples + + Background: + Given a file named "app.rb" with: + """ + require 'sinatra' + + class App < Sinatra::Base + get '/orders' do + content_type "application/vnd.api+json" + + [200, { + :page => 1, + :orders => [ + { name: 'Order 1', amount: 9.99, description: nil }, + { name: 'Order 2', amount: 100.0, description: 'A great order' } + ] + }.to_json] + end + + get '/orders/:id' do + content_type :json + + [200, { order: { name: 'Order 1', amount: 100.0, description: 'A great order' } }.to_json] + end + + post '/orders' do + content_type :json + + [201, { order: { name: 'Order 1', amount: 100.0, description: 'A great order' } }.to_json] + end + + put '/orders/:id' do + content_type :json + + if params[:id].to_i > 0 + [200, request.body.read] + 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 "open_api.json" with: + """ + { + "swagger": "2.0", + "info": { + "title": "OpenAPI App", + "description": "This is a sample of OpenAPI specification.", + "termsOfService": "http://open-api.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.open-api.io/support", + "email": "support@open-api.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.1" + }, + "host": "localhost:3000", + "schemes": [ + "http" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + { + "name": "Orders", + "description": "Order's tag description" + } + ], + "paths": { + "/should_be_hided": { + "hide": true + }, + "/not_hided": { + "hide": false, + "get": { + "hide": true + } + }, + "/instructions": { + "get": { + "description": "This description came from config.yml 1" + } + }, + "/orders": { + "post": { + "description": "This description came from config.yml 2" + } + } + } + } + """ + 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 = :open_api + config.configurations_dir = "." + 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" + + get '/orders' do + route_summary "This URL allows users to interact with all orders." + route_description "Long description." + + parameter :one_level_array, type: :array, items: {type: :string, enum: ['string1', 'string2']}, default: ['string1'] + parameter :two_level_array, type: :array, items: {type: :array, items: {type: :string}} + + parameter :one_level_arr, with_example: true + parameter :two_level_arr, with_example: true + + let(:one_level_arr) { ['value1', 'value2'] } + let(:two_level_arr) { [[5.1, 3.0], [1.0, 4.5]] } + + 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 '/orders' do + route_summary "This is used to create orders." + + header "Content-Type", "application/json" + + parameter :name, scope: :data, with_example: true, default: 'name' + parameter :description, scope: :data, with_example: true + parameter :amount, scope: :data, with_example: true, minimum: 0, maximum: 100 + parameter :values, scope: :data, with_example: true, enum: [1, 2, 3, 5] + + example 'Creating an order' do + request = { + data: { + name: "Order 1", + amount: 100.0, + description: "A description", + values: [5.0, 1.0] + } + } + do_request(request) + expect(status).to eq(201) + end + end + + get '/orders/:id' do + route_summary "This is used to return orders." + route_description "Returns a specific order." + + let(:id) { 1 } + + example_request 'Getting a specific order' do + expect(status).to eq(200) + expect(response_body).to eq('{"order":{"name":"Order 1","amount":100.0,"description":"A great order"}}') + end + end + + put '/orders/:id' do + route_summary "This is used to update orders." + + parameter :name, 'The order name', required: true, scope: :data, with_example: true + parameter :amount, required: false, scope: :data, with_example: true + parameter :description, 'The order description', required: false, scope: :data, with_example: true + + header "Content-Type", "application/json" + + context "with a valid id" do + let(:id) { 1 } + + example 'Update an order' do + request = { + data: { + name: 'order', + amount: 1, + description: 'fast order' + } + } + do_request(request) + expected_response = { + data: { + name: 'order', + amount: 1, + description: 'fast order' + } + } + 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 '/orders/:id' do + route_summary "This is used to delete orders." + + let(:id) { 1 } + + example_request "Deleting an order" do + expect(status).to eq(200) + expect(response_body).to eq('') + end + end + end + + resource 'Instructions' do + explanation 'Instructions help the users use the app.' + + get '/instructions' do + route_summary 'This should be used to get all instructions.' + + example_request 'List all instructions' do + 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 + """ + 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 + POST /orders + * Creating an order + GET /orders/:id + * Getting a specific order + PUT /orders/:id + with a valid id + * Update an order + with an invalid id + * Invalid request + DELETE /orders/:id + * Deleting an order + Instructions + GET /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/open_api.json" should contain exactly: + """ + { + "swagger": "2.0", + "info": { + "title": "OpenAPI App", + "description": "This is a sample of OpenAPI specification.", + "termsOfService": "http://open-api.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.open-api.io/support", + "email": "support@open-api.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.1" + }, + "host": "localhost:3000", + "schemes": [ + "http" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/not_hided": { + }, + "/instructions": { + "get": { + "tags": [ + "Instructions" + ], + "summary": "This should be used to get all instructions.", + "description": "This description came from config.yml 1", + "consumes": [ + + ], + "produces": [ + "text/html" + ], + "parameters": [ + + ], + "responses": { + "200": { + "description": "List all instructions", + "schema": { + "description": "", + "type": "object", + "properties": { + } + }, + "headers": { + "Content-Type": { + "description": "", + "type": "string", + "x-example-value": "text/html;charset=utf-8" + }, + "Content-Length": { + "description": "", + "type": "string", + "x-example-value": "57" + } + }, + "examples": { + "text/html": { + "data": { + "id": "1", + "type": "instructions", + "attributes": { + } + } + } + } + } + }, + "deprecated": false, + "security": [ + + ] + } + }, + "/orders": { + "get": { + "tags": [ + "Orders" + ], + "summary": "This URL allows users to interact with all orders.", + "description": "Long description.", + "consumes": [ + + ], + "produces": [ + "application/vnd.api+json" + ], + "parameters": [ + { + "name": "one_level_array", + "in": "query", + "description": " one level array", + "required": false, + "type": "array", + "items": { + "type": "string", + "enum": [ + "string1", + "string2" + ] + }, + "default": [ + "string1" + ] + }, + { + "name": "two_level_array", + "in": "query", + "description": " two level array", + "required": false, + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "one_level_arr", + "in": "query", + "description": " one level arr\nEg, `[\"value1\", \"value2\"]`", + "required": false, + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "two_level_arr", + "in": "query", + "description": " two level arr\nEg, `[[5.1, 3.0], [1.0, 4.5]]`", + "required": false, + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + ], + "responses": { + "200": { + "description": "Getting a list of orders", + "schema": { + "description": "", + "type": "object", + "properties": { + } + }, + "headers": { + "Content-Type": { + "description": "", + "type": "string", + "x-example-value": "application/vnd.api+json" + }, + "Content-Length": { + "description": "", + "type": "string", + "x-example-value": "137" + } + }, + "examples": { + "application/vnd.api+json": { + "page": 1, + "orders": [ + { + "name": "Order 1", + "amount": 9.99, + "description": null + }, + { + "name": "Order 2", + "amount": 100.0, + "description": "A great order" + } + ] + } + } + } + }, + "deprecated": false, + "security": [ + + ] + }, + "post": { + "tags": [ + "Orders" + ], + "summary": "This is used to create orders.", + "description": "This description came from config.yml 2", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "body", + "in": "body", + "description": "", + "required": false, + "schema": { + "description": "", + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Order 1", + "default": "name", + "description": "Data name" + }, + "description": { + "type": "string", + "example": "A description", + "description": "Data description" + }, + "amount": { + "type": "number", + "example": 100.0, + "description": "Data amount", + "minimum": 0, + "maximum": 100 + }, + "values": { + "type": "array", + "example": [ + 5.0, + 1.0 + ], + "description": "Data values", + "items": { + "type": "number", + "enum": [ + 1, + 2, + 3, + 5 + ] + } + } + } + } + } + } + } + ], + "responses": { + "201": { + "description": "Creating an order", + "schema": { + "description": "", + "type": "object", + "properties": { + } + }, + "headers": { + "Content-Type": { + "description": "", + "type": "string", + "x-example-value": "application/json" + }, + "Content-Length": { + "description": "", + "type": "string", + "x-example-value": "73" + } + }, + "examples": { + "application/json": { + "order": { + "name": "Order 1", + "amount": 100.0, + "description": "A great order" + } + } + } + } + }, + "deprecated": false, + "security": [ + + ] + } + }, + "/orders/{id}": { + "get": { + "tags": [ + "Orders" + ], + "summary": "This is used to return orders.", + "description": "Returns a specific order.", + "consumes": [ + + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "", + "required": true, + "type": "integer" + } + ], + "responses": { + "200": { + "description": "Getting a specific order", + "schema": { + "description": "", + "type": "object", + "properties": { + } + }, + "headers": { + "Content-Type": { + "description": "", + "type": "string", + "x-example-value": "application/json" + }, + "Content-Length": { + "description": "", + "type": "string", + "x-example-value": "73" + } + }, + "examples": { + "application/json": { + "order": { + "name": "Order 1", + "amount": 100.0, + "description": "A great order" + } + } + } + } + }, + "deprecated": false, + "security": [ + + ] + }, + "put": { + "tags": [ + "Orders" + ], + "summary": "This is used to update orders.", + "description": "", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "", + "required": true, + "type": "integer" + }, + { + "name": "body", + "in": "body", + "description": "", + "required": false, + "schema": { + "description": "", + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "order", + "description": "The order name" + }, + "amount": { + "type": "integer", + "example": 1, + "description": "Data amount" + }, + "description": { + "type": "string", + "example": "fast order", + "description": "The order description" + } + }, + "required": [ + "name" + ] + } + } + } + } + ], + "responses": { + "200": { + "description": "Update an order", + "schema": { + "description": "", + "type": "object", + "properties": { + } + }, + "headers": { + "Content-Type": { + "description": "", + "type": "string", + "x-example-value": "application/json" + }, + "Content-Length": { + "description": "", + "type": "string", + "x-example-value": "63" + } + }, + "examples": { + } + }, + "400": { + "description": "Invalid request", + "schema": { + "description": "", + "type": "object", + "properties": { + } + }, + "headers": { + "Content-Type": { + "description": "", + "type": "string", + "x-example-value": "application/json" + }, + "Content-Length": { + "description": "", + "type": "string", + "x-example-value": "0" + } + }, + "examples": { + } + } + }, + "deprecated": false, + "security": [ + + ] + }, + "delete": { + "tags": [ + "Orders" + ], + "summary": "This is used to delete orders.", + "description": "", + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "text/html" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "", + "required": true, + "type": "integer" + } + ], + "responses": { + "200": { + "description": "Deleting an order", + "schema": { + "description": "", + "type": "object", + "properties": { + } + }, + "headers": { + "Content-Type": { + "description": "", + "type": "string", + "x-example-value": "text/html;charset=utf-8" + }, + "Content-Length": { + "description": "", + "type": "string", + "x-example-value": "0" + } + }, + "examples": { + } + } + }, + "deprecated": false, + "security": [ + + ] + } + } + }, + "tags": [ + { + "name": "Orders", + "description": "Order's tag description" + }, + { + "name": "Instructions", + "description": "Instructions help the users use the app." + } + ] + } + """ + + 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 d19c74c1..5114783b 100644 --- a/lib/rspec_api_documentation.rb +++ b/lib/rspec_api_documentation.rb @@ -45,6 +45,31 @@ module Writers autoload :CombinedJsonWriter autoload :SlateWriter autoload :ApiBlueprintWriter + autoload :OpenApiWriter + end + + module OpenApi + extend ActiveSupport::Autoload + + autoload :Helper + autoload :Node + autoload :Root + autoload :Info + autoload :Contact + autoload :License + autoload :Paths + autoload :Path + autoload :Tag + autoload :Operation + autoload :Parameter + autoload :Responses + autoload :Response + autoload :Example + autoload :Headers + autoload :Header + autoload :Schema + autoload :SecurityDefinitions + autoload :SecuritySchema end module Views diff --git a/lib/rspec_api_documentation/configuration.rb b/lib/rspec_api_documentation/configuration.rb index fcc2a773..55054cb6 100644 --- a/lib/rspec_api_documentation/configuration.rb +++ b/lib/rspec_api_documentation/configuration.rb @@ -51,6 +51,14 @@ def self.add_setting(name, opts = {}) end end + add_setting :configurations_dir, :default => lambda { |config| + if defined?(Rails) + Rails.root.join('doc', 'configurations', 'api') + else + Pathname.new('doc/configurations/api') + end + } + add_setting :docs_dir, :default => lambda { |config| if defined?(Rails) Rails.root.join("doc", "api") diff --git a/lib/rspec_api_documentation/dsl/endpoint.rb b/lib/rspec_api_documentation/dsl/endpoint.rb index dcfc4888..1b221914 100644 --- a/lib/rspec_api_documentation/dsl/endpoint.rb +++ b/lib/rspec_api_documentation/dsl/endpoint.rb @@ -38,6 +38,9 @@ def do_request(extra_params = {}) params_or_body = nil path_or_query = path + extended_parameters + extract_route_parameters! + if http_method == :get && !query_string.blank? path_or_query += "?#{query_string}" else @@ -74,6 +77,36 @@ def header(name, value) example.metadata[:headers][name] = value end + def authentication(type, value, opts = {}) + name, new_opts = + case type + when :basic then ['Authorization', opts.merge(type: type)] + when :apiKey then [opts[:name], opts.merge(type: type, in: :header)] + else raise 'Not supported type for authentication' + end + header(name, value) + example.metadata[:authentications] ||= {} + example.metadata[:authentications][name] = new_opts + end + + def extract_route_parameters! + example.metadata[:route].gsub(URL_PARAMS_REGEX) do |match| + value = + if extra_params.keys.include?($1) + extra_params[$1] + elsif respond_to?($1) + send($1) + else + match + end + extended_parameters << {name: match[1..-1], value: value, in: :path} + end + end + + def extended_parameters + example.metadata[:extended_parameters] ||= Params.new(self, example, extra_params).extended + end + def headers return unless example.metadata[:headers] example.metadata[:headers].inject({}) do |hash, (header, value)| @@ -144,6 +177,5 @@ def extra_params def delete_extra_param(key) @extra_params.delete(key.to_sym) || @extra_params.delete(key.to_s) end - end end diff --git a/lib/rspec_api_documentation/dsl/endpoint/params.rb b/lib/rspec_api_documentation/dsl/endpoint/params.rb index d5d003d2..6500a747 100644 --- a/lib/rspec_api_documentation/dsl/endpoint/params.rb +++ b/lib/rspec_api_documentation/dsl/endpoint/params.rb @@ -23,6 +23,19 @@ def call ).deep_merge(extra_params) end + def extended + example.metadata.fetch(:parameters, {}).map do |param| + p = Marshal.load(Marshal.dump(param)) + p[:value] = SetParam.new(self, nil, p).value + unless p[:value] + cur = extra_params + [*p[:scope]].each { |scope| cur = cur && (cur[scope.to_sym] || cur[scope.to_s]) } + p[:value] = cur && (cur[p[:name].to_s] || cur[p[:name].to_sym]) + end + p + end + end + private attr_reader :extra_params diff --git a/lib/rspec_api_documentation/dsl/endpoint/set_param.rb b/lib/rspec_api_documentation/dsl/endpoint/set_param.rb index 63ba0462..1a52c692 100644 --- a/lib/rspec_api_documentation/dsl/endpoint/set_param.rb +++ b/lib/rspec_api_documentation/dsl/endpoint/set_param.rb @@ -15,6 +15,10 @@ def call hash.deep_merge build_param_hash(key_scope || [key]) end + def value + example_group.send(method_name) if method_name + end + private attr_reader :parent, :hash, :param diff --git a/lib/rspec_api_documentation/dsl/resource.rb b/lib/rspec_api_documentation/dsl/resource.rb index 1e45d4e2..f03a610b 100644 --- a/lib/rspec_api_documentation/dsl/resource.rb +++ b/lib/rspec_api_documentation/dsl/resource.rb @@ -70,6 +70,25 @@ def header(name, value) headers[name] = value end + def authentication(type, value, opts = {}) + name, new_opts = + case type + when :basic then ['Authorization', opts.merge(type: type)] + when :apiKey then [opts[:name], opts.merge(type: type, in: :header)] + else raise 'Not supported type for authentication' + end + header(name, value) + authentications[name] = new_opts + end + + def route_summary(text) + safe_metadata(:route_summary, text) + end + + def route_description(text) + safe_metadata(:route_description, text) + end + def explanation(text) safe_metadata(:resource_explanation, text) end @@ -107,6 +126,10 @@ def headers safe_metadata(:headers, {}) end + def authentications + safe_metadata(:authentications, {}) + end + def parameter_keys parameters.map { |param| param[:name] } end diff --git a/lib/rspec_api_documentation/open_api/contact.rb b/lib/rspec_api_documentation/open_api/contact.rb new file mode 100644 index 00000000..b6bc3c02 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/contact.rb @@ -0,0 +1,9 @@ +module RspecApiDocumentation + module OpenApi + class Contact < Node + add_setting :name, :default => 'API Support' + add_setting :url, :default => 'http://www.open-api.io/support' + add_setting :email, :default => 'support@open-api.io' + end + end +end diff --git a/lib/rspec_api_documentation/open_api/example.rb b/lib/rspec_api_documentation/open_api/example.rb new file mode 100644 index 00000000..c641b191 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/example.rb @@ -0,0 +1,7 @@ +module RspecApiDocumentation + module OpenApi + class Example < Node + CHILD_CLASS = true + end + end +end diff --git a/lib/rspec_api_documentation/open_api/header.rb b/lib/rspec_api_documentation/open_api/header.rb new file mode 100644 index 00000000..222e2694 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/header.rb @@ -0,0 +1,12 @@ +module RspecApiDocumentation + module OpenApi + class Header < Node + add_setting :description, :default => '' + add_setting :type, :required => true, :default => lambda { |header| + Helper.extract_type(header.public_send('x-example-value')) + } + add_setting :format + add_setting 'x-example-value' + end + end +end diff --git a/lib/rspec_api_documentation/open_api/headers.rb b/lib/rspec_api_documentation/open_api/headers.rb new file mode 100644 index 00000000..6a073a14 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/headers.rb @@ -0,0 +1,7 @@ +module RspecApiDocumentation + module OpenApi + class Headers < Node + CHILD_CLASS = Header + end + end +end diff --git a/lib/rspec_api_documentation/open_api/helper.rb b/lib/rspec_api_documentation/open_api/helper.rb new file mode 100644 index 00000000..0e25ad65 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/helper.rb @@ -0,0 +1,29 @@ +module RspecApiDocumentation + module OpenApi + module Helper + module_function + + def extract_type(value) + case value + when Rack::Test::UploadedFile then :file + when Array then :array + when Hash then :object + when TrueClass, FalseClass then :boolean + when Integer then :integer + when Float then :number + else :string + end + end + + def extract_items(value, opts = {}) + result = {type: extract_type(value)} + if result[:type] == :array + result[:items] = extract_items(value[0], opts) + else + opts.each { |k, v| result[k] = v if v } + end + result + end + end + end +end diff --git a/lib/rspec_api_documentation/open_api/info.rb b/lib/rspec_api_documentation/open_api/info.rb new file mode 100644 index 00000000..ff4c934a --- /dev/null +++ b/lib/rspec_api_documentation/open_api/info.rb @@ -0,0 +1,12 @@ +module RspecApiDocumentation + module OpenApi + class Info < Node + add_setting :title, :default => 'OpenAPI Specification', :required => true + add_setting :description, :default => 'This is a sample server Petstore server.' + add_setting :termsOfService, :default => 'http://open-api.io/terms/' + add_setting :contact, :default => Contact.new, :schema => Contact + add_setting :license, :default => License.new, :schema => License + add_setting :version, :default => '1.0.0', :required => true + end + end +end diff --git a/lib/rspec_api_documentation/open_api/license.rb b/lib/rspec_api_documentation/open_api/license.rb new file mode 100644 index 00000000..84537526 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/license.rb @@ -0,0 +1,8 @@ +module RspecApiDocumentation + module OpenApi + class License < Node + add_setting :name, :default => 'Apache 2.0', :required => true + add_setting :url, :default => 'http://www.apache.org/licenses/LICENSE-2.0.html' + end + end +end diff --git a/lib/rspec_api_documentation/open_api/node.rb b/lib/rspec_api_documentation/open_api/node.rb new file mode 100644 index 00000000..2f102c88 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/node.rb @@ -0,0 +1,112 @@ +module RspecApiDocumentation + module OpenApi + class Node + # this is used to define class of incoming option attribute + # If +false+ then do not create new setting + # If +true+ then create new setting with raw passed value + # If RspecApiDocumentation::OpenApi::Node then create new setting and wrap it in this class + CHILD_CLASS = false + + # This attribute allow us to hide some of children through configuration file + attr_accessor :hide + + def self.add_setting(name, opts = {}) + class_settings << name + + define_method("#{name}_schema") { opts[:schema] || NilClass } + define_method("#{name}=") { |value| settings[name] = value } + define_method("#{name}") do + if settings.has_key?(name) + settings[name] + elsif !opts[:default].nil? + if opts[:default].respond_to?(:call) + opts[:default].call(self) + else + opts[:default] + end + elsif opts[:required] + raise "setting: #{name} required in #{self}" + end + end + end + + def initialize(opts = {}) + return unless opts + + opts.each do |name, value| + if name.to_s == 'hide' + self.hide = value + elsif self.class::CHILD_CLASS + add_setting name, :value => self.class::CHILD_CLASS === true ? value : self.class::CHILD_CLASS.new(value) + elsif setting_exist?(name.to_sym) + schema = setting_schema(name) + converted = + case + when schema.is_a?(Array) && schema[0] <= Node then value.map { |v| v.is_a?(schema[0]) ? v : schema[0].new(v) } + when schema <= Node then value.is_a?(schema) ? value : schema.new(value) + else + value + end + assign_setting(name, converted) + else + public_send("#{name}=", value) if respond_to?("#{name}=") + end + end + end + + def assign_setting(name, value); public_send("#{name}=", value) unless value.nil? end + def safe_assign_setting(name, value); assign_setting(name, value) unless settings.has_key?(name) end + def setting(name); public_send(name) end + def setting_schema(name); public_send("#{name}_schema") end + def setting_exist?(name); existing_settings.include?(name) end + def existing_settings; self.class.class_settings + instance_settings end + + def add_setting(name, opts = {}) + return false if setting_exist?(name) + + instance_settings << name + + settings[name] = opts[:value] if opts[:value] + + define_singleton_method("#{name}_schema") { opts[:schema] || NilClass } + define_singleton_method("#{name}=") { |value| settings[name] = value } + define_singleton_method("#{name}") do + if settings.has_key?(name) + settings[name] + elsif !opts[:default].nil? + if opts[:default].respond_to?(:call) + opts[:default].call(self) + else + opts[:default] + end + elsif opts[:required] + raise "setting: #{name} required in #{self}" + end + end + end + + def as_json + existing_settings.inject({}) do |hash, name| + value = setting(name) + case + when value.is_a?(Node) + hash[name] = value.as_json unless value.hide + when value.is_a?(Array) && value[0].is_a?(Node) + tmp = value.select { |v| !v.hide }.map { |v| v.as_json } + hash[name] = tmp unless tmp.empty? + else + hash[name] = value + end unless value.nil? + + hash + end + end + + private + + def settings; @settings ||= {} end + def instance_settings; @instance_settings ||= [] end + def self.class_settings; @class_settings ||= [] end + end + end +end diff --git a/lib/rspec_api_documentation/open_api/operation.rb b/lib/rspec_api_documentation/open_api/operation.rb new file mode 100644 index 00000000..85db7c1b --- /dev/null +++ b/lib/rspec_api_documentation/open_api/operation.rb @@ -0,0 +1,18 @@ +module RspecApiDocumentation + module OpenApi + class Operation < Node + add_setting :tags, :default => [] + add_setting :summary + add_setting :description, :default => '' + add_setting :externalDocs + add_setting :operationId + add_setting :consumes + add_setting :produces + add_setting :parameters, :default => [], :schema => [Parameter] + add_setting :responses, :required => true, :schema => Responses + add_setting :schemes + add_setting :deprecated, :default => false + add_setting :security + end + end +end diff --git a/lib/rspec_api_documentation/open_api/parameter.rb b/lib/rspec_api_documentation/open_api/parameter.rb new file mode 100644 index 00000000..d16c5473 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/parameter.rb @@ -0,0 +1,33 @@ +module RspecApiDocumentation + module OpenApi + class Parameter < Node + # Required to write example values to description of parameter when option `with_example: true` is provided + attr_accessor :value + attr_accessor :with_example + + add_setting :name, :required => true + add_setting :in, :required => true + add_setting :description, :default => '' + add_setting :required, :default => lambda { |parameter| parameter.in.to_s == 'path' ? true : false } + add_setting :schema + add_setting :type + add_setting :items + add_setting :default + add_setting :minimum + add_setting :maximum + add_setting :enum + + def description_with_example + str = description_without_example.dup || '' + if with_example && value + str << "\n" unless str.empty? + str << "Eg, `#{value}`" + end + str + end + + alias_method :description_without_example, :description + alias_method :description, :description_with_example + end + end +end diff --git a/lib/rspec_api_documentation/open_api/path.rb b/lib/rspec_api_documentation/open_api/path.rb new file mode 100644 index 00000000..241bba8c --- /dev/null +++ b/lib/rspec_api_documentation/open_api/path.rb @@ -0,0 +1,13 @@ +module RspecApiDocumentation + module OpenApi + class Path < Node + add_setting :get, :schema => Operation + add_setting :put, :schema => Operation + add_setting :post, :schema => Operation + add_setting :delete, :schema => Operation + add_setting :options, :schema => Operation + add_setting :head, :schema => Operation + add_setting :patch, :schema => Operation + end + end +end diff --git a/lib/rspec_api_documentation/open_api/paths.rb b/lib/rspec_api_documentation/open_api/paths.rb new file mode 100644 index 00000000..b3a9efb1 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/paths.rb @@ -0,0 +1,7 @@ +module RspecApiDocumentation + module OpenApi + class Paths < Node + CHILD_CLASS = Path + end + end +end diff --git a/lib/rspec_api_documentation/open_api/response.rb b/lib/rspec_api_documentation/open_api/response.rb new file mode 100644 index 00000000..6584db6f --- /dev/null +++ b/lib/rspec_api_documentation/open_api/response.rb @@ -0,0 +1,10 @@ +module RspecApiDocumentation + module OpenApi + class Response < Node + add_setting :description, :required => true, :default => 'Successful operation' + add_setting :schema, :schema => Schema + add_setting :headers, :schema => Headers + add_setting :examples, :schema => Example + end + end +end diff --git a/lib/rspec_api_documentation/open_api/responses.rb b/lib/rspec_api_documentation/open_api/responses.rb new file mode 100644 index 00000000..4b8c7025 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/responses.rb @@ -0,0 +1,9 @@ +module RspecApiDocumentation + module OpenApi + class Responses < Node + CHILD_CLASS = Response + + add_setting :default, :default => lambda { |responses| responses.existing_settings.size > 1 ? nil : Response.new } + end + end +end diff --git a/lib/rspec_api_documentation/open_api/root.rb b/lib/rspec_api_documentation/open_api/root.rb new file mode 100644 index 00000000..edaeae96 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/root.rb @@ -0,0 +1,21 @@ +module RspecApiDocumentation + module OpenApi + class Root < Node + add_setting :swagger, :default => '2.0', :required => true + add_setting :info, :default => Info.new, :required => true, :schema => Info + add_setting :host, :default => 'localhost:3000' + add_setting :basePath + add_setting :schemes, :default => %w(http https) + add_setting :consumes, :default => %w(application/json application/xml) + add_setting :produces, :default => %w(application/json application/xml) + add_setting :paths, :default => Paths.new, :required => true, :schema => Paths + add_setting :definitions + add_setting :parameters + add_setting :responses + add_setting :securityDefinitions, :schema => SecurityDefinitions + add_setting :security + add_setting :tags, :default => [], :schema => [Tag] + add_setting :externalDocs + end + end +end diff --git a/lib/rspec_api_documentation/open_api/schema.rb b/lib/rspec_api_documentation/open_api/schema.rb new file mode 100644 index 00000000..c632c12f --- /dev/null +++ b/lib/rspec_api_documentation/open_api/schema.rb @@ -0,0 +1,15 @@ +module RspecApiDocumentation + module OpenApi + class Schema < Node + add_setting :format + add_setting :title + add_setting :description, :default => '' + add_setting :required + add_setting :enum + add_setting :type + add_setting :items + add_setting :properties + add_setting :example + end + end +end diff --git a/lib/rspec_api_documentation/open_api/security_definitions.rb b/lib/rspec_api_documentation/open_api/security_definitions.rb new file mode 100644 index 00000000..e1ddc136 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/security_definitions.rb @@ -0,0 +1,7 @@ +module RspecApiDocumentation + module OpenApi + class SecurityDefinitions < Node + CHILD_CLASS = SecuritySchema + end + end +end diff --git a/lib/rspec_api_documentation/open_api/security_schema.rb b/lib/rspec_api_documentation/open_api/security_schema.rb new file mode 100644 index 00000000..a1ba5f05 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/security_schema.rb @@ -0,0 +1,14 @@ +module RspecApiDocumentation + module OpenApi + class SecuritySchema < Node + add_setting :type, :required => true + add_setting :description, :default => '' + add_setting :name + add_setting :in + add_setting :flow + add_setting :authorizationUrl + add_setting :tokenUrl + add_setting :scopes + end + end +end diff --git a/lib/rspec_api_documentation/open_api/tag.rb b/lib/rspec_api_documentation/open_api/tag.rb new file mode 100644 index 00000000..6c8a82d8 --- /dev/null +++ b/lib/rspec_api_documentation/open_api/tag.rb @@ -0,0 +1,9 @@ +module RspecApiDocumentation + module OpenApi + class Tag < Node + add_setting :name, :required => true + add_setting :description, :default => '' + add_setting :externalDocs + end + end +end diff --git a/lib/rspec_api_documentation/writers/open_api_writer.rb b/lib/rspec_api_documentation/writers/open_api_writer.rb new file mode 100644 index 00000000..ce45080c --- /dev/null +++ b/lib/rspec_api_documentation/writers/open_api_writer.rb @@ -0,0 +1,244 @@ +require 'rspec_api_documentation/writers/formatter' +require 'yaml' + +module RspecApiDocumentation + module Writers + class OpenApiWriter < Writer + FILENAME = 'open_api' + + delegate :docs_dir, :configurations_dir, to: :configuration + + def write + File.open(docs_dir.join("#{FILENAME}.json"), 'w+') do |f| + f.write Formatter.to_json(OpenApiIndex.new(index, configuration, load_config)) + end + end + + private + + def load_config + return JSON.parse(File.read("#{configurations_dir}/open_api.json")) if File.exist?("#{configurations_dir}/open_api.json") + YAML.load_file("#{configurations_dir}/open_api.yml") if File.exist?("#{configurations_dir}/open_api.yml") + end + end + + class OpenApiIndex + attr_reader :index, :configuration, :init_config + + def initialize(index, configuration, init_config) + @index = index + @configuration = configuration + @init_config = init_config + end + + def as_json + @specs = OpenApi::Root.new(init_config) + add_tags! + add_paths! + add_security_definitions! + specs.as_json + end + + private + + attr_reader :specs + + def examples + index.examples.map { |example| OpenApiExample.new(example) } + end + + def add_security_definitions! + security_definitions = OpenApi::SecurityDefinitions.new + + arr = examples.map do |example| + example.respond_to?(:authentications) ? example.authentications : nil + end.compact + + arr.each do |securities| + securities.each do |security, opts| + schema = OpenApi::SecuritySchema.new( + name: opts[:name], + description: opts[:description], + type: opts[:type], + in: opts[:in] + ) + security_definitions.add_setting security, :value => schema + end + end + specs.securityDefinitions = security_definitions unless arr.empty? + end + + def add_tags! + tags = {} + examples.each do |example| + tags[example.resource_name] ||= example.resource_explanation + end + specs.safe_assign_setting(:tags, []) + tags.each do |name, desc| + specs.tags << OpenApi::Tag.new(name: name, description: desc) unless specs.tags.any? { |tag| tag.name == name } + end + end + + def add_paths! + specs.safe_assign_setting(:paths, OpenApi::Paths.new) + examples.each do |example| + specs.paths.add_setting example.route, :value => OpenApi::Path.new + + operation = specs.paths.setting(example.route).setting(example.http_method) || OpenApi::Operation.new + + operation.safe_assign_setting(:tags, [example.resource_name]) + operation.safe_assign_setting(:summary, example.respond_to?(:route_summary) ? example.route_summary : '') + operation.safe_assign_setting(:description, example.respond_to?(:route_description) ? example.route_description : '') + operation.safe_assign_setting(:responses, OpenApi::Responses.new) + operation.safe_assign_setting(:parameters, extract_parameters(example)) + operation.safe_assign_setting(:consumes, example.requests.map { |request| request[:request_content_type] }.compact.map { |q| q[/[^;]+/] }) + operation.safe_assign_setting(:produces, example.requests.map { |request| request[:response_content_type] }.compact.map { |q| q[/[^;]+/] }) + operation.safe_assign_setting(:security, example.respond_to?(:authentications) ? example.authentications.map { |(k, _)| {k => []} } : []) + + process_responses(operation.responses, example) + + specs.paths.setting(example.route).assign_setting(example.http_method, operation) + end + end + + def process_responses(responses, example) + schema = extract_schema(example.respond_to?(:response_fields) ? example.response_fields : []) + example.requests.each do |request| + response = OpenApi::Response.new( + description: example.description, + schema: schema + ) + + if request[:response_headers] + response.safe_assign_setting(:headers, OpenApi::Headers.new) + request[:response_headers].each do |header, value| + response.headers.add_setting header, :value => OpenApi::Header.new('x-example-value' => value) + end + end + + if /\A(?[^;]+)/ =~ request[:response_content_type] + response.safe_assign_setting(:examples, OpenApi::Example.new) + response_body = JSON.parse(request[:response_body]) rescue nil + response.examples.add_setting response_content_type, :value => response_body + end + responses.add_setting "#{request[:response_status]}", :value => response + end + end + + def extract_schema(fields) + schema = {type: 'object', properties: {}} + + fields.each do |field| + current = schema + if field[:scope] + [*field[:scope]].each do |scope| + current[:properties][scope] ||= {type: 'object', properties: {}} + current = current[:properties][scope] + end + end + current[:properties][field[:name]] = {type: field[:type] || OpenApi::Helper.extract_type(field[:value])} + current[:properties][field[:name]][:example] = field[:value] if field[:value] && field[:with_example] + current[:properties][field[:name]][:default] = field[:default] if field[:default] + current[:properties][field[:name]][:description] = field[:description] if field[:description] + + opts = {enum: field[:enum], minimum: field[:minimum], maximum: field[:maximum]} + + if current[:properties][field[:name]][:type] == :array + current[:properties][field[:name]][:items] = field[:items] || OpenApi::Helper.extract_items(field[:value][0], opts) + else + opts.each { |k, v| current[:properties][field[:name]][k] = v if v } + end + + current[:required] ||= [] << field[:name] if field[:required] + end + + OpenApi::Schema.new(schema) + end + + def extract_parameters(example) + extract_known_parameters(example.extended_parameters.select { |p| !p[:in].nil? }) + + extract_unknown_parameters(example, example.extended_parameters.select { |p| p[:in].nil? }) + end + + def extract_parameter(opts) + OpenApi::Parameter.new( + name: opts[:name], + in: opts[:in], + description: opts[:description], + required: opts[:required], + type: opts[:type] || OpenApi::Helper.extract_type(opts[:value]), + value: opts[:value], + with_example: opts[:with_example], + default: opts[:default], + ).tap do |elem| + if elem.type == :array + elem.items = opts[:items] || OpenApi::Helper.extract_items(opts[:value][0], { minimum: opts[:minimum], maximum: opts[:maximum], enum: opts[:enum] }) + else + elem.minimum = opts[:minimum] + elem.maximum = opts[:maximum] + elem.enum = opts[:enum] + end + end + end + + def extract_unknown_parameters(example, parameters) + if example.http_method == :get + parameters.map { |parameter| extract_parameter(parameter.merge(in: :query)) } + elsif parameters.any? { |parameter| !parameter[:scope].nil? } + [OpenApi::Parameter.new( + name: :body, + in: :body, + description: '', + schema: extract_schema(parameters) + )] + else + parameters.map { |parameter| extract_parameter(parameter.merge(in: :formData)) } + end + end + + def extract_known_parameters(parameters) + result = parameters.select { |parameter| %w(query path header formData).include?(parameter[:in].to_s) } + .map { |parameter| extract_parameter(parameter) } + + body = parameters.select { |parameter| %w(body).include?(parameter[:in].to_s) } + + result.unshift( + OpenApi::Parameter.new( + name: :body, + in: :body, + description: '', + schema: extract_schema(body) + ) + ) unless body.empty? + + result + end + end + + class OpenApiExample + def initialize(example) + @example = 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 http_method + metadata[:method] + end + + def requests + super.select { |request| request[:request_method].to_s.downcase == http_method.to_s.downcase } + end + + def route + super.gsub(/:(?[^\/]+)/, '{\k}') + end + end + end +end diff --git a/spec/dsl_spec.rb b/spec/dsl_spec.rb index 94253414..46430107 100644 --- a/spec/dsl_spec.rb +++ b/spec/dsl_spec.rb @@ -364,9 +364,22 @@ context "#explanation" do post "/orders" do + route_summary "Route summary" + route_description "Route description" + example "Creating an order" do |example| explanation "By creating an order..." expect(example.metadata[:explanation]).to eq("By creating an order...") + expect(example.metadata[:route_summary]).to eq("Route summary") + expect(example.metadata[:route_description]).to eq("Route description") + end + + context "Nested context" do + example "Inner example" do |example| + expect(example.metadata[:explanation]).to be_nil + expect(example.metadata[:route_summary]).to eq("Route summary") + expect(example.metadata[:route_description]).to eq("Route description") + end end end end @@ -573,6 +586,77 @@ end end + context "authentications" do + put "/orders" do + authentication :apiKey, "Api Key", :name => "API_AUTH" + authentication :basic, "Api Key" + + it "should be sent with the request" do |example| + expect(example.metadata[:authentications]).to eq( + { + "API_AUTH" => { + :in => :header, + :type => :apiKey, + :name => "API_AUTH" + }, + "Authorization" => { + :type => :basic + } + }) + end + + context "nested authentications" do + authentication :apiKey, "Api Key", :name => "API_AUTH" + + it "does not affect the outer context's assertions" do + # pass + end + end + end + + put "/orders" do + context "setting authentication in example level" do + before do + authentication :apiKey, "Api Key", :name => "API_AUTH" + end + + it "adds to headers" do |example| + expect(example.metadata[:authentications]).to eq({"API_AUTH" => { + :in => :header, + :type => :apiKey, + :name => "API_AUTH" + }}) + end + end + end + + put "/orders" do + authentication :apiKey, :api_key, :name => "API_AUTH" + + let(:api_key) { "API_KEY_TOKEN" } + + it "should be sent with the request" do |example| + expect(example.metadata[:authentications]).to eq({"API_AUTH" => { + :in => :header, + :type => :apiKey, + :name => "API_AUTH" + }}) + end + + it "should fill out into the headers" do + expect(headers).to eq({ "API_AUTH" => "API_KEY_TOKEN" }) + end + + context "nested authentications" do + authentication :apiKey, :api_key, :name => "API_AUTH" + + it "does not affect the outer context's assertions" do + expect(headers).to eq({ "API_AUTH" => "API_KEY_TOKEN" }) + end + end + end + end + context "post body formatter" do after do RspecApiDocumentation.instance_variable_set(:@configuration, RspecApiDocumentation::Configuration.new) @@ -671,8 +755,30 @@ end end end + + get "parameter with custom method only" do + parameter :custom, "Custom name parameter", method: :custom_method, scope: :some + + context do + let(:custom) { "Should not be taken" } + let(:some_custom) { "Should not be taken" } + + it "not uses custom as value" do + expect(params).to eq({}) + end + end + + context do + let(:custom_method) { "Should be taken" } + + it "uses custom_method as value" do + expect(params).to eq("some" => {"custom" => "Should be taken"}) + end + end + end end + resource "top level parameters" do parameter :page, "Current page" diff --git a/spec/fixtures/open_api.yml b/spec/fixtures/open_api.yml new file mode 100644 index 00000000..6ba6ab9d --- /dev/null +++ b/spec/fixtures/open_api.yml @@ -0,0 +1,296 @@ +swagger: '2.0' +info: + title: OpenAPI App + description: This is a sample server Petstore server. + termsOfService: 'http://open-api.io/terms/' + contact: + name: API Support + url: 'http://www.open-api.io/support' + email: support@open-api.io + license: + name: Apache 2.0 + url: 'http://www.apache.org/licenses/LICENSE-2.0.html' + version: 1.0.1 +host: 'localhost:3000' +schemes: + - http + - https +consumes: + - application/json + - application/xml +produces: + - application/json + - application/xml +paths: + /orders: + get: + tags: + - Orders + summary: Getting a list of orders + description: '' + consumes: + - application/json + produces: + - application/json + parameters: + - name: page + in: query + description: Current page of orders + required: false + type: integer + responses: + '200': + description: OK + schema: + description: '' + type: object + properties: {} + headers: {} + examples: + application/json: + - id: 1 + name: Old Name + paid: true + email: email@example.com + created_at: '2017-06-12T14:14:50.481Z' + updated_at: '2017-06-12T14:14:50.481Z' + - id: 2 + name: Old Name + paid: true + email: email@example.com + created_at: '2017-06-12T14:14:56.938Z' + updated_at: '2017-06-12T14:14:56.938Z' + - id: 3 + name: Order 0 + paid: true + email: email0@example.com + created_at: '2017-06-13T13:17:38.719Z' + updated_at: '2017-06-13T13:17:38.719Z' + - id: 4 + name: Order 1 + paid: true + email: email1@example.com + created_at: '2017-06-13T13:17:38.729Z' + updated_at: '2017-06-13T13:17:38.729Z' + deprecated: false + security: + - AUTH_TOKEN: [] + post: + tags: + - Orders + summary: Creating an order + description: '' + consumes: + - application/json + produces: + - application/json + parameters: + - name: body + in: body + description: '' + required: false + schema: + description: '' + type: object + properties: + order: + type: object + properties: + name: + type: string + paid: + type: boolean + email: + type: string + required: + - name + responses: + '201': + description: Created + schema: + description: '' + type: object + properties: + order: + type: object + properties: + name: + type: string + paid: + type: boolean + email: + type: string + headers: {} + examples: + application/json: + id: 3 + name: Order 1 + paid: true + email: email@example.com + created_at: '2017-06-13T13:17:38.825Z' + updated_at: '2017-06-13T13:17:38.825Z' + deprecated: false + security: [] + head: + tags: + - Orders + summary: Getting the headers + description: '' + consumes: + - application/json + produces: + - application/json + parameters: [] + responses: + '200': + description: OK + schema: + description: '' + type: object + properties: {} + headers: {} + examples: {} + deprecated: false + security: + - AUTH_TOKEN: [] + '/orders/{id}': + get: + tags: + - Orders + summary: Getting a specific order + description: '' + consumes: + - application/json + produces: + - application/json + parameters: + - name: id + in: path + description: '' + required: true + type: integer + responses: + '200': + description: OK + schema: + description: '' + type: object + properties: {} + headers: {} + examples: + application/json: + id: 3 + name: Old Name + paid: true + email: email@example.com + created_at: '2017-06-13T13:17:38.862Z' + updated_at: '2017-06-13T13:17:38.862Z' + deprecated: false + security: [] + put: + tags: + - Orders + summary: Updating an order + description: '' + consumes: + - application/json + produces: [] + parameters: + - name: id + in: path + description: '' + required: true + type: integer + - name: body + in: body + description: '' + required: false + schema: + description: '' + type: object + properties: + order: + type: object + properties: + name: + type: string + paid: + type: string + email: + type: string + responses: + '204': + description: No Content + schema: + description: '' + type: object + properties: {} + headers: {} + deprecated: false + security: [] + delete: + tags: + - Orders + summary: Deleting an order + description: '' + consumes: + - application/json + produces: [] + parameters: + - name: id + in: path + description: '' + required: true + type: integer + responses: + '204': + description: No Content + schema: + description: '' + type: object + properties: {} + headers: {} + deprecated: false + security: [] + /uploads: + post: + tags: + - Uploads + summary: Uploading a new file + description: '' + consumes: + - multipart/form-data + produces: + - text/html + parameters: + - name: file + in: formData + description: New file to upload + required: false + type: file + responses: + '201': + description: Created + schema: + description: '' + type: object + properties: {} + headers: {} + examples: {} + deprecated: false + security: + - Authorization: [] +securityDefinitions: + AUTH_TOKEN: + type: apiKey + description: '' + name: AUTH_TOKEN + in: header + Authorization: + type: basic + description: Api Key description +tags: + - name: Orders + description: Orders are top-level business objects + - name: Uploads + description: '' diff --git a/spec/open_api/contact_spec.rb b/spec/open_api/contact_spec.rb new file mode 100644 index 00000000..8de23ae0 --- /dev/null +++ b/spec/open_api/contact_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +describe RspecApiDocumentation::OpenApi::Contact do + let(:node) { RspecApiDocumentation::OpenApi::Contact.new } + subject { node } + + describe "default settings" do + its(:name) { should == 'API Support' } + its(:url) { should == 'http://www.open-api.io/support' } + its(:email) { should == 'support@open-api.io' } + end +end diff --git a/spec/open_api/info_spec.rb b/spec/open_api/info_spec.rb new file mode 100644 index 00000000..e54993c2 --- /dev/null +++ b/spec/open_api/info_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe RspecApiDocumentation::OpenApi::Info do + let(:node) { RspecApiDocumentation::OpenApi::Info.new } + subject { node } + + describe "default settings" do + class RspecApiDocumentation::OpenApi::Contact; end + class RspecApiDocumentation::OpenApi::License; end + + its(:title) { should == 'OpenAPI Specification' } + its(:description) { should == 'This is a sample server Petstore server.' } + its(:termsOfService) { should == 'http://open-api.io/terms/' } + its(:contact) { should be_a(RspecApiDocumentation::OpenApi::Contact) } + its(:license) { should be_a(RspecApiDocumentation::OpenApi::License) } + its(:version) { should == '1.0.0' } + end +end diff --git a/spec/open_api/license_spec.rb b/spec/open_api/license_spec.rb new file mode 100644 index 00000000..7fb887e3 --- /dev/null +++ b/spec/open_api/license_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe RspecApiDocumentation::OpenApi::License do + let(:node) { RspecApiDocumentation::OpenApi::License.new } + subject { node } + + describe "default settings" do + its(:name) { should == 'Apache 2.0' } + its(:url) { should == 'http://www.apache.org/licenses/LICENSE-2.0.html' } + end +end diff --git a/spec/open_api/node_spec.rb b/spec/open_api/node_spec.rb new file mode 100644 index 00000000..1e6db70e --- /dev/null +++ b/spec/open_api/node_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe RspecApiDocumentation::OpenApi::Node do + let(:node) { RspecApiDocumentation::OpenApi::Node.new } + its(:settings) { should == {} } + + describe ".add_setting" do + it "should allow creating a new setting" do + RspecApiDocumentation::OpenApi::Node.add_setting :new_setting + expect(node).to respond_to(:new_setting) + expect(node).to respond_to(:new_setting=) + end + + it "should allow setting a default" do + RspecApiDocumentation::OpenApi::Node.add_setting :new_setting, :default => "default" + expect(node.new_setting).to eq("default") + end + + it "should allow the default setting to be a lambda" do + RspecApiDocumentation::OpenApi::Node.add_setting :another_setting, :default => lambda { |config| config.new_setting } + expect(node.another_setting).to eq("default") + end + + it "should allow setting a schema" do + RspecApiDocumentation::OpenApi::Node.add_setting :schema_setting, :schema => String + expect(node.schema_setting_schema).to eq(String) + end + + context "setting can be required" do + it "should raise error without value and default option" do + RspecApiDocumentation::OpenApi::Node.add_setting :required_setting, :required => true + expect { node.required_setting }.to raise_error RuntimeError + end + + it "should not raise error with default option" do + RspecApiDocumentation::OpenApi::Node.add_setting :required_setting, :required => true, :default => "value" + expect(node.required_setting).to eq("value") + end + + it "should not raise error with value and without default option" do + RspecApiDocumentation::OpenApi::Node.add_setting :required_setting, :required => true + node.required_setting = "value" + expect(node.required_setting).to eq("value") + end + end + end +end diff --git a/spec/open_api/root_spec.rb b/spec/open_api/root_spec.rb new file mode 100644 index 00000000..52debbbf --- /dev/null +++ b/spec/open_api/root_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' +require 'yaml' +require 'json' + +describe RspecApiDocumentation::OpenApi::Root do + let(:node) { RspecApiDocumentation::OpenApi::Root.new } + subject { node } + + describe "default settings" do + class RspecApiDocumentation::OpenApi::Info; end + class RspecApiDocumentation::OpenApi::Paths; end + + its(:swagger) { should == '2.0' } + its(:info) { should be_a(RspecApiDocumentation::OpenApi::Info) } + its(:host) { should == 'localhost:3000' } + its(:basePath) { should be_nil } + its(:schemes) { should == %w(http https) } + its(:consumes) { should == %w(application/json application/xml) } + its(:produces) { should == %w(application/json application/xml) } + its(:paths) { should be_a(RspecApiDocumentation::OpenApi::Paths) } + its(:definitions) { should be_nil } + its(:parameters) { should be_nil } + its(:responses) { should be_nil } + its(:securityDefinitions) { should be_nil } + its(:security) { should be_nil } + its(:tags) { should == [] } + its(:externalDocs) { should be_nil } + end + + describe ".new" do + it "should allow initializing from hash" do + hash = YAML.load_file(File.expand_path('../../fixtures/open_api.yml', __FILE__)) + root = described_class.new(hash) + + expect(JSON.parse(JSON.generate(root.as_json))).to eq(hash) + end + end +end diff --git a/spec/writers/open_api_writer_spec.rb b/spec/writers/open_api_writer_spec.rb new file mode 100644 index 00000000..f288c5fc --- /dev/null +++ b/spec/writers/open_api_writer_spec.rb @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +require 'spec_helper' + +describe RspecApiDocumentation::Writers::OpenApiWriter 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 +end