Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
40 changes: 32 additions & 8 deletions lib/openapi_parser/schema_validator/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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
4 changes: 4 additions & 0 deletions lib/openapi_parser/schemas/discriminator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions lib/openapi_parser/spec_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -99,6 +100,7 @@ def rules
Rules::SecuritySchemeFieldsBefore32,
Rules::MediaTypesBefore32,
Rules::StreamingFieldsBefore32,
Rules::DefaultMappingBefore32,
]
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion sig/openapi_parser/schema_validators/base.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions sig/openapi_parser/spec_validator.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions sig/wip_types.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 38 additions & 0 deletions spec/data/openapi_3_2/default_mapping_31.yaml
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions spec/data/openapi_3_2/default_mapping_32.yaml
Original file line number Diff line number Diff line change
@@ -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
123 changes: 123 additions & 0 deletions spec/openapi_parser/schema_validator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions spec/openapi_parser/schemas/discriminator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions spec/openapi_parser/spec_validator/integration_3_2_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading