From df13e8d5c1b5aaeb1bf458190a33e80932defdf1 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 | 47 +++++-- 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 | 1 + 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, 381 insertions(+), 12 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 a0c90d61..9d04cbb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,14 @@ * `ExampleValueFieldsBefore32`: detect Example Object `dataValue` / `serializedValue` usage in pre-3.2 documents (3.2 additions) * `SecuritySchemeFieldsBefore32`: detect Security Scheme `deprecated` / `oauth2MetadataUrl` and the `deviceAuthorization` OAuth flow in pre-3.2 documents (3.2 additions) * `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) * return nil from `request_operation` for a document without `paths` (3.1 allows a document with only `webhooks`), instead of raising `NoMethodError` * validate a value against a schema without `type`: keywords such as `properties`, `required`, `items`, `maxLength`, `enum` and `minimum` now apply when the value has the type the keyword is for, as in JSON Schema. They were all skipped before, so a value that breaks them may now fail validation * 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 * accept a file object for array-form `type` with `format: binary` (3.1) when `coerce_value` is off, the same way as a single `type: string` * 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.5.0 (2026-10-21) * add OpenAPI 3.2 `SpecValidator` rules diff --git a/lib/openapi_parser/schema_validator/base.rb b/lib/openapi_parser/schema_validator/base.rb index e48119ed..1ad0e4be 100644 --- a/lib/openapi_parser/schema_validator/base.rb +++ b/lib/openapi_parser/schema_validator/base.rb @@ -14,19 +14,29 @@ def coerce_and_validate(_value, _schema, **_keyword_args) def validate_discriminator_schema(discriminator, value, candidate_schemas: nil, 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) + if explicit_target + mapping_target = explicit_target + resolved_schema = discriminator.root.find_object(mapping_target) + else + mapping_target = "#/components/schemas/#{mapping_key}" + # Implicit values are component names, not reference paths. + resolved_schema = discriminator.root.components&.schemas&.[](mapping_key.to_s) + resolved_schema = nil if candidate_schemas && resolved_schema && !candidate_schemas.include?(resolved_schema) + end + end + + # 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 - mapping_key = value[property_name] - - mapping_target = discriminator.mapping&.[](mapping_key) - if mapping_target - resolved_schema = discriminator.root.find_object(mapping_target) - else - mapping_target = "#/components/schemas/#{mapping_key}" - # Implicit values are component names, not reference paths. - resolved_schema = discriminator.root.components&.schemas&.[](mapping_key.to_s) - resolved_schema = nil if candidate_schemas && resolved_schema && !candidate_schemas.include?(resolved_schema) + + unless mapping_target + return [nil, OpenAPIParser::NotExistDiscriminatorPropertyName.new(discriminator.property_name, value, discriminator.object_reference)] end unless resolved_schema @@ -38,5 +48,18 @@ def validate_discriminator_schema(discriminator, value, candidate_schemas: nil, **{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 84653122..265fcc12 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -27,6 +27,7 @@ require_relative 'spec_validator/rules/example_value_fields_before_32' require_relative 'spec_validator/rules/security_scheme_fields_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 @@ -101,6 +102,7 @@ def rules Rules::ExampleValueFieldsBefore32, Rules::SecuritySchemeFieldsBefore32, 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 46b3f09d..c1247602 100644 --- a/sig/openapi_parser/schema_validators/base.rbs +++ b/sig/openapi_parser/schema_validators/base.rbs @@ -14,6 +14,7 @@ module OpenAPIParser ?candidate_schemas: Array[OpenAPIParser::Schemas::Schema] | nil, ?parent_discriminator_schemas: Array[OpenAPIParser::Schemas::Schema] ) -> [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 5cf46e38..c7f10920 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -155,6 +155,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 fc5f185f..46344beb 100644 --- a/sig/wip_types.rbs +++ b/sig/wip_types.rbs @@ -66,4 +66,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 87726ae6..e4ebfaf6 100644 --- a/spec/openapi_parser/schema_validator_spec.rb +++ b/spec/openapi_parser/schema_validator_spec.rb @@ -1246,4 +1246,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 7fab68f7..315aa674 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