From 3cd67b9855cd6d519a8fe44bc4a31438d6b447c7 Mon Sep 17 00:00:00 2001 From: David Harsha Date: Sat, 20 Jan 2024 11:00:53 -0800 Subject: [PATCH 1/3] Only parse `$schema` during schema validation Parsing the whole schema can lead to errors for malformed keyword values, eg: ``` >> JSONSchemer.schema({ 'properties' => '' }) /Users/dharsha/repos/json_schemer/lib/json_schemer/draft202012/vocab/applicator.rb:224:in `parse': undefined method `each_with_object' for an instance of String (NoMethodError) value.each_with_object({}) do |(property, subschema), out| ^^^^^^^^^^^^^^^^^ from /Users/dharsha/repos/json_schemer/lib/json_schemer/keyword.rb:14:in `initialize' ``` Instead, this creates a minimal parseable schema with just the `$schema` value, if it's present and a string. That way the meta schema can be determined as usual and then used to validate the provided schema. Addresses: https://github.com/davishmcclurg/json_schemer/issues/167 --- lib/json_schemer.rb | 15 ++++++-- test/json_schemer_test.rb | 75 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/lib/json_schemer.rb b/lib/json_schemer.rb index 49ad144..a5d314d 100644 --- a/lib/json_schemer.rb +++ b/lib/json_schemer.rb @@ -135,11 +135,11 @@ def schema(schema, meta_schema: draft202012, **options) end def valid_schema?(schema, **options) - schema(schema, **options).valid_schema? + meta_schema(schema, options).valid?(schema) end def validate_schema(schema, **options) - schema(schema, **options).validate_schema + meta_schema(schema, options).validate(schema) end def draft202012 @@ -245,6 +245,17 @@ def openapi30_document def openapi(document, **options) OpenAPI.new(document, **options) end + + private + + def meta_schema(schema, options) + parseable_schema = {} + if schema.is_a?(Hash) + meta_schema = schema['$schema'] || schema[:'$schema'] + parseable_schema['$schema'] = meta_schema if meta_schema.is_a?(String) + end + schema(parseable_schema, **options).meta_schema + end end META_SCHEMA_CALLABLES_BY_BASE_URI_STR = { diff --git a/test/json_schemer_test.rb b/test/json_schemer_test.rb index 22e31ca..4a40661 100644 --- a/test/json_schemer_test.rb +++ b/test/json_schemer_test.rb @@ -449,6 +449,81 @@ def test_it_allows_validating_schemas assert_equal([required_error], JSONSchemer.validate_schema(invalid_detected_draft4_schema).to_a) end + def test_schema_validation_invalid_meta_schema + refute(JSONSchemer.valid_schema?({ '$schema' => {} })) + end + + def test_schema_validation_parse_error + refute(JSONSchemer.valid_schema?({ 'properties' => '' })) + assert(JSONSchemer.valid_schema?({ 'properties' => {} })) + assert(JSONSchemer.valid_schema?({ 'items' => {} })) + refute(JSONSchemer.valid_schema?({ 'items' => [{}] })) + assert(JSONSchemer.valid_schema?({ 'items' => [{}] }, :meta_schema => JSONSchemer.draft201909)) + assert(JSONSchemer.valid_schema?({ '$schema' => 'https://json-schema.org/draft/2019-09/schema', 'items' => [{}] })) + assert(JSONSchemer.valid_schema?({ '$schema': 'https://json-schema.org/draft/2019-09/schema', 'items' => [{}] })) + + refute_empty(JSONSchemer.validate_schema({ 'properties' => '' }).to_a) + assert_empty(JSONSchemer.validate_schema({ 'properties' => {} }).to_a) + assert_empty(JSONSchemer.validate_schema({ 'items' => {} }).to_a) + refute_empty(JSONSchemer.validate_schema({ 'items' => [{}] }).to_a) + assert_empty(JSONSchemer.validate_schema({ 'items' => [{}] }, :meta_schema => JSONSchemer.draft201909).to_a) + assert_empty(JSONSchemer.validate_schema({ '$schema' => 'https://json-schema.org/draft/2019-09/schema', 'items' => [{}] }).to_a) + assert_empty(JSONSchemer.validate_schema({ '$schema': 'https://json-schema.org/draft/2019-09/schema', 'items' => [{}] }).to_a) + end + + def test_schema_validation_parse_error_with_custom_meta_schema + custom_meta_schema = { + '$vocabulary' => {}, + 'oneOf' => [ + { 'required' => ['$id'] }, + { 'required' => ['items'] } + ], + 'properties' => { + 'items' => { + 'type' => 'string' + } + } + } + custom_meta_schemer = JSONSchemer.schema(custom_meta_schema) + ref_resolver = { + URI('https://example.com/custom-meta-schema') => custom_meta_schema + }.to_proc + + assert(JSONSchemer.valid_schema?({})) + refute(JSONSchemer.valid_schema?({}, meta_schema: custom_meta_schemer)) + refute(JSONSchemer.valid_schema?({ '$schema' => 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver)) + refute(JSONSchemer.valid_schema?({ '$schema': 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver)) + refute(JSONSchemer.valid_schema?({ '$id' => {} })) + assert(JSONSchemer.valid_schema?({ '$id' => {} }, meta_schema: custom_meta_schemer)) + assert(JSONSchemer.valid_schema?({ '$id' => {}, '$schema' => 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver)) + assert(JSONSchemer.valid_schema?({ '$id' => {}, '$schema': 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver)) + assert(JSONSchemer.valid_schema?({ 'items' => {} })) + refute(JSONSchemer.valid_schema?({ 'items' => 'yah' })) + refute(JSONSchemer.valid_schema?({ 'items' => {} }, meta_schema: custom_meta_schemer)) + assert(JSONSchemer.valid_schema?({ 'items' => 'yah' }, meta_schema: custom_meta_schemer)) + refute(JSONSchemer.valid_schema?({ 'items' => {}, '$schema' => 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver)) + assert(JSONSchemer.valid_schema?({ 'items' => 'yah', '$schema' => 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver)) + refute(JSONSchemer.valid_schema?({ 'items' => {}, '$schema': 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver)) + assert(JSONSchemer.valid_schema?({ 'items' => 'yah', '$schema': 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver)) + + assert_empty(JSONSchemer.validate_schema({}).to_a) + refute_empty(JSONSchemer.validate_schema({}, meta_schema: custom_meta_schemer).to_a) + refute_empty(JSONSchemer.validate_schema({ '$schema' => 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver).to_a) + refute_empty(JSONSchemer.validate_schema({ '$schema': 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver).to_a) + refute_empty(JSONSchemer.validate_schema({ '$id' => {} }).to_a) + assert_empty(JSONSchemer.validate_schema({ '$id' => {} }, meta_schema: custom_meta_schemer).to_a) + assert_empty(JSONSchemer.validate_schema({ '$id' => {}, '$schema' => 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver).to_a) + assert_empty(JSONSchemer.validate_schema({ '$id' => {}, '$schema': 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver).to_a) + assert_empty(JSONSchemer.validate_schema({ 'items' => {} }).to_a) + refute_empty(JSONSchemer.validate_schema({ 'items' => 'yah' }).to_a) + refute_empty(JSONSchemer.validate_schema({ 'items' => {} }, meta_schema: custom_meta_schemer).to_a) + assert_empty(JSONSchemer.validate_schema({ 'items' => 'yah' }, meta_schema: custom_meta_schemer).to_a) + refute_empty(JSONSchemer.validate_schema({ 'items' => {}, '$schema' => 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver).to_a) + assert_empty(JSONSchemer.validate_schema({ 'items' => 'yah', '$schema' => 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver).to_a) + refute_empty(JSONSchemer.validate_schema({ 'items' => {}, '$schema': 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver).to_a) + assert_empty(JSONSchemer.validate_schema({ 'items' => 'yah', '$schema': 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver).to_a) + end + def test_non_string_keys schemer = JSONSchemer.schema({ properties: { From 91d2f2b1c37132059dd0f87ee2f6bf032240cd15 Mon Sep 17 00:00:00 2001 From: David Harsha Date: Sat, 20 Jan 2024 15:14:20 -0800 Subject: [PATCH 2/3] Support String and Pathname schema validation This aligns the schema generation (`JSONSchemer.schema`) and schema validation (`valid_schema?` and `validate_schema`) method interfaces, so that they all take the same schema object types. --- lib/json_schemer.rb | 36 ++++++++++++++++----------- test/json_schemer_test.rb | 33 ++++++++++++++++++++++++ test/schemas/$schema_invalid.json | 3 +++ test/schemas/$schema_items_array.json | 4 +++ test/schemas/items_array.json | 3 +++ test/schemas/items_object.json | 3 +++ 6 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 test/schemas/$schema_invalid.json create mode 100644 test/schemas/$schema_items_array.json create mode 100644 test/schemas/items_array.json create mode 100644 test/schemas/items_object.json diff --git a/lib/json_schemer.rb b/lib/json_schemer.rb index a5d314d..8b48846 100644 --- a/lib/json_schemer.rb +++ b/lib/json_schemer.rb @@ -114,20 +114,7 @@ class InvalidEcmaRegexp < StandardError; end class << self def schema(schema, meta_schema: draft202012, **options) - case schema - when String - schema = JSON.parse(schema) - when Pathname - base_uri = URI.parse(File.join('file:', URI::DEFAULT_PARSER.escape(schema.realpath.to_s))) - options[:base_uri] = base_uri - schema = if options.key?(:ref_resolver) - FILE_URI_REF_RESOLVER.call(base_uri) - else - ref_resolver = CachedResolver.new(&FILE_URI_REF_RESOLVER) - options[:ref_resolver] = ref_resolver - ref_resolver.call(base_uri) - end - end + schema = resolve(schema, options) unless meta_schema.is_a?(Schema) meta_schema = META_SCHEMAS_BY_BASE_URI_STR[meta_schema] || raise(UnsupportedMetaSchema, meta_schema) end @@ -135,10 +122,12 @@ def schema(schema, meta_schema: draft202012, **options) end def valid_schema?(schema, **options) + schema = resolve(schema, options) meta_schema(schema, options).valid?(schema) end def validate_schema(schema, **options) + schema = resolve(schema, options) meta_schema(schema, options).validate(schema) end @@ -248,6 +237,25 @@ def openapi(document, **options) private + def resolve(schema, options) + case schema + when String + JSON.parse(schema) + when Pathname + base_uri = URI.parse(File.join('file:', URI::DEFAULT_PARSER.escape(schema.realpath.to_s))) + options[:base_uri] = base_uri + if options.key?(:ref_resolver) + FILE_URI_REF_RESOLVER.call(base_uri) + else + ref_resolver = CachedResolver.new(&FILE_URI_REF_RESOLVER) + options[:ref_resolver] = ref_resolver + ref_resolver.call(base_uri) + end + else + schema + end + end + def meta_schema(schema, options) parseable_schema = {} if schema.is_a?(Hash) diff --git a/test/json_schemer_test.rb b/test/json_schemer_test.rb index 4a40661..0998ee6 100644 --- a/test/json_schemer_test.rb +++ b/test/json_schemer_test.rb @@ -524,6 +524,39 @@ def test_schema_validation_parse_error_with_custom_meta_schema assert_empty(JSONSchemer.validate_schema({ 'items' => 'yah', '$schema': 'https://example.com/custom-meta-schema' }, ref_resolver: ref_resolver).to_a) end + def test_schema_validation_json + refute(JSONSchemer.valid_schema?('{"$schema":{}}')) + assert(JSONSchemer.valid_schema?('{"items":{}}')) + refute(JSONSchemer.valid_schema?('{"items":[{}]}')) + assert(JSONSchemer.valid_schema?('{"items":[{}]}', :meta_schema => JSONSchemer.draft201909)) + assert(JSONSchemer.valid_schema?('{"items":[{}],"$schema":"https://json-schema.org/draft/2019-09/schema"}')) + + refute_empty(JSONSchemer.validate_schema('{"$schema":{}}').to_a) + assert_empty(JSONSchemer.validate_schema('{"items":{}}').to_a) + refute_empty(JSONSchemer.validate_schema('{"items":[{}]}').to_a) + assert_empty(JSONSchemer.validate_schema('{"items":[{}]}', :meta_schema => JSONSchemer.draft201909).to_a) + assert_empty(JSONSchemer.validate_schema('{"items":[{}],"$schema":"https://json-schema.org/draft/2019-09/schema"}').to_a) + end + + def test_schema_validation_pathname + schema_invalid = Pathname.new(__dir__).join('schemas', '$schema_invalid.json') + items_object = Pathname.new(__dir__).join('schemas', 'items_object.json') + items_array = Pathname.new(__dir__).join('schemas', 'items_array.json') + schema_items_array = Pathname.new(__dir__).join('schemas', '$schema_items_array.json') + + refute(JSONSchemer.valid_schema?(schema_invalid)) + assert(JSONSchemer.valid_schema?(items_object)) + refute(JSONSchemer.valid_schema?(items_array)) + assert(JSONSchemer.valid_schema?(items_array, :meta_schema => JSONSchemer.draft201909)) + assert(JSONSchemer.valid_schema?(schema_items_array)) + + refute_empty(JSONSchemer.validate_schema(schema_invalid).to_a) + assert_empty(JSONSchemer.validate_schema(items_object).to_a) + refute_empty(JSONSchemer.validate_schema(items_array).to_a) + assert_empty(JSONSchemer.validate_schema(items_array, :meta_schema => JSONSchemer.draft201909).to_a) + assert_empty(JSONSchemer.validate_schema(schema_items_array).to_a) + end + def test_non_string_keys schemer = JSONSchemer.schema({ properties: { diff --git a/test/schemas/$schema_invalid.json b/test/schemas/$schema_invalid.json new file mode 100644 index 0000000..81d4146 --- /dev/null +++ b/test/schemas/$schema_invalid.json @@ -0,0 +1,3 @@ +{ + "$schema": {} +} diff --git a/test/schemas/$schema_items_array.json b/test/schemas/$schema_items_array.json new file mode 100644 index 0000000..a83ec08 --- /dev/null +++ b/test/schemas/$schema_items_array.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "items": [{}] +} diff --git a/test/schemas/items_array.json b/test/schemas/items_array.json new file mode 100644 index 0000000..3423ec0 --- /dev/null +++ b/test/schemas/items_array.json @@ -0,0 +1,3 @@ +{ + "items": [{}] +} diff --git a/test/schemas/items_object.json b/test/schemas/items_object.json new file mode 100644 index 0000000..d30afd8 --- /dev/null +++ b/test/schemas/items_object.json @@ -0,0 +1,3 @@ +{ + "items": {} +} From ebb23fc8b7728e1b680bf4d52852cf523aec13ad Mon Sep 17 00:00:00 2001 From: David Harsha Date: Tue, 23 Jan 2024 13:43:15 -0800 Subject: [PATCH 3/3] Allow passing options to schema validation methods The default meta schemas don't inherit the provided options, because they're initialized with their own options and cached. To override validation-time options, they need to be passed directly to the validation methods. This is a little brittle because the validation-time options are listed separately to pull them out of the options hash. I couldn't think of a better way to split the initialization and validation options. --- lib/json_schemer.rb | 4 ++-- lib/json_schemer/schema.rb | 8 ++++---- test/json_schemer_test.rb | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/lib/json_schemer.rb b/lib/json_schemer.rb index 8b48846..4ea1aad 100644 --- a/lib/json_schemer.rb +++ b/lib/json_schemer.rb @@ -123,12 +123,12 @@ def schema(schema, meta_schema: draft202012, **options) def valid_schema?(schema, **options) schema = resolve(schema, options) - meta_schema(schema, options).valid?(schema) + meta_schema(schema, options).valid?(schema, **options.slice(:output_format, :resolve_enumerators, :access_mode)) end def validate_schema(schema, **options) schema = resolve(schema, options) - meta_schema(schema, options).validate(schema) + meta_schema(schema, options).validate(schema, **options.slice(:output_format, :resolve_enumerators, :access_mode)) end def draft202012 diff --git a/lib/json_schemer/schema.rb b/lib/json_schemer/schema.rb index b851efc..57da708 100644 --- a/lib/json_schemer/schema.rb +++ b/lib/json_schemer/schema.rb @@ -118,12 +118,12 @@ def validate(instance, output_format: @output_format, resolve_enumerators: @reso output end - def valid_schema? - meta_schema.valid?(value) + def valid_schema?(**options) + meta_schema.valid?(value, **options) end - def validate_schema - meta_schema.validate(value) + def validate_schema(**options) + meta_schema.validate(value, **options) end def ref(value) diff --git a/test/json_schemer_test.rb b/test/json_schemer_test.rb index 0998ee6..79a855b 100644 --- a/test/json_schemer_test.rb +++ b/test/json_schemer_test.rb @@ -557,6 +557,43 @@ def test_schema_validation_pathname assert_empty(JSONSchemer.validate_schema(schema_items_array).to_a) end + def test_schema_validation_options + custom_meta_schemer = JSONSchemer.schema({ + '$vocabulary' => {}, + 'properties' => { + 'yah' => { + 'readOnly' => true + } + } + }) + read_only_schemer = JSONSchemer.schema({ 'yah' => 1 }, meta_schema: custom_meta_schemer) + invalid_ref_schemer = JSONSchemer.schema({ '$ref' => {} }) + + assert(JSONSchemer.valid_schema?({ 'yah' => 1 }, meta_schema: custom_meta_schemer)) + assert(JSONSchemer.valid_schema?({ 'yah' => 1 }, meta_schema: custom_meta_schemer, access_mode: 'read')) + refute(JSONSchemer.valid_schema?({ 'yah' => 1 }, meta_schema: custom_meta_schemer, access_mode: 'write')) + + assert(read_only_schemer.valid_schema?) + assert(read_only_schemer.valid_schema?(access_mode: 'read')) + refute(read_only_schemer.valid_schema?(access_mode: 'write')) + + assert_equal(['string'], JSONSchemer.validate_schema({ '$schema' => {} }).map { |result| result.fetch('type') }) + refute(JSONSchemer.validate_schema({ '$schema' => {} }, output_format: 'basic').fetch('valid')) + assert_kind_of(Enumerator, JSONSchemer.validate_schema({ '$schema' => {} }, output_format: 'basic').fetch('errors')) + assert_kind_of(Array, JSONSchemer.validate_schema({ '$schema' => {} }, output_format: 'basic', resolve_enumerators: true).fetch('errors')) + assert_empty(JSONSchemer.validate_schema({ 'yah' => 1 }, meta_schema: custom_meta_schemer).to_a) + assert_empty(JSONSchemer.validate_schema({ 'yah' => 1 }, meta_schema: custom_meta_schemer, access_mode: 'read').to_a) + refute_empty(JSONSchemer.validate_schema({ 'yah' => 1 }, meta_schema: custom_meta_schemer, access_mode: 'write').to_a) + + assert_equal(['string'], invalid_ref_schemer.validate_schema.map { |result| result.fetch('type') }) + refute(invalid_ref_schemer.validate_schema(output_format: 'basic').fetch('valid')) + assert_kind_of(Enumerator, invalid_ref_schemer.validate_schema(output_format: 'basic').fetch('errors')) + assert_kind_of(Array, invalid_ref_schemer.validate_schema(output_format: 'basic', resolve_enumerators: true).fetch('errors')) + assert_empty(read_only_schemer.validate_schema.to_a) + assert_empty(read_only_schemer.validate_schema(access_mode: 'read').to_a) + refute_empty(read_only_schemer.validate_schema(access_mode: 'write').to_a) + end + def test_non_string_keys schemer = JSONSchemer.schema({ properties: {