Skip to content

Commit 16fe776

Browse files
Merge pull request #145 from davishmcclurg/discriminator
Limit anyOf/oneOf discriminator to listed refs
2 parents 4fafc60 + d5d5900 commit 16fe776

File tree

5 files changed

+197
-37
lines changed

5 files changed

+197
-37
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## [2.1.0] - XXXX-XX-XX
44

5+
### Bug Fixes
6+
7+
- Limit anyOf/oneOf discriminator to listed refs
8+
- Require discriminator `propertyName` property
9+
- Support `Schema#ref` in subschemas
10+
511
[2.1.0]: https://github.com/davishmcclurg/json_schemer/releases/tag/v2.1.0
612

713
## [2.0.0] - 2023-08-20

lib/json_schemer/openapi31/vocab/base.rb

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -34,51 +34,89 @@ def validate(*)
3434
end
3535

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

3940
attr_accessor :skip_ref_once
4041

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

45-
def validate(instance, instance_location, keyword_location, context)
46-
property_name = value.fetch('propertyName')
47-
mapping = value['mapping'] || {}
46+
def mapping
47+
@mapping ||= value['mapping'] || {}
48+
end
49+
50+
def subschemas_by_property_value
51+
@subschemas_by_property_value ||= if schema.parsed.key?('anyOf') || schema.parsed.key?('oneOf')
52+
subschemas = schema.parsed['anyOf']&.parsed || []
53+
subschemas += schema.parsed['oneOf']&.parsed || []
4854

49-
return result(instance, instance_location, keyword_location, true) unless instance.is_a?(Hash) && instance.key?(property_name)
55+
subschemas_by_ref = {}
56+
subschemas_by_schema_name = {}
5057

51-
property = instance.fetch(property_name)
52-
ref = mapping.fetch(property, property)
58+
subschemas.each do |subschema|
59+
subschema_ref = subschema.parsed.fetch('$ref').parsed
60+
subschemas_by_ref[subschema_ref] = subschema
5361

54-
ref_schema = nil
55-
unless ref.start_with?('#') && valid_json_pointer?(ref.delete_prefix('#'))
56-
ref_schema = begin
57-
root.resolve_ref(URI.join(schema.base_uri, "#/components/schemas/#{ref}"))
58-
rescue InvalidRefPointer
59-
nil
62+
if subschema_ref.start_with?('#/components/schemas/')
63+
schema_name = subschema_ref.delete_prefix('#/components/schemas/')
64+
subschemas_by_schema_name[schema_name] = subschema if FIXED_FIELD_REGEX.match?(schema_name)
65+
end
6066
end
61-
end
62-
ref_schema ||= root.resolve_ref(URI.join(schema.base_uri, ref))
6367

64-
return if skip_ref_once == ref_schema.absolute_keyword_location
68+
explicit_mapping = mapping.transform_values do |schema_name_or_ref|
69+
subschemas_by_schema_name.fetch(schema_name_or_ref) { subschemas_by_ref.fetch(schema_name_or_ref) }
70+
end
6571

66-
nested = []
72+
implicit_mapping = subschemas_by_schema_name.reject do |_schema_name, subschema|
73+
explicit_mapping.value?(subschema)
74+
end
6775

68-
if schema.parsed.key?('anyOf') || schema.parsed.key?('oneOf')
69-
subschemas = schema.parsed['anyOf']&.parsed || []
70-
subschemas += schema.parsed['oneOf']&.parsed || []
71-
subschemas.each do |subschema|
72-
if subschema.parsed.fetch('$ref').ref_schema.absolute_keyword_location == ref_schema.absolute_keyword_location
73-
nested << subschema.validate_instance(instance, instance_location, keyword_location, context)
76+
implicit_mapping.merge(explicit_mapping)
77+
else
78+
Hash.new do |hash, property_value|
79+
schema_name_or_ref = mapping.fetch(property_value, property_value)
80+
81+
subschema = nil
82+
83+
if FIXED_FIELD_REGEX.match?(schema_name_or_ref)
84+
subschema = begin
85+
schema.ref("#/components/schemas/#{schema_name_or_ref}")
86+
rescue InvalidRefPointer
87+
nil
88+
end
89+
end
90+
91+
subschema ||= begin
92+
schema.ref(schema_name_or_ref)
93+
rescue InvalidRefResolution, UnknownRef
94+
nil
7495
end
96+
97+
hash[property_value] = subschema
7598
end
76-
else
77-
ref_schema.parsed['allOf']&.skip_ref_once = schema.absolute_keyword_location
78-
nested << ref_schema.validate_instance(instance, instance_location, keyword_location, context)
7999
end
100+
end
101+
102+
def validate(instance, instance_location, keyword_location, context)
103+
return result(instance, instance_location, keyword_location, true) unless instance.is_a?(Hash)
104+
105+
property_name = value.fetch('propertyName')
106+
107+
return result(instance, instance_location, keyword_location, false) unless instance.key?(property_name)
108+
109+
property_value = instance.fetch(property_name)
110+
subschema = subschemas_by_property_value[property_value]
111+
112+
return result(instance, instance_location, keyword_location, false) unless subschema
113+
114+
return if skip_ref_once == subschema.absolute_keyword_location
115+
subschema.parsed['allOf']&.skip_ref_once = schema.absolute_keyword_location
116+
117+
subschema_result = subschema.validate_instance(instance, instance_location, keyword_location, context)
80118

81-
result(instance, instance_location, keyword_location, (nested.any? && nested.all?(&:valid)), nested)
119+
result(instance, instance_location, keyword_location, subschema_result.valid, subschema_result.nested)
82120
ensure
83121
self.skip_ref_once = nil
84122
end

lib/json_schemer/schema.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def validate_schema
113113
end
114114

115115
def ref(value)
116-
resolve_ref(URI.join(base_uri, value))
116+
root.resolve_ref(URI.join(base_uri, value))
117117
end
118118

119119
def validate_instance(instance, instance_location, keyword_location, context)

test/json_schemer_test.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,12 @@ def test_schema_ref
380380
'type' => 'integer',
381381
'$defs' => {
382382
'foo' => {
383+
'$id' => 'subschemer',
384+
'$defs' => {
385+
'bar' => {
386+
'required' => ['z']
387+
}
388+
},
383389
'type' => 'object',
384390
'required' => ['x', 'y'],
385391
'properties' => {
@@ -401,10 +407,20 @@ def test_schema_ref
401407

402408
refute(subschemer.valid?(1))
403409
assert_equal(
404-
[["/x", "/$defs/foo/properties/x", "string"], ["", "/$defs/foo", "required"]],
410+
[['/x', '/$defs/foo/properties/x', 'string'], ['', '/$defs/foo', 'required']],
405411
subschemer.validate({ 'x' => 1 }).map { |error| error.values_at('data_pointer', 'schema_pointer', 'type') }
406412
)
407413
assert(subschemer.valid?({ 'x' => '1', 'y' => 1 }))
414+
415+
subsubschemer = subschemer.ref('#/$defs/bar')
416+
refute(subsubschemer.valid?({ 'x' => 1 }))
417+
assert_equal(
418+
[['', '/$defs/foo/$defs/bar', 'required']],
419+
subsubschemer.validate({ 'x' => 1 }).map { |error| error.values_at('data_pointer', 'schema_pointer', 'type') }
420+
)
421+
422+
assert_equal(subschemer, subschemer.ref('#'))
423+
assert_equal(subschemer, subsubschemer.ref('#'))
408424
end
409425

410426
def test_published_meta_schemas

test/open_api_test.rb

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,9 @@ def test_discriminator_specification_example
205205
assert_equal([['enum', '/components/schemas/Cat/allOf/1/properties/huntingSkill']], schemer.validate(invalid_hunting_skill).map { |error| error.values_at('type', 'schema_pointer') })
206206
assert_equal([['required', '/components/schemas/Dog/allOf/1']], schemer.validate(missing_pack_size).map { |error| error.values_at('type', 'schema_pointer') })
207207
assert_equal([['format', '/components/schemas/Dog/allOf/1/properties/packSize']], schemer.validate(invalid_pack_size).map { |error| error.values_at('type', 'schema_pointer') })
208-
assert_equal([['required', '/components/schemas/Pet']], schemer.validate(missing_pet_type).map { |error| error.values_at('type', 'schema_pointer') })
208+
assert_equal([['required', '/components/schemas/Pet'], ['discriminator', '/components/schemas/Pet']], schemer.validate(missing_pet_type).map { |error| error.values_at('type', 'schema_pointer') })
209209
assert_equal([['required', '/components/schemas/Pet'], ['required', '/components/schemas/Cat/allOf/1']], schemer.validate(missing_name).map { |error| error.values_at('type', 'schema_pointer') })
210-
assert_raises(JSONSchemer::UnknownRef) { schemer.validate(invalid_pet_type) }
210+
assert_equal([['discriminator', '/components/schemas/Pet']], schemer.validate(invalid_pet_type).map { |error| error.values_at('type', 'schema_pointer') })
211211
end
212212

213213
def test_all_of_discriminator
@@ -404,7 +404,7 @@ def test_all_of_discriminator_subclass_schemas_work_on_their_own
404404
assert(schemer.valid?(CAT))
405405
assert(schemer.valid?(MISTY))
406406
assert_equal(['/components/schemas/Cat/allOf/1/properties/name'], schemer.validate(INVALID_CAT).map { |error| error.fetch('schema_pointer') })
407-
assert_equal([['required', '/components/schemas/Pet']], schemer.validate({}).map { |error| error.values_at('type', 'schema_pointer') })
407+
assert_equal([['required', '/components/schemas/Pet'], ['discriminator', '/components/schemas/Pet']], schemer.validate({}).map { |error| error.values_at('type', 'schema_pointer') })
408408
end
409409

410410
def test_all_of_discriminator_with_non_discriminator_ref
@@ -444,7 +444,52 @@ def test_all_of_discriminator_with_non_discriminator_ref
444444
refute(schemer.valid?(CAT))
445445
assert(schemer.valid?(CAT.merge('other' => 'y')))
446446
assert_equal(['/components/schemas/Other', '/components/schemas/Cat/allOf/2/properties/name'], schemer.validate(INVALID_CAT).map { |error| error.fetch('schema_pointer') })
447-
assert_equal([['required', '/components/schemas/Pet'], ['required', '/components/schemas/Other']], schemer.validate({}).map { |error| error.values_at('type', 'schema_pointer') })
447+
assert_equal([['required', '/components/schemas/Pet'], ['discriminator', '/components/schemas/Pet'], ['required', '/components/schemas/Other']], schemer.validate({}).map { |error| error.values_at('type', 'schema_pointer') })
448+
end
449+
450+
def test_all_of_discriminator_with_remote_ref
451+
schema = {
452+
'$id' => 'http://example.com/schema',
453+
'discriminator' => {
454+
'propertyName' => 'petType',
455+
'mapping' => {
456+
'Dog' => 'http://example.com/dog'
457+
}
458+
}
459+
}
460+
schemer = JSONSchemer.schema(
461+
schema,
462+
:meta_schema => JSONSchemer.openapi31,
463+
:ref_resolver => {
464+
URI('http://example.com/schema') => schema,
465+
URI('http://example.com/cat') => {
466+
'allOf' => [
467+
{ '$ref' => 'http://example.com/schema' },
468+
CAT_SCHEMA
469+
]
470+
},
471+
URI('http://example.com/dog') => {
472+
'allOf' => [
473+
{ '$ref' => 'http://example.com/schema' },
474+
DOG_SCHEMA
475+
]
476+
}
477+
}.to_proc
478+
)
479+
480+
assert(schemer.valid_schema?)
481+
refute(schemer.valid?(CAT))
482+
assert(schemer.valid?(CAT.merge('petType' => 'http://example.com/cat')))
483+
assert(schemer.valid?(DOG))
484+
485+
invalid_cat = INVALID_CAT.merge('petType' => 'http://example.com/cat')
486+
invalid_cat_result = schemer.validate(invalid_cat, output_format: 'basic', resolve_enumerators: true)
487+
assert_equal('/discriminator/allOf/1/properties/name/type', invalid_cat_result.dig('errors', 0, 'keywordLocation'))
488+
assert_equal('http://example.com/cat#/allOf/1/properties/name/type', invalid_cat_result.dig('errors', 0, 'absoluteKeywordLocation'))
489+
490+
invalid_dog_result = schemer.validate(INVALID_DOG, output_format: 'basic', resolve_enumerators: true)
491+
assert_equal('/discriminator/allOf/1/properties/bark/type', invalid_dog_result.dig('errors', 0, 'keywordLocation'))
492+
assert_equal('http://example.com/dog#/allOf/1/properties/bark/type', invalid_dog_result.dig('errors', 0, 'absoluteKeywordLocation'))
448493
end
449494

450495
def test_any_of_discriminator_without_matching_schema
@@ -513,6 +558,60 @@ def test_one_of_discriminator_without_matching_schema
513558
assert_equal([['discriminator', '/components/schemas/MyResponseType']], schemer.validate(INVALID_LIZARD).map { |error| error.values_at('type', 'schema_pointer') })
514559
end
515560

561+
def test_any_of_discriminator_ignores_nested_schemas
562+
openapi = {
563+
'openapi' => '3.1.0',
564+
'components' => {
565+
'schemas' => {
566+
'MyResponseType' => {
567+
'anyOf' => [
568+
{ '$ref' => '#/components/schemas/Cat' },
569+
{ '$ref' => '#/components/schemas/Cat/$defs/nah' }
570+
],
571+
'discriminator' => {
572+
'propertyName' => 'petType'
573+
}
574+
},
575+
'Cat' => CAT_SCHEMA.merge('$defs' => { 'nah' => {} })
576+
}
577+
}
578+
}
579+
580+
schemer = JSONSchemer.openapi(openapi).schema('MyResponseType')
581+
582+
assert(schemer.valid_schema?)
583+
assert(schemer.valid?(CAT))
584+
refute(schemer.valid?(CAT.merge('petType' => 'nah')))
585+
refute(schemer.valid?(CAT.merge('petType' => 'Cat/$defs/nah')))
586+
end
587+
588+
def test_one_of_discriminator_ignores_nested_schemas
589+
openapi = {
590+
'openapi' => '3.1.0',
591+
'components' => {
592+
'schemas' => {
593+
'MyResponseType' => {
594+
'oneOf' => [
595+
{ '$ref' => '#/components/schemas/Cat' },
596+
{ '$ref' => '#/components/schemas/Cat/$defs/nah' }
597+
],
598+
'discriminator' => {
599+
'propertyName' => 'petType'
600+
}
601+
},
602+
'Cat' => CAT_SCHEMA.merge('$defs' => { 'nah' => {} })
603+
}
604+
}
605+
}
606+
607+
schemer = JSONSchemer.openapi(openapi).schema('MyResponseType')
608+
609+
assert(schemer.valid_schema?)
610+
assert(schemer.valid?(CAT))
611+
refute(schemer.valid?(CAT.merge('petType' => 'nah')))
612+
refute(schemer.valid?(CAT.merge('petType' => 'Cat/$defs/nah')))
613+
end
614+
516615
def test_discrimator_mapping
517616
openapi = {
518617
'openapi' => '3.1.0',
@@ -542,7 +641,7 @@ def test_discrimator_mapping
542641

543642
assert(schemer.valid_schema?)
544643
assert(schemer.valid?(CAT.merge('petType' => 'c')))
545-
assert(schemer.valid?(MISTY.merge('petType' => 'Cat')))
644+
refute(schemer.valid?(MISTY.merge('petType' => 'Cat')))
546645
assert_equal(['/components/schemas/Cat/properties/name'], schemer.validate(INVALID_CAT.merge('petType' => 'c')).map { |error| error.fetch('schema_pointer') })
547646
assert(schemer.valid?(DOG.merge('petType' => 'd')))
548647
assert_equal(['/components/schemas/Dog/properties/bark'], schemer.validate(INVALID_DOG.merge('petType' => 'dog')).map { |error| error.fetch('schema_pointer') })
@@ -585,8 +684,9 @@ def test_non_json_pointer_discriminator
585684
assert(schemer.valid?(CAT))
586685
assert(schemer.valid?(MISTY))
587686
assert_equal(['/components/schemas/Cat/properties/name'], schemer.validate(INVALID_CAT).map { |error| error.fetch('schema_pointer') })
588-
assert(schemer.valid?(DOG))
589-
assert_equal(['/components/schemas/Dog/properties/bark'], schemer.validate(INVALID_DOG).map { |error| error.fetch('schema_pointer') })
687+
refute(schemer.valid?(DOG))
688+
assert_equal(['/components/schemas/MyResponseType'], schemer.validate(INVALID_DOG).map { |error| error.fetch('schema_pointer') })
689+
assert_equal(['/components/schemas/Dog/properties/bark'], schemer.validate(INVALID_DOG.merge('petType' => 'dog')).map { |error| error.fetch('schema_pointer') })
590690
assert(schemer.valid?(LIZARD))
591691
assert_equal(['/components/schemas/Lizard/properties/lovesRocks'], schemer.validate(INVALID_LIZARD).map { |error| error.fetch('schema_pointer') })
592692
assert(schemer.valid?(MONSTER))
@@ -597,7 +697,7 @@ def test_non_json_pointer_discriminator
597697
def test_discriminator_non_object_and_missing_property_name
598698
schemer = JSONSchemer.schema({ 'discriminator' => { 'propertyName' => 'x' } }, :meta_schema => JSONSchemer.openapi31)
599699
assert(schemer.valid?(1))
600-
assert(schemer.valid?({ 'y' => 'z' }))
700+
refute(schemer.valid?({ 'y' => 'z' }))
601701
end
602702

603703
def test_openapi31_formats

0 commit comments

Comments
 (0)