diff --git a/lib/swagger_rails/testing/example_builder.rb b/lib/swagger_rails/testing/example_builder.rb new file mode 100644 index 0000000..8cfcbae --- /dev/null +++ b/lib/swagger_rails/testing/example_builder.rb @@ -0,0 +1,85 @@ +module SwaggerRails + + class ExampleBuilder + attr_reader :expected_status + + def initialize(path_template, http_method, swagger) + @path_template = path_template + @http_method = http_method + @swagger = swagger + @swagger_operation = find_swagger_operation! + @expected_status = find_swagger_success_status! + @param_values = {} + end + + def expect(status) + @expected_status = status + end + + def set(param_values) + @param_values.merge!(param_values.stringify_keys) + end + + def path + @path_template.dup.tap do |template| + template.prepend(@swagger['basePath'].presence || '') + path_params = param_values_for('path') + path_params.each { |name, value| template.sub!("\{#{name}\}", value) } + end + end + + def params + query_params = param_values_for('query') + body_params = param_values_for('body') + query_params.merge(body_params.values.first || {}) + end + + def headers + param_values_for('header') + end + + private + + def find_swagger_operation! + find_swagger_item!('paths', @path_template, @http_method) + end + + def find_swagger_success_status! + path_keys = [ 'paths', @path_template, @http_method, 'responses' ] + responses = find_swagger_item!(*path_keys) + key = responses.keys.find { |k| k.start_with?('2') } + key ? key.to_i : (raise MetadataError.new(path_keys.concat('2xx'))) + end + + def find_swagger_item!(*path_keys) + item = @swagger + path_keys.each do |key| + item = item[key] || (raise MetadataError.new(*path_keys)) + end + item + end + + def param_values_for(location) + params = (@swagger_operation['parameters'] || []).select { |p| p['in'] == location } + Hash[params.map { |param| [ param['name'], value_for(param) ] }] + end + + def value_for(param) + return @param_values[param['name']] if @param_values.has_key?(param['name']) + return param['default'] unless param['in'] == 'body' + schema_for(param['schema'])['example'] + end + + def schema_for(schema_or_ref) + return schema_or_ref if schema_or_ref['$ref'].nil? + @swagger['definitions'][schema_or_ref['$ref'].sub('#/definitions/', '')] + end + end + + class MetadataError < StandardError + def initialize(*path_keys) + path = path_keys.map { |key| "['#{key}']" }.join('') + super("Swagger document is missing expected metadata at #{path}") + end + end +end diff --git a/lib/swagger_rails/testing/test_helpers.rb b/lib/swagger_rails/testing/test_helpers.rb new file mode 100644 index 0000000..517002d --- /dev/null +++ b/lib/swagger_rails/testing/test_helpers.rb @@ -0,0 +1,34 @@ +require 'swagger_rails/testing/test_visitor' + +module SwaggerRails + module TestHelpers + + def self.included(klass) + klass.extend(ClassMethods) + end + + module ClassMethods + attr_reader :test_visitor + + def swagger_doc(swagger_doc) + file_path = File.join(Rails.root, 'config/swagger', swagger_doc) + @swagger = JSON.parse(File.read(file_path)) + @test_visitor = SwaggerRails::TestVisitor.new(@swagger) + end + + def swagger_test_all + @swagger['paths'].each do |path, path_item| + path_item.keys.each do |method| + test "#{path} #{method}" do + swagger_test path, method + end + end + end + end + end + + def swagger_test(path, method) + self.class.test_visitor.run_test(path, method, self) + end + end +end diff --git a/lib/swagger_rails/testing/test_visitor.rb b/lib/swagger_rails/testing/test_visitor.rb new file mode 100644 index 0000000..9e74ad8 --- /dev/null +++ b/lib/swagger_rails/testing/test_visitor.rb @@ -0,0 +1,25 @@ +require 'swagger_rails/testing/example_builder' + +module SwaggerRails + + class TestVisitor + + def initialize(swagger) + @swagger = swagger + end + + def run_test(path_template, http_method, test, &block) + example = ExampleBuilder.new(path_template, http_method, @swagger) + example.instance_exec(&block) if block_given? + + test.send(http_method, + example.path, + example.params, + example.headers + ) + + test.assert_response(example.expected_status) + end + end +end + diff --git a/spec/dummy/app/controllers/application_controller.rb b/spec/dummy/app/controllers/application_controller.rb index d83690e..a4506fc 100644 --- a/spec/dummy/app/controllers/application_controller.rb +++ b/spec/dummy/app/controllers/application_controller.rb @@ -2,4 +2,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 + + wrap_parameters format: [ :json ] end diff --git a/spec/dummy/app/controllers/blogs_controller.rb b/spec/dummy/app/controllers/blogs_controller.rb new file mode 100644 index 0000000..ea51be9 --- /dev/null +++ b/spec/dummy/app/controllers/blogs_controller.rb @@ -0,0 +1,14 @@ +class BlogsController < ApplicationController + + def create + render json: {} + end + + def index + render json: {} + end + + def show + render json: {} + end +end diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb index 56a55cb..f0d61fd 100644 --- a/spec/dummy/config/application.rb +++ b/spec/dummy/config/application.rb @@ -6,7 +6,7 @@ require "action_controller/railtie" require "action_mailer/railtie" require "action_view/railtie" require "sprockets/railtie" -# require "rails/test_unit/railtie" +require "rails/test_unit/railtie" Bundler.require(*Rails.groups) require "swagger_rails" diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 8264b95..2636b39 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -1,4 +1,5 @@ Rails.application.routes.draw do mount SwaggerRails::Engine => '/api-docs' + resources :blogs, only: [ :create, :index, :show ] end diff --git a/spec/dummy/config/swagger/v1/swagger.json b/spec/dummy/config/swagger/v1/swagger.json index e18a96f..d5584c7 100644 --- a/spec/dummy/config/swagger/v1/swagger.json +++ b/spec/dummy/config/swagger/v1/swagger.json @@ -2,43 +2,117 @@ "swagger": "2.0", "info": { "version": "0.0.0", - "title": "[Enter a description for your API here]", - "description": "The docs below are powered by the default swagger.json that gets installed with swagger_rails. You'll need to update it to describe your API. See here for the complete swagger spec - https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md" + "title": "Dummy app for testing swagger_rails" }, "paths": { - "/a/sample/resource": { + "/blogs": { "post": { - "tags": [ - "a/sample/resource" - ], - "description": "Create a new sample resource", + "description": "Creates a new Blog", "parameters": [ { - "name": "body", + "name": "X-Forwarded-For", + "in": "header", + "type": "string", + "default": "client1" + }, + { + "name": "blog", "in": "body", "schema": { - "$ref": "#/definitions/CreateSampleResource" + "$ref": "#/definitions/Blog" } } ], "responses": { "200": { - "description": "Ok" + "description": "Ok", + "schema": { + "$ref": "#/definitions/Blog" + } + }, + "400": { + "description": "Invalid Request", + "schema": { + "$ref": "#/definitions/RequestError" + } + } + } + }, + "get": { + "description": "Searches Bloggs", + "parameters": [ + { + "name": "published", + "in": "query", + "type": "boolean", + "default": "true" + }, + { + "name": "keywords", + "in": "query", + "type": "string", + "default": "Ruby on Rails" + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "$ref": "#/definitions/Blog" + } + } + } + } + + }, + "/blogs/{id}": { + "get": { + "description": "Retrieves a specific Blog by unique ID", + "parameters": [ + { + "name": "id", + "in": "path", + "type": "string", + "default": "123" + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "$ref": "#/definitions/Blog" + } } } } } }, "definitions": { - "CreateSampleResource": { + "Blog": { "properties": { - "name": { + "title": { "type": "string" }, - "date_time": { + "content": { "type": "string", "format": "date-time" } + }, + "example": { + "title": "Test Blog", + "content": "Hello World" + } + }, + "RequestError": { + "type": "object", + "additionalProperties": { + "type": "array", + "item": { + "type": "string" + } + }, + "example": { + "title": [ "is required" ] } } } diff --git a/spec/dummy/test/integration/v1_contract_test.rb b/spec/dummy/test/integration/v1_contract_test.rb new file mode 100644 index 0000000..01f3fc2 --- /dev/null +++ b/spec/dummy/test/integration/v1_contract_test.rb @@ -0,0 +1,9 @@ +require 'test_helper' +require 'swagger_rails/testing/test_helpers' + +class V1ContractTest < ActionDispatch::IntegrationTest + include SwaggerRails::TestHelpers + + swagger_doc 'v1/swagger.json' + swagger_test_all +end diff --git a/spec/dummy/test/test_helper.rb b/spec/dummy/test/test_helper.rb new file mode 100644 index 0000000..8298517 --- /dev/null +++ b/spec/dummy/test/test_helper.rb @@ -0,0 +1,7 @@ +ENV["RAILS_ENV"] = "test" +require File.expand_path('../../config/environment', __FILE__) +require 'rails/test_help' + +class ActiveSupport::TestCase + # Add more helper methods to be used by all tests here... +end diff --git a/spec/testing/example_builder_spec.rb b/spec/testing/example_builder_spec.rb new file mode 100644 index 0000000..be8b73b --- /dev/null +++ b/spec/testing/example_builder_spec.rb @@ -0,0 +1,119 @@ +require 'rails_helper' +require 'swagger_rails/testing/example_builder' + +module SwaggerRails + + describe ExampleBuilder do + subject { described_class.new(path, method, swagger) } + let(:swagger) do + file_path = File.join(Rails.root, 'config/swagger', 'v1/swagger.json') + JSON.parse(File.read(file_path)) + end + + describe '#path' do + context 'operation with path params' do + let(:path) { '/blogs/{id}' } + let(:method) { 'get' } + + context 'by default' do + it "returns path based on 'default' values" do + expect(subject.path).to eq('/blogs/123') + end + end + + context 'values explicitly set' do + before { subject.set id: '456' } + it 'returns path based on set values' do + expect(subject.path).to eq('/blogs/456') + end + end + end + + context 'swagger includes basePath' do + before { swagger['basePath'] = '/foobar' } + let(:path) { '/blogs' } + let(:method) { 'post' } + + it 'returns path prefixed with basePath' do + expect(subject.path).to eq('/foobar/blogs') + end + end + end + + describe '#params' do + context 'operation with body param' do + let(:path) { '/blogs' } + let(:method) { 'post' } + + context 'by default' do + it "returns schema 'example'" do + expect(subject.params).to eq(swagger['definitions']['Blog']['example']) + end + end + + context 'value explicitly set' do + before { subject.set blog: { 'title' => 'foobar' } } + it 'returns params value' do + expect(subject.params).to eq({ 'title' => 'foobar' }) + end + end + end + + context 'operation with query params' do + let(:path) { '/blogs' } + let(:method) { 'get' } + + context 'by default' do + it "returns query params based on 'default' values" do + expect(subject.params).to eq({ 'published' => 'true', 'keywords' => 'Ruby on Rails' }) + end + end + + context 'values explicitly set' do + before { subject.set keywords: 'Java' } + it 'returns query params based on set values' do + expect(subject.params).to eq({ 'published' => 'true', 'keywords' => 'Java' }) + end + end + end + end + + describe '#headers' do + context 'operation with header params' do + let(:path) { '/blogs' } + let(:method) { 'post' } + + context 'by default' do + it "returns headers based on 'default' values" do + expect(subject.headers).to eq({ 'X-Forwarded-For' => 'client1' }) + end + end + + context 'values explicitly params' do + before { subject.set 'X-Forwarded-For' => '192.168.1.1' } + it 'returns headers based on params values' do + expect(subject.headers).to eq({ 'X-Forwarded-For' => '192.168.1.1' }) + end + end + end + end + + describe '#expected_status' do + let(:path) { '/blogs' } + let(:method) { 'post' } + + context 'by default' do + it "returns first 2xx status in 'responses'" do + expect(subject.expected_status).to eq(200) + end + end + + context 'expected status explicitly params' do + before { subject.expect 400 } + it "returns params status" do + expect(subject.expected_status).to eq(400) + end + end + end + end +end diff --git a/spec/testing/test_visitor_spec.rb b/spec/testing/test_visitor_spec.rb new file mode 100644 index 0000000..18ce6ef --- /dev/null +++ b/spec/testing/test_visitor_spec.rb @@ -0,0 +1,61 @@ +require 'rails_helper' +require 'swagger_rails/testing/test_visitor' + +module SwaggerRails + + describe TestVisitor do + subject { described_class.new(swagger) } + let(:swagger) do + file_path = File.join(Rails.root, 'config/swagger', 'v1/swagger.json') + JSON.parse(File.read(file_path)) + end + let(:test) { spy('test') } + + describe '#run_test' do + context 'by default' do + before { subject.run_test('/blogs', 'post', test) } + + it "submits request based on 'default' and 'example' param values" do + expect(test).to have_received(:post).with( + '/blogs', + { 'title' => 'Test Blog', 'content' => 'Hello World' }, + { 'X-Forwarded-For' => 'client1' } + ) + end + + it "asserts response matches first 2xx status in operation 'responses'" do + expect(test).to have_received(:assert_response).with(200) + end + end + + context 'param values explicitly provided' do + before do + subject.run_test('/blogs', 'post', test) do + set blog: { 'title' => 'foobar' } + set 'X-Forwarded-For' => '192.168.1.1' + end + end + + it 'submits a request based on provided param values' do + expect(test).to have_received(:post).with( + '/blogs', + { 'title' => 'foobar' }, + { 'X-Forwarded-For' => '192.168.1.1' } + ) + end + end + + context 'expected status explicitly params' do + before do + subject.run_test('/blogs', 'post', test) do + expect 400 + end + end + + it "asserts response matches params status" do + expect(test).to have_received(:assert_response).with(400) + end + end + end + end +end