diff --git a/lib/committee/middleware/options/response_validation.rb b/lib/committee/middleware/options/response_validation.rb index a86e59db..f158746d 100644 --- a/lib/committee/middleware/options/response_validation.rb +++ b/lib/committee/middleware/options/response_validation.rb @@ -10,9 +10,10 @@ class ResponseValidation < Base attr_reader :parse_response_by_content_type attr_reader :coerce_response_values attr_reader :streaming_content_parsers + attr_reader :strict_response_content_type # Default values - DEFAULTS = { strict: false, validate_success_only: true, parse_response_by_content_type: true, coerce_response_values: false }.freeze + DEFAULTS = { strict: false, validate_success_only: true, parse_response_by_content_type: true, coerce_response_values: false, strict_response_content_type: false }.freeze def initialize(options = {}) super(options) @@ -22,6 +23,7 @@ def initialize(options = {}) @validate_success_only = options.fetch(:validate_success_only, DEFAULTS[:validate_success_only]) @parse_response_by_content_type = options.fetch(:parse_response_by_content_type, DEFAULTS[:parse_response_by_content_type]) @coerce_response_values = options.fetch(:coerce_response_values, DEFAULTS[:coerce_response_values]) + @strict_response_content_type = options.fetch(:strict_response_content_type, DEFAULTS[:strict_response_content_type]) # Streaming @streaming_content_parsers = options[:streaming_content_parsers] || {} @@ -38,7 +40,7 @@ def validate_response_validation_options! end def build_hash - super.merge(strict: @strict, validate_success_only: @validate_success_only, parse_response_by_content_type: @parse_response_by_content_type, coerce_response_values: @coerce_response_values, streaming_content_parsers: @streaming_content_parsers) + super.merge(strict: @strict, validate_success_only: @validate_success_only, parse_response_by_content_type: @parse_response_by_content_type, coerce_response_values: @coerce_response_values, streaming_content_parsers: @streaming_content_parsers, strict_response_content_type: @strict_response_content_type) end end end diff --git a/lib/committee/schema_validator/open_api_3/operation_wrapper.rb b/lib/committee/schema_validator/open_api_3/operation_wrapper.rb index 46f6a202..f565363b 100644 --- a/lib/committee/schema_validator/open_api_3/operation_wrapper.rb +++ b/lib/committee/schema_validator/open_api_3/operation_wrapper.rb @@ -32,9 +32,23 @@ def coerce_path_parameter(validator_option) end # @param [Boolean] strict when not content_type or status code definition, raise error - def validate_response_params(status_code, headers, response_data, strict, check_header, validator_options: {}) + def validate_response_params(status_code, headers, response_data, strict, check_header, strict_response_content_type: false, validator_options: {}) response_body = OpenAPIParser::RequestOperation::ValidatableResponseBody.new(status_code, response_data, headers) + # When strict_response_content_type is enabled, reject responses whose Content-Type is not + # declared in the spec's content map for this status code. Responses with no content map + # (e.g. bare 204s) are skipped — openapi_parser's validate_response_body handles those. + if strict_response_content_type + response_object = find_response_object_for_status(request_operation.operation_object&.responses, status_code) + if response_object + content_type = Rack::MediaType.type(response_body.content_type) + matched = response_object.select_media_type(content_type) + if matched.nil? && response_object.content && !response_object.content.empty? + raise Committee::InvalidResponse, "Response Content-Type '#{content_type}' is not declared in the OpenAPI spec for this operation. Declared types: #{response_object.content.keys.join(', ')}" + end + end + end + return request_operation.validate_response_body(response_body, response_validate_options(strict, check_header, validator_options: validator_options)) rescue OpenAPIParser::OpenAPIError => e raise Committee::InvalidResponse.new(e.message, original_error: e) @@ -163,6 +177,18 @@ def validate_no_unknown_query_params(query_params) raise Committee::InvalidRequest.new("Unknown query parameter(s): #{unknown_params.join(', ')}") end + def find_response_object_for_status(responses, status_code) + return nil unless responses&.response + + response_hash = responses.response + return response_hash[status_code.to_s] if response_hash[status_code.to_s] + + wild_card = "#{status_code.to_i / 100}XX" + return response_hash[wild_card] if response_hash[wild_card] + + responses.default + end + def response_validate_options(strict, check_header, validator_options: {}) options = { strict: strict, validate_header: check_header } diff --git a/lib/committee/schema_validator/open_api_3/response_validator.rb b/lib/committee/schema_validator/open_api_3/response_validator.rb index 28b5ad15..7afedc0d 100644 --- a/lib/committee/schema_validator/open_api_3/response_validator.rb +++ b/lib/committee/schema_validator/open_api_3/response_validator.rb @@ -14,6 +14,7 @@ def initialize(operation_wrapper, validator_option) @check_header = validator_option.check_header @allow_empty_date_and_datetime = validator_option.allow_empty_date_and_datetime @coerce_response_values = validator_option.coerce_response_values + @strict_response_content_type = validator_option.strict_response_content_type end def call(status, headers, response_data, strict) @@ -21,7 +22,7 @@ def call(status, headers, response_data, strict) validator_options = { allow_empty_date_and_datetime: @allow_empty_date_and_datetime, coerce_value: @coerce_response_values } - operation_wrapper.validate_response_params(status, headers, response_data, strict, check_header, validator_options: validator_options) + operation_wrapper.validate_response_params(status, headers, response_data, strict, check_header, strict_response_content_type: @strict_response_content_type, validator_options: validator_options) end def validate?(status) diff --git a/lib/committee/schema_validator/option.rb b/lib/committee/schema_validator/option.rb index f71fa213..f12ac4f7 100644 --- a/lib/committee/schema_validator/option.rb +++ b/lib/committee/schema_validator/option.rb @@ -4,7 +4,7 @@ module Committee module SchemaValidator class Option # Boolean Options - attr_reader :allow_blank_structures, :allow_empty_date_and_datetime, :allow_form_params, :allow_get_body, :allow_query_params, :allow_non_get_query_params, :check_content_type, :check_header, :coerce_date_times, :coerce_form_params, :coerce_path_params, :coerce_query_params, :coerce_recursive, :coerce_response_values, :deserialize_parameters, :optimistic_json, :validate_success_only, :parse_response_by_content_type, :parameter_overwrite_by_rails_rule, :strict_query_params + attr_reader :allow_blank_structures, :allow_empty_date_and_datetime, :allow_form_params, :allow_get_body, :allow_query_params, :allow_non_get_query_params, :check_content_type, :check_header, :coerce_date_times, :coerce_form_params, :coerce_path_params, :coerce_query_params, :coerce_recursive, :coerce_response_values, :deserialize_parameters, :optimistic_json, :validate_success_only, :parse_response_by_content_type, :parameter_overwrite_by_rails_rule, :strict_query_params, :strict_response_content_type # Non-boolean options: attr_reader :headers_key, :params_key, :query_hash_key, :request_body_hash_key, :path_hash_key, :prefix @@ -32,6 +32,7 @@ def initialize(options, schema, schema_type) @optimistic_json = options.fetch(:optimistic_json, false) @parse_response_by_content_type = options.fetch(:parse_response_by_content_type, true) @strict_query_params = options.fetch(:strict_query_params, false) + @strict_response_content_type = options.fetch(:strict_response_content_type, false) @parameter_overwrite_by_rails_rule = if options.key?(:parameter_overwite_by_rails_rule) diff --git a/test/data/openapi3/normal.yaml b/test/data/openapi3/normal.yaml index f31013bd..409ff570 100644 --- a/test/data/openapi3/normal.yaml +++ b/test/data/openapi3/normal.yaml @@ -473,6 +473,35 @@ paths: type: string format: binary + /no_content_schema: + get: + description: endpoint with no content schema on its response + responses: + '200': + description: success + + /wildcard_response: + get: + description: endpoint with wildcard status code response + responses: + '4XX': + description: client error + content: + application/json: + schema: + type: object + + /default_response: + get: + description: endpoint with default response only + responses: + default: + description: any status + content: + application/json: + schema: + type: object + /validate_no_parameter: patch: description: validate no body diff --git a/test/middleware/options/response_validation_test.rb b/test/middleware/options/response_validation_test.rb index 83d1db4e..e485a871 100644 --- a/test/middleware/options/response_validation_test.rb +++ b/test/middleware/options/response_validation_test.rb @@ -94,6 +94,16 @@ end assert_equal "streaming_content_parsers must be a Hash", e.message end + + it "sets strict_response_content_type option with default false" do + options = Committee::Middleware::Options::ResponseValidation.new(schema: hyper_schema) + assert_equal false, options.strict_response_content_type + end + + it "sets strict_response_content_type option when provided" do + options = Committee::Middleware::Options::ResponseValidation.new(schema: hyper_schema, strict_response_content_type: true) + assert_equal true, options.strict_response_content_type + end end describe "#to_h" do @@ -103,6 +113,11 @@ assert_equal true, hash[:strict] assert_equal false, hash[:validate_success_only] end + + it "includes strict_response_content_type in hash" do + options = Committee::Middleware::Options::ResponseValidation.new(schema: hyper_schema, strict_response_content_type: true) + assert_equal true, options.to_h[:strict_response_content_type] + end end describe ".from" do diff --git a/test/middleware/response_validation_open_api_3_test.rb b/test/middleware/response_validation_open_api_3_test.rb index 19d959fe..66316015 100644 --- a/test/middleware/response_validation_open_api_3_test.rb +++ b/test/middleware/response_validation_open_api_3_test.rb @@ -303,6 +303,68 @@ def app end end + describe "strict_response_content_type option" do + it "passes through with undeclared content type when strict_response_content_type: false (default)" do + @app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema) + get "/characters" + assert_equal 200, last_response.status + end + + it "raises with undeclared content type when strict_response_content_type: true" do + @app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema, strict_response_content_type: true, raise: true) + assert_raises(Committee::InvalidResponse) do + get "/characters" + end + end + + it "returns 500 with undeclared content type when strict_response_content_type: true without raise" do + @app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema, strict_response_content_type: true) + get "/characters" + assert_equal 500, last_response.status + end + + it "passes through when response has no content schema with strict_response_content_type: true" do + @app = new_response_rack("", { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema, strict_response_content_type: true) + get "/no_content_schema" + assert_equal 200, last_response.status + end + + it "passes through with declared content type and strict_response_content_type: true" do + @app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/json" }, schema: open_api_3_schema, strict_response_content_type: true) + get "/characters" + assert_equal 200, last_response.status + end + + it "passes through with declared content type and charset param with strict_response_content_type: true" do + @app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/json; charset=utf-8" }, schema: open_api_3_schema, strict_response_content_type: true) + get "/characters" + assert_equal 200, last_response.status + end + + it "raises with undeclared content type matched via wildcard status code with strict_response_content_type: true" do + @app = new_response_rack("{}", { "Content-Type" => "application/vnd.api+json" }, { schema: open_api_3_schema, strict_response_content_type: true, raise: true, validate_success_only: false }, { status: 400 }) + assert_raises(Committee::InvalidResponse) do + get "/wildcard_response" + end + end + + it "raises with undeclared content type matched via default response with strict_response_content_type: true" do + @app = new_response_rack("{}", { "Content-Type" => "application/vnd.api+json" }, { schema: open_api_3_schema, strict_response_content_type: true, raise: true, validate_success_only: false }, { status: 500 }) + assert_raises(Committee::InvalidResponse) do + get "/default_response" + end + end + + it "calls error_handler with strict_response_content_type: true and raise: false" do + called_err = nil + pr = ->(e, _env) { called_err = e } + @app = new_response_rack(JSON.generate(CHARACTERS_RESPONSE), { "Content-Type" => "application/vnd.api+json" }, schema: open_api_3_schema, strict_response_content_type: true, error_handler: pr) + get "/characters" + assert_equal 500, last_response.status + assert_kind_of Committee::InvalidResponse, called_err + end + end + private def new_response_rack(response, headers = {}, options = {}, rack_options = {}) diff --git a/test/schema_validator/open_api_3/operation_wrapper_test.rb b/test/schema_validator/open_api_3/operation_wrapper_test.rb index e7d8db59..1a279618 100644 --- a/test/schema_validator/open_api_3/operation_wrapper_test.rb +++ b/test/schema_validator/open_api_3/operation_wrapper_test.rb @@ -223,5 +223,43 @@ def operation_object assert_kind_of(Integer, body_params["integer"]) end end + + describe '#find_response_object_for_status' do + def responses_for(path, method = 'get') + open_api_3_schema.operation_object(path, method).request_operation.operation_object.responses + end + + def find(path, status, method = 'get') + wrapper = open_api_3_schema.operation_object(path, method) + wrapper.send(:find_response_object_for_status, responses_for(path, method), status) + end + + it 'returns nil when responses is nil' do + wrapper = open_api_3_schema.operation_object('/characters', 'get') + assert_nil wrapper.send(:find_response_object_for_status, nil, 200) + end + + it 'returns exact status code match' do + result = find('/characters', 200) + assert_kind_of OpenAPIParser::Schemas::Response, result + assert result.content.key?('application/json') + end + + it 'returns wildcard match when exact status not defined' do + result = find('/wildcard_response', 400) + assert_kind_of OpenAPIParser::Schemas::Response, result + assert result.content.key?('application/json') + end + + it 'returns default when no exact or wildcard match' do + result = find('/default_response', 500) + assert_kind_of OpenAPIParser::Schemas::Response, result + assert result.content.key?('application/json') + end + + it 'returns nil when no exact, wildcard, or default match' do + assert_nil find('/characters', 999) + end + end end end