Skip to content

Commit

Permalink
Limit anyOf/oneOf discriminator to listed refs
Browse files Browse the repository at this point in the history
This addresses an issue where `resolve_ref` gets called for refs that
aren't explicitly listed in `anyOf` or `oneOf`. The [specification][0]
says:

> In both the oneOf and anyOf use cases, all possible schemas MUST be listed explicitly.

So for anyOf/oneOf discriminators, this uses the provided refs to build
a mapping of property values to schema objects for lookup during
validation. Explicit mappings are found by schema name or full ref to
support:

```yaml
discriminator:
  propertyName: petType
  mapping:
    cat: Cat
    dog: '#/components/schemas/Dog'
```

Impicit mappings are only created for refs under `#/components/schemas/`
and are overridden by any explicit mappings that point to the same
schema.

`allOf` discriminators still resolve refs because there isn't an
explicit list of allowed refs. Switched to `Schema#ref` now that it
calls `root.resolve_ref` itself.

`FIXED_FIELD_REGEX` comes from the [spec][1]:

> All the fixed fields declared above are objects that MUST use keys that match the regular expression: ^[a-zA-Z0-9\.\-_]+$.

It's used in all cases to make sure schemas are only looked up by name
if the property value is a valid schema name.

Closes: #144

[0]: https://spec.openapis.org/oas/v3.1.0#discriminator-object
[1]: https://spec.openapis.org/oas/v3.1.0#components-object
  • Loading branch information
davishmcclurg committed Sep 26, 2023
1 parent e5ca2b1 commit d5d5900
Show file tree
Hide file tree
Showing 3 changed files with 170 additions and 32 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Bug Fixes

- Limit anyOf/oneOf discriminator to listed refs
- Require discriminator `propertyName` property
- Support `Schema#ref` in subschemas

Expand Down
93 changes: 65 additions & 28 deletions lib/json_schemer/openapi31/vocab/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,52 +34,89 @@ def validate(*)
end

class Discriminator < Keyword
include Format::JSONPointer
# https://spec.openapis.org/oas/v3.1.0#components-object
FIXED_FIELD_REGEX = /\A[a-zA-Z0-9\.\-_]+$\z/

attr_accessor :skip_ref_once

def error(formatted_instance_location:, **)
"value at #{formatted_instance_location} does not match `discriminator` schema"
end

def validate(instance, instance_location, keyword_location, context)
property_name = value.fetch('propertyName')
mapping = value['mapping'] || {}
def mapping
@mapping ||= value['mapping'] || {}
end

return result(instance, instance_location, keyword_location, true) unless instance.is_a?(Hash)
return result(instance, instance_location, keyword_location, false) unless instance.key?(property_name)
def subschemas_by_property_value
@subschemas_by_property_value ||= if schema.parsed.key?('anyOf') || schema.parsed.key?('oneOf')
subschemas = schema.parsed['anyOf']&.parsed || []
subschemas += schema.parsed['oneOf']&.parsed || []

property = instance.fetch(property_name)
ref = mapping.fetch(property, property)
subschemas_by_ref = {}
subschemas_by_schema_name = {}

subschemas.each do |subschema|
subschema_ref = subschema.parsed.fetch('$ref').parsed
subschemas_by_ref[subschema_ref] = subschema

ref_schema = nil
unless ref.start_with?('#') && valid_json_pointer?(ref.delete_prefix('#'))
ref_schema = begin
root.resolve_ref(URI.join(schema.base_uri, "#/components/schemas/#{ref}"))
rescue InvalidRefPointer
nil
if subschema_ref.start_with?('#/components/schemas/')
schema_name = subschema_ref.delete_prefix('#/components/schemas/')
subschemas_by_schema_name[schema_name] = subschema if FIXED_FIELD_REGEX.match?(schema_name)
end
end
end
ref_schema ||= root.resolve_ref(URI.join(schema.base_uri, ref))

return if skip_ref_once == ref_schema.absolute_keyword_location
explicit_mapping = mapping.transform_values do |schema_name_or_ref|
subschemas_by_schema_name.fetch(schema_name_or_ref) { subschemas_by_ref.fetch(schema_name_or_ref) }
end

nested = []
implicit_mapping = subschemas_by_schema_name.reject do |_schema_name, subschema|
explicit_mapping.value?(subschema)
end

