-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathJsonSchemaCompatibilityGuard.rb
More file actions
1288 lines (1076 loc) · 44 KB
/
JsonSchemaCompatibilityGuard.rb
File metadata and controls
1288 lines (1076 loc) · 44 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'json'
require 'yaml'
require 'set'
require 'optparse'
module JsonSchemaCompatibilityGuard
VERSION = '1.0.0'
COMPATIBLE_EXIT = 0
INCOMPATIBLE_EXIT = 1
ERROR_EXIT = 2
ANNOTATION_KEYS = Set.new(%w[
$schema
$id
title
description
examples
example
default
deprecated
readOnly
writeOnly
$comment
]).freeze
UNSUPPORTED_COMPARISON_KEYWORDS = %w[
anyOf
oneOf
not
if
then
else
dependentSchemas
unevaluatedProperties
unevaluatedItems
contentSchema
].freeze
class Error < StandardError; end
Issue = Struct.new(:severity, :path, :message, :details, keyword_init: true) do
def to_h
payload = {
severity: severity.to_s,
path: path,
message: message
}
payload[:details] = details if details && !details.empty?
payload
end
end
class Report
attr_reader :mode, :source_path, :candidate_path, :issues
def initialize(mode:, source_path:, candidate_path:)
@mode = mode
@source_path = source_path
@candidate_path = candidate_path
@issues = []
end
def add(severity, path, message, details = nil)
@issues << Issue.new(severity: severity, path: normalize_path(path), message: message, details: details)
end
def breaking(path, message, details = nil)
add(:breaking, path, message, details)
end
def warning(path, message, details = nil)
add(:warning, path, message, details)
end
def note(path, message, details = nil)
add(:note, path, message, details)
end
def breaking?
@issues.any? { |issue| issue.severity == :breaking }
end
def warning?
@issues.any? { |issue| issue.severity == :warning }
end
def compatible?
!breaking?
end
def exit_code(fail_on)
return INCOMPATIBLE_EXIT if fail_on == 'warning' && (breaking? || warning?)
return INCOMPATIBLE_EXIT if breaking?
COMPATIBLE_EXIT
end
def to_h
{
mode: mode,
source_schema: source_path,
candidate_schema: candidate_path,
rule: rule_text,
compatible: compatible?,
counts: {
breaking: @issues.count { |issue| issue.severity == :breaking },
warning: @issues.count { |issue| issue.severity == :warning },
note: @issues.count { |issue| issue.severity == :note }
},
issues: @issues.map(&:to_h)
}
end
def to_text
lines = []
lines << "mode: #{mode}"
lines << "source schema: #{source_path}"
lines << "candidate schema: #{candidate_path}"
lines << "rule: #{rule_text}"
lines << "compatible: #{compatible?}"
lines << "breaking issues: #{@issues.count { |issue| issue.severity == :breaking }}"
lines << "warnings: #{@issues.count { |issue| issue.severity == :warning }}"
if @issues.empty?
lines << 'issues: none'
else
lines << 'issues:'
@issues.each do |issue|
label = issue.severity.to_s.upcase
line = "- [#{label}] #{issue.path}: #{issue.message}"
line += " (#{issue.details})" if issue.details && !issue.details.empty?
lines << line
end
end
lines.join("\n")
end
private
def normalize_path(path)
return '/' if path.nil? || path.empty?
path
end
def rule_text
if mode == 'backward'
'candidate must accept every instance accepted by the source schema'
else
'source schema must accept every instance accepted by the candidate schema'
end
end
end
module Helpers
module_function
def deep_copy(value)
Marshal.load(Marshal.dump(value))
end
def deep_stringify(value)
case value
when Hash
value.each_with_object({}) do |(key, inner), result|
result[key.to_s] = deep_stringify(inner)
end
when Array
value.map { |item| deep_stringify(item) }
else
value
end
end
def canonical_json(value)
JSON.generate(sort_for_json(value))
end
def escape_pointer(token)
token.to_s.gsub('~', '~0').gsub('/', '~1')
end
def pointer_join(path, token)
path = '' if path == '/'
"#{path}/#{escape_pointer(token)}"
end
def value_set(values)
Array(values).map { |value| canonical_json(value) }.to_set
end
def type_set(schema)
return Set.new if schema == true || schema == false || !schema.is_a?(Hash)
raw = schema['type']
types = case raw
when nil then []
when Array then raw.map(&:to_s)
else [raw.to_s]
end
types << 'null' if schema['nullable'] == true
types.to_set
end
def schema_accepts_type?(schema, type_name)
return false if schema == false
return true if schema == true || !schema.is_a?(Hash)
types = type_set(schema)
return true if types.empty?
case type_name
when 'integer'
types.include?('integer') || types.include?('number')
else
types.include?(type_name)
end
end
def candidate_covers_type?(candidate_types, source_type)
return true if candidate_types.empty?
case source_type
when 'integer'
candidate_types.include?('integer') || candidate_types.include?('number')
else
candidate_types.include?(source_type)
end
end
def finite_value_schema?(schema)
schema.is_a?(Hash) && (schema.key?('const') || schema.key?('enum'))
end
def finite_values(schema)
return [schema['const']] if schema.key?('const')
return Array(schema['enum']) if schema.key?('enum')
[]
end
def truthy?(value)
value == true
end
def sort_for_json(value)
case value
when Hash
value.keys.sort.each_with_object({}) do |key, result|
result[key] = sort_for_json(value[key])
end
when Array
value.map { |item| sort_for_json(item) }
else
value
end
end
def parse_yaml(text)
YAML.safe_load(text, [], [], true)
rescue ArgumentError
YAML.safe_load(text, permitted_classes: [], permitted_symbols: [], aliases: true)
end
def integer_value?(value)
value.is_a?(Integer)
end
def number_value?(value)
value.is_a?(Numeric)
end
def regex_matches?(pattern, candidate)
Regexp.new(pattern).match?(candidate)
rescue RegexpError
false
end
end
class Loader
def self.load_file(path)
text = File.read(path)
value = parse_by_extension(path, text)
normalized = Helpers.deep_stringify(value)
unless normalized.is_a?(Hash) || normalized == true || normalized == false
raise Error, "Schema root must be an object or boolean: #{path}"
end
normalized
rescue Errno::ENOENT
raise Error, "Schema file not found: #{path}"
rescue Psych::SyntaxError => e
raise Error, "YAML parse error in #{path}: #{e.message}"
rescue JSON::ParserError => e
raise Error, "JSON parse error in #{path}: #{e.message}"
end
def self.parse_by_extension(path, text)
extension = File.extname(path).downcase
if %w[.yaml .yml].include?(extension)
Helpers.parse_yaml(text)
else
JSON.parse(text)
end
rescue JSON::ParserError
Helpers.parse_yaml(text)
end
end
class Resolver
include Helpers
def initialize(root_schema)
@root = Helpers.deep_stringify(root_schema)
@resolved_cache = {}
end
def resolve(schema = @root, stack = [])
case schema
when TrueClass, FalseClass
schema
when Array
schema.map { |item| resolve(item, stack) }
when Hash
resolve_hash(schema, stack)
else
schema
end
end
private
def resolve_hash(schema, stack)
current = Helpers.deep_stringify(schema)
if current.key?('$ref')
reference = current['$ref']
raise Error, "External $ref is not supported: #{reference}" unless reference.start_with?('#')
raise Error, "Cyclic $ref detected: #{(stack + [reference]).join(' -> ')}" if stack.include?(reference)
resolved = Helpers.deep_copy(resolve_ref(reference, stack + [reference]))
current = merge_all_of_fragments(resolved, current.reject { |key, _| key == '$ref' }, '/')
end
transformed = current.each_with_object({}) do |(key, value), result|
next if annotation_key?(key)
result[key] = resolve(value, stack)
end
transformed = normalize_nullable(transformed)
if transformed['allOf'].is_a?(Array)
base = transformed.reject { |key, _| key == 'allOf' }
transformed['allOf'].each do |fragment|
base = merge_all_of_fragments(base, fragment, '/')
end
transformed = base
end
transformed.delete('$defs')
transformed.delete('definitions')
transformed
end
def resolve_ref(reference, stack)
@resolved_cache[reference] ||= resolve(pointer_lookup(reference), stack)
end
def pointer_lookup(reference)
return Helpers.deep_copy(@root) if reference == '#'
current = @root
reference.sub(%r{\A#/}, '').split('/').each do |segment|
key = segment.gsub('~1', '/').gsub('~0', '~')
current = if current.is_a?(Hash)
raise Error, "Unresolved $ref segment #{key.inspect} in #{reference}" unless current.key?(key)
current[key]
elsif current.is_a?(Array)
index = Integer(key)
raise Error, "Unresolved $ref index #{key.inspect} in #{reference}" unless index >= 0 && index < current.length
current[index]
else
raise Error, "Invalid $ref target in #{reference}"
end
end
Helpers.deep_copy(current)
rescue ArgumentError
raise Error, "Invalid array index in $ref #{reference}"
end
def annotation_key?(key)
ANNOTATION_KEYS.include?(key) || key.start_with?('x-')
end
def normalize_nullable(schema)
return schema unless schema.is_a?(Hash)
return schema unless schema['nullable'] == true
types = Helpers.type_set(schema).to_a
types = ['null'] if types.empty?
types << 'null' unless types.include?('null')
normalized = Helpers.deep_copy(schema)
normalized.delete('nullable')
normalized['type'] = types.sort
normalized
end
def merge_all_of_fragments(base, overlay, path)
return false if base == false || overlay == false
return Helpers.deep_copy(overlay) if base == true
return Helpers.deep_copy(base) if overlay == true
base = Helpers.deep_stringify(base)
overlay = Helpers.deep_stringify(overlay)
result = Helpers.deep_copy(base)
overlay.each do |key, value|
next if annotation_key?(key)
if result.key?(key)
result[key] = merge_keyword(key, result[key], value, Helpers.pointer_join(path, key))
else
result[key] = Helpers.deep_copy(value)
end
end
normalize_nullable(result)
end
def merge_keyword(key, left, right, path)
return Helpers.deep_copy(left) if left == right
case key
when 'type'
merge_types(left, right, path)
when 'required'
(Array(left) + Array(right)).map(&:to_s).uniq.sort
when 'enum'
merge_enums(left, right)
when 'const'
raise Error, "Conflicting allOf const values at #{path}" unless left == right
left
when 'properties', 'patternProperties', '$defs', 'definitions'
merge_schema_map(left, right, path)
when 'additionalProperties', 'propertyNames', 'items', 'additionalItems', 'contains'
merge_all_of_fragments(left, right, path)
when 'minimum', 'exclusiveMinimum', 'minLength', 'minItems', 'minProperties', 'minContains'
[left, right].compact.max
when 'maximum', 'exclusiveMaximum', 'maxLength', 'maxItems', 'maxProperties', 'maxContains'
[left, right].compact.min
when 'uniqueItems'
Helpers.truthy?(left) || Helpers.truthy?(right)
when 'multipleOf'
merge_multiple_of(left, right, path)
when 'pattern', 'format', 'contentEncoding', 'contentMediaType'
raise Error, "Unsupported allOf merge for #{key} at #{path}" unless left == right
left
else
raise Error, "Unsupported allOf merge for #{key} at #{path}" unless left == right
left
end
end
def merge_types(left, right, path)
left_set = Array(left).map(&:to_s).to_set
right_set = Array(right).map(&:to_s).to_set
merged = if left_set.empty?
right_set
elsif right_set.empty?
left_set
else
left_set & right_set
end
raise Error, "allOf type intersection is empty at #{path}" if merged.empty?
merged.to_a.sort
end
def merge_enums(left, right)
left_values = Helpers.value_set(left)
right_values = Helpers.value_set(right)
allowed = left_values & right_values
raise Error, 'allOf enum intersection is empty' if allowed.empty?
Array(left).select { |value| allowed.include?(Helpers.canonical_json(value)) }
end
def merge_schema_map(left, right, path)
left = Helpers.deep_stringify(left || {})
right = Helpers.deep_stringify(right || {})
keys = (left.keys + right.keys).uniq
keys.each_with_object({}) do |key, result|
if left.key?(key) && right.key?(key)
result[key] = merge_all_of_fragments(left[key], right[key], Helpers.pointer_join(path, key))
elsif left.key?(key)
result[key] = Helpers.deep_copy(left[key])
else
result[key] = Helpers.deep_copy(right[key])
end
end
end
def merge_multiple_of(left, right, path)
left_r = Rational(left.to_s)
right_r = Rational(right.to_s)
larger = [left_r, right_r].max
smaller = [left_r, right_r].min
if (larger / smaller).denominator == 1
larger.to_f % 1 == 0 ? larger.to_i : larger.to_f
else
raise Error, "Unsupported allOf multipleOf merge at #{path}"
end
rescue StandardError
raise Error, "Unsupported allOf multipleOf merge at #{path}"
end
end
class Validator
include Helpers
def initialize(strict_format: false)
@strict_format = strict_format
end
def valid?(value, schema)
return true if schema == true
return false if schema == false
return false unless schema.is_a?(Hash)
schema = Helpers.deep_stringify(schema)
return false unless validate_boolean_combiners(value, schema)
return false unless validate_type(value, schema)
return false unless validate_const_and_enum(value, schema)
return false unless validate_numeric(value, schema)
return false unless validate_string(value, schema)
return false unless validate_object(value, schema)
return false unless validate_array(value, schema)
return false unless validate_format(value, schema)
true
end
private
def validate_boolean_combiners(value, schema)
if schema['allOf'].is_a?(Array)
return false unless schema['allOf'].all? { |fragment| valid?(value, fragment) }
end
if schema['anyOf'].is_a?(Array)
return false unless schema['anyOf'].any? { |fragment| valid?(value, fragment) }
end
if schema['oneOf'].is_a?(Array)
return false unless schema['oneOf'].count { |fragment| valid?(value, fragment) } == 1
end
if schema.key?('not')
return false if valid?(value, schema['not'])
end
return true unless schema.key?('if')
if valid?(value, schema['if'])
return false if schema.key?('then') && !valid?(value, schema['then'])
elsif schema.key?('else')
return false unless valid?(value, schema['else'])
end
true
end
def validate_type(value, schema)
types = Helpers.type_set(schema)
return true if types.empty?
types.any? { |type_name| type_matches?(value, type_name) }
end
def type_matches?(value, type_name)
case type_name
when 'null' then value.nil?
when 'boolean' then value == true || value == false
when 'string' then value.is_a?(String)
when 'integer' then Helpers.integer_value?(value)
when 'number' then Helpers.number_value?(value)
when 'object' then value.is_a?(Hash)
when 'array' then value.is_a?(Array)
else false
end
end
def validate_const_and_enum(value, schema)
return false if schema.key?('const') && value != schema['const']
return true unless schema.key?('enum')
schema['enum'].any? { |candidate| candidate == value }
end
def validate_numeric(value, schema)
return true unless Helpers.number_value?(value)
if schema.key?('minimum') && value < schema['minimum']
return false
end
if schema.key?('exclusiveMinimum') && value <= schema['exclusiveMinimum']
return false
end
if schema.key?('maximum') && value > schema['maximum']
return false
end
if schema.key?('exclusiveMaximum') && value >= schema['exclusiveMaximum']
return false
end
if schema.key?('multipleOf')
dividend = Rational(value.to_s) / Rational(schema['multipleOf'].to_s)
return false unless dividend.denominator == 1
end
true
rescue StandardError
false
end
def validate_string(value, schema)
return true unless value.is_a?(String)
return false if schema.key?('minLength') && value.length < schema['minLength']
return false if schema.key?('maxLength') && value.length > schema['maxLength']
return false if schema.key?('pattern') && !Helpers.regex_matches?(schema['pattern'], value)
true
end
def validate_object(value, schema)
return true unless value.is_a?(Hash)
required = Array(schema['required']).map(&:to_s)
return false unless required.all? { |name| value.key?(name) }
return false if schema.key?('minProperties') && value.length < schema['minProperties']
return false if schema.key?('maxProperties') && value.length > schema['maxProperties']
if schema.key?('propertyNames')
return false unless value.keys.all? { |name| valid?(name, schema['propertyNames']) }
end
dependent_required = Helpers.deep_stringify(schema['dependentRequired'] || {})
dependent_required.each do |name, dependencies|
next unless value.key?(name)
return false unless Array(dependencies).all? { |dependency| value.key?(dependency.to_s) }
end
properties = Helpers.deep_stringify(schema['properties'] || {})
pattern_properties = Helpers.deep_stringify(schema['patternProperties'] || {})
additional = schema.key?('additionalProperties') ? schema['additionalProperties'] : true
value.each do |name, inner|
matched = false
if properties.key?(name)
return false unless valid?(inner, properties[name])
matched = true
end
pattern_properties.each do |pattern, property_schema|
next unless Helpers.regex_matches?(pattern, name)
return false unless valid?(inner, property_schema)
matched = true
end
next if matched
return false unless valid_additional?(inner, additional)
end
true
end
def valid_additional?(value, schema)
case schema
when true, nil then true
when false then false
else valid?(value, schema)
end
end
def validate_array(value, schema)
return true unless value.is_a?(Array)
return false if schema.key?('minItems') && value.length < schema['minItems']
return false if schema.key?('maxItems') && value.length > schema['maxItems']
if Helpers.truthy?(schema['uniqueItems'])
canonical = value.map { |item| Helpers.canonical_json(item) }
return false unless canonical.uniq.length == canonical.length
end
prefix_items = tuple_items(schema)
tail_schema = tail_items_schema(schema)
value.each_with_index do |item, index|
if index < prefix_items.length
return false unless valid?(item, prefix_items[index])
else
return false unless valid_additional?(item, tail_schema)
end
end
if schema.key?('contains')
matches = value.count { |item| valid?(item, schema['contains']) }
min_contains = schema.key?('minContains') ? schema['minContains'] : 1
max_contains = schema['maxContains']
return false if matches < min_contains
return false if max_contains && matches > max_contains
end
true
end
def tuple_items(schema)
raw = if schema['prefixItems'].is_a?(Array)
schema['prefixItems']
elsif schema['items'].is_a?(Array)
schema['items']
else
[]
end
raw.map { |item| Helpers.deep_stringify(item) }
end
def tail_items_schema(schema)
if schema['prefixItems'].is_a?(Array)
return schema.key?('items') ? schema['items'] : true
end
if schema['items'].is_a?(Array)
return schema.key?('additionalItems') ? schema['additionalItems'] : true
end
schema.key?('items') ? schema['items'] : true
end
def validate_format(value, schema)
return true unless @strict_format
return true unless value.is_a?(String)
return true unless schema.key?('format')
case schema['format']
when 'date-time'
value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/)
when 'date'
value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
when 'uuid'
value.match?(/\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\z/)
when 'email'
value.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
when 'uri', 'url'
value.match?(/\Ahttps?:\/\//)
else
true
end
end
end
class Comparator
include Helpers
def initialize(strict_format: false)
@strict_format = strict_format
@validator = Validator.new(strict_format: strict_format)
end
def compare(source_schema, candidate_schema, report)
compare_schema(source_schema, candidate_schema, '', report)
end
private
def compare_schema(source, candidate, path, report)
return if source == candidate
return if source == false
if candidate == true
compare_unsupported_keyword_changes(source, candidate, path, report)
return
end
if source == true
report.breaking(path, 'Candidate adds constraints to a previously unconstrained schema')
compare_unsupported_keyword_changes(source, candidate, path, report)
return
end
if candidate == false
report.breaking(path, 'Candidate rejects values previously accepted by the source schema')
return
end
source = Helpers.deep_stringify(source)
candidate = Helpers.deep_stringify(candidate)
compare_unsupported_keyword_changes(source, candidate, path, report)
if Helpers.finite_value_schema?(source)
compare_finite_schema(source, candidate, path, report)
return
end
compare_enum_and_const_constraints(source, candidate, path, report)
compare_type_coverage(source, candidate, path, report)
compare_numeric_constraints(source, candidate, path, report) if source_may_accept_numeric?(source)
compare_string_constraints(source, candidate, path, report) if Helpers.schema_accepts_type?(source, 'string')
compare_object_constraints(source, candidate, path, report) if Helpers.schema_accepts_type?(source, 'object')
compare_array_constraints(source, candidate, path, report) if Helpers.schema_accepts_type?(source, 'array')
end
def compare_finite_schema(source, candidate, path, report)
failures = Helpers.finite_values(source).reject { |value| @validator.valid?(value, candidate) }
return if failures.empty?
sample = failures.first(3).map { |value| Helpers.canonical_json(value) }.join(', ')
details = failures.length > 3 ? "Examples rejected by candidate: #{sample} and #{failures.length - 3} more" : "Examples rejected by candidate: #{sample}"
report.breaking(path, 'Candidate rejects values enumerated by the source schema', details)
end
def compare_enum_and_const_constraints(source, candidate, path, report)
if !source.key?('const') && candidate.key?('const')
report.breaking(path, 'Candidate narrows the schema to a single constant')
end
if !source.key?('enum') && candidate.key?('enum')
report.breaking(path, 'Candidate adds an enum restriction')
end
end
def compare_type_coverage(source, candidate, path, report)
source_types = Helpers.type_set(source)
candidate_types = Helpers.type_set(candidate)
if source_types.empty?
unless candidate_types.empty?
report.breaking(path, "Candidate adds an explicit type restriction: #{candidate_types.to_a.sort.join(', ')}")
end
return
end
removed = source_types.reject { |type_name| Helpers.candidate_covers_type?(candidate_types, type_name) }
return if removed.empty?
report.breaking(path, "Candidate no longer accepts source types: #{removed.to_a.sort.join(', ')}")
end
def source_may_accept_numeric?(schema)
Helpers.schema_accepts_type?(schema, 'number') || Helpers.schema_accepts_type?(schema, 'integer')
end
def compare_numeric_constraints(source, candidate, path, report)
compare_lower_bound(source, candidate, path, report)
compare_upper_bound(source, candidate, path, report)
compare_multiple_of(source, candidate, path, report)
end
def compare_lower_bound(source, candidate, path, report)
source_lower = lower_bound(source)
candidate_lower = lower_bound(candidate)
return if candidate_lower.nil?
if source_lower.nil?
report.breaking(path, 'Candidate adds a numeric lower bound')
return
end
if candidate_lower[:value] > source_lower[:value]
report.breaking(path, 'Candidate raises the numeric lower bound')
elsif candidate_lower[:value] == source_lower[:value] && candidate_lower[:exclusive] && !source_lower[:exclusive]
report.breaking(path, 'Candidate makes the numeric lower bound exclusive')
end
end
def compare_upper_bound(source, candidate, path, report)
source_upper = upper_bound(source)
candidate_upper = upper_bound(candidate)
return if candidate_upper.nil?
if source_upper.nil?
report.breaking(path, 'Candidate adds a numeric upper bound')
return
end
if candidate_upper[:value] < source_upper[:value]
report.breaking(path, 'Candidate lowers the numeric upper bound')
elsif candidate_upper[:value] == source_upper[:value] && candidate_upper[:exclusive] && !source_upper[:exclusive]
report.breaking(path, 'Candidate makes the numeric upper bound exclusive')
end
end
def compare_multiple_of(source, candidate, path, report)
return unless candidate.key?('multipleOf')
unless source.key?('multipleOf')
report.breaking(path, 'Candidate adds a multipleOf constraint')
return
end
source_value = Rational(source['multipleOf'].to_s)
candidate_value = Rational(candidate['multipleOf'].to_s)
ratio = source_value / candidate_value
return if ratio.denominator == 1
report.breaking(path, 'Candidate makes the multipleOf constraint stricter')
rescue StandardError
report.warning(path, 'Could not reason about multipleOf compatibility exactly')
end
def compare_string_constraints(source, candidate, path, report)
compare_minimum_like(source, candidate, path, report, 'minLength', 0, 'Candidate increases minLength')
compare_maximum_like(source, candidate, path, report, 'maxLength', nil, 'Candidate decreases maxLength')
if candidate.key?('pattern')
if !source.key?('pattern')
report.breaking(path, 'Candidate adds a string pattern restriction')
elsif source['pattern'] != candidate['pattern']
report.warning(path, 'Pattern changed and may be stricter than the source pattern')
end
end
if @strict_format && candidate.key?('format')
if !source.key?('format')
report.breaking(path, 'Candidate adds a strict string format requirement')
elsif source['format'] != candidate['format']
report.warning(path, 'String format changed and may need manual review')
end
end
%w[contentEncoding contentMediaType].each do |key|
next unless candidate.key?(key)
next if source[key] == candidate[key]
if source.key?(key)
report.warning(path, "#{key} changed and may affect validator behavior")
else
report.warning(path, "Candidate adds #{key}; some validators treat that as a restriction")
end
end
end
def compare_object_constraints(source, candidate, path, report)
compare_minimum_like(source, candidate, path, report, 'minProperties', 0, 'Candidate increases minProperties')
compare_maximum_like(source, candidate, path, report, 'maxProperties', nil, 'Candidate decreases maxProperties')
compare_required(source, candidate, path, report)
compare_property_names(source, candidate, path, report)
compare_dependent_required(source, candidate, path, report)
compare_properties(source, candidate, path, report)
compare_pattern_properties(source, candidate, path, report)
compare_additional_properties(source, candidate, path, report)
end
def compare_required(source, candidate, path, report)
source_required = Array(source['required']).map(&:to_s).to_set
candidate_required = Array(candidate['required']).map(&:to_s).to_set
added = candidate_required - source_required
return if added.empty?
report.breaking(path, "Candidate adds required properties: #{added.to_a.sort.join(', ')}")
end
def compare_property_names(source, candidate, path, report)
return unless candidate.key?('propertyNames')
unless source.key?('propertyNames')
report.breaking(Helpers.pointer_join(path, 'propertyNames'), 'Candidate adds a propertyNames restriction')
return
end
compare_schema(source['propertyNames'], candidate['propertyNames'], Helpers.pointer_join(path, 'propertyNames'), report)
end
def compare_dependent_required(source, candidate, path, report)
source_map = Helpers.deep_stringify(source['dependentRequired'] || {})
candidate_map = Helpers.deep_stringify(candidate['dependentRequired'] || {})
candidate_map.each do |name, dependencies|
source_dependencies = Array(source_map[name]).map(&:to_s).to_set
candidate_dependencies = Array(dependencies).map(&:to_s).to_set
added = candidate_dependencies - source_dependencies
next if added.empty?
report.breaking(Helpers.pointer_join(path, 'dependentRequired'), "Candidate adds dependentRequired entries for #{name}: #{added.to_a.sort.join(', ')}")
end
end
def compare_properties(source, candidate, path, report)
source_properties = Helpers.deep_stringify(source['properties'] || {})
candidate_properties = Helpers.deep_stringify(candidate['properties'] || {})
fallback = candidate_additional_properties(candidate)
source_properties.each do |name, property_schema|
property_path = Helpers.pointer_join(Helpers.pointer_join(path, 'properties'), name)
if candidate_properties.key?(name)
compare_schema(property_schema, candidate_properties[name], property_path, report)
next
end
case fallback