diff --git a/README.md b/README.md index 41a511e..73d2164 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,13 @@ To create the Swagger files use the rake task: bundle exec rake swagger ``` +For OpenAPI use: + +``` +bundle exec rake openapi +``` + + Now you can use Swagger UI or the renderer of your choice to display the formatted documentation. [swagger_engine](https://github.com/batdevis/swagger_engine) works pretty well and supports multiple documents. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..6e77343 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,6 @@ +Example of standalone swagger UI with assets loaded from CDN + +How to run: + +1. `cd examples && ruby -run -e httpd . -p 4000` +2. Visit http://localhost:4000/`` diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 0000000..06a844a --- /dev/null +++ b/examples/index.html @@ -0,0 +1,22 @@ + + + + + + Example API + + + + +
+ + + + + diff --git a/examples/swagger.json b/examples/swagger.json new file mode 100644 index 0000000..e930600 --- /dev/null +++ b/examples/swagger.json @@ -0,0 +1,106 @@ +{ + "swagger": "2.0", + "info": { + "title": "API V1", + "version": "v1" + }, + "paths": { + "/posts": { + "get": { + "responses": { + "200": { + "description": "successful" + } + }, + "tags": [ + "context_tag", + "path_tag", + "operation_tag" + ], + "summary": "fetch list", + "produces": [ + "application/json" + ] + }, + "post": { + "responses": { + "201": { + "description": "successfully created", + "content": { + "application/json": { + "schema": { + "example": { + "id": 1, + "title": "asdf", + "body": "blah", + "created_at": "2019-05-27T09:54:55.606Z", + "updated_at": "2019-05-27T09:54:55.606Z" + } + } + } + } + } + }, + "requestBody": { + "required": null, + "content": { + "application/json": { + "schema": { + "foo": "bar" + }, + "examples": { + } + } + } + }, + "parameters": [ + + ], + "tags": [ + "context_tag", + "path_tag" + ], + "summary": "create", + "produces": [ + "application/json" + ] + } + }, + "/posts/{post_id}": { + "parameters": [ + { + "in": "path", + "name": "post_id", + "required": true + } + ], + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "example": { + "id": 1, + "title": null, + "body": null, + "created_at": "2019-05-27T09:54:55.611Z", + "updated_at": "2019-05-27T09:54:55.611Z" + } + } + } + }, + "description": "success" + } + }, + "tags": [ + "context_tag" + ], + "summary": "fetch item", + "produces": [ + "application/json" + ] + } + } + } +} diff --git a/lib/rspec/rails/swagger.rb b/lib/rspec/rails/swagger.rb index 489ab79..932186e 100644 --- a/lib/rspec/rails/swagger.rb +++ b/lib/rspec/rails/swagger.rb @@ -2,6 +2,7 @@ require 'rspec/rails/swagger/configuration' require 'rspec/rails/swagger/document' require 'rspec/rails/swagger/formatter' +require 'rspec/rails/swagger/formatter_open_api' require 'rspec/rails/swagger/helpers' require 'rspec/rails/swagger/response_formatters' require 'rspec/rails/swagger/request_builder' diff --git a/lib/rspec/rails/swagger/formatter_open_api.rb b/lib/rspec/rails/swagger/formatter_open_api.rb new file mode 100644 index 0000000..c284fd3 --- /dev/null +++ b/lib/rspec/rails/swagger/formatter_open_api.rb @@ -0,0 +1,90 @@ +RSpec::Support.require_rspec_core "formatters/base_text_formatter" +RSpec::Support.require_rspec_core "formatters/console_codes" + +require_relative 'formatter' + +module RSpec + module Rails + module Swagger + class FormatterOpenApi < RSpec::Rails::Swagger::Formatter + RSpec::Core::Formatters.register self, :example_group_started, + :example_passed, :example_pending, :example_failed, :example_finished, + :close + + def response_for(operation, swagger_response) + status = swagger_response[:status_code] + + content_type = operation[:consumes] && operation[:consumes][0] || 'application/json' + operation.delete(:consumes) + + operation[:responses][status] ||= {} + operation[:responses][status].tap do |response| + prepare_response_contents(response, swagger_response, content_type) + end + end + + def prepare_response_contents(response, swagger_response, content_type) + if swagger_response[:examples] + schema = swagger_response[:schema] || {} + response[:content] ||= {} + swagger_response[:examples].each_pair do |format, resp| + formatted = ResponseFormatters[format].call(resp) + response[:content][format] ||= {schema: schema.merge(example: formatted)} + end + elsif swagger_response[:schema] + response[:content] = {content_type => {schema: swagger_response[:schema]}} + end + + response.merge!(swagger_response.slice(:description, :headers)) + end + + def path_item_for(document, swagger_path_item) + name = swagger_path_item[:path] + + document[:paths] ||= {} + document[:paths][name] ||= {} + if swagger_path_item[:parameters] + apply_params(document[:paths][name], swagger_path_item[:parameters].dup) + end + document[:paths][name] + end + + def operation_for(path, swagger_operation) + method = swagger_operation[:method] + + path[method] ||= {responses: {}} + path[method].tap do |operation| + if swagger_operation[:parameters] + apply_params(operation, swagger_operation[:parameters].dup) + end + operation.merge!(swagger_operation.slice( + :tags, :summary, :description, :externalDocs, :operationId, + :consumes, :produces, :schemes, :deprecated, :security + )) + end + end + + def apply_params(object, parameters) + body = parameters.delete('body&body') + if body + object[:requestBody] = { + required: body[:required], + content: { + 'application/json' => { + schema: body[:schema], + examples: body[:examples] || {} + } + } + } + end + + object[:parameters] = parameters.values.map do |param| + param.slice(:in, :name, :required, :schema, :description, :style, + :explode, :allowEmptyValue, :example, :examples, :deprecated) + end + end + + end + end + end +end diff --git a/lib/rspec/rails/swagger/tasks/swagger.rake b/lib/rspec/rails/swagger/tasks/swagger.rake index 02840da..d3a202a 100644 --- a/lib/rspec/rails/swagger/tasks/swagger.rake +++ b/lib/rspec/rails/swagger/tasks/swagger.rake @@ -5,3 +5,8 @@ RSpec::Core::RakeTask.new(:swagger) do |t| t.verbose = false t.rspec_opts = "-f RSpec::Rails::Swagger::Formatter --order defined -t swagger_object" end + +RSpec::Core::RakeTask.new(:openapi) do |t| + t.verbose = false + t.rspec_opts = "-f RSpec::Rails::Swagger::FormatterOpenApi --order defined -t swagger_object" +end diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 2456ae2..a36bcb1 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -4,3 +4,4 @@ set -x -e bundle exec rspec # Duplicating the body of the rake task. Need to figure out how to call it directly. bundle exec rspec -f RSpec::Rails::Swagger::Formatter --order defined -t swagger_object +bundle exec rspec -f RSpec::Rails::Swagger::FormatterOpenApi --order defined -t swagger_object diff --git a/spec/rspec/rails/swagger/formatter_openapi_spec.rb b/spec/rspec/rails/swagger/formatter_openapi_spec.rb new file mode 100644 index 0000000..163286d --- /dev/null +++ b/spec/rspec/rails/swagger/formatter_openapi_spec.rb @@ -0,0 +1,273 @@ +require 'swagger_helper' + +RSpec.describe RSpec::Rails::Swagger::FormatterOpenApi do + let(:output) { StringIO.new } + let(:formatter) { described_class.new(output) } + let(:documents) { {'minimal.json' => minimal} } + # Make this a method to bypass rspec's memoization. + def minimal + { + openapi: '3.0.0', + info: { + version: '0.0.0', + title: 'Simple API' + } + } + end + + before do + RSpec.configure {|c| c.swagger_docs = documents } + end + + describe "#example_finished" do + let(:example_notification) { double('Notification', example: double('Example', metadata: metadata)) } + let(:metadata) { {} } + + context "with a single document" do + let(:metadata) do + { + swagger_object: :response, + swagger_path_item: {path: "/ping"}, + swagger_operation: {method: :put}, + swagger_response: {status_code: 200, description: "OK"}, + } + end + + it "copies the requests into the document" do + formatter.example_finished(example_notification) + + expect(formatter.documents.values.first[:paths]).to eq({ + '/ping' => { + put: { + responses: {200 => {description: 'OK'}} + } + } + }) + end + end + + context "with multiple documents" do + let(:documents) { {'doc1.json' => minimal, 'doc2.json' => minimal} } + let(:metadata) do + { + swagger_object: :response, + swagger_doc: 'doc2.json', + swagger_path_item: {path: "/ping"}, + swagger_operation: {method: :put}, + swagger_response: {status_code: 200, description: "OK"}, + } + end + + it "puts the response on the right document" do + formatter.example_finished(example_notification) + + expect(formatter.documents['doc1.json'][:paths]).to be_blank + expect(formatter.documents['doc2.json'][:paths].length).to eq(1) + end + end + + context "with a response examples" do + let(:metadata_examples) { {'application/json' => JSON.dump({foo: :bar})} } + let(:metadata) do + { + swagger_object: :response, + swagger_path_item: {path: "/ping"}, + swagger_operation: {method: :put}, + swagger_response: {status_code: 200, description: "OK", examples: metadata_examples}, + } + end + + shared_examples 'response example formatter' do + it "copies the requests into the document" do + formatter.example_finished(example_notification) + expected_paths = { + '/ping' => { + put: { + responses: { + 200 => { + content: { + "application/json" => { + schema: { + example: output_example + } + } + }, + description: "OK" + } + } + } + } + } + expect(formatter.documents.values.first[:paths]).to eq(expected_paths) + end + end + + context "with a default formatter" do + before(:example) do + RSpec::Rails::Swagger::ResponseFormatters.register( + 'application/json', + RSpec::Rails::Swagger::ResponseFormatters::JSON.new + ) + end + + let(:output_example) { {"foo" => "bar"} } + include_examples 'response example formatter' + end + + context "custom application/json formatter" do + before(:example) do + RSpec::Rails::Swagger::ResponseFormatters.register('application/json', ->(resp) { resp }) + end + + let(:output_example) { JSON.dump({"foo" => "bar"}) } + include_examples 'response example formatter' + end + end + + context "with a response schema" do + let(:metadata) do + { + swagger_object: :response, + swagger_path_item: {path: "/ping"}, + swagger_operation: {method: :put}, + swagger_response: { + status_code: 200, + description: "OK", + schema: { :'$ref' => "#/components/schemas/BasicRequest" } + } + } + end + + it "copies the requests into the document" do + formatter.example_finished(example_notification) + expected_paths = { + '/ping' => { + put: { + responses: { + 200 => { + content: { + "application/json" => { + schema: { :'$ref' => "#/components/schemas/BasicRequest" } + } + }, + description: "OK" + } + } + } + } + } + + expect(formatter.documents.values.first[:paths]).to eq(expected_paths) + end + end + end + + describe "#close" do + let(:blank_notification) { double('Notification') } + + context "no relevant examples" do + it "writes document with no changes" do + expect(formatter).to receive(:write_file).with(documents.keys.first, documents.values.first) + formatter.close(blank_notification) + end + end + + context "with a relevant example" do + let(:example_notification) { double(example: double(metadata: metadata)) } + let(:metadata) do + { + swagger_object: :response, + swagger_path_item: {path: "/ping"}, + swagger_operation: {method: :get, produces: ["application/json"]}, + swagger_response: {status_code: 200, description: 'all good'}, + } + end + + it "writes a document with the request" do + formatter.example_finished(example_notification) + + expect(formatter).to receive(:write_file).with( + documents.keys.first, + documents.values.first.merge({ + paths: { + '/ping' => { + get: { + responses: {200 => {description: 'all good'}}, + produces: ["application/json"] + } + } + } + }) + ) + + formatter.close(blank_notification) + end + + describe 'output formats' do + let(:documents) { {file_name => minimal} } + + subject do + formatter.example_finished(example_notification) + formatter.close(blank_notification) + Pathname(file_name).expand_path(::RSpec.configuration.swagger_root).read + end + + %w(yaml yml).each do |extension| + context "with a name that ends in .#{extension}" do + let(:file_name) { "minimal.#{extension}" } + + it 'outputs YAML' do + expect(subject).to eq < { + openapi: '3.0.0', + info: { + title: 'API V1', + version: 'v1' + } } } end