senko 0.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.
@@ -0,0 +1,991 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+ require 'set'
5
+
6
+ require_relative 'compiler/instruction'
7
+ require_relative 'compiler/optimizer'
8
+ require_relative 'compiler/ref_resolver'
9
+ require_relative 'dialect'
10
+ require_relative 'errors'
11
+
12
+ module Senko
13
+ class Compiler
14
+ DEFAULT_OPTIONS = {
15
+ draft: nil,
16
+ format: :annotation,
17
+ ref_resolver: nil,
18
+ schemas: {},
19
+ fail_fast: false,
20
+ validate_meta_schema: false,
21
+ custom_formats: {},
22
+ custom_keywords: {},
23
+ codegen: :auto,
24
+ messages: {}
25
+ }.freeze
26
+
27
+ APPLICATOR_KEYWORDS = %w[allOf anyOf oneOf].freeze
28
+ SCHEMA_ARRAY_KEYWORDS = %w[allOf anyOf oneOf prefixItems].freeze
29
+ SCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
30
+ SCHEMA_VALUE_KEYWORDS = %w[
31
+ items additionalItems additionalProperties propertyNames contains not if then else
32
+ unevaluatedProperties unevaluatedItems
33
+ ].freeze
34
+ DATA_VALUE_KEYWORDS = %w[const enum default examples required dependentRequired].freeze
35
+
36
+ attr_reader :options
37
+
38
+ def initialize(options = {})
39
+ @options = DEFAULT_OPTIONS.merge(options)
40
+ @ref_cache = {}
41
+ @resolving = Set.new
42
+ @instruction_arrays = {}
43
+ end
44
+
45
+ def compile(schema)
46
+ stringified = deep_stringify(schema)
47
+ validate_meta_schema!(stringified) if @options[:validate_meta_schema]
48
+ @draft = Dialect.detect(stringified, @options[:draft])
49
+ @root_schema = normalize_schema(stringified, @draft)
50
+ @ref_resolver = RefResolver.new(
51
+ @root_schema,
52
+ schemas: @options[:schemas] || {},
53
+ ref_resolver: @options[:ref_resolver]
54
+ )
55
+ @root_scope = RefResolver::ROOT_SCOPE
56
+ @validation_vocabulary_enabled = validation_vocabulary_enabled?(@root_schema)
57
+
58
+ Optimizer.new.optimize(compile_schema(@root_schema, '', @root_scope))
59
+ end
60
+
61
+ private
62
+
63
+ def compile_schema(schema, path, scope)
64
+ case schema
65
+ when true
66
+ []
67
+ when false
68
+ [instruction(:false_schema, {}, path, schema)]
69
+ when Hash
70
+ compile_hash_schema(schema, path, scope)
71
+ else
72
+ raise CompileError, 'schema must be a Hash, true, or false'
73
+ end
74
+ end
75
+
76
+ def compile_hash_schema(schema, path, scope)
77
+ instructions = []
78
+ @instruction_arrays[[scope, path]] = instructions
79
+ instructions.concat(compile_ref(schema, path, scope))
80
+ instructions.concat(compile_dynamic_ref(schema, path, scope))
81
+ if @validation_vocabulary_enabled
82
+ compile_type(schema, path, instructions)
83
+ compile_enum(schema, path, instructions)
84
+ compile_const(schema, path, instructions)
85
+ compile_numeric(schema, path, instructions)
86
+ compile_string(schema, path, instructions)
87
+ end
88
+ compile_format(schema, path, instructions)
89
+ compile_array(schema, path, scope, instructions)
90
+ compile_object(schema, path, scope, instructions)
91
+ compile_applicators(schema, path, scope, instructions)
92
+ compile_unevaluated(schema, path, scope, instructions)
93
+ compile_custom_keywords(schema, path, instructions)
94
+ compile_dynamic_scope(schema, path, scope, instructions)
95
+ instructions
96
+ end
97
+
98
+ def compile_ref(schema, path, scope)
99
+ return [] unless schema.key?('$ref')
100
+
101
+ uri = schema.fetch('$ref').to_s
102
+ resolved = @ref_resolver.resolve(uri, from: path, scope: scope)
103
+ target_instructions = compile_ref_target(ref_cache_key(uri, resolved), resolved)
104
+
105
+ [
106
+ instruction(
107
+ :ref,
108
+ { uri: uri, instructions: target_instructions },
109
+ join_pointer(path, '$ref'),
110
+ schema['$ref']
111
+ )
112
+ ]
113
+ end
114
+
115
+ def compile_dynamic_ref(schema, path, scope)
116
+ return [] unless schema.key?('$dynamicRef')
117
+
118
+ uri = schema.fetch('$dynamicRef').to_s
119
+ resolved = @ref_resolver.resolve_dynamic(uri, from: path, scope: scope)
120
+ target_instructions = compile_ref_target(ref_cache_key(uri, resolved, prefix: 'dynamic'), resolved)
121
+ anchor = dynamic_ref_anchor(uri)
122
+ dynamic = anchor && resolved.schema.is_a?(Hash) && resolved.schema['$dynamicAnchor'] == anchor
123
+
124
+ [
125
+ instruction(
126
+ :dynamic_ref,
127
+ { uri: uri, anchor: anchor, dynamic: dynamic, instructions: target_instructions },
128
+ join_pointer(path, '$dynamicRef'),
129
+ schema['$dynamicRef']
130
+ )
131
+ ]
132
+ end
133
+
134
+ def compile_ref_target(uri, resolved)
135
+ return @ref_cache[uri] if @ref_cache.key?(uri)
136
+
137
+ placeholder = []
138
+ @ref_cache[uri] = placeholder
139
+ @resolving.add(uri)
140
+ normalized = normalize_schema(deep_stringify(resolved.schema), @draft)
141
+ target_draft = Dialect.schema_draft(normalized) || @draft
142
+ previous_draft = @draft
143
+ @draft = target_draft
144
+ normalized = normalize_schema(normalized, target_draft)
145
+ placeholder.replace(compile_schema(normalized, resolved.keyword_location, resolved.scope))
146
+ placeholder
147
+ rescue StandardError
148
+ @ref_cache.delete(uri) if @ref_cache[uri].equal?(placeholder)
149
+ raise
150
+ ensure
151
+ @draft = previous_draft if defined?(previous_draft) && previous_draft
152
+ @resolving.delete(uri)
153
+ end
154
+
155
+ def ref_cache_key(uri, resolved, prefix: 'ref')
156
+ "#{prefix}:#{resolved.scope}:#{resolved.base_uri}##{resolved.keyword_location}:#{uri}"
157
+ end
158
+
159
+ def compile_dynamic_scope(_schema, path, scope, instructions)
160
+ @instruction_arrays[[scope, path]] = instructions
161
+ base_uri = @ref_resolver.base_uri_for(path, scope)
162
+ anchors = @ref_resolver.dynamic_anchors_for(base_uri)
163
+ return if anchors.empty?
164
+
165
+ compiled = anchors.each_with_object({}) do |(anchor, resolved), result|
166
+ result[anchor] = @instruction_arrays[[resolved.scope, resolved.keyword_location]] ||
167
+ compile_ref_target(ref_cache_key(anchor, resolved, prefix: 'dynamic-anchor'), resolved)
168
+ end
169
+
170
+ instructions.unshift(
171
+ instruction(
172
+ :dynamic_scope,
173
+ { base_uri: base_uri, anchors: compiled },
174
+ path,
175
+ nil
176
+ )
177
+ )
178
+ end
179
+
180
+ def dynamic_ref_anchor(uri)
181
+ _base, fragment = uri.to_s.split('#', 2)
182
+ return nil if fragment.nil? || fragment.empty? || fragment.start_with?('/')
183
+
184
+ URI::DEFAULT_PARSER.unescape(fragment)
185
+ end
186
+
187
+ def compile_type(schema, path, instructions)
188
+ return unless schema.key?('type')
189
+
190
+ type_value = schema.fetch('type')
191
+ types = type_value.is_a?(Array) ? type_value : [type_value]
192
+ mask = types.reduce(0) do |memo, type|
193
+ type_name = type.to_s
194
+ bit = Instructions::TYPE_MAP[type_name]
195
+ raise CompileError, "unsupported JSON Schema type: #{type.inspect}" unless bit
196
+
197
+ memo | bit
198
+ end
199
+
200
+ instructions << instruction(
201
+ :type,
202
+ { mask: mask, expected: types.map(&:to_s) },
203
+ join_pointer(path, 'type'),
204
+ type_value
205
+ )
206
+ end
207
+
208
+ def compile_enum(schema, path, instructions)
209
+ return unless schema.key?('enum')
210
+
211
+ values = schema.fetch('enum')
212
+ raise CompileError, 'enum must be an Array' unless values.is_a?(Array)
213
+
214
+ instructions << instruction(:enum, { values: values }, join_pointer(path, 'enum'), values)
215
+ end
216
+
217
+ def compile_const(schema, path, instructions)
218
+ return unless schema.key?('const')
219
+
220
+ instructions << instruction(:const, { value: schema.fetch('const') }, join_pointer(path, 'const'),
221
+ schema['const'])
222
+ end
223
+
224
+ def compile_numeric(schema, path, instructions)
225
+ if schema.key?('multipleOf')
226
+ factor = numeric_value!(schema.fetch('multipleOf'), 'multipleOf')
227
+ raise CompileError, 'multipleOf must be greater than 0' unless factor.positive?
228
+
229
+ instructions << instruction(:multiple_of, { factor: factor }, join_pointer(path, 'multipleOf'), factor)
230
+ end
231
+
232
+ if schema.key?('maximum')
233
+ limit = numeric_value!(schema.fetch('maximum'), 'maximum')
234
+ instructions << instruction(:maximum, { limit: limit, exclusive: false }, join_pointer(path, 'maximum'), limit)
235
+ end
236
+
237
+ if schema.key?('exclusiveMaximum')
238
+ limit = numeric_value!(schema.fetch('exclusiveMaximum'), 'exclusiveMaximum')
239
+ instructions << instruction(:maximum, { limit: limit, exclusive: true }, join_pointer(path, 'exclusiveMaximum'),
240
+ limit)
241
+ end
242
+
243
+ if schema.key?('minimum')
244
+ limit = numeric_value!(schema.fetch('minimum'), 'minimum')
245
+ instructions << instruction(:minimum, { limit: limit, exclusive: false }, join_pointer(path, 'minimum'), limit)
246
+ end
247
+
248
+ return unless schema.key?('exclusiveMinimum')
249
+
250
+ limit = numeric_value!(schema.fetch('exclusiveMinimum'), 'exclusiveMinimum')
251
+ instructions << instruction(:minimum, { limit: limit, exclusive: true }, join_pointer(path, 'exclusiveMinimum'),
252
+ limit)
253
+ end
254
+
255
+ def compile_string(schema, path, instructions)
256
+ if schema.key?('maxLength')
257
+ limit = non_negative_integer!(schema.fetch('maxLength'), 'maxLength')
258
+ instructions << instruction(:max_length, { limit: limit }, join_pointer(path, 'maxLength'), limit)
259
+ end
260
+
261
+ if schema.key?('minLength')
262
+ limit = non_negative_integer!(schema.fetch('minLength'), 'minLength')
263
+ instructions << instruction(:min_length, { limit: limit }, join_pointer(path, 'minLength'), limit)
264
+ end
265
+
266
+ return unless schema.key?('pattern')
267
+
268
+ pattern = schema.fetch('pattern').to_s
269
+ regexp = Regexp.new(Senko::Format.ecma_pattern_source(pattern))
270
+ instructions << instruction(:pattern, { pattern: regexp, source: pattern }, join_pointer(path, 'pattern'),
271
+ pattern)
272
+ rescue RegexpError => e
273
+ raise CompileError, "invalid pattern #{pattern.inspect}: #{e.message}"
274
+ end
275
+
276
+ def compile_format(schema, path, instructions)
277
+ return unless schema.key?('format')
278
+
279
+ format = schema.fetch('format').to_s
280
+ instructions << instruction(
281
+ :format,
282
+ { format: format, assertion: format_assertion_enabled? },
283
+ join_pointer(path, 'format'),
284
+ format
285
+ )
286
+ end
287
+
288
+ def compile_array(schema, path, scope, instructions)
289
+ if @validation_vocabulary_enabled
290
+ if schema.key?('maxItems')
291
+ limit = non_negative_integer!(schema.fetch('maxItems'), 'maxItems')
292
+ instructions << instruction(:max_items, { limit: limit }, join_pointer(path, 'maxItems'), limit)
293
+ end
294
+
295
+ if schema.key?('minItems')
296
+ limit = non_negative_integer!(schema.fetch('minItems'), 'minItems')
297
+ instructions << instruction(:min_items, { limit: limit }, join_pointer(path, 'minItems'), limit)
298
+ end
299
+
300
+ if schema['uniqueItems'] == true
301
+ instructions << instruction(:unique_items, {}, join_pointer(path, 'uniqueItems'),
302
+ true)
303
+ end
304
+ end
305
+
306
+ prefix_count = compile_prefix_items(schema, path, scope, instructions)
307
+ compile_items(schema, path, scope, prefix_count, instructions)
308
+ compile_contains(schema, path, scope, instructions)
309
+ end
310
+
311
+ def compile_prefix_items(schema, path, scope, instructions)
312
+ return 0 unless schema.key?('prefixItems')
313
+
314
+ prefix_items = schema.fetch('prefixItems')
315
+ raise CompileError, 'prefixItems must be an Array' unless prefix_items.is_a?(Array)
316
+
317
+ compiled = prefix_items.each_with_index.map do |subschema, index|
318
+ compile_schema(subschema, join_pointer(path, 'prefixItems', index.to_s), scope)
319
+ end
320
+
321
+ instructions << instruction(:prefix_items, { schemas: compiled }, join_pointer(path, 'prefixItems'), prefix_items)
322
+ compiled.length
323
+ end
324
+
325
+ def compile_items(schema, path, scope, prefix_count, instructions)
326
+ return unless schema.key?('items')
327
+
328
+ instructions << instruction(
329
+ :items,
330
+ { schema: compile_schema(schema.fetch('items'), join_pointer(path, 'items'), scope),
331
+ start_index: prefix_count },
332
+ join_pointer(path, 'items'),
333
+ schema['items']
334
+ )
335
+ end
336
+
337
+ def compile_contains(schema, path, scope, instructions)
338
+ return unless schema.key?('contains')
339
+
340
+ min = if @validation_vocabulary_enabled && schema.key?('minContains')
341
+ non_negative_integer!(schema.fetch('minContains'), 'minContains')
342
+ else
343
+ 1
344
+ end
345
+ max = if @validation_vocabulary_enabled && schema.key?('maxContains')
346
+ non_negative_integer!(schema.fetch('maxContains'), 'maxContains')
347
+ end
348
+
349
+ instructions << instruction(
350
+ :contains,
351
+ {
352
+ schema: compile_schema(schema.fetch('contains'), join_pointer(path, 'contains'), scope),
353
+ min: min,
354
+ max: max
355
+ },
356
+ join_pointer(path, 'contains'),
357
+ schema['contains']
358
+ )
359
+ end
360
+
361
+ def compile_object(schema, path, scope, instructions)
362
+ if @validation_vocabulary_enabled
363
+ if schema.key?('maxProperties')
364
+ limit = non_negative_integer!(schema.fetch('maxProperties'), 'maxProperties')
365
+ instructions << instruction(:max_properties, { limit: limit }, join_pointer(path, 'maxProperties'), limit)
366
+ end
367
+
368
+ if schema.key?('minProperties')
369
+ limit = non_negative_integer!(schema.fetch('minProperties'), 'minProperties')
370
+ instructions << instruction(:min_properties, { limit: limit }, join_pointer(path, 'minProperties'), limit)
371
+ end
372
+
373
+ compile_required(schema, path, instructions)
374
+ compile_dependent_required(schema, path, instructions)
375
+ end
376
+
377
+ compile_properties(schema, path, scope, instructions)
378
+ compile_pattern_properties(schema, path, scope, instructions)
379
+ compile_additional_properties(schema, path, scope, instructions)
380
+ compile_property_names(schema, path, scope, instructions)
381
+ compile_dependent_schemas(schema, path, scope, instructions)
382
+ end
383
+
384
+ def compile_required(schema, path, instructions)
385
+ return unless schema.key?('required')
386
+
387
+ required = schema.fetch('required')
388
+ raise CompileError, 'required must be an Array' unless required.is_a?(Array)
389
+
390
+ instructions << instruction(
391
+ :required,
392
+ { keys: required.to_set(&:to_s) },
393
+ join_pointer(path, 'required'),
394
+ required
395
+ )
396
+ end
397
+
398
+ def compile_properties(schema, path, scope, instructions)
399
+ return unless schema.key?('properties')
400
+
401
+ properties = schema.fetch('properties')
402
+ raise CompileError, 'properties must be a Hash' unless properties.is_a?(Hash)
403
+
404
+ compiled = properties.each_with_object({}) do |(name, subschema), result|
405
+ key = name.to_s
406
+ result[key] = compile_schema(subschema, join_pointer(path, 'properties', key), scope)
407
+ end
408
+
409
+ instructions << instruction(:properties, { schemas: compiled }, join_pointer(path, 'properties'), properties)
410
+ end
411
+
412
+ def compile_pattern_properties(schema, path, scope, instructions)
413
+ return unless schema.key?('patternProperties')
414
+
415
+ pattern_properties = schema.fetch('patternProperties')
416
+ raise CompileError, 'patternProperties must be a Hash' unless pattern_properties.is_a?(Hash)
417
+
418
+ compiled = pattern_properties.map do |pattern, subschema|
419
+ regexp = Regexp.new(Senko::Format.ecma_pattern_source(pattern.to_s))
420
+ {
421
+ pattern: regexp,
422
+ source: pattern.to_s,
423
+ schema: compile_schema(subschema, join_pointer(path, 'patternProperties', pattern.to_s), scope)
424
+ }
425
+ rescue RegexpError => e
426
+ raise CompileError, "invalid patternProperties pattern #{pattern.inspect}: #{e.message}"
427
+ end
428
+
429
+ instructions << instruction(:pattern_properties, { patterns: compiled }, join_pointer(path, 'patternProperties'),
430
+ pattern_properties)
431
+ end
432
+
433
+ def compile_additional_properties(schema, path, scope, instructions)
434
+ return unless schema.key?('additionalProperties')
435
+
436
+ additional = schema.fetch('additionalProperties')
437
+
438
+ patterns = (schema['patternProperties'] || {}).keys.map { |pattern| Regexp.new(pattern.to_s) }
439
+ payload = {
440
+ known: (schema['properties'] || {}).keys.to_set(&:to_s),
441
+ patterns: patterns,
442
+ schema: (if additional == false
443
+ false
444
+ else
445
+ compile_schema(additional, join_pointer(path, 'additionalProperties'),
446
+ scope)
447
+ end)
448
+ }
449
+
450
+ instructions << instruction(:additional_properties, payload, join_pointer(path, 'additionalProperties'),
451
+ additional)
452
+ end
453
+
454
+ def compile_property_names(schema, path, scope, instructions)
455
+ return unless schema.key?('propertyNames')
456
+
457
+ instructions << instruction(
458
+ :property_names,
459
+ { schema: compile_schema(schema.fetch('propertyNames'), join_pointer(path, 'propertyNames'), scope) },
460
+ join_pointer(path, 'propertyNames'),
461
+ schema['propertyNames']
462
+ )
463
+ end
464
+
465
+ def compile_dependent_required(schema, path, instructions)
466
+ return unless schema.key?('dependentRequired')
467
+
468
+ requirements = schema.fetch('dependentRequired')
469
+ raise CompileError, 'dependentRequired must be a Hash' unless requirements.is_a?(Hash)
470
+
471
+ compiled = requirements.transform_values do |keys|
472
+ raise CompileError, 'dependentRequired values must be Arrays' unless keys.is_a?(Array)
473
+
474
+ keys.to_set(&:to_s)
475
+ end
476
+
477
+ instructions << instruction(:dependent_required, { requirements: compiled },
478
+ join_pointer(path, 'dependentRequired'), requirements)
479
+ end
480
+
481
+ def compile_dependent_schemas(schema, path, scope, instructions)
482
+ return unless schema.key?('dependentSchemas')
483
+
484
+ dependent_schemas = schema.fetch('dependentSchemas')
485
+ raise CompileError, 'dependentSchemas must be a Hash' unless dependent_schemas.is_a?(Hash)
486
+
487
+ compiled = dependent_schemas.each_with_object({}) do |(key, subschema), result|
488
+ name = key.to_s
489
+ result[name] = compile_schema(subschema, join_pointer(path, 'dependentSchemas', name), scope)
490
+ end
491
+
492
+ instructions << instruction(:dependent_schemas, { schemas: compiled }, join_pointer(path, 'dependentSchemas'),
493
+ dependent_schemas)
494
+ end
495
+
496
+ def compile_applicators(schema, path, scope, instructions)
497
+ APPLICATOR_KEYWORDS.each do |keyword|
498
+ next unless schema.key?(keyword)
499
+
500
+ subschemas = schema.fetch(keyword)
501
+ raise CompileError, "#{keyword} must be an Array" unless subschemas.is_a?(Array)
502
+
503
+ discriminator = detect_explicit_discriminator(schema, keyword, path, scope) || detect_discriminator(subschemas)
504
+ if discriminator && %w[anyOf oneOf].include?(keyword)
505
+ instructions << compile_discriminator(keyword, discriminator, path, scope, subschemas)
506
+ next
507
+ end
508
+
509
+ compiled = subschemas.each_with_index.map do |subschema, index|
510
+ compile_schema(subschema, join_pointer(path, keyword, index.to_s), scope)
511
+ end
512
+
513
+ instructions << instruction(keyword_to_op(keyword), { schemas: compiled }, join_pointer(path, keyword),
514
+ subschemas)
515
+ end
516
+
517
+ compile_not(schema, path, scope, instructions)
518
+ compile_if_then_else(schema, path, scope, instructions)
519
+ end
520
+
521
+ def compile_not(schema, path, scope, instructions)
522
+ return unless schema.key?('not')
523
+
524
+ instructions << instruction(
525
+ :not,
526
+ { schema: compile_schema(schema.fetch('not'), join_pointer(path, 'not'), scope) },
527
+ join_pointer(path, 'not'),
528
+ schema['not']
529
+ )
530
+ end
531
+
532
+ def detect_discriminator(subschemas)
533
+ candidates = discriminator_candidates(subschemas.first)
534
+ candidates.each do |property|
535
+ mapping = discriminator_mapping(subschemas, property)
536
+ return { property: property, mapping: mapping } if mapping && mapping.length == subschemas.length
537
+ end
538
+
539
+ nil
540
+ end
541
+
542
+ def detect_explicit_discriminator(schema, keyword, path, scope)
543
+ config = schema['discriminator']
544
+ return nil unless config.is_a?(Hash) && config['propertyName'].is_a?(String)
545
+
546
+ subschemas = schema[keyword]
547
+ property = config.fetch('propertyName')
548
+ mapping = explicit_discriminator_mapping(config.fetch('mapping', {}), subschemas, property, path, scope)
549
+ return nil unless mapping && !mapping.empty?
550
+
551
+ { property: property, mapping: mapping }
552
+ end
553
+
554
+ def explicit_discriminator_mapping(mapping_config, subschemas, property, path, scope)
555
+ if mapping_config.is_a?(Hash) && !mapping_config.empty?
556
+ return mapping_config.each_with_object({}) do |(value, ref), result|
557
+ resolved = @ref_resolver.resolve(ref.to_s, from: path, scope: scope)
558
+ result[value.to_s] = normalize_schema(deep_stringify(resolved.schema), @draft)
559
+ end
560
+ end
561
+
562
+ discriminator_mapping(subschemas, property)
563
+ end
564
+
565
+ def discriminator_candidates(schema)
566
+ return [] unless schema.is_a?(Hash)
567
+
568
+ required = Array(schema['required']).map(&:to_s)
569
+ properties = schema['properties']
570
+ return [] unless properties.is_a?(Hash)
571
+
572
+ properties.each_with_object([]) do |(property, subschema), result|
573
+ key = property.to_s
574
+ result << key if required.include?(key) && subschema.is_a?(Hash) && subschema.key?('const')
575
+ end
576
+ end
577
+
578
+ def discriminator_mapping(subschemas, property)
579
+ values = {}
580
+
581
+ subschemas.each do |subschema|
582
+ return nil unless subschema.is_a?(Hash)
583
+ return nil unless Array(subschema['required']).map(&:to_s).include?(property)
584
+
585
+ property_schema = subschema.fetch('properties', {})[property]
586
+ return nil unless property_schema.is_a?(Hash) && property_schema.key?('const')
587
+
588
+ value = property_schema.fetch('const')
589
+ return nil if values.key?(value)
590
+
591
+ values[value] = subschema
592
+ end
593
+
594
+ values
595
+ end
596
+
597
+ def compile_discriminator(keyword, discriminator, path, scope, subschemas)
598
+ mapping = discriminator[:mapping].transform_values.with_index do |subschema, index|
599
+ compile_schema(subschema, join_pointer(path, keyword, index.to_s), scope)
600
+ end
601
+
602
+ instruction(
603
+ :discriminator,
604
+ {
605
+ mode: keyword_to_op(keyword),
606
+ property: discriminator[:property],
607
+ mapping: mapping
608
+ },
609
+ join_pointer(path, keyword),
610
+ subschemas
611
+ )
612
+ end
613
+
614
+ def compile_if_then_else(schema, path, scope, instructions)
615
+ return unless schema.key?('if')
616
+
617
+ payload = {
618
+ if_schema: compile_schema(schema.fetch('if'), join_pointer(path, 'if'), scope),
619
+ then_schema: (compile_schema(schema.fetch('then'), join_pointer(path, 'then'), scope) if schema.key?('then')),
620
+ else_schema: (compile_schema(schema.fetch('else'), join_pointer(path, 'else'), scope) if schema.key?('else'))
621
+ }
622
+
623
+ instructions << instruction(:if_then_else, payload, join_pointer(path, 'if'), schema['if'])
624
+ end
625
+
626
+ def compile_unevaluated(schema, path, scope, instructions)
627
+ if schema.key?('unevaluatedProperties')
628
+ value = schema.fetch('unevaluatedProperties')
629
+ instructions << instruction(
630
+ :unevaluated_properties,
631
+ { schema: if value == false
632
+ false
633
+ else
634
+ compile_schema(value, join_pointer(path, 'unevaluatedProperties'),
635
+ scope)
636
+ end },
637
+ join_pointer(path, 'unevaluatedProperties'),
638
+ value
639
+ )
640
+ end
641
+
642
+ return unless schema.key?('unevaluatedItems')
643
+
644
+ value = schema.fetch('unevaluatedItems')
645
+ instructions << instruction(
646
+ :unevaluated_items,
647
+ { schema: value == false ? false : compile_schema(value, join_pointer(path, 'unevaluatedItems'), scope) },
648
+ join_pointer(path, 'unevaluatedItems'),
649
+ value
650
+ )
651
+ end
652
+
653
+ def compile_custom_keywords(schema, path, instructions)
654
+ (@options[:custom_keywords] || {}).each do |keyword, validator|
655
+ key = keyword.to_s
656
+ next unless schema.key?(key)
657
+
658
+ instructions << instruction(
659
+ :custom_keyword,
660
+ { keyword: key, value: schema.fetch(key), validator: validator },
661
+ join_pointer(path, key),
662
+ schema.fetch(key)
663
+ )
664
+ end
665
+ end
666
+
667
+ def normalize_schema(value, draft, schema_position: true)
668
+ case value
669
+ when Hash
670
+ normalized = normalize_openapi_nullable(normalize_schema_hash(value, draft))
671
+ schema_position ? normalize_legacy_schema(normalized, draft) : normalized
672
+ when Array
673
+ value.map { |child| normalize_schema(child, draft, schema_position: false) }
674
+ else
675
+ value
676
+ end
677
+ end
678
+
679
+ def validate_meta_schema!(schema, path = '')
680
+ case schema
681
+ when true, false
682
+ nil
683
+ when Hash
684
+ validate_schema_object!(schema, path)
685
+ schema.each do |key, child|
686
+ validate_meta_child!(key, child, join_pointer(path, key))
687
+ end
688
+ else
689
+ raise CompileError, "schema at #{path.empty? ? '/' : path} must be an object or boolean"
690
+ end
691
+ end
692
+
693
+ def validate_meta_child!(key, child, path)
694
+ if DATA_VALUE_KEYWORDS.include?(key)
695
+ validate_data_keyword!(key, child, path)
696
+ elsif key == 'items' && child.is_a?(Array)
697
+ child.each_with_index { |subschema, index| validate_meta_schema!(subschema, join_pointer(path, index.to_s)) }
698
+ elsif SCHEMA_ARRAY_KEYWORDS.include?(key)
699
+ validate_schema_array!(key, child, path)
700
+ elsif SCHEMA_MAP_KEYWORDS.include?(key)
701
+ validate_schema_map!(key, child, path)
702
+ elsif SCHEMA_VALUE_KEYWORDS.include?(key) || %w[$ref $dynamicRef $recursiveRef].include?(key)
703
+ validate_meta_schema!(child, path) unless %w[$ref $dynamicRef $recursiveRef].include?(key)
704
+ end
705
+ end
706
+
707
+ def validate_schema_object!(schema, path)
708
+ validate_type_keyword!(schema['type'], join_pointer(path, 'type')) if schema.key?('type')
709
+ validate_array_keyword!(schema, 'required', path, strings: true)
710
+ validate_array_keyword!(schema, 'enum', path)
711
+ validate_numeric_keyword!(schema, 'multipleOf', path, positive: true)
712
+ %w[maximum exclusiveMaximum minimum exclusiveMinimum].each do |keyword|
713
+ validate_numeric_keyword!(schema, keyword, path)
714
+ end
715
+ %w[maxLength minLength maxItems minItems maxProperties minProperties maxContains minContains].each do |keyword|
716
+ validate_non_negative_integer_keyword!(schema, keyword, path)
717
+ end
718
+ validate_boolean_keyword!(schema, 'uniqueItems', path)
719
+ end
720
+
721
+ def validate_data_keyword!(key, value, path)
722
+ return validate_array_value!(value, path, strings: true) if key == 'required'
723
+ return validate_schema_map_value!(value, path, arrays_of_strings: true) if key == 'dependentRequired'
724
+ return validate_array_value!(value, path) if key == 'enum'
725
+
726
+ true
727
+ end
728
+
729
+ def validate_schema_array!(key, value, path)
730
+ validate_array_value!(value, path)
731
+ value.each_with_index { |subschema, index| validate_meta_schema!(subschema, join_pointer(path, index.to_s)) }
732
+ rescue NoMethodError
733
+ raise CompileError, "#{key} at #{path} must be an array"
734
+ end
735
+
736
+ def validate_schema_map!(key, value, path)
737
+ validate_schema_map_value!(value, path)
738
+ value.each do |property, subschema|
739
+ validate_meta_schema!(subschema, join_pointer(path, property.to_s))
740
+ end
741
+ rescue NoMethodError
742
+ raise CompileError, "#{key} at #{path} must be an object"
743
+ end
744
+
745
+ def validate_type_keyword!(value, path)
746
+ values = value.is_a?(Array) ? value : [value]
747
+ raise CompileError, "type at #{path} must be a string or array" unless value.is_a?(String) || value.is_a?(Array)
748
+
749
+ values.each do |type|
750
+ raise CompileError, "type value at #{path} must be a string" unless type.is_a?(String)
751
+ unless Instructions::TYPE_MAP.key?(type)
752
+ raise CompileError, "unsupported JSON Schema type at #{path}: #{type.inspect}"
753
+ end
754
+ end
755
+ end
756
+
757
+ def validate_array_keyword!(schema, keyword, path, strings: false)
758
+ return unless schema.key?(keyword)
759
+
760
+ validate_array_value!(schema.fetch(keyword), join_pointer(path, keyword), strings: strings)
761
+ end
762
+
763
+ def validate_array_value!(value, path, strings: false)
764
+ raise CompileError, "#{path} must be an array" unless value.is_a?(Array)
765
+
766
+ return unless strings
767
+
768
+ value.each do |item|
769
+ raise CompileError, "#{path} must contain only strings" unless item.is_a?(String)
770
+ end
771
+ end
772
+
773
+ def validate_schema_map_value!(value, path, arrays_of_strings: false)
774
+ raise CompileError, "#{path} must be an object" unless value.is_a?(Hash)
775
+
776
+ return unless arrays_of_strings
777
+
778
+ value.each_value { |item| validate_array_value!(item, path, strings: true) }
779
+ end
780
+
781
+ def validate_numeric_keyword!(schema, keyword, path, positive: false)
782
+ return unless schema.key?(keyword)
783
+
784
+ value = schema.fetch(keyword)
785
+ raise CompileError, "#{keyword} at #{join_pointer(path, keyword)} must be numeric" unless value.is_a?(Numeric)
786
+ return unless positive && !value.positive?
787
+
788
+ raise CompileError,
789
+ "#{keyword} at #{join_pointer(path, keyword)} must be greater than 0"
790
+ end
791
+
792
+ def validate_non_negative_integer_keyword!(schema, keyword, path)
793
+ return unless schema.key?(keyword)
794
+
795
+ value = schema.fetch(keyword)
796
+ return if value.is_a?(Integer) && value >= 0
797
+
798
+ raise CompileError, "#{keyword} at #{join_pointer(path, keyword)} must be a non-negative integer"
799
+ end
800
+
801
+ def validate_boolean_keyword!(schema, keyword, path)
802
+ return unless schema.key?(keyword)
803
+ return if [true, false].include?(schema.fetch(keyword))
804
+
805
+ raise CompileError, "#{keyword} at #{join_pointer(path, keyword)} must be boolean"
806
+ end
807
+
808
+ def normalize_schema_hash(schema, draft)
809
+ schema.each_with_object({}) do |(key, child), result|
810
+ result[key] = normalize_schema_child(key, child, draft)
811
+ end
812
+ end
813
+
814
+ def normalize_schema_child(key, child, draft)
815
+ if DATA_VALUE_KEYWORDS.include?(key)
816
+ child
817
+ elsif key == 'items' && child.is_a?(Array)
818
+ child.map { |subschema| normalize_schema(subschema, draft) }
819
+ elsif SCHEMA_ARRAY_KEYWORDS.include?(key) && child.is_a?(Array)
820
+ child.map { |subschema| normalize_schema(subschema, draft) }
821
+ elsif SCHEMA_MAP_KEYWORDS.include?(key) && child.is_a?(Hash)
822
+ child.transform_values { |subschema| normalize_schema(subschema, draft) }
823
+ elsif SCHEMA_VALUE_KEYWORDS.include?(key)
824
+ normalize_schema(child, draft)
825
+ else
826
+ child
827
+ end
828
+ end
829
+
830
+ def validation_vocabulary_enabled?(schema)
831
+ return true unless schema.is_a?(Hash) && schema['$schema'].is_a?(String)
832
+
833
+ meta_schema = (@options[:schemas] || {})[schema['$schema']]
834
+ return true unless meta_schema.is_a?(Hash)
835
+
836
+ vocabulary = meta_schema['$vocabulary'] || meta_schema[:$vocabulary]
837
+ return true unless vocabulary.is_a?(Hash)
838
+
839
+ vocabulary.fetch('https://json-schema.org/draft/2020-12/vocab/validation', false) == true ||
840
+ vocabulary.fetch('https://json-schema.org/draft/2019-09/vocab/validation', false) == true
841
+ end
842
+
843
+ def format_assertion_enabled?
844
+ return true if @options[:format] == :assertion
845
+ return false unless @root_schema.is_a?(Hash) && @root_schema['$schema'].is_a?(String)
846
+
847
+ meta_schema = (@options[:schemas] || {})[@root_schema['$schema']]
848
+ return false unless meta_schema.is_a?(Hash)
849
+
850
+ vocabulary = meta_schema['$vocabulary'] || meta_schema[:$vocabulary]
851
+ return false unless vocabulary.is_a?(Hash)
852
+
853
+ vocabulary.key?('https://json-schema.org/draft/2020-12/vocab/format-assertion')
854
+ end
855
+
856
+ def normalize_legacy_schema(schema, draft)
857
+ normalized = schema.dup
858
+ normalize_recursive_keywords(normalized)
859
+ normalize_legacy_id(normalized, draft)
860
+ normalize_legacy_exclusive_bounds(normalized, draft)
861
+ normalize_legacy_dependencies(normalized)
862
+ return normalized if draft == :'2020-12'
863
+
864
+ normalized.delete('prefixItems')
865
+ if normalized.key?('definitions') && !normalized.key?('$defs')
866
+ normalized['$defs'] = normalized.delete('definitions')
867
+ end
868
+
869
+ normalize_legacy_items(normalized)
870
+ normalized
871
+ end
872
+
873
+ def normalize_recursive_keywords(schema)
874
+ schema['$dynamicRef'] ||= schema.delete('$recursiveRef') if schema.key?('$recursiveRef')
875
+ schema['$dynamicAnchor'] ||= '' if schema.delete('$recursiveAnchor') == true
876
+ end
877
+
878
+ def normalize_legacy_id(schema, draft)
879
+ return if draft == :'2020-12'
880
+ return unless schema.key?('id') && !schema.key?('$id')
881
+
882
+ schema['$id'] = schema.delete('id')
883
+ end
884
+
885
+ def normalize_legacy_exclusive_bounds(schema, draft)
886
+ return unless draft == :'draft-04'
887
+
888
+ if schema.key?('exclusiveMaximum') && boolean_value?(schema['exclusiveMaximum'])
889
+ exclusive = schema.delete('exclusiveMaximum')
890
+ schema['exclusiveMaximum'] = schema.delete('maximum') if exclusive && schema.key?('maximum')
891
+ end
892
+
893
+ return unless schema.key?('exclusiveMinimum') && boolean_value?(schema['exclusiveMinimum'])
894
+
895
+ exclusive = schema.delete('exclusiveMinimum')
896
+ schema['exclusiveMinimum'] = schema.delete('minimum') if exclusive && schema.key?('minimum')
897
+ end
898
+
899
+ def boolean_value?(value)
900
+ [true, false].include?(value)
901
+ end
902
+
903
+ def normalize_openapi_nullable(schema)
904
+ return schema unless schema['nullable'] == true && schema.key?('type')
905
+
906
+ normalized = schema.dup
907
+ type = normalized['type']
908
+ normalized['type'] = Array(type).map(&:to_s).tap do |types|
909
+ types << 'null' unless types.include?('null')
910
+ end
911
+ normalized
912
+ end
913
+
914
+ def normalize_legacy_items(schema)
915
+ return unless schema['items'].is_a?(Array)
916
+
917
+ schema['prefixItems'] ||= schema.delete('items')
918
+ schema['items'] = schema.delete('additionalItems') if schema.key?('additionalItems')
919
+ end
920
+
921
+ def normalize_legacy_dependencies(schema)
922
+ dependencies = schema.delete('dependencies')
923
+ return unless dependencies.is_a?(Hash)
924
+
925
+ dependent_required = schema['dependentRequired'] ||= {}
926
+ dependent_schemas = schema['dependentSchemas'] ||= {}
927
+
928
+ dependencies.each do |key, value|
929
+ if value.is_a?(Array)
930
+ dependent_required[key] = value
931
+ else
932
+ dependent_schemas[key] = normalize_schema(value, @draft)
933
+ end
934
+ end
935
+ end
936
+
937
+ def deep_stringify(value)
938
+ case value
939
+ when Hash
940
+ value.each_with_object({}) do |(key, child), result|
941
+ result[key.to_s] = deep_stringify(child)
942
+ end
943
+ when Array
944
+ value.map { |child| deep_stringify(child) }
945
+ else
946
+ value
947
+ end
948
+ end
949
+
950
+ def numeric_value!(value, keyword)
951
+ return value if value.is_a?(Numeric)
952
+
953
+ raise CompileError, "#{keyword} must be numeric"
954
+ end
955
+
956
+ def non_negative_integer!(value, keyword)
957
+ return value.to_i if integer_number?(value) && value >= 0
958
+
959
+ raise CompileError, "#{keyword} must be a non-negative integer"
960
+ end
961
+
962
+ def integer_number?(value)
963
+ case value
964
+ when Integer
965
+ true
966
+ when Float, BigDecimal
967
+ value.finite? && value == value.to_i
968
+ else
969
+ false
970
+ end
971
+ end
972
+
973
+ def instruction(op, payload, keyword_location, schema)
974
+ Instruction.new(op: op, payload: payload, keyword_location: keyword_location, schema: schema)
975
+ end
976
+
977
+ def keyword_to_op(keyword)
978
+ keyword.gsub(/([A-Z])/, '_\1').downcase.to_sym
979
+ end
980
+
981
+ def join_pointer(base, *tokens)
982
+ tokens.reduce(base) do |memo, token|
983
+ "#{memo}/#{escape_pointer_token(token)}"
984
+ end
985
+ end
986
+
987
+ def escape_pointer_token(token)
988
+ token.to_s.gsub('~', '~0').gsub('/', '~1')
989
+ end
990
+ end
991
+ end