if schema.parsed.key?('anyOf') || schema.parsed.key?('oneOf')
subschemas = schema.parsed['anyOf']&.parsed || []
subschemas += schema.parsed['oneOf']&.parsed || []
subschemas.each do |subschema|
if subschema.parsed.fetch('$ref').ref_schema.absolute_keyword_location == ref_schema.absolute_keyword_location
nested << subschema.validate_instance(instance, instance_location, keyword_location, context)
implicit_mapping.merge(explicit_mapping)
else
Hash.new do |hash, property_value|
schema_name_or_ref = mapping.fetch(property_value, property_value)

subschema = nil

if FIXED_FIELD_REGEX.match?(schema_name_or_ref)
subschema = begin
schema.ref("#/components/schemas/#{schema_name_or_ref}")
rescue InvalidRefPointer
nil
end
end

subschema ||= begin
schema.ref(schema_name_or_ref)
rescue InvalidRefResolution, UnknownRef
nil
end

hash[property_value] = subschema
end
else
ref_schema.parsed['allOf']&.skip_ref_once = schema.absolute_keyword_location
nested << ref_schema.validate_instance(instance, instance_location, keyword_location, context)
end
end

def validate(instance, instance_location, keyword_location, context)
return result(instance, instance_location, keyword_location, true) unless instance.is_a?(Hash)

property_name = value.fetch('propertyName')

return result(instance, instance_location, keyword_location, false) unless instance.key?(property_name)

property_value = instance.fetch(property_name)
subschema = subschemas_by_property_value[property_value]

return result(instance, instance_location, keyword_location, false) unless subschema

return if skip_ref_once == subschema.absolute_keyword_location
subschema.parsed['allOf']&.skip_ref_once = schema.absolute_keyword_location

subschema_result = subschema.validate_instance(instance, instance_location, keyword_location, context)

result(instance, instance_location, keyword_location, (nested.any? && nested.all?(&:valid)), nested)
result(instance, instance_location, keyword_location, subschema_result.valid, subschema_result.nested)
ensure
self.skip_ref_once = nil
end
Expand Down
108 changes: 104 additions & 4 deletions test/open_api_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ def test_discriminator_specification_example
assert_equal([['format', '/components/schemas/Dog/allOf/1/properties/packSize']], schemer.validate(invalid_pack_size).map { |error| error.values_at('type', 'schema_pointer') })
assert_equal([['required', '/components/schemas/Pet'], ['discriminator', '/components/schemas/Pet']], schemer.validate(missing_pet_type).map { |error| error.values_at('type', 'schema_pointer') })
assert_equal([['required', '/components/schemas/Pet'], ['required', '/components/schemas/Cat/allOf/1']], schemer.validate(missing_name).map { |error| error.values_at('type', 'schema_pointer') })
assert_raises(JSONSchemer::UnknownRef) { schemer.validate(invalid_pet_type) }
assert_equal([['discriminator', '/components/schemas/Pet']], schemer.validate(invalid_pet_type).map { |error| error.values_at('type', 'schema_pointer') })
end

def test_all_of_discriminator
Expand Down Expand Up @@ -447,6 +447,51 @@ def test_all_of_discriminator_with_non_discriminator_ref
assert_equal([['required', '/components/schemas/Pet'], ['discriminator', '/components/schemas/Pet'], ['required', '/components/schemas/Other']], schemer.validate({}).map { |error| error.values_at('type', 'schema_pointer') })
end

def test_all_of_discriminator_with_remote_ref
schema = {
'$id' => 'http://example.com/schema',
'discriminator' => {
'propertyName' => 'petType',
'mapping' => {
'Dog' => 'http://example.com/dog'
}
}
}
schemer = JSONSchemer.schema(
schema,
:meta_schema => JSONSchemer.openapi31,
:ref_resolver => {
URI('http://example.com/schema') => schema,
URI('http://example.com/cat') => {
'allOf' => [
{ '$ref' => 'http://example.com/schema' },
CAT_SCHEMA
]
},
URI('http://example.com/dog') => {
'allOf' => [
{ '$ref' => 'http://example.com/schema' },
DOG_SCHEMA
]
}
}.to_proc
)

assert(schemer.valid_schema?)
refute(schemer.valid?(CAT))
assert(schemer.valid?(CAT.merge('petType' => 'http://example.com/cat')))
assert(schemer.valid?(DOG))

invalid_cat = INVALID_CAT.merge('petType' => 'http://example.com/cat')
invalid_cat_result = schemer.validate(invalid_cat, output_format: 'basic', resolve_enumerators: true)
assert_equal('/discriminator/allOf/1/properties/name/type', invalid_cat_result.dig('errors', 0, 'keywordLocation'))
assert_equal('http://example.com/cat#/allOf/1/properties/name/type', invalid_cat_result.dig('errors', 0, 'absoluteKeywordLocation'))

