Skip to content
Closed
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 @@ -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
Expand Down
47 changes: 35 additions & 12 deletions lib/openapi_parser/schema_validator/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
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 @@ -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
Expand Down Expand Up @@ -101,6 +102,7 @@ def rules
Rules::ExampleValueFieldsBefore32,
Rules::SecuritySchemeFieldsBefore32,
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
1 change: 1 addition & 0 deletions sig/openapi_parser/schema_validators/base.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions sig/openapi_parser/spec_validator.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions sig/wip_types.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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
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