json_schemer 2.0.0 → 2.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -2,6 +2,17 @@
2
2
  module JSONSchemer
3
3
  module Draft6
4
4
  BASE_URI = URI('http://json-schema.org/draft-06/schema#')
5
+ FORMATS = Draft7::FORMATS.dup
6
+ FORMATS.delete('date')
7
+ FORMATS.delete('time')
8
+ FORMATS.delete('idn-email')
9
+ FORMATS.delete('idn-hostname')
10
+ FORMATS.delete('iri')
11
+ FORMATS.delete('iri-reference')
12
+ FORMATS.delete('relative-json-pointer')
13
+ FORMATS.delete('regex')
14
+ CONTENT_ENCODINGS = Draft7::CONTENT_ENCODINGS
15
+ CONTENT_MEDIA_TYPES = Draft7::CONTENT_MEDIA_TYPES
5
16
  SCHEMA = {
6
17
  '$schema' => 'http://json-schema.org/draft-06/schema#',
7
18
  '$id' => 'http://json-schema.org/draft-06/schema#',
@@ -2,6 +2,11 @@
2
2
  module JSONSchemer
3
3
  module Draft7
4
4
  BASE_URI = URI('http://json-schema.org/draft-07/schema#')
5
+ FORMATS = Draft201909::FORMATS.dup
6
+ FORMATS.delete('duration')
7
+ FORMATS.delete('uuid')
8
+ CONTENT_ENCODINGS = Draft201909::CONTENT_ENCODINGS
9
+ CONTENT_MEDIA_TYPES = Draft201909::CONTENT_MEDIA_TYPES
5
10
  SCHEMA = {
6
11
  '$schema' => 'http://json-schema.org/draft-07/schema#',
7
12
  '$id' => 'http://json-schema.org/draft-07/schema#',
@@ -35,7 +35,7 @@ module JSONSchemer
35
35
  end
36
36
  end
37
37
 
38
- class ContentEncoding < Keyword
38
+ class ContentEncoding < Draft202012::Vocab::Content::ContentEncoding
39
39
  def error(formatted_instance_location:, **)
40
40
  "string at #{formatted_instance_location} could not be decoded using encoding: #{value}"
41
41
  end
@@ -43,13 +43,13 @@ module JSONSchemer
43
43
  def validate(instance, instance_location, keyword_location, _context)
44
44
  return result(instance, instance_location, keyword_location, true) unless instance.is_a?(String)
45
45
 
46
- valid, annotation = Format.decode_content_encoding(instance, value)
46
+ valid, annotation = parsed.call(instance)
47
47
 
48
48
  result(instance, instance_location, keyword_location, valid, :annotation => annotation)
49
49
  end
50
50
  end
51
51
 
52
- class ContentMediaType < Keyword
52
+ class ContentMediaType < Draft202012::Vocab::Content::ContentMediaType
53
53
  def error(formatted_instance_location:, **)
54
54
  "string at #{formatted_instance_location} could not be parsed using media type: #{value}"
55
55
  end
@@ -58,7 +58,7 @@ module JSONSchemer
58
58
  return result(instance, instance_location, keyword_location, true) unless instance.is_a?(String)
59
59
 
60
60
  decoded_instance = context.adjacent_results[ContentEncoding]&.annotation || instance
61
- valid, annotation = Format.parse_content_media_type(decoded_instance, value)
61
+ valid, annotation = parsed.call(decoded_instance)
62
62
 
63
63
  result(instance, instance_location, keyword_location, valid, :annotation => annotation)
64
64
  end
@@ -1,15 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
  module JSONSchemer
3
3
  module Format
4
- include Duration
5
- include Email
6
- include Hostname
7
- include JSONPointer
8
- include URITemplate
4
+ # https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-01#section-7.3
5
+ DATE_TIME = proc do |instance, _format|
6
+ !instance.is_a?(String) || valid_date_time?(instance)
7
+ end
8
+ DATE = proc do |instance, _format|
9
+ !instance.is_a?(String) || valid_date_time?("#{instance}T04:05:06.123456789+07:00")
10
+ end
11
+ TIME = proc do |instance, _format|
12
+ !instance.is_a?(String) || valid_date_time?("2001-02-03T#{instance}")
13
+ end
14
+ DURATION = proc do |instance, _format|
15
+ !instance.is_a?(String) || valid_duration?(instance)
16
+ end
17
+ # https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-01#section-7.3.2
18
+ EMAIL = proc do |instance, _format|
19
+ !instance.is_a?(String) || instance.ascii_only? && valid_email?(instance)
20
+ end
21
+ IDN_EMAIL = proc do |instance, _format|
22
+ !instance.is_a?(String) || valid_email?(instance)
23
+ end
24
+ # https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-01#section-7.3.3
25
+ HOSTNAME = proc do |instance, _format|
26
+ !instance.is_a?(String) || instance.ascii_only? && valid_hostname?(instance)
27
+ end
28
+ IDN_HOSTNAME = proc do |instance, _format|
29
+ !instance.is_a?(String) || valid_hostname?(instance)
30
+ end
31
+ # https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-01#section-7.3.4
32
+ IPV4 = proc do |instance, _format|
33
+ !instance.is_a?(String) || valid_ip?(instance, Socket::AF_INET)
34
+ end
35
+ IPV6 = proc do |instance, _format|
36
+ !instance.is_a?(String) || valid_ip?(instance, Socket::AF_INET6)
37
+ end
38
+ # https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-01#section-7.3.5
39
+ URI = proc do |instance, _format|
40
+ !instance.is_a?(String) || valid_uri?(instance)
41
+ end
42
+ URI_REFERENCE = proc do |instance, _format|
43
+ !instance.is_a?(String) || valid_uri_reference?(instance)
44
+ end
45
+ IRI = proc do |instance, _format|
46
+ !instance.is_a?(String) || valid_uri?(iri_escape(instance))
47
+ end
48
+ IRI_REFERENCE = proc do |instance, _format|
49
+ !instance.is_a?(String) || valid_uri_reference?(iri_escape(instance))
50
+ end
51
+ UUID = proc do |instance, _format|
52
+ !instance.is_a?(String) || valid_uuid?(instance)
53
+ end
54
+ # https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-01#section-7.3.6
55
+ URI_TEMPLATE = proc do |instance, _format|
56
+ !instance.is_a?(String) || valid_uri_template?(instance)
57
+ end
58
+ # https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-01#section-7.3.7
59
+ JSON_POINTER = proc do |instance, _format|
60
+ !instance.is_a?(String) || valid_json_pointer?(instance)
61
+ end
62
+ RELATIVE_JSON_POINTER = proc do |instance, _format|
63
+ !instance.is_a?(String) || valid_relative_json_pointer?(instance)
64
+ end
65
+ # https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-01#section-7.3.8
66
+ REGEX = proc do |instance, _format|
67
+ !instance.is_a?(String) || valid_regex?(instance)
68
+ end
9
69
 
10
70
  DATE_TIME_OFFSET_REGEX = /(Z|[\+\-]([01][0-9]|2[0-3]):[0-5][0-9])\z/i.freeze
11
- HOUR_24_REGEX = /T24/.freeze
12
- LEAP_SECOND_REGEX = /T\d{2}:\d{2}:6/.freeze
71
+ DATE_TIME_SEPARATOR_CHARACTER_CLASS = '[Tt\s]'
72
+ HOUR_24_REGEX = /#{DATE_TIME_SEPARATOR_CHARACTER_CLASS}24:/.freeze
73
+ LEAP_SECOND_REGEX = /#{DATE_TIME_SEPARATOR_CHARACTER_CLASS}\d{2}:\d{2}:6/.freeze
13
74
  IP_REGEX = /\A[\h:.]+\z/.freeze
14
75
  INVALID_QUERY_REGEX = /\s/.freeze
15
76
  IRI_ESCAPE_REGEX = /[^[:ascii:]]/
@@ -20,6 +81,12 @@ module JSONSchemer
20
81
  end.freeze
21
82
 
22
83
  class << self
84
+ include Duration
85
+ include Email
86
+ include Hostname
87
+ include JSONPointer
88
+ include URITemplate
89
+
23
90
  def percent_encode(data, regexp)
24
91
  data = data.dup
25
92
  data.force_encoding(Encoding::ASCII_8BIT)
@@ -27,126 +94,55 @@ module JSONSchemer
27
94
  data.force_encoding(Encoding::US_ASCII)
28
95
  end
29
96
 
30
- def decode_content_encoding(data, content_encoding)
31
- case content_encoding
32
- when 'base64'
33
- begin
34
- [true, Base64.strict_decode64(data)]
35
- rescue
36
- [false, nil]
37
- end
38
- else
39
- raise UnknownContentEncoding, content_encoding
40
- end
97
+ def valid_date_time?(data)
98
+ return false if HOUR_24_REGEX.match?(data)
99
+ datetime = DateTime.rfc3339(data)
100
+ return false if LEAP_SECOND_REGEX.match?(data) && datetime.new_offset.strftime('%H:%M') != '23:59'
101
+ DATE_TIME_OFFSET_REGEX.match?(data)
102
+ rescue ArgumentError
103
+ false
41
104
  end
42
105
 
43
- def parse_content_media_type(data, content_media_type)
44
- case content_media_type
45
- when 'application/json'
46
- begin
47
- [true, JSON.parse(data)]
48
- rescue
49
- [false, nil]
50
- end
51
- else
52
- raise UnknownContentMediaType, content_media_type
53
- end
106
+ def valid_ip?(data, family)
107
+ IPAddr.new(data, family)
108
+ IP_REGEX.match?(data)
109
+ rescue IPAddr::Error
110
+ false
54
111
  end
55
- end
56
112
 
57
- def valid_spec_format?(data, format)
58
- case format
59
- when 'date-time'
60
- valid_date_time?(data)
61
- when 'date'
62
- valid_date_time?("#{data}T04:05:06.123456789+07:00")
63
- when 'time'
64
- valid_date_time?("2001-02-03T#{data}")
65
- when 'email'
66
- data.ascii_only? && valid_email?(data)
67
- when 'idn-email'
68
- valid_email?(data)
69
- when 'hostname'
70
- data.ascii_only? && valid_hostname?(data)
71
- when 'idn-hostname'
72
- valid_hostname?(data)
73
- when 'ipv4'
74
- valid_ip?(data, Socket::AF_INET)
75
- when 'ipv6'
76
- valid_ip?(data, Socket::AF_INET6)
77
- when 'uri'
78
- valid_uri?(data)
79
- when 'uri-reference'
80
- valid_uri_reference?(data)
81
- when 'iri'
82
- valid_uri?(iri_escape(data))
83
- when 'iri-reference'
84
- valid_uri_reference?(iri_escape(data))
85
- when 'uri-template'
86
- valid_uri_template?(data)
87
- when 'json-pointer'
88
- valid_json_pointer?(data)
89
- when 'relative-json-pointer'
90
- valid_relative_json_pointer?(data)
91
- when 'regex'
92
- valid_regex?(data)
93
- when 'duration'
94
- valid_duration?(data)
95
- when 'uuid'
96
- valid_uuid?(data)
97
- else
98
- raise UnknownFormat, format
113
+ def parse_uri_scheme(data)
114
+ scheme, _userinfo, _host, _port, _registry, _path, opaque, query, _fragment = ::URI::RFC3986_PARSER.split(data)
115
+ # ::URI::RFC3986_PARSER.parse allows spaces in these and I don't think it should
116
+ raise ::URI::InvalidURIError if INVALID_QUERY_REGEX.match?(query) || INVALID_QUERY_REGEX.match?(opaque)
117
+ scheme
99
118
  end
100
- end
101
-
102
- def valid_date_time?(data)
103
- return false if HOUR_24_REGEX.match?(data)
104
- datetime = DateTime.rfc3339(data)
105
- return false if LEAP_SECOND_REGEX.match?(data) && datetime.new_offset.strftime('%H:%M') != '23:59'
106
- DATE_TIME_OFFSET_REGEX.match?(data)
107
- rescue ArgumentError
108
- false
109
- end
110
-
111
- def valid_ip?(data, family)
112
- IPAddr.new(data, family)
113
- IP_REGEX.match?(data)
114
- rescue IPAddr::Error
115
- false
116
- end
117
-
118
- def parse_uri_scheme(data)
119
- scheme, _userinfo, _host, _port, _registry, _path, opaque, query, _fragment = URI::RFC3986_PARSER.split(data)
120
- # URI::RFC3986_PARSER.parse allows spaces in these and I don't think it should
121
- raise URI::InvalidURIError if INVALID_QUERY_REGEX.match?(query) || INVALID_QUERY_REGEX.match?(opaque)
122
- scheme
123
- end
124
119
 
125
- def valid_uri?(data)
126
- !!parse_uri_scheme(data)
127
- rescue URI::InvalidURIError
128
- false
129
- end
120
+ def valid_uri?(data)
121
+ !!parse_uri_scheme(data)
122
+ rescue ::URI::InvalidURIError
123
+ false
124
+ end
130
125
 
131
- def valid_uri_reference?(data)
132
- parse_uri_scheme(data)
133
- true
134
- rescue URI::InvalidURIError
135
- false
136
- end
126
+ def valid_uri_reference?(data)
127
+ parse_uri_scheme(data)
128
+ true
129
+ rescue ::URI::InvalidURIError
130
+ false
131
+ end
137
132
 
138
- def iri_escape(data)
139
- Format.percent_encode(data, IRI_ESCAPE_REGEX)
140
- end
133
+ def iri_escape(data)
134
+ Format.percent_encode(data, IRI_ESCAPE_REGEX)
135
+ end
141
136
 
142
- def valid_regex?(data)
143
- !!EcmaRegexp.ruby_equivalent(data)
144
- rescue InvalidEcmaRegexp
145
- false
146
- end
137
+ def valid_regex?(data)
138
+ !!EcmaRegexp.ruby_equivalent(data)
139
+ rescue InvalidEcmaRegexp
140
+ false
141
+ end
147
142
 
148
- def valid_uuid?(data)
149
- UUID_REGEX.match?(data) || NIL_UUID == data
143
+ def valid_uuid?(data)
144
+ UUID_REGEX.match?(data) || NIL_UUID == data
145
+ end
150
146
  end
151
147
  end
152
148
  end
@@ -26,6 +26,18 @@ module JSONSchemer
26
26
  @schema_pointer ||= "#{parent.schema_pointer}/#{escaped_keyword}"
27
27
  end
28
28
 
29
+ def error_key
30
+ keyword
31
+ end
32
+
33
+ def fetch(key)
34
+ parsed.fetch(parsed.is_a?(Array) ? key.to_i : key)
35
+ end
36
+
37
+ def parsed_schema
38
+ parsed.is_a?(Schema) ? parsed : nil
39
+ end
40
+
29
41
  private
30
42
 
31
43
  def parse
@@ -1584,8 +1584,7 @@ module JSONSchemer
1584
1584
  'type' => 'string'
1585
1585
  },
1586
1586
  'operationRef' => {
1587
- 'type' => 'string',
1588
- 'format' => 'uri-reference'
1587
+ 'type' => 'string'
1589
1588
  },
1590
1589
  'parameters' => {
1591
1590
  'type' => 'object',
@@ -2,6 +2,12 @@
2
2
  module JSONSchemer
3
3
  module OpenAPI30
4
4
  BASE_URI = URI('json-schemer://openapi30/schema')
5
+ # https://spec.openapis.org/oas/v3.0.3#data-types
6
+ FORMATS = OpenAPI31::FORMATS.merge(
7
+ 'byte' => proc { |instance, _value| ContentEncoding::BASE64.call(instance).first },
8
+ 'binary' => proc { |instance, _value| instance.is_a?(String) && instance.encoding == Encoding::ASCII_8BIT },
9
+ 'date' => Format::DATE
10
+ )
5
11
  SCHEMA = {
6
12
  'id' => 'json-schemer://openapi30/schema',
7
13
  '$schema' => 'http://json-schema.org/draft-04/schema#',
@@ -270,8 +270,7 @@ module JSONSchemer
270
270
  'type' => 'object',
271
271
  'properties' => {
272
272
  'url' => {
273
- 'type' => 'string',
274
- 'format' => 'uri-reference'
273
+ 'type' => 'string'
275
274
  },
276
275
  'description' => {
277
276
  'type' => 'string'
@@ -1054,8 +1053,7 @@ module JSONSchemer
1054
1053
  'type' => 'object',
1055
1054
  'properties' => {
1056
1055
  'operationRef' => {
1057
- 'type' => 'string',
1058
- 'format' => 'uri-reference'
1056
+ 'type' => 'string'
1059
1057
  },
1060
1058
  'operationId' => {
1061
1059
  'type' => 'string'
@@ -2,6 +2,14 @@
2
2
  module JSONSchemer
3
3
  module OpenAPI31
4
4
  BASE_URI = URI('https://spec.openapis.org/oas/3.1/dialect/base')
5
+ # https://spec.openapis.org/oas/v3.1.0#data-types
6
+ FORMATS = {
7
+ 'int32' => proc { |instance, _format| instance.is_a?(Integer) && instance.bit_length <= 32 },
8
+ 'int64' => proc { |instance, _format| instance.is_a?(Integer) && instance.bit_length <= 64 },
9
+ 'float' => proc { |instance, _format| instance.is_a?(Float) },
10
+ 'double' => proc { |instance, _format| instance.is_a?(Float) },
11
+ 'password' => proc { |_instance, _format| true }
12
+ }
5
13
  SCHEMA = {
6
14
  '$id' => 'https://spec.openapis.org/oas/3.1/dialect/base',
7
15
  '$schema' => 'https://json-schema.org/draft/2020-12/schema',
@@ -34,7 +34,8 @@ module JSONSchemer
34
34
  end
35
35
 
36
36
  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/
38
39
 
39
40
  attr_accessor :skip_ref_once
40
41
 
@@ -42,43 +43,80 @@ module JSONSchemer
42
43
  "value at #{formatted_instance_location} does not match `discriminator` schema"
43
44
  end
44
45
 
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 || []
48
54
 
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 = {}
50
57
 
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
53
61
 
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
60
66
  end
61
- end
62
- ref_schema ||= root.resolve_ref(URI.join(schema.base_uri, ref))
63
67
 
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
65
71
 
66
- nested = []
72
+ implicit_mapping = subschemas_by_schema_name.reject do |_schema_name, subschema|
73
+ explicit_mapping.value?(subschema)
74
+ end
67
75
 
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
74
95
  end
96
+
97
+ hash[property_value] = subschema
75
98
  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)
79
99
  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)
80
118
 
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)
82
120
  ensure
83
121
  self.skip_ref_once = nil
84
122
  end
@@ -5,14 +5,15 @@ module JSONSchemer
5
5
 
6
6
  attr_reader :keyword, :schema
7
7
 
8
+ def x_error
9
+ return @x_error if defined?(@x_error)
10
+ @x_error = schema.parsed['x-error']&.message(error_key)
11
+ end
12
+
8
13
  private
9
14
 
10
15
  def result(instance, instance_location, keyword_location, valid, nested = nil, type: nil, annotation: nil, details: nil, ignore_nested: false)
11
- if valid
12
- Result.new(self, instance, instance_location, keyword_location, valid, nested, type, annotation, details, ignore_nested, 'annotations')
13
- else
14
- Result.new(self, instance, instance_location, keyword_location, valid, nested, type, annotation, details, ignore_nested, 'errors')
15
- end
16
+ Result.new(self, instance, instance_location, keyword_location, valid, nested, type, annotation, details, ignore_nested, valid ? 'annotations' : 'errors')
16
17
  end
17
18
 
18
19
  def escaped_keyword
@@ -1,10 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
  module JSONSchemer
3
- Result = Struct.new(:source, :instance, :instance_location, :keyword_location, :valid, :nested, :type, :annotation, :details, :ignore_nested, :nested_key) do
4
- CLASSIC_ERROR_TYPES = Hash.new do |hash, klass|
5
- hash[klass] = klass.name.rpartition('::').last.sub(/\A[[:alpha:]]/, &:downcase)
6
- end
3
+ CATCHALL = '*'
4
+ I18N_SEPARATOR = "\x1F" # unit separator
5
+ I18N_SCOPE = 'json_schemer'
6
+ I18N_ERRORS_SCOPE = "#{I18N_SCOPE}#{I18N_SEPARATOR}errors"
7
+ X_ERROR_REGEX = /%\{(instance|instanceLocation|keywordLocation|absoluteKeywordLocation)\}/
8
+ CLASSIC_ERROR_TYPES = Hash.new do |hash, klass|
9
+ hash[klass] = klass.name.rpartition('::').last.sub(/\A[[:alpha:]]/, &:downcase)
10
+ end
7
11
 
12
+ Result = Struct.new(:source, :instance, :instance_location, :keyword_location, :valid, :nested, :type, :annotation, :details, :ignore_nested, :nested_key) do
8
13
  def output(output_format)
9
14
  case output_format
10
15
  when 'classic'
@@ -24,10 +29,59 @@ module JSONSchemer
24
29
 
25
30
  def error
26
31
  return @error if defined?(@error)
27
- resolved_instance_location = Location.resolve(instance_location)
28
- @error = source.error(
29
- :formatted_instance_location => resolved_instance_location.empty? ? 'root' : "`#{resolved_instance_location}`",
30
- :details => details
32
+ if source.x_error
33
+ # not using sprintf because it warns: "too many arguments for format string"
34
+ @error = source.x_error.gsub(
35
+ X_ERROR_REGEX,
36
+ '%{instance}' => instance,
37
+ '%{instanceLocation}' => Location.resolve(instance_location),
38
+ '%{keywordLocation}' => Location.resolve(keyword_location),
39
+ '%{absoluteKeywordLocation}' => source.absolute_keyword_location
40
+ )
41
+ @x_error = true
42
+ else
43
+ resolved_instance_location = Location.resolve(instance_location)
44
+ formatted_instance_location = resolved_instance_location.empty? ? 'root' : "`#{resolved_instance_location}`"
45
+ @error = source.error(:formatted_instance_location => formatted_instance_location, :details => details)
46
+ if i18n?
47
+ begin
48
+ @error = i18n!
49
+ @i18n = true
50
+ rescue I18n::MissingTranslationData
51
+ end
52
+ end
53
+ end
54
+ @error
55
+ end
56
+
57
+ def i18n?
58
+ return @@i18n if defined?(@@i18n)
59
+ @@i18n = defined?(I18n) && I18n.exists?(I18N_SCOPE)
60
+ end
61
+
62
+ def i18n!
63
+ base_uri_str = source.schema.base_uri.to_s
64
+ meta_schema_base_uri_str = source.schema.meta_schema.base_uri.to_s
65
+ resolved_keyword_location = Location.resolve(keyword_location)
66
+ error_key = source.error_key
67
+ I18n.translate!(
68
+ source.absolute_keyword_location,
69
+ :default => [
70
+ "#{base_uri_str}#{I18N_SEPARATOR}##{resolved_keyword_location}",
71
+ "##{resolved_keyword_location}",
72
+ "#{base_uri_str}#{I18N_SEPARATOR}#{error_key}",
73
+ "#{base_uri_str}#{I18N_SEPARATOR}#{CATCHALL}",
74
+ "#{meta_schema_base_uri_str}#{I18N_SEPARATOR}#{error_key}",
75
+ "#{meta_schema_base_uri_str}#{I18N_SEPARATOR}#{CATCHALL}",
76
+ error_key,
77
+ CATCHALL
78
+ ].map!(&:to_sym),
79
+ :separator => I18N_SEPARATOR,
80
+ :scope => I18N_ERRORS_SCOPE,
81
+ :instance => instance,
82
+ :instanceLocation => Location.resolve(instance_location),
83
+ :keywordLocation => resolved_keyword_location,
84
+ :absoluteKeywordLocation => source.absolute_keyword_location
31
85
  )
32
86
  end
33
87
 
@@ -38,8 +92,13 @@ module JSONSchemer
38
92
  'absoluteKeywordLocation' => source.absolute_keyword_location,
39
93
  'instanceLocation' => Location.resolve(instance_location)
40
94
  }
41
- out['error'] = error unless valid
42
- out['annotation'] = annotation if valid && annotation
95
+ if valid
96
+ out['annotation'] = annotation if annotation
97
+ else
98
+ out['error'] = error
99
+ out['x-error'] = true if @x_error
100
+ out['i18n'] = true if @i18n
101
+ end
43
102
  out
44
103
  end
45
104
 
@@ -54,6 +113,8 @@ module JSONSchemer
54
113
  'type' => type || CLASSIC_ERROR_TYPES[source.class]
55
114
  }
56
115
  out['error'] = error
116
+ out['x-error'] = true if @x_error
117
+ out['i18n'] = true if @i18n
57
118
  out['details'] = details if details
58
119
  out
59
120
  end