From 94cac5c8d855b13fed38a74d6c07a261d6c28388 Mon Sep 17 00:00:00 2001 From: jonathan schatz Date: Thu, 17 Sep 2026 18:46:54 -0700 Subject: [PATCH] Support Discriminator defaultMapping for OpenAPI 3.2 (parse + runtime fallback + DefaultMappingBefore32 rule) Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + lib/openapi_parser/schema_validator/base.rb | 40 ++++-- lib/openapi_parser/schemas/discriminator.rb | 4 + lib/openapi_parser/spec_validator.rb | 2 + .../rules/default_mapping_before_32.rb | 29 +++++ sig/openapi_parser/schema_validators/base.rbs | 3 +- sig/openapi_parser/spec_validator.rbs | 4 + sig/wip_types.rbs | 1 + spec/data/openapi_3_2/default_mapping_31.yaml | 38 ++++++ spec/data/openapi_3_2/default_mapping_32.yaml | 37 ++++++ spec/openapi_parser/schema_validator_spec.rb | 123 ++++++++++++++++++ .../schemas/discriminator_spec.rb | 9 ++ .../spec_validator/integration_3_2_spec.rb | 14 ++ .../rules/default_mapping_before_32_spec.rb | 82 ++++++++++++ 14 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 lib/openapi_parser/spec_validator/rules/default_mapping_before_32.rb create mode 100644 spec/data/openapi_3_2/default_mapping_31.yaml create mode 100644 spec/data/openapi_3_2/default_mapping_32.yaml create mode 100644 spec/openapi_parser/spec_validator/rules/default_mapping_before_32_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 104b0208..861d4cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ * `SecuritySchemeFieldsBefore32`: detect Security Scheme `deprecated` / `oauth2MetadataUrl` and the `deviceAuthorization` OAuth flow in pre-3.2 documents (3.2 additions) * `MediaTypesBefore32`: detect `components.mediaTypes` usage in pre-3.2 documents (3.2 addition) * `StreamingFieldsBefore32`: detect Media Type `itemSchema` / `itemEncoding` / `prefixEncoding` and nested Encoding Object `encoding` / `itemEncoding` / `prefixEncoding` usage in pre-3.2 documents (3.2 additions) + * `DefaultMappingBefore32`: detect Discriminator `defaultMapping` usage in pre-3.2 documents (3.2 addition) * expose the declared version as `OpenAPI#openapi_version` (a `Gem::Version`, or nil when the field is missing or malformed) so `SpecValidator` rules compare version ranges; a 3.2 document is checked by the 3.1-or-later rules * support 3.1-style numeric `exclusiveMinimum` / `exclusiveMaximum` in value validation (standalone bound, not a Boolean modifier on `minimum` / `maximum`) * support `type: "null"` (3.1 primitive) in value validation @@ -40,6 +41,7 @@ * add `allow_3_2_features` config to apply OpenAPI 3.2 runtime behavior to documents that declare an earlier version (default `false`: pre-3.2 documents behave as before) * support `components.mediaTypes` (OpenAPI 3.2), resolving `$ref`s in request body and response `content` in 3.2 documents; an unresolved one accepts any body unless `strict_reference_validation` is set * support `itemSchema` (OpenAPI 3.2) in the parse layer; it is not yet used to validate sequential media type bodies +* support Discriminator `defaultMapping` (OpenAPI 3.2): fallback schema when the discriminator property is absent or its value has no explicit or implicit mapping; honored in 3.2 documents ## 2.3.1 (2025-11-14) * add optional date coercion with behavior matching existing datetime coercion diff --git a/lib/openapi_parser/schema_validator/base.rb b/lib/openapi_parser/schema_validator/base.rb index 3bc91de9..25ede14a 100644 --- a/lib/openapi_parser/schema_validator/base.rb +++ b/lib/openapi_parser/schema_validator/base.rb @@ -14,17 +14,28 @@ def coerce_and_validate(_value, _schema, **_keyword_args) def validate_discriminator_schema(discriminator, value, parent_discriminator_schemas: []) property_name = discriminator.property_name - if property_name.nil? || !value.key?(property_name) - return [nil, OpenAPIParser::NotExistDiscriminatorPropertyName.new(discriminator.property_name, value, discriminator.object_reference)] + if property_name && value.key?(property_name) + mapping_key = value[property_name] + explicit_target = discriminator.mapping&.[](mapping_key) + + # it's allowed to have discriminator without mapping, then we need to lookup discriminator.property_name + # but the format is not the full path, just model name in the components + mapping_target = explicit_target || "#/components/schemas/#{mapping_key}" + + # Find object does O(n) search at worst, then caches the result, so this is ok for repeated search + resolved_schema = discriminator.root.find_object(mapping_target) end - mapping_key = value[property_name] - # it's allowed to have discriminator without mapping, then we need to lookup discriminator.property_name - # but the format is not the full path, just model name in the components - mapping_target = discriminator.mapping&.[](mapping_key) || "#/components/schemas/#{mapping_key}" + # defaultMapping (3.2) applies when the property is absent or its value + # has neither an explicit mapping nor an implicit schema match + if resolved_schema.nil? && explicit_target.nil? && (default_target = default_mapping_target(discriminator)) + mapping_target = default_target + resolved_schema = discriminator.root.find_object(default_target) + end - # Find object does O(n) search at worst, then caches the result, so this is ok for repeated search - resolved_schema = discriminator.root.find_object(mapping_target) + unless mapping_target + return [nil, OpenAPIParser::NotExistDiscriminatorPropertyName.new(discriminator.property_name, value, discriminator.object_reference)] + end unless resolved_schema return [nil, OpenAPIParser::NotExistDiscriminatorMappedSchema.new(mapping_target, discriminator.object_reference)] @@ -35,5 +46,18 @@ def validate_discriminator_schema(discriminator, value, parent_discriminator_sch **{discriminator_property_name: discriminator.property_name, parent_discriminator_schemas: parent_discriminator_schemas} ) end + + private + + # defaultMapping holds a schema name or a URI reference; only + # same-document references resolve. Ignored where 3.2 behavior doesn't apply + def default_mapping_target(discriminator) + return nil unless discriminator.root.use_3_2_features? + + target = discriminator.default_mapping + return nil unless target.is_a?(String) + + target.start_with?('#') || target.include?('/') ? target : "#/components/schemas/#{target}" + end end end diff --git a/lib/openapi_parser/schemas/discriminator.rb b/lib/openapi_parser/schemas/discriminator.rb index bbea24d1..7d83eb44 100644 --- a/lib/openapi_parser/schemas/discriminator.rb +++ b/lib/openapi_parser/schemas/discriminator.rb @@ -7,5 +7,9 @@ class Discriminator < Base # @!attribute [r] mapping # @return [Hash{String => String] openapi_attr_value :mapping + + # @!attribute [r] default_mapping + # @return [String, nil] fallback schema name or reference (OpenAPI 3.2+) + openapi_attr_value :default_mapping, schema_key: :defaultMapping end end diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index 8749a431..fc2ec173 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -26,6 +26,7 @@ require_relative 'spec_validator/rules/security_scheme_fields_before_32' require_relative 'spec_validator/rules/media_types_before_32' require_relative 'spec_validator/rules/streaming_fields_before_32' +require_relative 'spec_validator/rules/default_mapping_before_32' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -99,6 +100,7 @@ def rules Rules::SecuritySchemeFieldsBefore32, Rules::MediaTypesBefore32, Rules::StreamingFieldsBefore32, + Rules::DefaultMappingBefore32, ] end end diff --git a/lib/openapi_parser/spec_validator/rules/default_mapping_before_32.rb b/lib/openapi_parser/spec_validator/rules/default_mapping_before_32.rb new file mode 100644 index 00000000..b4359cdf --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/default_mapping_before_32.rb @@ -0,0 +1,29 @@ +module OpenAPIParser + class SpecValidator + module Rules + # `defaultMapping` on the Discriminator Object is a 3.2 addition. The + # parse layer accepts it permissively; this rule reports the version + # mismatch. + class DefaultMappingBefore32 < Rule + def check(root) + return [] unless version_before?('3.2') + + violations = [] + each_schema(root) do |schema| + raw = schema.raw_schema + next unless raw.is_a?(Hash) + + discriminator = raw['discriminator'] + next unless discriminator.is_a?(Hash) && discriminator.key?('defaultMapping') + + violations << violation( + path: "#{schema.object_reference}/discriminator/defaultMapping", + message: '`defaultMapping` on a Discriminator Object is a 3.2 addition; earlier documents have no such field', + ) + end + violations + end + end + end + end +end diff --git a/sig/openapi_parser/schema_validators/base.rbs b/sig/openapi_parser/schema_validators/base.rbs index 98514372..f04e5b00 100644 --- a/sig/openapi_parser/schema_validators/base.rbs +++ b/sig/openapi_parser/schema_validators/base.rbs @@ -12,7 +12,8 @@ module OpenAPIParser OpenAPIParser::Schemas::Discriminator discriminator, Hash[String, bot] value, ?parent_discriminator_schemas: Array[OpenAPIParser::Schemas::Schema] - ) -> [Object | nil, OpenAPIParser::OpenAPIError] + ) -> [Object | nil, (OpenAPIParser::OpenAPIError | nil)] + private def default_mapping_target: (OpenAPIParser::Schemas::Discriminator discriminator) -> (String | nil) end end end diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index 82e31c61..e9651d3d 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -151,6 +151,10 @@ module OpenAPIParser private def encoding_violations: (untyped node, untyped encodings) -> Array[SpecValidator::SpecViolation] def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class DefaultMappingBefore32 < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end diff --git a/sig/wip_types.rbs b/sig/wip_types.rbs index 212a7436..436e4b36 100644 --- a/sig/wip_types.rbs +++ b/sig/wip_types.rbs @@ -61,4 +61,5 @@ end class OpenAPIParser::Schemas::Discriminator < OpenAPIParser::Schemas::Base attr_reader property_name: (String | nil) attr_reader mapping: Hash[String, String] + attr_reader default_mapping: (String | nil) end diff --git a/spec/data/openapi_3_2/default_mapping_31.yaml b/spec/data/openapi_3_2/default_mapping_31.yaml new file mode 100644 index 00000000..d837544f --- /dev/null +++ b/spec/data/openapi_3_2/default_mapping_31.yaml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + post: + summary: Create a pet + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: Created +components: + schemas: + Pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + discriminator: + propertyName: petType + # `defaultMapping` on a Discriminator Object is a 3.2 addition; a + # 3.1 document has no such field, so its use here is a spec + # violation. + defaultMapping: Dog + Cat: + type: object + properties: + petType: + type: string + Dog: + type: object + properties: + petType: + type: string diff --git a/spec/data/openapi_3_2/default_mapping_32.yaml b/spec/data/openapi_3_2/default_mapping_32.yaml new file mode 100644 index 00000000..c9dc665b --- /dev/null +++ b/spec/data/openapi_3_2/default_mapping_32.yaml @@ -0,0 +1,37 @@ +openapi: 3.2.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + post: + summary: Create a pet + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: Created +components: + schemas: + Pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + discriminator: + propertyName: petType + # `defaultMapping` is a legitimate Discriminator Object field under + # 3.2, so no violation is expected here. + defaultMapping: Dog + Cat: + type: object + properties: + petType: + type: string + Dog: + type: object + properties: + petType: + type: string diff --git a/spec/openapi_parser/schema_validator_spec.rb b/spec/openapi_parser/schema_validator_spec.rb index 7b4decde..e3ad4222 100644 --- a/spec/openapi_parser/schema_validator_spec.rb +++ b/spec/openapi_parser/schema_validator_spec.rb @@ -1108,4 +1108,127 @@ class ValidatableTest end end end + + describe 'discriminator defaultMapping semantic (3.2)' do + let(:options) { ::OpenAPIParser::SchemaValidator::Options.new } + + def pet_schema(discriminator, version = '3.2.0', config = {}) + raw = { + 'openapi' => version, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { + 'schemas' => { + 'Pet' => { + 'oneOf' => [ + { '$ref' => '#/components/schemas/Cat' }, + { '$ref' => '#/components/schemas/Dog' }, + ], + 'discriminator' => discriminator, + }, + 'Cat' => { + 'type' => 'object', + 'required' => ['meow'], + 'properties' => { 'petType' => { 'type' => 'string' }, 'meow' => { 'type' => 'string' } }, + }, + 'Dog' => { + 'type' => 'object', + 'required' => ['bark'], + 'properties' => { 'petType' => { 'type' => 'string' }, 'bark' => { 'type' => 'string' } }, + }, + }, + }, + } + OpenAPIParser.parse(raw, { strict_reference_validation: false }.merge(config)).components.schemas['Pet'] + end + + context 'when the discriminator property matches a mapping' do + it 'validates against the mapped schema' do + schema = pet_schema('propertyName' => 'petType', 'defaultMapping' => 'Dog') + value = { 'petType' => 'Cat', 'meow' => 'meow' } + expect(OpenAPIParser::SchemaValidator.validate(value, schema, options)).to eq value + end + end + + context 'when the discriminator property value matches no schema' do + it 'falls back to the defaultMapping schema' do + schema = pet_schema('propertyName' => 'petType', 'defaultMapping' => 'Dog') + value = { 'petType' => 'Hamster', 'bark' => 'woof' } + expect(OpenAPIParser::SchemaValidator.validate(value, schema, options)).to eq value + end + + it 'validates the value against the fallback schema' do + schema = pet_schema('propertyName' => 'petType', 'defaultMapping' => 'Dog') + expect do + OpenAPIParser::SchemaValidator.validate({ 'petType' => 'Hamster' }, schema, options) + end.to raise_error(OpenAPIParser::NotExistRequiredKey) + end + end + + context 'when the discriminator property is absent' do + it 'falls back to the defaultMapping schema' do + schema = pet_schema('propertyName' => 'petType', 'defaultMapping' => 'Dog') + value = { 'bark' => 'woof' } + expect(OpenAPIParser::SchemaValidator.validate(value, schema, options)).to eq value + end + end + + context 'when an explicit mapping points at a missing schema' do + it 'reports the broken mapping instead of falling back' do + schema = pet_schema('propertyName' => 'petType', 'mapping' => { 'hamster' => '#/components/schemas/Hamster' }, 'defaultMapping' => 'Dog') + expect do + OpenAPIParser::SchemaValidator.validate({ 'petType' => 'hamster', 'bark' => 'woof' }, schema, options) + end.to raise_error(OpenAPIParser::NotExistDiscriminatorMappedSchema, /Hamster/) + end + end + + context 'when defaultMapping is a full reference' do + it 'resolves the reference' do + schema = pet_schema('propertyName' => 'petType', 'defaultMapping' => '#/components/schemas/Dog') + value = { 'bark' => 'woof' } + expect(OpenAPIParser::SchemaValidator.validate(value, schema, options)).to eq value + end + end + + context 'in a 3.1 document' do + it 'ignores defaultMapping, as before 3.2' do + schema = pet_schema({ 'propertyName' => 'petType', 'defaultMapping' => 'Dog' }, '3.1.0') + expect do + OpenAPIParser::SchemaValidator.validate({ 'bark' => 'woof' }, schema, options) + end.to raise_error(OpenAPIParser::NotExistDiscriminatorPropertyName) + expect do + OpenAPIParser::SchemaValidator.validate({ 'petType' => 'Hamster', 'bark' => 'woof' }, schema, options) + end.to raise_error(OpenAPIParser::NotExistDiscriminatorMappedSchema) + end + + it 'falls back to defaultMapping with allow_3_2_features' do + schema = pet_schema({ 'propertyName' => 'petType', 'defaultMapping' => 'Dog' }, '3.1.0', { allow_3_2_features: true }) + value = { 'bark' => 'woof' } + expect(OpenAPIParser::SchemaValidator.validate(value, schema, options)).to eq value + end + end + + context 'without defaultMapping (pre-3.2 behavior)' do + it 'still raises when the discriminator property is absent' do + schema = pet_schema('propertyName' => 'petType') + expect do + OpenAPIParser::SchemaValidator.validate({ 'bark' => 'woof' }, schema, options) + end.to raise_error(OpenAPIParser::NotExistDiscriminatorPropertyName) + end + + it 'raises a mapped-schema error when the discriminator value is null' do + schema = pet_schema('propertyName' => 'petType') + expect do + OpenAPIParser::SchemaValidator.validate({ 'petType' => nil }, schema, options) + end.to raise_error(OpenAPIParser::NotExistDiscriminatorMappedSchema) + end + + it 'still raises when the discriminator value matches no schema' do + schema = pet_schema('propertyName' => 'petType') + expect do + OpenAPIParser::SchemaValidator.validate({ 'petType' => 'Hamster' }, schema, options) + end.to raise_error(OpenAPIParser::NotExistDiscriminatorMappedSchema) + end + end + end end diff --git a/spec/openapi_parser/schemas/discriminator_spec.rb b/spec/openapi_parser/schemas/discriminator_spec.rb index ccf7154d..ab548504 100644 --- a/spec/openapi_parser/schemas/discriminator_spec.rb +++ b/spec/openapi_parser/schemas/discriminator_spec.rb @@ -188,4 +188,13 @@ end end end + + describe 'defaultMapping (OpenAPI 3.2)' do + let(:root) { OpenAPIParser.parse(load_yaml_file('./spec/data/openapi_3_2/default_mapping_32.yaml'), {}) } + + it 'is parsed onto the Discriminator object' do + discriminator = root.find_object('#/components/schemas/Pet').discriminator + expect(discriminator.default_mapping).to eq 'Dog' + end + end end diff --git a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb index 309dfd38..62ba80c9 100644 --- a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb @@ -164,4 +164,18 @@ def expect_clean(file) expect_clean('streaming_fields_32.yaml') end end + + describe 'Discriminator defaultMapping (3.2 addition)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('default_mapping_31.yaml', [:default_mapping_before32]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('default_mapping_31.yaml', [:default_mapping_before32]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('default_mapping_32.yaml') + end + end end diff --git a/spec/openapi_parser/spec_validator/rules/default_mapping_before_32_spec.rb b/spec/openapi_parser/spec_validator/rules/default_mapping_before_32_spec.rb new file mode 100644 index 00000000..4a95a105 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/default_mapping_before_32_spec.rb @@ -0,0 +1,82 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::DefaultMappingBefore32' do + def base_doc(openapi_version_string, sample_schema) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { + 'schemas' => { + 'Sample' => sample_schema, + 'Dog' => { 'type' => 'object' }, + }, + }, + } + end + + def doc_with_default_mapping(openapi_version_string) + sample = { + 'oneOf' => [{ '$ref' => '#/components/schemas/Dog' }], + 'discriminator' => { 'propertyName' => 'petType', 'defaultMapping' => 'Dog' }, + } + OpenAPIParser.parse(base_doc(openapi_version_string, sample), strict_reference_validation: false) + end + + def doc_without_default_mapping(openapi_version_string) + sample = { + 'oneOf' => [{ '$ref' => '#/components/schemas/Dog' }], + 'discriminator' => { 'propertyName' => 'petType' }, + } + OpenAPIParser.parse(base_doc(openapi_version_string, sample), strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::DefaultMappingBefore32.new(root.openapi_version).check(root) + end + + context 'with a 3.2 document using defaultMapping' do + it 'reports no violation' do + root = doc_with_default_mapping('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.2 document without defaultMapping' do + it 'reports no violation' do + root = doc_without_default_mapping('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using defaultMapping' do + it 'reports one violation pointing at the offending discriminator' do + root = doc_with_default_mapping('3.1.0') + violations = run_rule_for(root) + expect(violations.size).to eq 1 + expect(violations.first.path).to eq '#/components/schemas/Sample/discriminator/defaultMapping' + expect(violations.first.rule_name).to eq :default_mapping_before32 + end + end + + context 'with a 3.1 document without defaultMapping' do + it 'reports no violation' do + root = doc_without_default_mapping('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using defaultMapping' do + it 'reports one violation' do + root = doc_with_default_mapping('3.0.0') + expect(run_rule_for(root).size).to eq 1 + end + end + + context 'with a document whose openapi field is not a version' do + it 'reports no violation (rule skipped)' do + root = doc_with_default_mapping('not-a-version') + expect(run_rule_for(root)).to eq [] + end + end +end