From f4643d653782c5f503b8ee5f854eb1fa14925aa0 Mon Sep 17 00:00:00 2001 From: ydah Date: Fri, 26 Dec 2025 17:56:40 +0900 Subject: [PATCH] Add coerce_response_values option to enable type coercion in response validation Fixes: https://github.com/interagent/committee/issues/445 --- README.md | 1 + .../open_api_3/operation_wrapper.rb | 12 +++++--- .../open_api_3/response_validator.rb | 3 +- lib/committee/schema_validator/option.rb | 3 +- .../response_validation_open_api_3_test.rb | 30 +++++++++++++++++++ 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fc8fda5c..3bad609b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ This piece of middleware validates the parameters of incoming requests to make s |coerce_path_params| false | true | The same as `coerce_form_params`, but tries to coerce parameters encoded in a request's URL path. | |coerce_query_params| false | true | The same as `coerce_form_params`, but tries to coerce `GET` parameters encoded in a request's query string. | |coerce_recursive| false | always true | Coerce data in arrays and other nested objects | +|coerce_response_values| false | false | Enable type coercion for response body validation. When `true`, allows string values like `"726"` to be coerced to numbers. When `false` (default), response bodies must match exact types defined in the schema. | |optimistic_json| false | false | Will attempt to parse JSON in the request body even without a `Content-Type: application/json` before falling back to other options. | |raise| false | false | Raise an exception on error instead of responding with a generic error body. | |strict| false | false | Puts the middleware into strict mode, meaning that paths which are not defined in the schema will be responded to with a 404 instead of being run. | 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 f3d81828..a21263f0 100644 --- a/lib/committee/schema_validator/open_api_3/operation_wrapper.rb +++ b/lib/committee/schema_validator/open_api_3/operation_wrapper.rb @@ -138,11 +138,15 @@ def validate_path_and_query_params(path_params, query_params, headers, validator def response_validate_options(strict, check_header, validator_options: {}) options = { strict: strict, validate_header: check_header } - if OpenAPIParser::SchemaValidator::ResponseValidateOptions.method_defined?(:validator_options) - ::OpenAPIParser::SchemaValidator::ResponseValidateOptions.new(**options, **validator_options) - else - ::OpenAPIParser::SchemaValidator::ResponseValidateOptions.new(**options) + if validator_options[:coerce_value] + options[:coerce_value] = validator_options[:coerce_value] + end + + if validator_options[:allow_empty_date_and_datetime] + options[:allow_empty_date_and_datetime] = validator_options[:allow_empty_date_and_datetime] end + + ::OpenAPIParser::SchemaValidator::ResponseValidateOptions.new(**options) end end end 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 d5659601..b0fb1441 100644 --- a/lib/committee/schema_validator/open_api_3/response_validator.rb +++ b/lib/committee/schema_validator/open_api_3/response_validator.rb @@ -13,12 +13,13 @@ def initialize(operation_wrapper, validator_option) @validate_success_only = validator_option.validate_success_only @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 end def call(status, headers, response_data, strict) return unless Committee::Middleware::ResponseValidation.validate?(status, validate_success_only) - validator_options = { allow_empty_date_and_datetime: @allow_empty_date_and_datetime } + 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) end diff --git a/lib/committee/schema_validator/option.rb b/lib/committee/schema_validator/option.rb index 5b020973..0cfdff97 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, :optimistic_json, :validate_success_only, :parse_response_by_content_type, :parameter_overwrite_by_rails_rule + 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, :optimistic_json, :validate_success_only, :parse_response_by_content_type, :parameter_overwrite_by_rails_rule # Non-boolean options: attr_reader :headers_key, :params_key, :query_hash_key, :request_body_hash_key, :path_hash_key, :prefix @@ -28,6 +28,7 @@ def initialize(options, schema, schema_type) @check_content_type = options.fetch(:check_content_type, true) @check_header = options.fetch(:check_header, true) @coerce_recursive = options.fetch(:coerce_recursive, true) + @coerce_response_values = options.fetch(:coerce_response_values, false) @optimistic_json = options.fetch(:optimistic_json, false) @parse_response_by_content_type = options.fetch(:parse_response_by_content_type, true) diff --git a/test/middleware/response_validation_open_api_3_test.rb b/test/middleware/response_validation_open_api_3_test.rb index cd45df9f..42bd4ab4 100644 --- a/test/middleware/response_validation_open_api_3_test.rb +++ b/test/middleware/response_validation_open_api_3_test.rb @@ -225,6 +225,36 @@ def app end end + describe 'response type validation' do + it "detects string value for number field when coerce_response_values is false" do + @app = new_response_rack({ integer: '726' }.to_json, {}, app_status: 400, schema: open_api_3_schema, raise: true, validate_success_only: false, coerce_response_values: false) + + e = assert_raises(Committee::InvalidResponse) do + get "/characters" + end + + assert_match(/expected integer, but received String/i, e.message) + end + + it "passes string value for number field when coerce_response_values is true" do + @app = new_response_rack({ integer: '726' }.to_json, {}, app_status: 400, schema: open_api_3_schema, raise: true, validate_success_only: false, coerce_response_values: true) + + get "/characters" + + assert_equal 400, last_response.status + end + + it "detects string value for number field by default (coerce_response_values defaults to false)" do + @app = new_response_rack({ integer: '726' }.to_json, {}, app_status: 400, schema: open_api_3_schema, raise: true, validate_success_only: false) + + e = assert_raises(Committee::InvalidResponse) do + get "/characters" + end + + assert_match(/expected integer, but received String/i, e.message) + end + end + it 'does not suppress application error' do @app = Rack::Builder.new { use Committee::Middleware::ResponseValidation, { schema: open_api_3_schema, raise: true }