schemurai 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2b340649fb4d070d05390ec257487c1f37e45287ef16325f0cfe3e4fb10ae72
4
- data.tar.gz: e9e93301abe43e21ff297b66a3d9cb4d43376c8ac2b80ee4e650c1705f5dbc99
3
+ metadata.gz: f1b157d6e772537cab2886ef6d89bd547992c7359b73a13a49b9d9cfcdcc9808
4
+ data.tar.gz: f0e8e28536c58c95cf1848db949dad8007df0c7a6550dd8b0fd76439841617cd
5
5
  SHA512:
6
- metadata.gz: a134b68cc4c6a2fc991822d63f841e6bec085a1c33109aca32e75feebd4cd95ca28dad001835591b200515170aa44bbf2c32275a610b2f7acc878d41f8a46bab
7
- data.tar.gz: f9fd8b9be4432b61d153be039e39e59b0749ada777e15ebbebf007c69d237d356f5b6feea1c790d8e51201e6bedda0cc26e0c533f499e49062e461f78fa03bd3
6
+ metadata.gz: b6f8206124aab29ae13908951efdd011e276ca347a75c0abf79da9fb3a9988f91a0a59b0ec559b85a88f01d5975b9ea251f9f14bb7aba634b7c8f3625c288167
7
+ data.tar.gz: 3fcd551c456cb81b53842c0736ae9e629676ca8c7c0e43017ee43991b00b995fde7a530f1c0cb38f81d5e7bb640b68a8ad8ad388934af73b06b4ef77c4e46a34
data/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Schemurai
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/schemurai.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/schemurai)
4
+ [![test / rspec](https://github.com/oakcask/schemurai/actions/workflows/test--rspec.yaml/badge.svg)](https://github.com/oakcask/schemurai/actions/workflows/test--rspec.yaml)
5
+ [![lint / rubocop](https://github.com/oakcask/schemurai/actions/workflows/lint--rubocop.yaml/badge.svg)](https://github.com/oakcask/schemurai/actions/workflows/lint--rubocop.yaml)
6
+
3
7
  A small, light-weight-dependency JSON Schema validator for Ruby supporting Draft 7,
4
8
  Draft 2019-09, and Draft 2020-12. It covers the required cases in the official
5
9
  JSON-Schema-Test-Suite, as well as the applicable optional tests for numeric
@@ -116,6 +120,38 @@ validation for `contentEncoding` and `contentMediaType` with `content: true`.
116
120
  Enable optional format assertions with `format: true`; support for each format is
117
121
  listed separately below.
118
122
 
123
+ ### Validation errors
124
+
125
+ ```ruby
126
+ result = Schemurai.validate(
127
+ { "$ref" => "https://example.test/positive" },
128
+ -1,
129
+ schemas: { "https://example.test/positive" => { "type" => "integer", "minimum" => 1 } }
130
+ )
131
+
132
+ validation_error = result.errors[0]
133
+ p validation_error.keyword # => "minimum"
134
+ p validation_error.instance_path # => ""
135
+ p validation_error.schema_path # => "/$ref/minimum"
136
+ p validation_error.message # => "number must be greater than or equal to 1"
137
+ ```
138
+
139
+ `Result#errors` contains `Schemurai::ValidationError` objects.
140
+ Check the following attributes to investigate schema errors:
141
+
142
+ - `keyword` identifies the failed JSON Schema keyword. Schemurai uses
143
+ `falseSchema` for a boolean `false` schema, which has no keyword of its own.
144
+ - `instance_path` is a JSON Pointer to the value that failed validation.
145
+ - `schema_path` is a JSON Pointer to the schema location that produced the
146
+ error.
147
+ - `message` is a human-readable `String` describing the failure.
148
+
149
+ An empty path points to the instance or schema root. Error order is stable.
150
+ The presence and type of `message` are part of the public API, but its exact
151
+ wording is not. Use `keyword`, `instance_path`, and `schema_path`, rather than
152
+ matching `message`, when handling errors programmatically. `ValidationError#to_h`
153
+ returns these four attributes as a Hash.
154
+
119
155
  ### Thread / Ractor support
120
156
 
121
157
  To share a registry between threads or Ractors, finish registering schemas and
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schemurai
4
+ module Internal
5
+ # Builds validation messages shared by every evaluator backend.
6
+ module ErrorMessage
7
+ module_function def false_schema = "value is not allowed by this schema"
8
+
9
+ module_function def type(expected, value)
10
+ expected = Array(expected).map { |name| name.inspect }.join(" or ")
11
+ "value has type #{json_type(value).inspect}, but must have type #{expected}"
12
+ end
13
+
14
+ module_function def enum = "value must equal one of the values defined by `enum`"
15
+ module_function def const = "value must equal the value defined by `const`"
16
+
17
+ module_function def numeric_limit(keyword, limit)
18
+ comparison = {
19
+ "maximum" => "less than or equal to",
20
+ "minimum" => "greater than or equal to",
21
+ "exclusiveMaximum" => "less than",
22
+ "exclusiveMinimum" => "greater than"
23
+ }.fetch(keyword)
24
+ "number must be #{comparison} #{display_number(limit)}"
25
+ end
26
+
27
+ module_function def multiple_of(divisor) = "number must be a multiple of #{display_number(divisor)}"
28
+
29
+ module_function def size(keyword, limit, actual)
30
+ subject, unit, comparison = {
31
+ "maxLength" => ["string", "characters", "at most"],
32
+ "minLength" => ["string", "characters", "at least"],
33
+ "maxItems" => ["array", "items", "at most"],
34
+ "minItems" => ["array", "items", "at least"],
35
+ "maxProperties" => ["object", "properties", "at most"],
36
+ "minProperties" => ["object", "properties", "at least"]
37
+ }.fetch(keyword)
38
+ unit = {"characters" => "character", "items" => "item", "properties" => "property"}.fetch(unit) if limit == 1
39
+ "#{subject} must contain #{comparison} #{limit} #{unit} (found #{actual})"
40
+ end
41
+
42
+ module_function def pattern(pattern) = "string must match pattern #{pattern.inspect}"
43
+ module_function def invalid_pattern(pattern) = "schema pattern #{pattern.inspect} is not a valid regular expression"
44
+ module_function def format(name) = "string must match the #{name} format"
45
+ module_function def content_encoding = "string must be valid base64"
46
+ module_function def content_media_type = "string must contain valid JSON"
47
+ module_function def unique_items = "array items must be unique"
48
+
49
+ module_function def contains(actual, minimum, maximum)
50
+ expected = if maximum.infinite?
51
+ "at least #{minimum}"
52
+ elsif minimum == maximum
53
+ "exactly #{minimum}"
54
+ elsif minimum.zero?
55
+ "at most #{maximum}"
56
+ else
57
+ "between #{minimum} and #{maximum}"
58
+ end
59
+ "array must contain #{expected} items matching `contains` (found #{actual})"
60
+ end
61
+
62
+ module_function def required(name) = "object is missing required property #{name.inspect}"
63
+
64
+ module_function def dependent_required(name, required_name)
65
+ "property #{required_name.inspect} is required when property #{name.inspect} is present"
66
+ end
67
+
68
+ module_function def any_of = "value must match at least one subschema"
69
+ module_function def one_of(matches) = "value must match exactly one subschema (matched #{matches})"
70
+ module_function def not = "value must not match the subschema"
71
+
72
+ module_function def json_type(value)
73
+ case value
74
+ when nil then "null"
75
+ when true, false then "boolean"
76
+ when Hash then "object"
77
+ when Array then "array"
78
+ when String then "string"
79
+ when Numeric
80
+ if value.is_a?(Complex)
81
+ value.class.name
82
+ elsif value.finite? && value.to_i == value
83
+ "integer"
84
+ else
85
+ "number"
86
+ end
87
+ else
88
+ value.class.name
89
+ end
90
+ end
91
+ private_class_method :json_type
92
+
93
+ module_function def display_number(value)
94
+ rational = value.is_a?(Rational) ? value : Rational(value.to_s)
95
+ return rational.numerator.to_s if rational.denominator == 1
96
+
97
+ rational.to_f.to_s
98
+ end
99
+ private_class_method :display_number
100
+ end
101
+ end
102
+ end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "base64"
5
5
  require_relative "evaluation"
6
+ require_relative "error_message"
6
7
 
7
8
  module Schemurai
8
9
  module Internal
@@ -333,7 +334,7 @@ module Schemurai
333
334
  schema = node.schema
334
335
  return Evaluation.valid if schema == true
335
336
  if schema == false
336
- add_error("falseSchema", "boolean schema is false", append_keyword: false)
337
+ add_error("falseSchema", append_keyword: false) { ErrorMessage.false_schema }
337
338
  return Evaluation.invalid
338
339
  end
339
340
  return Evaluation.valid unless schema.is_a?(Hash)
@@ -442,15 +443,15 @@ module Schemurai
442
443
  types = Array(schema["type"])
443
444
  return if types.any? { |type| type?(value, type) }
444
445
 
445
- add_error("type", "expected #{types.join(" or ")}")
446
+ add_error("type") { ErrorMessage.type(types, value) }
446
447
  end
447
448
 
448
449
  private def check_enum(schema, value)
449
450
  if schema.key?("enum") && !schema["enum"].any? { |candidate| json_equal?(candidate, value) }
450
- add_error("enum", "value is not in enum")
451
+ add_error("enum") { ErrorMessage.enum }
451
452
  end
452
453
  if schema.key?("const") && !json_equal?(schema["const"], value)
453
- add_error("const", "value does not equal const")
454
+ add_error("const") { ErrorMessage.const }
454
455
  end
455
456
  end
456
457
 
@@ -472,7 +473,7 @@ module Schemurai
472
473
  matches << result if result.valid?
473
474
  end
474
475
  if matches.empty?
475
- add_error("anyOf", "no subschema matched")
476
+ add_error("anyOf") { ErrorMessage.any_of }
476
477
  else
477
478
  matches.each { |result| evaluation = evaluation.merge(result) }
478
479
  end
@@ -487,12 +488,12 @@ module Schemurai
487
488
  if matches.length == 1
488
489
  evaluation = evaluation.merge(matches.first)
489
490
  else
490
- add_error("oneOf", "expected exactly one match, got #{matches.length}")
491
+ add_error("oneOf") { ErrorMessage.one_of(matches.length) }
491
492
  end
492
493
  end
493
494
 
494
495
  if schema.key?("not") && trial_at(node.child("not"), value, MISSING_SEGMENT, "not").valid?
495
- add_error("not", "subschema matched")
496
+ add_error("not") { ErrorMessage.not }
496
497
  end
497
498
 
498
499
  if schema.key?("if")
@@ -516,14 +517,14 @@ module Schemurai
516
517
 
517
518
  divisor = decimal(schema["multipleOf"])
518
519
  valid = divisor.positive? && decimal(value).remainder(divisor).zero?
519
- add_error("multipleOf", "number is not a multiple") unless valid
520
+ add_error("multipleOf") { ErrorMessage.multiple_of(schema["multipleOf"]) } unless valid
520
521
  end
521
522
 
522
523
  private def compare(schema, keyword, value)
523
524
  return unless schema.key?(keyword)
524
525
  return if yield(decimal(value), decimal(schema[keyword]))
525
526
 
526
- add_error(keyword, "numeric limit was exceeded")
527
+ add_error(keyword) { ErrorMessage.numeric_limit(keyword, schema[keyword]) }
527
528
  end
528
529
 
529
530
  private def check_string(node, value)
@@ -534,14 +535,14 @@ module Schemurai
534
535
 
535
536
  if schema.key?("pattern")
536
537
  matched = ecma_regexp(schema["pattern"]).match?(value)
537
- add_error("pattern", "string does not match pattern") unless matched
538
+ add_error("pattern") { ErrorMessage.pattern(schema["pattern"]) } unless matched
538
539
  end
539
540
  if format_asserted?(node)
540
- add_error("format", "string is not a valid #{node.format.name}") unless node.format.call(value)
541
+ add_error("format") { ErrorMessage.format(node.format.name) } unless node.format.call(value)
541
542
  end
542
543
  check_content(schema, value) if @validate_content
543
544
  rescue RegexpError
544
- add_error("pattern", "invalid regular expression")
545
+ add_error("pattern") { ErrorMessage.invalid_pattern(schema["pattern"]) }
545
546
  end
546
547
 
547
548
  private def format_asserted?(node)
@@ -558,7 +559,7 @@ module Schemurai
558
559
  duplicate = value.each_with_index.any? do |item, index|
559
560
  value[0...index].any? { |previous| json_equal?(previous, item) }
560
561
  end
561
- add_error("uniqueItems", "array items are not unique") if duplicate
562
+ add_error("uniqueItems") { ErrorMessage.unique_items } if duplicate
562
563
  end
563
564
 
564
565
  prefix_items = schema["prefixItems"]
@@ -600,7 +601,7 @@ module Schemurai
600
601
  minimum = schema.fetch("minContains", 1)
601
602
  maximum = schema.fetch("maxContains", Float::INFINITY)
602
603
  unless matched.length.between?(minimum, maximum)
603
- add_error("contains", "matched #{matched.length} array items")
604
+ add_error("contains") { ErrorMessage.contains(matched.length, minimum, maximum) }
604
605
  end
605
606
  evaluated.concat(matched)
606
607
  end
@@ -623,7 +624,7 @@ module Schemurai
623
624
  limit(schema, "minProperties", value.length) { |actual, expected| actual >= expected }
624
625
 
625
626
  Array(schema["required"]).each do |name|
626
- add_error("required", "required property #{name.inspect} is missing") unless value.key?(name)
627
+ add_error("required") { ErrorMessage.required(name) } unless value.key?(name)
627
628
  end
628
629
 
629
630
  properties = schema.fetch("properties", {})
@@ -657,7 +658,7 @@ module Schemurai
657
658
  next unless value.key?(name)
658
659
  if dependency.is_a?(Array)
659
660
  dependency.each do |required_name|
660
- add_error("dependencies", "property #{required_name.inspect} is required by #{name.inspect}") unless value.key?(required_name)
661
+ add_error("dependencies") { ErrorMessage.dependent_required(name, required_name) } unless value.key?(required_name)
661
662
  end
662
663
  else
663
664
  result = evaluate_at(node.child("dependencies", name), value, MISSING_SEGMENT, "dependencies", name)
@@ -669,7 +670,7 @@ module Schemurai
669
670
  next unless value.key?(name)
670
671
  required_names.each do |required_name|
671
672
  unless value.key?(required_name)
672
- add_error("dependentRequired", "property #{required_name.inspect} is required by #{name.inspect}")
673
+ add_error("dependentRequired") { ErrorMessage.dependent_required(name, required_name) }
673
674
  end
674
675
  end
675
676
  end
@@ -707,7 +708,7 @@ module Schemurai
707
708
  return unless schema.key?(keyword)
708
709
  return if yield(actual, schema[keyword])
709
710
 
710
- add_error(keyword, "size limit was exceeded")
711
+ add_error(keyword) { ErrorMessage.size(keyword, schema[keyword], actual) }
711
712
  end
712
713
 
713
714
  private def type?(value, type)
@@ -803,10 +804,12 @@ module Schemurai
803
804
  JSON.parse(decoded)
804
805
  rescue ArgumentError, JSON::ParserError
805
806
  keyword = (schema["contentEncoding"] == "base64") ? "contentEncoding" : "contentMediaType"
806
- add_error(keyword, "string content is invalid")
807
+ add_error(keyword) do
808
+ (keyword == "contentEncoding") ? ErrorMessage.content_encoding : ErrorMessage.content_media_type
809
+ end
807
810
  end
808
811
 
809
- private def add_error(keyword, message, append_keyword: true)
812
+ private def add_error(keyword, message = nil, append_keyword: true)
810
813
  @error_count += 1
811
814
  if @error_callback
812
815
  schema_keyword = append_keyword ? keyword : MISSING_SEGMENT
@@ -815,7 +818,7 @@ module Schemurai
815
818
  keyword: keyword,
816
819
  instance_path: pointer(@instance_path),
817
820
  schema_path: pointer(@schema_path, schema_keyword),
818
- message: message
821
+ message: message || yield
819
822
  )
820
823
  )
821
824
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Schemurai
4
4
  # The installed Schemurai version.
5
- VERSION = "2.0.0"
5
+ VERSION = "2.1.0"
6
6
  end
@@ -3,6 +3,7 @@
3
3
  require "base64"
4
4
  require "json"
5
5
  require_relative "../evaluation"
6
+ require_relative "../error_message"
6
7
  require_relative "compiler"
7
8
 
8
9
  module Schemurai
@@ -132,7 +133,7 @@ module Schemurai
132
133
  case opcode
133
134
  when :boolean
134
135
  if operand == false
135
- add_error("falseSchema", "boolean schema is false", append_keyword: false)
136
+ add_error("falseSchema", append_keyword: false) { Internal::ErrorMessage.false_schema }
136
137
  evaluation = Evaluation.invalid
137
138
  end
138
139
  when :ref
@@ -142,25 +143,25 @@ module Schemurai
142
143
  when :dynamic_ref
143
144
  evaluation = evaluation.merge(evaluate_dynamic_ref(program, operand, instance))
144
145
  when :type_null
145
- check_compiled_type("null", instance.nil?)
146
+ check_compiled_type("null", instance.nil?, instance)
146
147
  when :type_boolean
147
- check_compiled_type("boolean", instance == true || instance == false)
148
+ check_compiled_type("boolean", instance == true || instance == false, instance)
148
149
  when :type_object
149
- check_compiled_type("object", instance.is_a?(Hash))
150
+ check_compiled_type("object", instance.is_a?(Hash), instance)
150
151
  when :type_array
151
- check_compiled_type("array", instance.is_a?(Array))
152
+ check_compiled_type("array", instance.is_a?(Array), instance)
152
153
  when :type_number
153
- check_compiled_type("number", number?(instance))
154
+ check_compiled_type("number", number?(instance), instance)
154
155
  when :type_integer
155
- check_compiled_type("integer", integer?(instance))
156
+ check_compiled_type("integer", integer?(instance), instance)
156
157
  when :type_string
157
- check_compiled_type("string", instance.is_a?(String))
158
+ check_compiled_type("string", instance.is_a?(String), instance)
158
159
  when :types
159
160
  check_compiled_types(operand, instance)
160
161
  when :enum
161
- add_error("enum", "value is not in enum") unless operand.any? { |candidate| json_equal?(candidate, instance) }
162
+ add_error("enum") { Internal::ErrorMessage.enum } unless operand.any? { |candidate| json_equal?(candidate, instance) }
162
163
  when :const
163
- add_error("const", "value does not equal const") unless json_equal?(operand, instance)
164
+ add_error("const") { Internal::ErrorMessage.const } unless json_equal?(operand, instance)
164
165
  when :allOf, :anyOf, :oneOf, :not, :conditional
165
166
  evaluation = evaluation.merge(check_combiner(opcode, operand, instance))
166
167
  when :number
@@ -271,15 +272,15 @@ module Schemurai
271
272
  active[program.node.object_id] ||= {}
272
273
  end
273
274
 
274
- private def check_compiled_type(name, valid)
275
- add_error("type", "expected #{name}") unless valid
275
+ private def check_compiled_type(name, valid, value)
276
+ add_error("type") { Internal::ErrorMessage.type(name, value) } unless valid
276
277
  end
277
278
 
278
279
  private def check_compiled_types(rules, value)
279
280
  instance_mask = instance_type_mask(value, integer: (rules.mask & TYPE_INTEGER) != 0)
280
281
  return unless (rules.mask & instance_mask).zero?
281
282
 
282
- add_error("type", "expected #{rules.names.join(" or ")}")
283
+ add_error("type") { Internal::ErrorMessage.type(rules.names, value) }
283
284
  end
284
285
 
285
286
  private def instance_type_mask(value, integer:)
@@ -314,22 +315,26 @@ module Schemurai
314
315
  mask = rules.mask
315
316
  actual = decimal(value)
316
317
  if (mask & MAXIMUM) != 0 && actual > rules.maximum
317
- add_error("maximum", "numeric limit was exceeded")
318
+ add_error("maximum") { Internal::ErrorMessage.numeric_limit("maximum", rules.maximum) }
318
319
  end
319
320
  if (mask & MINIMUM) != 0 && actual < rules.minimum
320
- add_error("minimum", "numeric limit was exceeded")
321
+ add_error("minimum") { Internal::ErrorMessage.numeric_limit("minimum", rules.minimum) }
321
322
  end
322
323
  if (mask & EXCLUSIVE_MAXIMUM) != 0 && actual >= rules.exclusive_maximum
323
- add_error("exclusiveMaximum", "numeric limit was exceeded")
324
+ add_error("exclusiveMaximum") do
325
+ Internal::ErrorMessage.numeric_limit("exclusiveMaximum", rules.exclusive_maximum)
326
+ end
324
327
  end
325
328
  if (mask & EXCLUSIVE_MINIMUM) != 0 && actual <= rules.exclusive_minimum
326
- add_error("exclusiveMinimum", "numeric limit was exceeded")
329
+ add_error("exclusiveMinimum") do
330
+ Internal::ErrorMessage.numeric_limit("exclusiveMinimum", rules.exclusive_minimum)
331
+ end
327
332
  end
328
333
  return if (mask & MULTIPLE_OF).zero?
329
334
 
330
335
  divisor = rules.multiple_of
331
336
  valid = divisor.positive? && actual.remainder(divisor).zero?
332
- add_error("multipleOf", "number is not a multiple") unless valid
337
+ add_error("multipleOf") { Internal::ErrorMessage.multiple_of(divisor) } unless valid
333
338
  end
334
339
 
335
340
  private def valid_string?(rules, value)
@@ -350,20 +355,20 @@ module Schemurai
350
355
  private def check_string(rules, value)
351
356
  length = value.length
352
357
  if rules.has_max_length && length > rules.max_length
353
- add_error("maxLength", "size limit was exceeded")
358
+ add_error("maxLength") { Internal::ErrorMessage.size("maxLength", rules.max_length, length) }
354
359
  end
355
360
  if rules.has_min_length && length < rules.min_length
356
- add_error("minLength", "size limit was exceeded")
361
+ add_error("minLength") { Internal::ErrorMessage.size("minLength", rules.min_length, length) }
357
362
  end
358
363
  if rules.has_pattern && !ecma_regexp(rules.pattern).match?(value)
359
- add_error("pattern", "string does not match pattern")
364
+ add_error("pattern") { Internal::ErrorMessage.pattern(rules.pattern) }
360
365
  end
361
366
  if format_asserted?(rules) && !rules.format.call(value)
362
- add_error("format", "string is not a valid #{rules.format.name}")
367
+ add_error("format") { Internal::ErrorMessage.format(rules.format.name) }
363
368
  end
364
369
  check_content(rules, value) if @validate_content
365
370
  rescue RegexpError
366
- add_error("pattern", "invalid regular expression")
371
+ add_error("pattern") { Internal::ErrorMessage.invalid_pattern(rules.pattern) }
367
372
  end
368
373
 
369
374
  private def format_asserted?(rules)
@@ -385,7 +390,9 @@ module Schemurai
385
390
  JSON.parse(decoded)
386
391
  rescue ArgumentError, JSON::ParserError
387
392
  keyword = rules.decode_base64 ? "contentEncoding" : "contentMediaType"
388
- add_error(keyword, "string content is invalid")
393
+ add_error(keyword) do
394
+ (keyword == "contentEncoding") ? Internal::ErrorMessage.content_encoding : Internal::ErrorMessage.content_media_type
395
+ end
389
396
  end
390
397
 
391
398
  private def valid_array?(rules, value)
@@ -437,16 +444,16 @@ module Schemurai
437
444
  private def check_array(rules, value, prior_evaluation)
438
445
  evaluated = []
439
446
  if rules.has_max_items && value.length > rules.max_items
440
- add_error("maxItems", "size limit was exceeded")
447
+ add_error("maxItems") { Internal::ErrorMessage.size("maxItems", rules.max_items, value.length) }
441
448
  end
442
449
  if rules.has_min_items && value.length < rules.min_items
443
- add_error("minItems", "size limit was exceeded")
450
+ add_error("minItems") { Internal::ErrorMessage.size("minItems", rules.min_items, value.length) }
444
451
  end
445
452
  if rules.unique
446
453
  duplicate = value.each_with_index.any? do |item, index|
447
454
  value[0...index].any? { |previous| json_equal?(previous, item) }
448
455
  end
449
- add_error("uniqueItems", "array items are not unique") if duplicate
456
+ add_error("uniqueItems") { Internal::ErrorMessage.unique_items } if duplicate
450
457
  end
451
458
 
452
459
  if (prefix_items = rules.prefix_items)
@@ -483,7 +490,9 @@ module Schemurai
483
490
  trial_at(contains, value[index], index, "contains").valid?
484
491
  end
485
492
  unless matched.length.between?(rules.min_contains, rules.max_contains)
486
- add_error("contains", "matched #{matched.length} array items")
493
+ add_error("contains") do
494
+ Internal::ErrorMessage.contains(matched.length, rules.min_contains, rules.max_contains)
495
+ end
487
496
  end
488
497
  evaluated.concat(matched)
489
498
  end
@@ -548,14 +557,18 @@ module Schemurai
548
557
  private def check_object(rules, value, prior_evaluation)
549
558
  evaluated = []
550
559
  if rules.has_max_properties && value.length > rules.max_properties
551
- add_error("maxProperties", "size limit was exceeded")
560
+ add_error("maxProperties") do
561
+ Internal::ErrorMessage.size("maxProperties", rules.max_properties, value.length)
562
+ end
552
563
  end
553
564
  if rules.has_min_properties && value.length < rules.min_properties
554
- add_error("minProperties", "size limit was exceeded")
565
+ add_error("minProperties") do
566
+ Internal::ErrorMessage.size("minProperties", rules.min_properties, value.length)
567
+ end
555
568
  end
556
569
  if (required = rules.required)
557
570
  required.each do |name|
558
- add_error("required", "required property #{name.inspect} is missing") unless value.key?(name)
571
+ add_error("required") { Internal::ErrorMessage.required(name) } unless value.key?(name)
559
572
  end
560
573
  end
561
574
 
@@ -588,7 +601,7 @@ module Schemurai
588
601
  if dependency.is_a?(Array)
589
602
  dependency.each do |required_name|
590
603
  unless value.key?(required_name)
591
- add_error("dependencies", "property #{required_name.inspect} is required by #{name.inspect}")
604
+ add_error("dependencies") { Internal::ErrorMessage.dependent_required(name, required_name) }
592
605
  end
593
606
  end
594
607
  else
@@ -600,7 +613,7 @@ module Schemurai
600
613
  next unless value.key?(name)
601
614
  required_names.each do |required_name|
602
615
  unless value.key?(required_name)
603
- add_error("dependentRequired", "property #{required_name.inspect} is required by #{name.inspect}")
616
+ add_error("dependentRequired") { Internal::ErrorMessage.dependent_required(name, required_name) }
604
617
  end
605
618
  end
606
619
  end
@@ -633,7 +646,7 @@ module Schemurai
633
646
  result if result.valid?
634
647
  end
635
648
  if matches.empty?
636
- add_error("anyOf", "no subschema matched")
649
+ add_error("anyOf") { Internal::ErrorMessage.any_of }
637
650
  else
638
651
  matches.each { |result| evaluation = evaluation.merge(result) }
639
652
  end
@@ -645,10 +658,10 @@ module Schemurai
645
658
  if matches.length == 1
646
659
  evaluation = evaluation.merge(matches.first)
647
660
  else
648
- add_error("oneOf", "expected exactly one match, got #{matches.length}")
661
+ add_error("oneOf") { Internal::ErrorMessage.one_of(matches.length) }
649
662
  end
650
663
  when :not
651
- add_error("not", "subschema matched") if trial_at(operand, value, MISSING_SEGMENT, "not").valid?
664
+ add_error("not") { Internal::ErrorMessage.not } if trial_at(operand, value, MISSING_SEGMENT, "not").valid?
652
665
  when :conditional
653
666
  condition = trial_at(operand.condition, value, MISSING_SEGMENT, "if")
654
667
  evaluation = evaluation.merge(condition) if condition.valid?
@@ -694,7 +707,7 @@ module Schemurai
694
707
  @instance_path.pop unless instance_segment.equal?(MISSING_SEGMENT)
695
708
  end
696
709
 
697
- private def add_error(keyword, message, append_keyword: true)
710
+ private def add_error(keyword, message = nil, append_keyword: true)
698
711
  @error_count += 1
699
712
  return false unless @errors
700
713
 
@@ -703,7 +716,7 @@ module Schemurai
703
716
  keyword: keyword,
704
717
  instance_path: pointer(@instance_path),
705
718
  schema_path: pointer(@schema_path, final_segment),
706
- message: message
719
+ message: message || yield
707
720
  )
708
721
  false
709
722
  end
data/lib/schemurai.rb CHANGED
@@ -34,10 +34,13 @@ module Schemurai
34
34
  end
35
35
  end
36
36
 
37
- # Describes one failed JSON Schema assertion.
37
+ # Public value type describing one failed JSON Schema assertion.
38
38
  #
39
- # +instance_path+ and +schema_path+ are JSON Pointers. An empty string points
40
- # to the root instance or schema.
39
+ # +keyword+ identifies the failed keyword. +instance_path+ and +schema_path+
40
+ # are JSON Pointers; an empty string points to the root instance or schema.
41
+ # +message+ is a human-readable String. Its presence and type are public API,
42
+ # but its exact wording is not. Use +keyword+ and the paths for programmatic
43
+ # error handling.
41
44
  class ValidationError < Data.define(:keyword, :instance_path, :schema_path, :message)
42
45
  # Returns the error fields as a Hash.
43
46
  def to_h
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schemurai
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kuya KOHARA (oakcask)
@@ -42,6 +42,7 @@ files:
42
42
  - lib/schemurai/dialects/draft2019_09.rb
43
43
  - lib/schemurai/dialects/draft2020_12.rb
44
44
  - lib/schemurai/dialects/draft7.rb
45
+ - lib/schemurai/error_message.rb
45
46
  - lib/schemurai/evaluation.rb
46
47
  - lib/schemurai/evaluator.rb
47
48
  - lib/schemurai/formats.rb
@@ -61,7 +62,7 @@ licenses:
61
62
  metadata:
62
63
  homepage_uri: https://github.com/oakcask/schemurai
63
64
  source_code_uri: https://github.com/oakcask/schemurai/tree/main
64
- documentation_uri: https://rubydoc.info/gems/schemurai/2.0.0
65
+ documentation_uri: https://rubydoc.info/gems/schemurai/2.1.0
65
66
  rubygems_mfa_required: 'true'
66
67
  rdoc_options:
67
68
  - "--main"