diff --git a/README.rst b/README.rst index 003d6c99..3eb5ba90 100644 --- a/README.rst +++ b/README.rst @@ -842,6 +842,77 @@ The concern needs to be included to the controller after the methods are defined (either at the end of the class, or by using ``Controller.send(:include, Concerns::OauthConcern)``. + +Response validation +------------------- + +The swagger definitions created by Apipie can be used to auto-generate clients that access the +described APIs. Those clients will break if the responses returned from the API do not match +the declarations. As such, it is very important to include unit tests that validate the actual +responses against the swagger definitions. + +The implemented mechanism provides two ways to include such validations in RSpec unit tests: +manual (using an RSpec matcher) and automated (by injecting a test into the http operations 'get', 'post', +raising an error if there is no match). + +Example of the manual mechanism: +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: ruby + + require 'apipie/rspec/response_validation_helper' + + RSpec.describe MyController, :type => :controller, :show_in_doc => true do + + describe "GET stuff with response validation" do + render_views # this makes sure the 'get' operation will actually + # return the rendered view even though this is a Controller spec + + it "does something" do + response = get :index, {format: :json} + + # the following expectation will fail if the returned object + # does not match the 'returns' declaration in the Controller, + # or if there is no 'returns' declaration for the returned + # HTTP status code + expect(response).to match_declared_responses + end + end + end + + +Example of the automated mechanism: +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: ruby + + require 'apipie/rspec/response_validation_helper' + + RSpec.describe MyController, :type => :controller, :show_in_doc => true do + + describe "GET stuff with response validation" do + render_views + auto_validate_rendered_views + + it "does something" do + get :index, {format: :json} + end + it "does something else" do + get :another_index, {format: :json} + end + end + + describe "GET stuff without response validation" do + it "does something" do + get :index, {format: :json} + end + it "does something else" do + get :another_index, {format: :json} + end + end + end + + ========================= Configuration Reference ========================= diff --git a/lib/apipie/apipie_module.rb b/lib/apipie/apipie_module.rb index 016dc7e6..8711cf1d 100644 --- a/lib/apipie/apipie_module.rb +++ b/lib/apipie/apipie_module.rb @@ -18,6 +18,17 @@ def self.to_swagger_json(version = nil, resource_name = nil, method_name = nil, app.to_swagger_json(version, resource_name, method_name, lang, clear_warnings) end + def self.json_schema_for_method_response(controller_name, method_name, return_code, allow_nulls) + # note: this does not support versions (only the default version is queried)! + version ||= Apipie.configuration.default_version + app.json_schema_for_method_response(version, controller_name, method_name, return_code, allow_nulls) + end + + def self.json_schema_for_self_describing_class(cls, allow_nulls=true) + app.json_schema_for_self_describing_class(cls, allow_nulls) + end + + # all calls delegated to Apipie::Application instance def self.method_missing(method, *args, &block) app.respond_to?(method) ? app.send(method, *args, &block) : super diff --git a/lib/apipie/application.rb b/lib/apipie/application.rb index 3d615adc..ee4c8f83 100644 --- a/lib/apipie/application.rb +++ b/lib/apipie/application.rb @@ -255,6 +255,16 @@ def reload_examples @recorded_examples = nil end + def json_schema_for_method_response(version, controller_name, method_name, return_code, allow_nulls) + method = @resource_descriptions[version][controller_name].method_description(method_name) + raise NoDocumentedMethod.new(controller_name, method_name) if method.nil? + @swagger_generator.json_schema_for_method_response(method, return_code, allow_nulls) + end + + def json_schema_for_self_describing_class(cls, allow_nulls) + @swagger_generator.json_schema_for_self_describing_class(cls, allow_nulls) + end + def to_swagger_json(version, resource_name, method_name, lang, clear_warnings=false) return unless valid_search_args?(version, resource_name, method_name) diff --git a/lib/apipie/configuration.rb b/lib/apipie/configuration.rb index 407218f7..83048cf0 100644 --- a/lib/apipie/configuration.rb +++ b/lib/apipie/configuration.rb @@ -11,13 +11,14 @@ class Configuration :persist_show_in_doc, :authorize, :swagger_include_warning_tags, :swagger_content_type_input, :swagger_json_input_uses_refs, :swagger_suppress_warnings, :swagger_api_host, :swagger_generate_x_computed_id_field, - :swagger_allow_additional_properties_in_response + :swagger_allow_additional_properties_in_response, :swagger_responses_use_refs alias_method :validate?, :validate alias_method :required_by_default?, :required_by_default alias_method :namespaced_resources?, :namespaced_resources alias_method :swagger_include_warning_tags?, :swagger_include_warning_tags alias_method :swagger_json_input_uses_refs?, :swagger_json_input_uses_refs + alias_method :swagger_responses_use_refs?, :swagger_responses_use_refs alias_method :swagger_generate_x_computed_id_field?, :swagger_generate_x_computed_id_field # matcher to be used in Dir.glob to find controllers to be reloaded e.g. @@ -179,6 +180,7 @@ def initialize @swagger_api_host = "localhost:3000" @swagger_generate_x_computed_id_field = false @swagger_allow_additional_properties_in_response = false + @swagger_responses_use_refs = true end end end diff --git a/lib/apipie/errors.rb b/lib/apipie/errors.rb index a4cdf8e5..e3df6c33 100644 --- a/lib/apipie/errors.rb +++ b/lib/apipie/errors.rb @@ -57,4 +57,30 @@ def to_s "Invalid parameter '#{@param}' value #{@value.inspect}: #{@error}" end end + + class ResponseDoesNotMatchSwaggerSchema < Error + def initialize(controller_name, method_name, response_code, error_messages, schema, returned_object) + @controller_name = controller_name + @method_name = method_name + @response_code = response_code + @error_messages = error_messages + @schema = schema + @returned_object = returned_object + end + + def to_s + "Response does not match swagger schema (#{@controller_name}##{@method_name} #{@response_code}): #{@error_messages}\nSchema: #{JSON(@schema)}\nReturned object: #{@returned_object}" + end + end + + class NoDocumentedMethod < Error + def initialize(controller_name, method_name) + @method_name = method_name + @controller_name = controller_name + end + + def to_s + "There is no documented method #{@controller_name}##{@method_name}" + end + end end diff --git a/lib/apipie/response_description.rb b/lib/apipie/response_description.rb index 753caa13..bb90b9ab 100644 --- a/lib/apipie/response_description.rb +++ b/lib/apipie/response_description.rb @@ -5,13 +5,14 @@ class ResponseObject include Apipie::DSL::Base include Apipie::DSL::Param - attr_accessor :additional_properties + attr_accessor :additional_properties, :typename - def initialize(method_description, scope, block) + def initialize(method_description, scope, block, typename) @method_description = method_description @scope = scope @param_group = {scope: scope} @additional_properties = false + @typename = typename self.instance_exec(&block) if block @@ -67,6 +68,11 @@ def is_array? @is_array_of != false end + def typename + @response_object.typename + end + + def initialize(method_description, code, options, scope, block, adapter) @type_ref = options[:param_group] @@ -93,7 +99,7 @@ def initialize(method_description, code, options, scope, block, adapter) if adapter @response_object = adapter else - @response_object = ResponseObject.new(method_description, scope, block) + @response_object = ResponseObject.new(method_description, scope, block, @type_ref) end @response_object.additional_properties ||= options[:additional_properties] diff --git a/lib/apipie/response_description_adapter.rb b/lib/apipie/response_description_adapter.rb index 02e012b0..069a2fa0 100644 --- a/lib/apipie/response_description_adapter.rb +++ b/lib/apipie/response_description_adapter.rb @@ -147,18 +147,19 @@ def validator class ResponseDescriptionAdapter def self.from_self_describing_class(cls) - adapter = ResponseDescriptionAdapter.new + adapter = ResponseDescriptionAdapter.new(cls.to_s) props = cls.describe_own_properties adapter.add_property_descriptions(props) adapter end - def initialize + def initialize(typename) @property_descs = [] @additional_properties = false + @typename = typename end - attr_accessor :additional_properties + attr_accessor :additional_properties, :typename def allow_additional_properties additional_properties diff --git a/lib/apipie/rspec/response_validation_helper.rb b/lib/apipie/rspec/response_validation_helper.rb new file mode 100644 index 00000000..a0e5bdc0 --- /dev/null +++ b/lib/apipie/rspec/response_validation_helper.rb @@ -0,0 +1,194 @@ +#---------------------------------------------------------------------------------------------- +# response_validation_helper.rb: +# +# this is an rspec utility to allow validation of responses against the swagger schema generated +# from the Apipie 'returns' definition for the call. +# +# +# to use this file in a controller rspec you should +# require 'apipie/rspec/response_validation_helper' in the spec file +# +# +# this utility provides two mechanisms: matcher-based validation and auto-validation +# +# matcher-based: an rspec matcher allowing 'expect(response).to match_declared_responses' +# auto-validation: all responses returned from 'get', 'post', etc. are automatically tested +# +# =================================== +# Matcher-based validation - example +# =================================== +# Assume the file 'my_controller_spec.rb': +# +# require 'apipie/rspec/response_validation_helper' +# +# RSpec.describe MyController, :type => :controller, :show_in_doc => true do +# +# describe "GET stuff with response validation" do +# render_views # this makes sure the 'get' operation will actually +# # return the rendered view even though this is a Controller spec +# +# it "does something" do +# response = get :index, {format: :json} +# +# # the following expectation will fail if the returned object +# # does not match the 'returns' declaration in the Controller, +# # or if there is no 'returns' declaration for the returned +# # HTTP status code +# expect(response).to match_declared_responses +# end +# end +# +# +# =================================== +# Auto-validation +# =================================== +# To use auto-validation, at the beginning of the block in which you want to turn on validation: +# -) turn on view rendering (by stating 'render_views') +# -) turn on response validation by stating 'auto_validate_rendered_views' +# +# For example, assume the file 'my_controller_spec.rb': +# +# require 'apipie/rspec/response_validation_helper' +# +# RSpec.describe MyController, :type => :controller, :show_in_doc => true do +# +# describe "GET stuff with response validation" do +# render_views +# auto_validate_rendered_views +# +# it "does something" do +# get :index, {format: :json} +# end +# it "does something else" do +# get :another_index, {format: :json} +# end +# end +# +# describe "GET stuff without response validation" do +# it "does something" do +# get :index, {format: :json} +# end +# it "does something else" do +# get :another_index, {format: :json} +# end +# end +# +# +# Once this is done, responses from http operations ('get', 'post', 'delete', etc.) +# will fail the test if the response structure does not match the 'returns' declaration +# on the method (for the actual HTTP status code), or if there is no 'returns' declaration +# for the HTTP status code. +#---------------------------------------------------------------------------------------------- + + +#---------------------------------------------------------------------------------------------- +# Response validation: core logic (used by auto-validation and manual-validation mechanisms) +#---------------------------------------------------------------------------------------------- +class ActionController::Base + module Apipie::ControllerValidationHelpers + # this method is injected into ActionController::Base in order to + # get access to the names of the current controller, current action, as well as to the response + def schema_validation_errors_for_response + unprocessed_schema = Apipie::json_schema_for_method_response(controller_name, action_name, response.code, true) + + if unprocessed_schema.nil? + err = "no schema defined for #{controller_name}##{action_name}[#{response.code}]" + return [nil, [err], RuntimeError.new(err)] + end + + schema = JSON.parse(JSON(unprocessed_schema)) + + error_list = JSON::Validator.fully_validate(schema, response.body, :strict => false, :version => :draft4, :json => true) + + error_object = Apipie::ResponseDoesNotMatchSwaggerSchema.new(controller_name, action_name, response.code, error_list, schema, response.body) + + [schema, error_list, error_object] + rescue Apipie::NoDocumentedMethod + [nil, [], nil] + end + end + + include Apipie::ControllerValidationHelpers +end + +module Apipie + def self.print_validation_errors(validation_errors, schema, response, error_object=nil) + Rails.logger.warn(validation_errors.to_s) + if Rails.env.test? + puts "schema validation errors:" + validation_errors.each { |e| puts "--> #{e.to_s}" } + puts "schema: #{schema.nil? ? '' : JSON(schema)}" + puts "response: #{response.body}" + raise error_object if error_object + end + end +end + +#--------------------------------- +# Manual-validation (RSpec matcher) +#--------------------------------- +RSpec::Matchers.define :match_declared_responses do + match do |actual| + (schema, validation_errors) = subject.send(:schema_validation_errors_for_response) + valid = (validation_errors == []) + Apipie::print_validation_errors(validation_errors, schema, response) unless valid + + valid + end +end + + +#--------------------------------- +# Auto-validation logic +#--------------------------------- +module RSpec::Rails::ViewRendering + # Augment the RSpec DSL + module ClassMethods + def auto_validate_rendered_views + before do + @is_response_validation_on = true + end + + after do + @is_response_validation_on = false + end + end + end +end + + +ActionController::TestCase::Behavior.instance_eval do + # instrument the 'process' method in ActionController::TestCase to enable response validation + module Apipie::ResponseValidationHelpers + @is_response_validation_on = false + def process(*args) + result = super(*args) + validate_response if @is_response_validation_on + + result + end + + def validate_response + controller.send(:validate_response_and_abort_with_info_if_errors) + end + end + + prepend Apipie::ResponseValidationHelpers +end + + +class ActionController::Base + module Apipie::ControllerValidationHelpers + def validate_response_and_abort_with_info_if_errors + + (schema, validation_errors, error_object) = schema_validation_errors_for_response + + valid = (validation_errors == []) + if !valid + Apipie::print_validation_errors(validation_errors, schema, response, error_object) + end + end + end +end + + diff --git a/lib/apipie/swagger_generator.rb b/lib/apipie/swagger_generator.rb index 3d347484..49713ab9 100644 --- a/lib/apipie/swagger_generator.rb +++ b/lib/apipie/swagger_generator.rb @@ -24,6 +24,10 @@ def params_in_body_use_reference? Apipie.configuration.swagger_json_input_uses_refs end + def responses_use_reference? + Apipie.configuration.swagger_responses_use_refs? + end + def include_warning_tags? Apipie.configuration.swagger_include_warning_tags end @@ -259,6 +263,10 @@ def swagger_op_id_for_method(method) remove_colons method.resource.controller.name + "::" + method.method end + def swagger_id_for_typename(typename) + typename + end + def swagger_op_id_for_path(http_method, path) # using lowercase http method, because the 'swagger-codegen' tool outputs # strange method names if the http method is in uppercase @@ -334,19 +342,42 @@ def swagger_param_type(param_desc) # Responses #-------------------------------------------------------------------------- - def response_schema(response) + def json_schema_for_method_response(method, return_code, allow_nulls) + @definitions = {} + for response in method.returns + if response.code.to_s == return_code.to_s + schema = response_schema(response, allow_nulls) if response.code.to_s == return_code.to_s + schema[:definitions] = @definitions if @definitions != {} + return schema + end + end + nil + end + + def json_schema_for_self_describing_class(cls, allow_nulls) + adapter = ResponseDescriptionAdapter.from_self_describing_class(cls) + response_schema(adapter, allow_nulls) + end + + def response_schema(response, allow_nulls=false) begin # no need to warn about "missing default value for optional param" when processing response definitions prev_value = @disable_default_value_warning @disable_default_value_warning = true - schema = json_schema_obj_from_params_array(response.params_ordered) + + if responses_use_reference? && response.typename + schema = {"$ref" => gen_referenced_block_from_params_array(swagger_id_for_typename(response.typename), response.params_ordered, allow_nulls)} + else + schema = json_schema_obj_from_params_array(response.params_ordered, allow_nulls) + end + ensure @disable_default_value_warning = prev_value end if response.is_array? && schema schema = { - type: "array", + type: allow_nulls ? ["array","null"] : "array", items: schema } end @@ -423,7 +454,7 @@ def add_missing_params(method, path) # The core routine for creating a swagger parameter definition block. # The output is slightly different when the parameter is inside a schema block. #-------------------------------------------------------------------------- - def swagger_atomic_param(param_desc, in_schema, name) + def swagger_atomic_param(param_desc, in_schema, name, allow_nulls) def save_field(entry, openapi_key, v, apipie_key=openapi_key, translate=false) if v.key?(apipie_key) if translate @@ -444,7 +475,7 @@ def save_field(entry, openapi_key, v, apipie_key=openapi_key, translate=false) end if swagger_def[:type] == "array" - swagger_def[:items] = {type: "string"} # TODO: add support for arrays of non-string items + swagger_def[:items] = {type: "string"} end if swagger_def[:type] == "enum" @@ -457,6 +488,21 @@ def save_field(entry, openapi_key, v, apipie_key=openapi_key, translate=false) warn_hash_without_internal_typespec(param_desc.name) end + if param_desc.is_array? + new_swagger_def = { + items: swagger_def, + type: 'array' + } + swagger_def = new_swagger_def + if allow_nulls + swagger_def[:type] = [swagger_def[:type], "null"] + end + end + + if allow_nulls + swagger_def[:type] = [swagger_def[:type], "null"] + end + if !in_schema swagger_def[:in] = param_desc.options.fetch(:in, @default_value_for_param_in) swagger_def[:required] = param_desc.required if param_desc.required @@ -487,8 +533,8 @@ def ref_to(name) end - def json_schema_obj_from_params_array(params_array) - (param_defs, required_params) = json_schema_param_defs_from_params_array(params_array) + def json_schema_obj_from_params_array(params_array, allow_nulls = false) + (param_defs, required_params) = json_schema_param_defs_from_params_array(params_array, allow_nulls) result = {type: "object"} result[:properties] = param_defs @@ -498,17 +544,17 @@ def json_schema_obj_from_params_array(params_array) param_defs.length > 0 ? result : nil end - def gen_referenced_block_from_params_array(name, params_array) + def gen_referenced_block_from_params_array(name, params_array, allow_nulls=false) return ref_to(:name) if @definitions.key(:name) - schema_obj = json_schema_obj_from_params_array(params_array) + schema_obj = json_schema_obj_from_params_array(params_array, allow_nulls) return nil if schema_obj.nil? @definitions[name.to_sym] = schema_obj ref_to(name.to_sym) end - def json_schema_param_defs_from_params_array(params_array) + def json_schema_param_defs_from_params_array(params_array, allow_nulls = false) param_defs = {} required_params = [] @@ -526,7 +572,7 @@ def json_schema_param_defs_from_params_array(params_array) param_type = swagger_param_type(param_desc) if param_type == "object" && param_desc.validator.params_ordered - schema = json_schema_obj_from_params_array(param_desc.validator.params_ordered) + schema = json_schema_obj_from_params_array(param_desc.validator.params_ordered, allow_nulls) if param_desc.additional_properties schema[:additionalProperties] = true end @@ -539,9 +585,18 @@ def json_schema_param_defs_from_params_array(params_array) schema = new_schema end + if allow_nulls + # ideally we would write schema[:type] = ["object", "null"] + # but due to a bug in the json-schema gem, we need to use anyOf + # see https://github.com/ruby-json-schema/json-schema/issues/404 + new_schema = { + anyOf: [schema, {type: "null"}] + } + schema = new_schema + end param_defs[param_desc.name.to_sym] = schema if !schema.nil? else - param_defs[param_desc.name.to_sym] = swagger_atomic_param(param_desc, true, nil) + param_defs[param_desc.name.to_sym] = swagger_atomic_param(param_desc, true, nil, allow_nulls) end end @@ -619,7 +674,7 @@ def add_params_from_hash(swagger_params_array, param_defs, prefix=nil, default_v warn_param_ignored_in_form_data(desc.name) end else - param_entry = swagger_atomic_param(desc, false, name) + param_entry = swagger_atomic_param(desc, false, name, false) if param_entry[:required] swagger_params_array.unshift(param_entry) else diff --git a/spec/dummy/app/controllers/pets_controller.rb b/spec/dummy/app/controllers/pets_controller.rb index efbe76c7..a3383285 100644 --- a/spec/dummy/app/controllers/pets_controller.rb +++ b/spec/dummy/app/controllers/pets_controller.rb @@ -140,6 +140,10 @@ def get_vote_by_owner_name param_group :pet_history end end + returns :code => 204 do + property :int_array, :array_of => Integer + property :enum_array, :array_of => ['v1','v2','v3'] + end returns :code => :unprocessable_entity, :desc => "Fleas were discovered on the pet" do param_group :pet property :num_fleas, Integer, :desc => "Number of fleas on this pet" @@ -244,6 +248,12 @@ def return_and_validate_type_mismatch render :json => result end + #----------------------------------------------------------- + # A method with no documentation + #----------------------------------------------------------- + def undocumented_method + render :json => {:result => "ok"} + end #----------------------------------------------------------- # A method which has a response with a missing field @@ -335,7 +345,6 @@ def sub_object_allowed_extra_property render :json => result end - #======================================================================= # Methods for testing array field responses #======================================================================= diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index a0a8e4da..3952d2c1 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -26,6 +26,22 @@ get :contributors end end + + get "/pets/return_and_validate_expected_response" => "pets#return_and_validate_expected_response" + get "/pets/return_and_validate_expected_array_response" => "pets#return_and_validate_expected_array_response" + get "/pets/return_and_validate_type_mismatch" => "pets#return_and_validate_type_mismatch" + get "/pets/return_and_validate_missing_field" => "pets#return_and_validate_missing_field" + get "/pets/return_and_validate_extra_property" => "pets#return_and_validate_extra_property" + get "/pets/return_and_validate_allowed_extra_property" => "pets#return_and_validate_allowed_extra_property" + get "/pets/sub_object_invalid_extra_property" => "pets#sub_object_invalid_extra_property" + get "/pets/sub_object_allowed_extra_property" => "pets#sub_object_allowed_extra_property" + get "/pets/return_and_validate_unexpected_array_response" => "pets#return_and_validate_unexpected_array_response" + get "/pets/return_and_validate_expected_response_with_null" => "pets#return_and_validate_expected_response_with_null" + get "/pets/return_and_validate_expected_response_with_null_object" => "pets#return_and_validate_expected_response_with_null_object" + + get "/pets/returns_response_with_valid_array" => "pets#returns_response_with_valid_array" + get "/pets/returns_response_with_invalid_array" => "pets#returns_response_with_invalid_array" + get "/pets/undocumented_method" => "pets#undocumented_method" end apipie diff --git a/spec/lib/swagger/response_validation_spec.rb b/spec/lib/swagger/response_validation_spec.rb new file mode 100644 index 00000000..5b5fd7f5 --- /dev/null +++ b/spec/lib/swagger/response_validation_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' +require 'rack/utils' +require 'rspec/expectations' +require 'apipie/rspec/response_validation_helper' +require "json-schema" + +RSpec.describe PetsController, :type => :controller do + before :each do + Apipie.configuration.swagger_allow_additional_properties_in_response = false + end + + it "does not raise error when rendered output matches the described response" do + response = get :return_and_validate_expected_response, {format: :json} + expect(response).to match_declared_responses + end + + it "does not raise error when rendered output (array) matches the described response" do + response = get :return_and_validate_expected_array_response, {format: :json} + expect(response).to match_declared_responses + end + + it "does not raises error when rendered output includes null in the response" do + response = get :return_and_validate_expected_response_with_null, {format: :json} + expect(response).to match_declared_responses + end + + it "does not raise error when rendered output includes null (instead of an object) in the response" do + response = get :return_and_validate_expected_response_with_null_object, {format: :json} + expect(response).to match_declared_responses + end + + it "raises error when a response field has the wrong type" do + response = get :return_and_validate_type_mismatch, {format: :json} + expect(response).not_to match_declared_responses + end + + it "raises error when a response has a missing field" do + response = get :return_and_validate_missing_field, {format: :json} + expect(response).not_to match_declared_responses + end + + it "raises error when a response has an extra property and 'swagger_allow_additional_properties_in_response' is false" do + response = get :return_and_validate_extra_property, {format: :json} + expect(response).not_to match_declared_responses + end + + it "raises error when a response has is array instead of object" do + # note: this action returns HTTP 201, not HTTP 200! + response = get :return_and_validate_unexpected_array_response, {format: :json} + expect(response).not_to match_declared_responses + end + + it "does not raise error when a response has an extra property and 'swagger_allow_additional_properties_in_response' is true" do + Apipie.configuration.swagger_allow_additional_properties_in_response = true + response = get :return_and_validate_extra_property, {format: :json} + expect(response).to match_declared_responses + end + + it "does not raise error when a response has an extra field and 'additional_properties' is specified in the response" do + Apipie.configuration.swagger_allow_additional_properties_in_response = false + response = get :return_and_validate_allowed_extra_property, {format: :json} + expect(response).to match_declared_responses + end + + it "raises error when a response sub-object has an extra field and 'additional_properties' is not specified on it, but specified on the top level of the response" do + Apipie.configuration.swagger_allow_additional_properties_in_response = false + response = get :sub_object_invalid_extra_property, {format: :json} + expect(response).not_to match_declared_responses + end + + it "does not rais error when a response sub-object has an extra field and 'additional_properties' is specified on it" do + Apipie.configuration.swagger_allow_additional_properties_in_response = false + response = get :sub_object_allowed_extra_property, {format: :json} + expect(response).to match_declared_responses + end + + describe "auto validation" do + auto_validate_rendered_views + it "raises exception when a response field has the wrong type and auto validation is turned on" do + expect { get :return_and_validate_type_mismatch, {format: :json} }.to raise_error(Apipie::ResponseDoesNotMatchSwaggerSchema) + end + + it "does not raise an exception when calling an undocumented method" do + expect { get :undocumented_method, {format: :json} }.not_to raise_error + end + + end + + + describe "with array field" do + it "no error for valid response" do + response = get :returns_response_with_valid_array, {format: :json} + expect(response).to match_declared_responses + end + + it "error if type of element in the array is wrong" do + response = get :returns_response_with_invalid_array, {format: :json} + expect(response).not_to match_declared_responses + end + end + + + +end \ No newline at end of file diff --git a/spec/lib/swagger/swagger_dsl_spec.rb b/spec/lib/swagger/swagger_dsl_spec.rb index b747c713..ef537873 100644 --- a/spec/lib/swagger/swagger_dsl_spec.rb +++ b/spec/lib/swagger/swagger_dsl_spec.rb @@ -12,8 +12,22 @@ let(:controller_class ) { described_class } + def get_ref(ref) + name = ref.split('#/definitions/')[1].to_sym + swagger[:definitions][name] + end + + def resolve_refs(schema) + if schema['$ref'] + return get_ref(schema['$ref']) + end + schema + end + def swagger_response_for(path, code=200, method='get') - swagger[:paths][path][method][:responses][code] + response = swagger[:paths][path][method][:responses][code] + response[:schema] = resolve_refs(response[:schema]) + response end def swagger_params_for(path, method='get') @@ -32,6 +46,64 @@ def swagger_param_by_name(param_name, path, method='get') + + # + # Matcher to validate the hierarchy of fields described in an internal 'returns' object (without checking their type) + # + # For example, code such as: + # returns_obj = Apipie.get_resource_description(...)._methods.returns.detect{|e| e.code=200}) + # expect(returns_obj).to match_param_structure([:pet_name, :animal_type, :pet_measurements => [:weight, :height]]) + # + # will verify that the payload structure described for the response of return code 200 is: + # { + # "pet_name": , + # "animal_type": , + # "pet_measurements": { + # "weight": , + # "height": + # } + # } + # + # + RSpec::Matchers.define :match_field_structure do |expected| + @last_message = nil + + match do |actual| + deep_match?(actual, expected) + end + + def deep_match?(actual, expected, breadcrumb=[]) + num = 0 + for pdesc in expected do + if pdesc.is_a? Symbol + return false unless fields_match?(actual.params_ordered[num], pdesc, breadcrumb) + elsif pdesc.is_a? Hash + return false unless fields_match?(actual.params_ordered[num], pdesc.keys[0], breadcrumb) + return false unless deep_match?(actual.params_ordered[num].validator, pdesc.values[0], breadcrumb + [pdesc.keys[0]]) + end + num+=1 + end + @fail_message = "expected property count#{breadcrumb == [] ? '' : ' of ' + (breadcrumb).join('.')} (#{actual.params_ordered.count}) to be #{num}" + actual.params_ordered.count == num + end + + def fields_match?(param, expected_name, breadcrumb) + return false unless have_field?(param, expected_name, breadcrumb) + @fail_message = "expected #{(breadcrumb + [param.name]).join('.')} to eq #{(breadcrumb + [expected_name]).join('.')}" + param.name.to_s == expected_name.to_s + end + + def have_field?(field, expected_name, breadcrumb) + @fail_message = "expected property #{(breadcrumb+[expected_name]).join('.')}" + !field.nil? + end + + failure_message do |actual| + @fail_message + end + end + + describe PetsController do @@ -57,7 +129,7 @@ def swagger_param_by_name(param_name, path, method='get') schema = response[:schema] expect(schema[:type]).to eq("array") - a_schema = schema[:items] + a_schema = resolve_refs(schema[:items]) expect(a_schema).to have_field(:pet_name, 'string', {:description => 'Name of pet', :required => false}) expect(a_schema).to have_field(:animal_type, 'string', {:description => 'Type of pet', :enum => ['dog','cat','iguana','kangaroo']}) end @@ -320,6 +392,24 @@ def swagger_param_by_name(param_name, path, method='get') expect(pai_schema).to have_field(:avg_meals_per_day, 'number') end + it "should return code 204 with array of integer" do + returns_obj = subject.returns.detect{|e| e.code == 204 } + + puts returns_obj.to_json + expect(returns_obj.code).to eq(204) + expect(returns_obj.is_array?).to eq(false) + + expect(returns_obj).to match_field_structure([:int_array, :enum_array]) + end + it 'should have the 204 response described in the swagger' do + response = swagger_response_for('/pets/{id}/extra_info', 204) + + schema = response[:schema] + expect(schema).to have_field(:int_array, 'array', {items: {type: 'number'}}) + expect(schema).to have_field(:enum_array, 'array', {items: {type: 'string', enum: ['v1','v2','v3']}}) + end + + it "should return code matching :unprocessable_entity (422) with spread out 'pet' and 'num_fleas'" do returns_obj = subject.returns.detect{|e| e.code == 422 } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4cb4b616..948c5429 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -34,62 +34,6 @@ def compatible_request(method, action, hash = {}) end -# -# Matcher to validate the hierarchy of fields described in an internal 'returns' object (without checking their type) -# -# For example, code such as: -# returns_obj = Apipie.get_resource_description(...)._methods.returns.detect{|e| e.code=200}) -# expect(returns_obj).to match_param_structure([:pet_name, :animal_type, :pet_measurements => [:weight, :height]]) -# -# will verify that the payload structure described for the response of return code 200 is: -# { -# "pet_name": , -# "animal_type": , -# "pet_measurements": { -# "weight": , -# "height": -# } -# } -# -# -RSpec::Matchers.define :match_field_structure do |expected| - @last_message = nil - - match do |actual| - deep_match?(actual, expected) - end - - def deep_match?(actual, expected, breadcrumb=[]) - num = 0 - expected.each do |pdesc| - if pdesc.is_a? Symbol - return false unless matching_param(actual.params_ordered, pdesc, breadcrumb) - elsif pdesc.is_a? Hash - param = matching_param(actual.params_ordered, pdesc.keys[0], breadcrumb) - return false unless param - return false unless deep_match?(param.validator, pdesc.values[0], breadcrumb + [pdesc.keys[0]]) - end - num+=1 - end - @fail_message = "expected property count#{breadcrumb == [] ? '' : ' of ' + (breadcrumb).join('.')} (#{actual.params_ordered.count}) to be #{num}" - actual.params_ordered.count == num - end - - def matching_param(params, expected_name, breadcrumb) - param = params.find { |p| p.name.to_s == expected_name.to_s } - unless param - @fail_message = "expected [#{ params.map(&:name).join(', ') }] to include #{(breadcrumb + [expected_name]).join('.')}" - end - param - end - - failure_message do |actual| - @fail_message - end -end - - - # # Matcher to validate the properties (name, type and options) of a single field in the # internal representation of a swagger schema @@ -112,12 +56,14 @@ def fail(msg) @fail_message end - match do |actual| + match do |unresolved| + actual = resolve_refs(unresolved) return fail("expected schema to have type 'object' (got '#{actual[:type]}')") if (actual[:type]) != 'object' return fail("expected schema to include param named '#{name}' (got #{actual[:properties].keys})") if (prop = actual[:properties][name]).nil? return fail("expected param '#{name}' to have type '#{type}' (got '#{prop[:type]}')") if prop[:type] != type return fail("expected param '#{name}' to have description '#{opts[:description]}' (got '#{prop[:description]}')") if opts[:description] && prop[:description] != opts[:description] return fail("expected param '#{name}' to have enum '#{opts[:enum]}' (got #{prop[:enum]})") if opts[:enum] && prop[:enum] != opts[:enum] + return fail("expected param '#{name}' to have items '#{opts[:items]}' (got #{prop[:items]})") if opts[:items] && prop[:items] != opts[:items] if !opts.include?(:required) || opts[:required] == true return fail("expected param '#{name}' to be required") unless actual[:required].include?(name) else