invalid_dog_result = schemer.validate(INVALID_DOG, output_format: 'basic', resolve_enumerators: true)
assert_equal('/discriminator/allOf/1/properties/bark/type', invalid_dog_result.dig('errors', 0, 'keywordLocation'))
assert_equal('http://example.com/dog#/allOf/1/properties/bark/type', invalid_dog_result.dig('errors', 0, 'absoluteKeywordLocation'))
end

def test_any_of_discriminator_without_matching_schema
openapi = {
'openapi' => '3.1.0',
Expand Down Expand Up @@ -513,6 +558,60 @@ def test_one_of_discriminator_without_matching_schema
assert_equal([['discriminator', '/components/schemas/MyResponseType']], schemer.validate(INVALID_LIZARD).map { |error| error.values_at('type', 'schema_pointer') })
end

def test_any_of_discriminator_ignores_nested_schemas
openapi = {
'openapi' => '3.1.0',
'components' => {
'schemas' => {
'MyResponseType' => {
'anyOf' => [
{ '$ref' => '#/components/schemas/Cat' },
{ '$ref' => '#/components/schemas/Cat/$defs/nah' }
],
'discriminator' => {
'propertyName' => 'petType'
}
},
'Cat' => CAT_SCHEMA.merge('$defs' => { 'nah' => {} })
}
}
}

schemer = JSONSchemer.openapi(openapi).schema('MyResponseType')

assert(schemer.valid_schema?)
assert(schemer.valid?(CAT))
refute(schemer.valid?(CAT.merge('petType' => 'nah')))
refute(schemer.valid?(CAT.merge('petType' => 'Cat/$defs/nah')))
end

def test_one_of_discriminator_ignores_nested_schemas
openapi = {
'openapi' => '3.1.0',
'components' => {
'schemas' => {
'MyResponseType' => {
'oneOf' => [
{ '$ref' => '#/components/schemas/Cat' },
{ '$ref' => '#/components/schemas/Cat/$defs/nah' }
],
'discriminator' => {
'propertyName' => 'petType'
}
},
'Cat' => CAT_SCHEMA.merge('$defs' => { 'nah' => {} })
}
}
}

schemer = JSONSchemer.openapi(openapi).schema('MyResponseType')

assert(schemer.valid_schema?)
assert(schemer.valid?(CAT))
refute(schemer.valid?(CAT.merge('petType' => 'nah')))
refute(schemer.valid?(CAT.merge('petType' => 'Cat/$defs/nah')))
end

def test_discrimator_mapping
openapi = {
'openapi' => '3.1.0',
Expand Down Expand Up @@ -542,7 +641,7 @@ def test_discrimator_mapping

assert(schemer.valid_schema?)
assert(schemer.valid?(CAT.merge('petType' => 'c')))
assert(schemer.valid?(MISTY.merge('petType' => 'Cat')))
refute(schemer.valid?(MISTY.merge('petType' => 'Cat')))
assert_equal(['/components/schemas/Cat/properties/name'], schemer.validate(INVALID_CAT.merge('petType' => 'c')).map { |error| error.fetch('schema_pointer') })
assert(schemer.valid?(DOG.merge('petType' => 'd')))
assert_equal(['/components/schemas/Dog/properties/bark'], schemer.validate(INVALID_DOG.merge('petType' => 'dog')).map { |error| error.fetch('schema_pointer') })
Expand Down Expand Up @@ -585,8 +684,9 @@ def test_non_json_pointer_discriminator
assert(schemer.valid?(CAT))
assert(schemer.valid?(MISTY))
assert_equal(['/components/schemas/Cat/properties/name'], schemer.validate(INVALID_CAT).map { |error| error.fetch('schema_pointer') })
assert(schemer.valid?(DOG))
assert_equal(['/components/schemas/Dog/properties/bark'], schemer.validate(INVALID_DOG).map { |error| error.fetch('schema_pointer') })
refute(schemer.valid?(DOG))
assert_equal(['/components/schemas/MyResponseType'], schemer.validate(INVALID_DOG).map { |error| error.fetch('schema_pointer') })
assert_equal(['/components/schemas/Dog/properties/bark'], schemer.validate(INVALID_DOG.merge('petType' => 'dog')).map { |error| error.fetch('schema_pointer') })
assert(schemer.valid?(LIZARD))
assert_equal(['/components/schemas/Lizard/properties/lovesRocks'], schemer.validate(INVALID_LIZARD).map { |error| error.fetch('schema_pointer') })
assert(schemer.valid?(MONSTER))
Expand Down

0 comments on commit d5d5900

Please sign in to comment.