diff --git a/CHANGELOG.md b/CHANGELOG.md index e6785f8..583d240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ * support root-level `webhooks` (OpenAPI 3.1) in the parse layer * support `const` (OpenAPI 3.1) with exact-equality value validation * support `contentSchema` (OpenAPI 3.1) in the parse layer +* coerce String values into array-form `type` (3.1) when `coerce_value` is on, so params like `type: [integer, "null"]` accept `"5"` ## 2.3.1 (2025-11-14) * add optional date coercion with behavior matching existing datetime coercion diff --git a/lib/openapi_parser/schema_validator.rb b/lib/openapi_parser/schema_validator.rb index 7221c44..e13ca99 100644 --- a/lib/openapi_parser/schema_validator.rb +++ b/lib/openapi_parser/schema_validator.rb @@ -82,6 +82,8 @@ def validate_schema(value, schema, **keyword_args) return [nil, OpenAPIParser::ValidateError.new(value, "const #{schema.const.inspect}", schema.object_reference)] if value != schema.const end + return coerce_into_type_array(value, schema, **keyword_args) if coerce_into_type_array?(value, schema) + if (v = validator(value, schema)) if keyword_args.empty? return v.coerce_and_validate(value, schema) @@ -124,7 +126,12 @@ def validator(value, schema) effective_type = matched end - case effective_type + validator_for_type(effective_type) + end + + # @return [OpenAPIParser::SchemaValidator::Base] + def validator_for_type(type) + case type when 'string' string_validator when 'integer' @@ -147,6 +154,36 @@ def validator(value, schema) end end + # 3.1: request params arrive as Strings, so `type: [integer, "null"]` + # never matches by class. When coercing, try each listed type instead. + # @return [Boolean] + def coerce_into_type_array?(value, schema) + return false unless @coerce_value && value.is_a?(String) + return false if schema.any_of || schema.all_of || schema.one_of + + schema.type.is_a?(Array) && pick_array_type(value, schema.type).nil? + end + + # 3.1: returns the first listed type that coerces, or the first type's error. + # @return [Array] coerced value and error + def coerce_into_type_array(value, schema, **keyword_args) + first_error = nil + (schema.type - ['null']).each do |type| + v = validator_for_type(type) + coerced, err = if keyword_args.empty? + v.coerce_and_validate(value, schema) + else + v.coerce_and_validate(value, schema, **keyword_args) + end + return [coerced, nil] if err.nil? + + first_error ||= err + end + return [nil, first_error] if first_error + + type_mismatch_validator.coerce_and_validate(value, schema) + end + def type_mismatch_validator @type_mismatch_validator ||= OpenAPIParser::SchemaValidator::TypeMismatchValidator.new(self, @coerce_value) end diff --git a/sig/openapi_parser/schema_validator.rbs b/sig/openapi_parser/schema_validator.rbs index 160c999..7414480 100644 --- a/sig/openapi_parser/schema_validator.rbs +++ b/sig/openapi_parser/schema_validator.rbs @@ -27,6 +27,9 @@ module OpenAPIParser private def validator: (Object value, OpenAPIParser::Schemas::Schema schema) -> [OpenAPIParser::SchemaValidator::Base, OpenAPIParser::validate_error] + def validator_for_type: (String? type) -> OpenAPIParser::SchemaValidator::Base + def coerce_into_type_array?: (Object value, OpenAPIParser::Schemas::Schema schema) -> bool + def coerce_into_type_array: (Object value, OpenAPIParser::Schemas::Schema schema, **untyped) -> [Object, OpenAPIParser::validate_error] def string_validator: -> OpenAPIParser::SchemaValidator::StringValidator def integer_validator: -> OpenAPIParser::SchemaValidator::IntegerValidator def float_validator: -> OpenAPIParser::SchemaValidator::FloatValidator diff --git a/spec/openapi_parser/schema_validator_spec.rb b/spec/openapi_parser/schema_validator_spec.rb index 7b4decd..35e1b31 100644 --- a/spec/openapi_parser/schema_validator_spec.rb +++ b/spec/openapi_parser/schema_validator_spec.rb @@ -1108,4 +1108,66 @@ class ValidatableTest end end end + + describe 'array-form type coercion (3.1)' do + let(:options) { ::OpenAPIParser::SchemaValidator::Options.new(coerce_value: true) } + let(:target) { { 'type' => ['integer', 'null'], 'minimum' => 0 } } + let(:schema) do + raw = { + 'openapi' => '3.1.0', + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'Target' => target } }, + } + OpenAPIParser.parse(raw, strict_reference_validation: false).components.schemas['Target'] + end + + context 'when a String coerces into a listed type' do + it 'returns the coerced value' do + expect(OpenAPIParser::SchemaValidator.validate('5', schema, options)).to eq 5 + end + end + + context 'when an earlier listed type does not coerce' do + let(:target) { { 'type' => ['integer', 'boolean'] } } + + it 'tries the next listed type' do + expect(OpenAPIParser::SchemaValidator.validate('true', schema, options)).to eq true + end + end + + context 'when string is listed' do + let(:target) { { 'type' => ['integer', 'string'] } } + + it 'keeps the String' do + expect(OpenAPIParser::SchemaValidator.validate('5', schema, options)).to eq '5' + end + end + + context 'when no listed type coerces' do + it 'raises the first type error' do + expect do + OpenAPIParser::SchemaValidator.validate('abc', schema, options) + end.to raise_error(OpenAPIParser::ValidateError, /integer/) + end + end + + context 'when the coerced value violates a constraint' do + it 'raises a validation error' do + expect do + OpenAPIParser::SchemaValidator.validate('-1', schema, options) + end.to raise_error(OpenAPIParser::LessThanMinimum) + end + end + + context 'when coerce_value is off' do + let(:options) { ::OpenAPIParser::SchemaValidator::Options.new } + + it 'raises a type-mismatch error' do + expect do + OpenAPIParser::SchemaValidator.validate('5', schema, options) + end.to raise_error(OpenAPIParser::ValidateError) + end + end + end end