From 48ee19155b4586e5ceaa617fe6c52d2e01f0dff2 Mon Sep 17 00:00:00 2001 From: David Harsha Date: Sun, 15 Oct 2023 14:09:12 -0700 Subject: [PATCH 1/2] Support custom error messages This introduces two ways of defining custom error messages: the `x-error` keyword and I18n translations. `x-error` is a json_schemer-specific schema keyword that allows overriding error messsages for a schema. It can either be a string, which overrides errors for all keywords (and for the schema itself), or a hash of keyword-specific messages. Interpolation of some values is supported using `gsub` because `sprintf` logs annoying warnings if all arguments aren't present in the string. I18n translations are enabled when the `i18n` gem is loaded and a `json_schemer` translation key exists. Translation keys are based on schema $id, keyword location, meta-schema $id, and keyword. The lookup is ordered from most specific to least specific so that overrides can be applied as necessary. Notes: - Both `x-error` and I18n use special catch-all (`*`) and schema (`^`) "keywords" to support fallbacks and schema-level errors, respectively. - `x-error` uses the `x-` prefix to match the future spec for unknown keywords: https://github.com/orgs/json-schema-org/discussions/329 - The I18n check to enable translations is cached forever because I18n is slow and I didn't want it to hurt performance when it's not used. - I18n uses the ASCII unit separator (\x1F) because the `.` default doesn't work well with absolute URIs ($id and $schema). - I moved `CLASSIC_ERROR_TYPES` out of `JSONSchemer::Result` because that's actually where it's being defined. The `Struct.new` block scope doesn't hold constants like a class. Closes: https://github.com/davishmcclurg/json_schemer/issues/143 --- Gemfile.lock | 7 + README.md | 110 ++++++++++ json_schemer.gemspec | 2 + lib/json_schemer/draft202012/vocab.rb | 4 +- lib/json_schemer/draft202012/vocab/core.rb | 6 + lib/json_schemer/keyword.rb | 4 + lib/json_schemer/output.rb | 5 + lib/json_schemer/result.rb | 66 +++++- lib/json_schemer/schema.rb | 4 + test/errors_test.rb | 243 +++++++++++++++++++++ test/exe_test.rb | 2 + 11 files changed, 444 insertions(+), 9 deletions(-) create mode 100644 test/errors_test.rb diff --git a/Gemfile.lock b/Gemfile.lock index 8e3e063e..5f08ad3b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,8 +9,13 @@ PATH GEM remote: https://rubygems.org/ specs: + concurrent-ruby (1.2.2) docile (1.4.0) hana (1.3.7) + i18n (1.14.1) + concurrent-ruby (~> 1.0) + i18n-debug (1.2.0) + i18n (< 2) minitest (5.15.0) rake (13.0.6) regexp_parser (2.8.1) @@ -33,6 +38,8 @@ PLATFORMS DEPENDENCIES bundler (~> 2.0) + i18n + i18n-debug json_schemer! minitest (~> 5.0) rake (~> 13.0) diff --git a/README.md b/README.md index b119fa6a..e15946bd 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,116 @@ JSONSchemer.schema( ) ``` +## Custom Error Messages + +Error messages can be customized using the `x-error` keyword and/or [I18n](https://github.com/ruby-i18n/i18n) translations. `x-error` takes precedence if both are defined. + +### `x-error` Keyword + +```ruby +# override all errors for a schema +schemer = JSONSchemer.schema({ + 'type' => 'string', + 'x-error' => 'custom error for schema and all keywords' +}) + +schemer.validate(1).map { _1.fetch('error') } +# => ["custom error for schema and all keywords"] + +schemer.validate(1, :output_format => 'basic').fetch('error') +# => "custom error for schema and all keywords" + +# keyword-specific errors +schemer = JSONSchemer.schema({ + 'type' => 'string', + 'minLength' => 10, + 'x-error' => { + 'type' => 'custom error for `type` keyword', + # special `^` keyword for schema-level error + '^' => 'custom error for schema', + # same behavior as when `x-error` is a string + '*' => 'fallback error for schema and all keywords' + } +}) + +schemer.validate(1).map { _1.fetch('error') } +# => ["custom error for `type` keyword"] + +schemer.validate('1').map { _1.fetch('error') } +# => ["custom error for schema and all keywords"] + +schemer.validate(1, :output_format => 'basic').fetch('error') +# => "custom error for schema" +``` + +### I18n + +When the [I18n gem](https://github.com/ruby-i18n/i18n) is loaded, custom error messages are looked up under the `json_schemer` key. It may be necessary to restart your application after adding the root key because the existence check is cached for performance reasons. + +Translation keys are looked up in this order: + +1. `$LOCALE.json_schemer.errors.$ABSOLUTE_KEYWORD_LOCATION` +2. `$LOCALE.json_schemer.errors.$SCHEMA_ID.$KEYWORD_LOCATION` +3. `$LOCALE.json_schemer.errors.$KEYWORD_LOCATION` +4. `$LOCALE.json_schemer.errors.$SCHEMA_ID.$KEYWORD` +5. `$LOCALE.json_schemer.errors.$SCHEMA_ID.*` +6. `$LOCALE.json_schemer.errors.$META_SCHEMA_ID.$KEYWORD` +7. `$LOCALE.json_schemer.errors.$META_SCHEMA_ID.*` +8. `$LOCALE.json_schemer.errors.$KEYWORD` +9. `$LOCALE.json_schemer.errors.*` + +For example, this validation: + +```ruby +I18n.locale = :en # $LOCALE=en + +schemer = JSONSchemer.schema({ + '$id' => 'https://example.com/schema', # $SCHEMA_ID=https://example.com/schema + '$schema' => 'http://json-schema.org/draft-07/schema#', # $META_SCHEMA_ID=http://json-schema.org/draft-07/schema# + 'properties' => { + 'abc' => { + 'type' => 'integer' # $KEYWORD=type + } # $KEYWORD_LOCATION=#/properties/abc/type + } # $ABSOLUTE_KEYWORD_LOCATION=https://example.com/schema#/properties/abc/type +}) + +schemer.validate({ 'abc' => 'not-an-integer' }).to_a +``` + +looks up custom error messages using these keys (in order): + +1. `en.json_schemer.errors.'https://example.com/schema#/properties/abc/type'` +2. `en.json_schemer.errors.'https://example.com/schema'.'#/properties/abc/type'` +3. `en.json_schemer.errors.'#/properties/abc/type'` +4. `en.json_schemer.errors.'https://example.com/schema'.type` +5. `en.json_schemer.errors.'https://example.com/schema'.*` +6. `en.json_schemer.errors.'http://json-schema.org/draft-07/schema#'.type` +7. `en.json_schemer.errors.'http://json-schema.org/draft-07/schema#'.*` +8. `en.json_schemer.errors.type` +9. `en.json_schemer.errors.*` + +Example translations file: + +```yaml +en: + json_schemer: + errors: + 'https://example.com/schema#/properties/abc/type': custom error for absolute keyword location + 'https://example.com/schema': + '#/properties/abc/type': custom error for keyword location, nested under schema $id + 'type': custom error for `type` keyword, nested under schema $id + '^': custom error for schema, nested under schema $id + '*': fallback error for schema and all keywords, nested under schema $id + '#/properties/abc/type': custom error for keyword location + 'http://json-schema.org/draft-07/schema#': + 'type': custom error for `type` keyword, nested under meta-schema $id ($schema) + '^': custom error for schema, nested under meta-schema $id + '*': fallback error for schema and all keywords, nested under meta-schema $id ($schema) + 'type': custom error for `type` keyword + '^': custom error for schema + '*': fallback error for schema and all keywords +``` + ## OpenAPI ```ruby diff --git a/json_schemer.gemspec b/json_schemer.gemspec index be40b9d0..122a2dc6 100644 --- a/json_schemer.gemspec +++ b/json_schemer.gemspec @@ -26,6 +26,8 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "minitest", "~> 5.0" spec.add_development_dependency "simplecov", "~> 0.22" + spec.add_development_dependency "i18n" + spec.add_development_dependency "i18n-debug" spec.add_runtime_dependency "hana", "~> 1.3" spec.add_runtime_dependency "regexp_parser", "~> 2.0" diff --git a/lib/json_schemer/draft202012/vocab.rb b/lib/json_schemer/draft202012/vocab.rb index 7eaafc51..a77b70b0 100644 --- a/lib/json_schemer/draft202012/vocab.rb +++ b/lib/json_schemer/draft202012/vocab.rb @@ -16,7 +16,9 @@ module Vocab '$defs' => Core::Defs, 'definitions' => Core::Defs, # https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-01#section-8.3 - '$comment' => Core::Comment + '$comment' => Core::Comment, + # https://github.com/orgs/json-schema-org/discussions/329 + 'x-error' => Core::XError } # https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-01#section-10 APPLICATOR = { diff --git a/lib/json_schemer/draft202012/vocab/core.rb b/lib/json_schemer/draft202012/vocab/core.rb index 8d288d06..66fea2c1 100644 --- a/lib/json_schemer/draft202012/vocab/core.rb +++ b/lib/json_schemer/draft202012/vocab/core.rb @@ -119,6 +119,12 @@ def parse class Comment < Keyword; end + class XError < Keyword + def message(error_key) + value.is_a?(Hash) ? (value[error_key] || value[CATCHALL]) : value + end + end + class UnknownKeyword < Keyword def parse if value.is_a?(Hash) diff --git a/lib/json_schemer/keyword.rb b/lib/json_schemer/keyword.rb index 5b8a5cc1..091d7d8e 100644 --- a/lib/json_schemer/keyword.rb +++ b/lib/json_schemer/keyword.rb @@ -26,6 +26,10 @@ def schema_pointer @schema_pointer ||= "#{parent.schema_pointer}/#{escaped_keyword}" end + def error_key + keyword + end + private def parse diff --git a/lib/json_schemer/output.rb b/lib/json_schemer/output.rb index 9ca0c13e..74c55ec6 100644 --- a/lib/json_schemer/output.rb +++ b/lib/json_schemer/output.rb @@ -5,6 +5,11 @@ module Output attr_reader :keyword, :schema + def x_error + return @x_error if defined?(@x_error) + @x_error = schema.parsed['x-error']&.message(error_key) + end + private def result(instance, instance_location, keyword_location, valid, nested = nil, type: nil, annotation: nil, details: nil, ignore_nested: false) diff --git a/lib/json_schemer/result.rb b/lib/json_schemer/result.rb index 3612c892..e0249038 100644 --- a/lib/json_schemer/result.rb +++ b/lib/json_schemer/result.rb @@ -1,10 +1,15 @@ # frozen_string_literal: true module JSONSchemer - Result = Struct.new(:source, :instance, :instance_location, :keyword_location, :valid, :nested, :type, :annotation, :details, :ignore_nested, :nested_key) do - CLASSIC_ERROR_TYPES = Hash.new do |hash, klass| - hash[klass] = klass.name.rpartition('::').last.sub(/\A[[:alpha:]]/, &:downcase) - end + CATCHALL = '*' + I18N_SEPARATOR = "\x1F" # unit separator + I18N_SCOPE = 'json_schemer' + I18N_ERRORS_SCOPE = "#{I18N_SCOPE}#{I18N_SEPARATOR}errors" + X_ERROR_REGEX = /%\{(instance|instanceLocation|keywordLocation|absoluteKeywordLocation)\}/ + CLASSIC_ERROR_TYPES = Hash.new do |hash, klass| + hash[klass] = klass.name.rpartition('::').last.sub(/\A[[:alpha:]]/, &:downcase) + end + Result = Struct.new(:source, :instance, :instance_location, :keyword_location, :valid, :nested, :type, :annotation, :details, :ignore_nested, :nested_key) do def output(output_format) case output_format when 'classic' @@ -24,10 +29,55 @@ def output(output_format) def error return @error if defined?(@error) - resolved_instance_location = Location.resolve(instance_location) - @error = source.error( - :formatted_instance_location => resolved_instance_location.empty? ? 'root' : "`#{resolved_instance_location}`", - :details => details + if source.x_error + # not using sprintf because it warns: "too many arguments for format string" + @error = source.x_error.gsub( + X_ERROR_REGEX, + '%{instance}' => instance, + '%{instanceLocation}' => Location.resolve(instance_location), + '%{keywordLocation}' => Location.resolve(keyword_location), + '%{absoluteKeywordLocation}' => source.absolute_keyword_location + ) + else + resolved_instance_location = Location.resolve(instance_location) + formatted_instance_location = resolved_instance_location.empty? ? 'root' : "`#{resolved_instance_location}`" + @error = source.error(:formatted_instance_location => formatted_instance_location, :details => details) + @error = i18n(@error) if i18n? + end + @error + end + + def i18n? + return @@i18n if defined?(@@i18n) + @@i18n = defined?(I18n) && I18n.exists?(I18N_SCOPE) + end + + def i18n(default) + base_uri_str = source.schema.base_uri.to_s + meta_schema_base_uri_str = source.schema.meta_schema.base_uri.to_s + resolved_keyword_location = Location.resolve(keyword_location) + error_key = source.error_key + keys = [ + "#{base_uri_str}#{I18N_SEPARATOR}##{resolved_keyword_location}", + "##{resolved_keyword_location}", + "#{base_uri_str}#{I18N_SEPARATOR}#{error_key}", + "#{base_uri_str}#{I18N_SEPARATOR}#{CATCHALL}", + "#{meta_schema_base_uri_str}#{I18N_SEPARATOR}#{error_key}", + "#{meta_schema_base_uri_str}#{I18N_SEPARATOR}#{CATCHALL}", + error_key, + CATCHALL + ] + keys.map!(&:to_sym) + keys << @error + @error = I18n.t( + source.absolute_keyword_location, + :scope => I18N_ERRORS_SCOPE, + :default => keys, + :separator => I18N_SEPARATOR, + :instance => instance, + :instanceLocation => Location.resolve(instance_location), + :keywordLocation => resolved_keyword_location, + :absoluteKeywordLocation => source.absolute_keyword_location ) end diff --git a/lib/json_schemer/schema.rb b/lib/json_schemer/schema.rb index 3c6ed8d4..06cb2b31 100644 --- a/lib/json_schemer/schema.rb +++ b/lib/json_schemer/schema.rb @@ -287,6 +287,10 @@ def schema_pointer end end + def error_key + '^' + end + def fetch_format(format, *args, &block) if meta_schema == self formats.fetch(format, *args, &block) diff --git a/test/errors_test.rb b/test/errors_test.rb new file mode 100644 index 00000000..ba6a2106 --- /dev/null +++ b/test/errors_test.rb @@ -0,0 +1,243 @@ +require 'test_helper' + +class ErrorsTest < Minitest::Test + def test_x_error + schema = { + 'oneOf' => [ + { + 'x-error' => 'properties a and b were provided, however only one or the other may be specified', + 'required' => ['a'], + 'not' => { 'required' => ['b'] } + }, + { + 'x-error' => { + 'not' => '%{instance} `%{instanceLocation}` %{keywordLocation} %{absoluteKeywordLocation}' + }, + 'required' => ['b'], + 'not' => { 'required' => ['a'] } + } + ], + 'x-error' => { + '*' => 'schema error', + 'oneOf' => 'oneOf error' + } + } + data = { + 'a' => 'foo', + 'b' => 'bar' + } + assert_equal( + [ + 'properties a and b were provided, however only one or the other may be specified', + '{"a"=>"foo", "b"=>"bar"} `` /oneOf/1/not json-schemer://schema#/oneOf/1/not' + ].sort, + JSONSchemer.schema(schema).validate(data).map { |error| error.fetch('error') }.sort + ) + + assert_equal('schema error', JSONSchemer.schema(schema).validate(data, :output_format => 'basic').fetch('error')) + assert_equal('oneOf error', JSONSchemer.schema(schema).validate(data, :output_format => 'detailed').fetch('error')) + end + + def test_x_error_override + schema = { + 'required' => ['a'], + 'minProperties' => 2 + } + assert_equal( + ['object at root is missing required properties: a', 'object size at root is less than: 2'].sort, + JSONSchemer.schema(schema).validate({}).map { |error| error.fetch('error') }.sort + ) + + schema.merge!('x-error' => 'schema error') + assert_equal( + ['schema error', 'schema error'].sort, + JSONSchemer.schema(schema).validate({}).map { |error| error.fetch('error') }.sort + ) + assert_equal( + 'schema error', + JSONSchemer.schema(schema).validate({}, :output_format => 'basic').fetch('error') + ) + + schema.merge!('x-error' => { 'required' => 'required error' }) + assert_equal( + ['required error', 'object size at root is less than: 2'].sort, + JSONSchemer.schema(schema).validate({}).map { |error| error.fetch('error') }.sort + ) + + schema.merge!('x-error' => { 'required' => 'required error', 'minProperties' => 'minProperties error' }) + assert_equal( + ['required error', 'minProperties error'].sort, + JSONSchemer.schema(schema).validate({}).map { |error| error.fetch('error') }.sort + ) + + schema.merge!('x-error' => { '*' => 'catchall', 'minProperties' => 'minProperties error' }) + assert_equal( + ['catchall', 'minProperties error'].sort, + JSONSchemer.schema(schema).validate({}).map { |error| error.fetch('error') }.sort + ) + assert_equal( + 'catchall', + JSONSchemer.schema(schema).validate({}, :output_format => 'basic').fetch('error') + ) + + schema.merge!('x-error' => { '^' => 'schema error', 'minProperties' => 'minProperties error' }) + assert_equal( + ['object at root is missing required properties: a', 'minProperties error'].sort, + JSONSchemer.schema(schema).validate({}).map { |error| error.fetch('error') }.sort + ) + assert_equal( + 'schema error', + JSONSchemer.schema(schema).validate({}, :output_format => 'basic').fetch('error') + ) + + schema.merge!('x-error' => { '^' => 'schema error', '*' => 'catchall' }) + assert_equal( + ['catchall', 'catchall'].sort, + JSONSchemer.schema(schema).validate({}).map { |error| error.fetch('error') }.sort + ) + assert_equal( + 'schema error', + JSONSchemer.schema(schema).validate({}, :output_format => 'basic').fetch('error') + ) + end + + def test_x_error_precedence + schema = { + '$id' => 'https://example.com/schema', + 'required' => ['a'] + } + x_error_schema = schema.merge( + 'x-error' => { + 'required' => 'x error' + } + ) + + assert_equal('x error', JSONSchemer.schema(x_error_schema).validate({}).first.fetch('error')) + assert_equal('object at root is missing required properties: a', JSONSchemer.schema(schema).validate({}).first.fetch('error')) + + i18n({ 'https://example.com/schema#/required' => 'i18n error' }) do + assert_equal('x error', JSONSchemer.schema(x_error_schema).validate({}).first.fetch('error')) + assert_equal('i18n error', JSONSchemer.schema(schema).validate({}).first.fetch('error')) + end + end + + def test_i18n_error + schema = { + '$id' => 'https://example.com/schema', + '$schema' => 'https://json-schema.org/draft/2019-09/schema', + 'properties' => { + 'yah' => { + 'type' => 'string' + } + } + } + schemer = JSONSchemer.schema(schema) + data = { 'yah' => 1 } + + errors = { + 'https://example.com/schema#' => 'A', + 'https://example.com/schema#/properties/yah/type' => '1', + 'https://example.com/schema' => { + '#' => 'B', + '#/properties/yah/type' => '2', + '^' => 'D', + 'type' => '4', + '*' => 'E/5' + }, + '#/properties/yah/type' => '3', + '#' => 'C', + 'https://json-schema.org/draft/2019-09/schema' => { + '^' => 'F', + 'type' => '6', + '*' => 'G/7' + }, + '^' => 'H', + 'type' => '8', + '*' => 'I/9', + + 'https://example.com/differentschema#/properties/yah/type' => '?', + 'https://example.com/differentschema' => { + '#/properties/yah/type' => '?', + 'type' => '?', + '*' => '?' + }, + '?' => '?' + } + assert_equal('A', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) + assert_equal('1', i18n(errors) { schemer.validate(data).first.fetch('error') }) + + errors.delete('https://example.com/schema#') + assert_equal('B', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) + + errors.delete('https://example.com/schema#/properties/yah/type') + assert_equal('2', i18n(errors) { schemer.validate(data).first.fetch('error') }) + + errors.fetch('https://example.com/schema').delete('#') + assert_equal('C', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) + + errors.fetch('https://example.com/schema').delete('#/properties/yah/type') + assert_equal('3', i18n(errors) { schemer.validate(data).first.fetch('error') }) + + errors.delete('#') + assert_equal('D', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) + + errors.delete('#/properties/yah/type') + assert_equal('4', i18n(errors) { schemer.validate(data).first.fetch('error') }) + + errors.fetch('https://example.com/schema').delete('^') + assert_equal('E/5', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) + + errors.fetch('https://example.com/schema').delete('type') + assert_equal('E/5', i18n(errors) { schemer.validate(data).first.fetch('error') }) + + errors.fetch('https://example.com/schema').delete('*') + assert_equal('F', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) + assert_equal('6', i18n(errors) { schemer.validate(data).first.fetch('error') }) + + errors.fetch('https://json-schema.org/draft/2019-09/schema').delete('^') + assert_equal('G/7', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) + + errors.fetch('https://json-schema.org/draft/2019-09/schema').delete('type') + assert_equal('G/7', i18n(errors) { schemer.validate(data).first.fetch('error') }) + + errors.fetch('https://json-schema.org/draft/2019-09/schema').delete('*') + assert_equal('H', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) + assert_equal('8', i18n(errors) { schemer.validate(data).first.fetch('error') }) + + errors.delete('^') + assert_equal('I/9', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) + + errors.delete('type') + assert_equal('I/9', i18n(errors) { schemer.validate(data).first.fetch('error') }) + + errors.delete('*') + assert_equal('value at root does not match schema', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) + assert_equal('value at `/yah` is not a string', i18n(errors) { schemer.validate(data).first.fetch('error') }) + end + +private + + def i18n(errors) + require 'yaml' + require 'i18n' + # require 'i18n/debug' + + JSONSchemer.remove_class_variable(:@@i18n) if JSONSchemer.class_variable_defined?(:@@i18n) + # @on_lookup ||= I18n::Debug.on_lookup + # I18n::Debug.on_lookup(&@on_lookup) + + Tempfile.create(['translations', '.yml']) do |file| + file.write(YAML.dump({ 'en' => { 'json_schemer' => { 'errors' => errors } } })) + file.flush + + I18n.load_path += [file.path] + + yield + ensure + I18n.load_path -= [file.path] + end + ensure + JSONSchemer.remove_class_variable(:@@i18n) if JSONSchemer.class_variable_defined?(:@@i18n) + # I18n::Debug.on_lookup {} + end +end diff --git a/test/exe_test.rb b/test/exe_test.rb index 8f37a1c1..9b73a386 100644 --- a/test/exe_test.rb +++ b/test/exe_test.rb @@ -175,7 +175,9 @@ def test_stdin def exe(*args, **kwargs) Open3.capture3('bundle', 'exec', 'json_schemer', *args, **kwargs).tap do |_stdout, stderr, _status| + # :nocov: stderr.gsub!(RUBY_2_5_WARNING_REGEX, '') if RUBY_ENGINE == 'ruby' && RUBY_VERSION.match?(/\A2\.5\.\d+\z/) + # :nocov: end end From 2c3112d75b7f9093266d68133983c8f4403b6aea Mon Sep 17 00:00:00 2001 From: David Harsha Date: Mon, 23 Oct 2023 19:45:43 -0700 Subject: [PATCH 2/2] Add `x-error` and `i18n` keys to relevant errors These keys make it possible to determine how error messages were generated. https://github.com/davishmcclurg/json_schemer/pull/149#issuecomment-1771993047 --- README.md | 110 +++++++++++++++++++++++++++---------- lib/json_schemer/result.rb | 49 ++++++++++------- test/errors_test.rb | 51 +++++++++++++---- 3 files changed, 150 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index e15946bd..6a20f3a6 100644 --- a/README.md +++ b/README.md @@ -238,11 +238,24 @@ schemer = JSONSchemer.schema({ 'x-error' => 'custom error for schema and all keywords' }) -schemer.validate(1).map { _1.fetch('error') } -# => ["custom error for schema and all keywords"] - -schemer.validate(1, :output_format => 'basic').fetch('error') -# => "custom error for schema and all keywords" +schemer.validate(1).first +# => {"data"=>1, +# "data_pointer"=>"", +# "schema"=>{"type"=>"string", "x-error"=>"custom error for schema and all keywords"}, +# "schema_pointer"=>"", +# "root_schema"=>{"type"=>"string", "x-error"=>"custom error for schema and all keywords"}, +# "type"=>"string", +# "error"=>"custom error for schema and all keywords", +# "x-error"=>true} + +schemer.validate(1, :output_format => 'basic') +# => {"valid"=>false, +# "keywordLocation"=>"", +# "absoluteKeywordLocation"=>"json-schemer://schema#", +# "instanceLocation"=>"", +# "error"=>"custom error for schema and all keywords", +# "x-error"=>true, +# "errors"=>#} # keyword-specific errors schemer = JSONSchemer.schema({ @@ -265,6 +278,28 @@ schemer.validate('1').map { _1.fetch('error') } schemer.validate(1, :output_format => 'basic').fetch('error') # => "custom error for schema" + +# variable interpolation (instance/instanceLocation/keywordLocation/absoluteKeywordLocation) +schemer = JSONSchemer.schema({ + '$id' => 'https://example.com/schema', + 'properties' => { + 'abc' => { + 'type' => 'string', + 'x-error' => <<~ERROR + instance: %{instance} + instance location: %{instanceLocation} + keyword location: %{keywordLocation} + absolute keyword location: %{absoluteKeywordLocation} + ERROR + } + } +}) + +puts schemer.validate({ 'abc' => 1 }).first.fetch('error') +# instance: 1 +# instance location: /abc +# keyword location: /properties/abc/type +# absolute keyword location: https://example.com/schema#/properties/abc/type ``` ### I18n @@ -283,9 +318,38 @@ Translation keys are looked up in this order: 8. `$LOCALE.json_schemer.errors.$KEYWORD` 9. `$LOCALE.json_schemer.errors.*` -For example, this validation: +Example translations file: + +```yaml +en: + json_schemer: + errors: + 'https://example.com/schema#/properties/abc/type': custom error for absolute keyword location + 'https://example.com/schema': + '#/properties/abc/type': custom error for keyword location, nested under schema $id + 'type': custom error for `type` keyword, nested under schema $id + '^': custom error for schema, nested under schema $id + '*': fallback error for schema and all keywords, nested under schema $id + '#/properties/abc/type': custom error for keyword location + 'http://json-schema.org/draft-07/schema#': + 'type': custom error for `type` keyword, nested under meta-schema $id ($schema) + '^': custom error for schema, nested under meta-schema $id + '*': fallback error for schema and all keywords, nested under meta-schema $id ($schema) + 'type': custom error for `type` keyword + '^': custom error for schema + # variable interpolation (instance/instanceLocation/keywordLocation/absoluteKeywordLocation) + '*': | + fallback error for schema and all keywords + instance: %{instance} + instance location: %{instanceLocation} + keyword location: %{keywordLocation} + absolute keyword location: %{absoluteKeywordLocation} +``` + +And output: ```ruby +require 'i18n' I18n.locale = :en # $LOCALE=en schemer = JSONSchemer.schema({ @@ -298,10 +362,18 @@ schemer = JSONSchemer.schema({ } # $ABSOLUTE_KEYWORD_LOCATION=https://example.com/schema#/properties/abc/type }) -schemer.validate({ 'abc' => 'not-an-integer' }).to_a +schemer.validate({ 'abc' => 'not-an-integer' }).first +# => {"data"=>"not-an-integer", +# "data_pointer"=>"/abc", +# "schema"=>{"type"=>"integer"}, +# "schema_pointer"=>"/properties/abc", +# "root_schema"=>{"$id"=>"https://example.com/schema", "$schema"=>"http://json-schema.org/draft-07/schema#", "properties"=>{"abc"=>{"type"=>"integer"}}}, +# "type"=>"integer", +# "error"=>"custom error for absolute keyword location", +# "i18n"=>true ``` -looks up custom error messages using these keys (in order): +In the example above, custom error messsages are looked up using the following keys (in order until one is found): 1. `en.json_schemer.errors.'https://example.com/schema#/properties/abc/type'` 2. `en.json_schemer.errors.'https://example.com/schema'.'#/properties/abc/type'` @@ -313,28 +385,6 @@ looks up custom error messages using these keys (in order): 8. `en.json_schemer.errors.type` 9. `en.json_schemer.errors.*` -Example translations file: - -```yaml -en: - json_schemer: - errors: - 'https://example.com/schema#/properties/abc/type': custom error for absolute keyword location - 'https://example.com/schema': - '#/properties/abc/type': custom error for keyword location, nested under schema $id - 'type': custom error for `type` keyword, nested under schema $id - '^': custom error for schema, nested under schema $id - '*': fallback error for schema and all keywords, nested under schema $id - '#/properties/abc/type': custom error for keyword location - 'http://json-schema.org/draft-07/schema#': - 'type': custom error for `type` keyword, nested under meta-schema $id ($schema) - '^': custom error for schema, nested under meta-schema $id - '*': fallback error for schema and all keywords, nested under meta-schema $id ($schema) - 'type': custom error for `type` keyword - '^': custom error for schema - '*': fallback error for schema and all keywords -``` - ## OpenAPI ```ruby diff --git a/lib/json_schemer/result.rb b/lib/json_schemer/result.rb index e0249038..52b05d95 100644 --- a/lib/json_schemer/result.rb +++ b/lib/json_schemer/result.rb @@ -38,11 +38,18 @@ def error '%{keywordLocation}' => Location.resolve(keyword_location), '%{absoluteKeywordLocation}' => source.absolute_keyword_location ) + @x_error = true else resolved_instance_location = Location.resolve(instance_location) formatted_instance_location = resolved_instance_location.empty? ? 'root' : "`#{resolved_instance_location}`" @error = source.error(:formatted_instance_location => formatted_instance_location, :details => details) - @error = i18n(@error) if i18n? + if i18n? + begin + @error = i18n! + @i18n = true + rescue I18n::MissingTranslationData + end + end end @error end @@ -52,28 +59,25 @@ def i18n? @@i18n = defined?(I18n) && I18n.exists?(I18N_SCOPE) end - def i18n(default) + def i18n! base_uri_str = source.schema.base_uri.to_s meta_schema_base_uri_str = source.schema.meta_schema.base_uri.to_s resolved_keyword_location = Location.resolve(keyword_location) error_key = source.error_key - keys = [ - "#{base_uri_str}#{I18N_SEPARATOR}##{resolved_keyword_location}", - "##{resolved_keyword_location}", - "#{base_uri_str}#{I18N_SEPARATOR}#{error_key}", - "#{base_uri_str}#{I18N_SEPARATOR}#{CATCHALL}", - "#{meta_schema_base_uri_str}#{I18N_SEPARATOR}#{error_key}", - "#{meta_schema_base_uri_str}#{I18N_SEPARATOR}#{CATCHALL}", - error_key, - CATCHALL - ] - keys.map!(&:to_sym) - keys << @error - @error = I18n.t( + I18n.translate!( source.absolute_keyword_location, - :scope => I18N_ERRORS_SCOPE, - :default => keys, + :default => [ + "#{base_uri_str}#{I18N_SEPARATOR}##{resolved_keyword_location}", + "##{resolved_keyword_location}", + "#{base_uri_str}#{I18N_SEPARATOR}#{error_key}", + "#{base_uri_str}#{I18N_SEPARATOR}#{CATCHALL}", + "#{meta_schema_base_uri_str}#{I18N_SEPARATOR}#{error_key}", + "#{meta_schema_base_uri_str}#{I18N_SEPARATOR}#{CATCHALL}", + error_key, + CATCHALL + ].map!(&:to_sym), :separator => I18N_SEPARATOR, + :scope => I18N_ERRORS_SCOPE, :instance => instance, :instanceLocation => Location.resolve(instance_location), :keywordLocation => resolved_keyword_location, @@ -88,8 +92,13 @@ def to_output_unit 'absoluteKeywordLocation' => source.absolute_keyword_location, 'instanceLocation' => Location.resolve(instance_location) } - out['error'] = error unless valid - out['annotation'] = annotation if valid && annotation + if valid + out['annotation'] = annotation if annotation + else + out['error'] = error + out['x-error'] = true if @x_error + out['i18n'] = true if @i18n + end out end @@ -104,6 +113,8 @@ def to_classic 'type' => type || CLASSIC_ERROR_TYPES[source.class] } out['error'] = error + out['x-error'] = true if @x_error + out['i18n'] = true if @i18n out['details'] = details if details out end diff --git a/test/errors_test.rb b/test/errors_test.rb index ba6a2106..6a57bd9c 100644 --- a/test/errors_test.rb +++ b/test/errors_test.rb @@ -36,6 +36,14 @@ def test_x_error assert_equal('schema error', JSONSchemer.schema(schema).validate(data, :output_format => 'basic').fetch('error')) assert_equal('oneOf error', JSONSchemer.schema(schema).validate(data, :output_format => 'detailed').fetch('error')) + + assert_equal(true, JSONSchemer.schema(schema).validate(data, :output_format => 'basic').fetch('x-error')) + assert_equal(true, JSONSchemer.schema(schema).validate(data, :output_format => 'detailed').fetch('x-error')) + assert_equal([true, true], JSONSchemer.schema(schema).validate(data).map { |error| error.fetch('x-error') }) + + refute(JSONSchemer.schema(schema).validate(data, :output_format => 'basic').key?('i18n')) + refute(JSONSchemer.schema(schema).validate(data, :output_format => 'detailed').key?('i18n')) + assert_equal([false, false], JSONSchemer.schema(schema).validate(data).map { |error| error.key?('i18n') }) end def test_x_error_override @@ -153,7 +161,7 @@ def test_i18n_error }, '^' => 'H', 'type' => '8', - '*' => 'I/9', + '*' => 'I/9: %{instance} `%{instanceLocation}` %{keywordLocation} %{absoluteKeywordLocation}', 'https://example.com/differentschema#/properties/yah/type' => '?', 'https://example.com/differentschema' => { @@ -163,8 +171,11 @@ def test_i18n_error }, '?' => '?' } - assert_equal('A', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) - assert_equal('1', i18n(errors) { schemer.validate(data).first.fetch('error') }) + + i18n(errors) do + assert_equal('A', schemer.validate(data, :output_format => 'basic').fetch('error')) + assert_equal('1', schemer.validate(data).first.fetch('error')) + end errors.delete('https://example.com/schema#') assert_equal('B', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) @@ -191,8 +202,10 @@ def test_i18n_error assert_equal('E/5', i18n(errors) { schemer.validate(data).first.fetch('error') }) errors.fetch('https://example.com/schema').delete('*') - assert_equal('F', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) - assert_equal('6', i18n(errors) { schemer.validate(data).first.fetch('error') }) + i18n(errors) do + assert_equal('F', schemer.validate(data, :output_format => 'basic').fetch('error')) + assert_equal('6', schemer.validate(data).first.fetch('error')) + end errors.fetch('https://json-schema.org/draft/2019-09/schema').delete('^') assert_equal('G/7', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) @@ -201,18 +214,34 @@ def test_i18n_error assert_equal('G/7', i18n(errors) { schemer.validate(data).first.fetch('error') }) errors.fetch('https://json-schema.org/draft/2019-09/schema').delete('*') - assert_equal('H', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) - assert_equal('8', i18n(errors) { schemer.validate(data).first.fetch('error') }) + i18n(errors) do + assert_equal('H', schemer.validate(data, :output_format => 'basic').fetch('error')) + assert_equal('8', schemer.validate(data).first.fetch('error')) + end errors.delete('^') - assert_equal('I/9', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) + assert_equal('I/9: {"yah"=>1} `` https://example.com/schema#', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) errors.delete('type') - assert_equal('I/9', i18n(errors) { schemer.validate(data).first.fetch('error') }) + assert_equal('I/9: 1 `/yah` /properties/yah/type https://example.com/schema#/properties/yah/type', i18n(errors) { schemer.validate(data).first.fetch('error') }) + + i18n(errors) do + assert_equal(true, schemer.validate(data).first.fetch('i18n')) + refute(schemer.validate(data).first.key?('x-error')) + assert_equal(true, schemer.validate(data, :output_format => 'basic').fetch('i18n')) + refute(schemer.validate(data, :output_format => 'basic').key?('x-error')) + end errors.delete('*') - assert_equal('value at root does not match schema', i18n(errors) { schemer.validate(data, :output_format => 'basic').fetch('error') }) - assert_equal('value at `/yah` is not a string', i18n(errors) { schemer.validate(data).first.fetch('error') }) + i18n(errors) do + assert_equal('value at root does not match schema', schemer.validate(data, :output_format => 'basic').fetch('error')) + assert_equal('value at `/yah` is not a string', schemer.validate(data).first.fetch('error')) + + refute(schemer.validate(data).first.key?('i18n')) + refute(schemer.validate(data).first.key?('x-error')) + refute(schemer.validate(data, :output_format => 'basic').key?('i18n')) + refute(schemer.validate(data, :output_format => 'basic').key?('x-error')) + end end private