Skip to content

Commit

Permalink
Support custom error messages
Browse files Browse the repository at this point in the history
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: #143
  • Loading branch information
davishmcclurg committed Oct 17, 2023
1 parent ccffac7 commit 48ee191
Show file tree
Hide file tree
Showing 11 changed files with 444 additions and 9 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
110 changes: 110 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
66 changes: 58 additions & 8 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,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

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 @@ -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)
Expand Down
Loading

0 comments on commit 48ee191

Please sign in to comment.