Skip to content

Commit

Permalink
Merge pull request #149 from davishmcclurg/errors
Browse files Browse the repository at this point in the history
Support custom error messages
  • Loading branch information
davishmcclurg authored Nov 15, 2023
2 parents 2c1be1d + 2c3112d commit 257e9b9
Show file tree
Hide file tree
Showing 11 changed files with 536 additions and 11 deletions.
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -33,6 +38,8 @@ PLATFORMS

DEPENDENCIES
bundler (~> 2.0)
i18n
i18n-debug
json_schemer!
minitest (~> 5.0)
rake (~> 13.0)
Expand Down
160 changes: 160 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,166 @@ 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).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"=>#<Enumerator: ...>}

# 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"

# 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

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.*`

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({
'$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' }).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
```

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'`
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.*`

## OpenAPI

```ruby
Expand Down
2 changes: 2 additions & 0 deletions json_schemer.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion lib/json_schemer/draft202012/vocab.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
6 changes: 6 additions & 0 deletions lib/json_schemer/draft202012/vocab/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions lib/json_schemer/keyword.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ def schema_pointer
@schema_pointer ||= "#{parent.schema_pointer}/#{escaped_keyword}"
end

def error_key
keyword
end

private

def parse
Expand Down
5 changes: 5 additions & 0 deletions lib/json_schemer/output.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
81 changes: 71 additions & 10 deletions lib/json_schemer/result.rb
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -24,10 +29,59 @@ 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
)
@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)
if i18n?
begin
@error = i18n!
@i18n = true
rescue I18n::MissingTranslationData
end
end
end
@error
end

def i18n?
return @@i18n if defined?(@@i18n)
@@i18n = defined?(I18n) && I18n.exists?(I18N_SCOPE)
end

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
I18n.translate!(
source.absolute_keyword_location,
: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,
:absoluteKeywordLocation => source.absolute_keyword_location
)
end

Expand All @@ -38,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

Expand All @@ -54,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
Expand Down
4 changes: 4 additions & 0 deletions lib/json_schemer/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,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)
Expand Down
Loading

0 comments on commit 257e9b9

Please sign in to comment.