schemurai 1.0.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,481 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "dialects/draft7"
5
+ require_relative "dialects/draft2019_09"
6
+ require_relative "dialects/draft2020_12"
7
+ require_relative "meta_schemas/draft7"
8
+ require_relative "meta_schemas/draft2019_09"
9
+ require_relative "meta_schemas/draft2020_12"
10
+ require_relative "schema_node"
11
+
12
+ module Schemurai
13
+ module Internal
14
+ class SchemaGraph
15
+ class Resource
16
+ attr_reader :uri, :root
17
+
18
+ def initialize(uri, root)
19
+ @uri = uri
20
+ @root = root
21
+ @nodes = nil
22
+ end
23
+
24
+ def add(node)
25
+ (@nodes ||= {})[node.resource_path] = node unless node.equal?(root)
26
+ node.resource = self unless node.resource.equal?(self)
27
+ end
28
+
29
+ def node_at(pointer)
30
+ return root if pointer.empty?
31
+
32
+ @nodes&.[](pointer)
33
+ end
34
+
35
+ def nodes
36
+ @nodes || EMPTY_NODES
37
+ end
38
+
39
+ EMPTY_NODES = {}.freeze
40
+ private_constant :EMPTY_NODES
41
+ end
42
+
43
+ attr_reader :resources, :uri_registry, :nodes
44
+
45
+ def initialize(schemas: {}, dialect: Dialect.resolve)
46
+ @external_schemas = schemas.dup
47
+ @indexed_external_schemas = nil
48
+ @compiled_roots = {}.compare_by_identity
49
+ @resources = {}
50
+ @uri_registry = {}
51
+ @nodes = []
52
+ @nodes_by_document_location = nil
53
+ @resolved_refs = nil
54
+ @dynamic_anchors = {}
55
+ # External schemas are indexed lazily, so their references are not yet
56
+ # available to compile_node. Track conservatively from the caller.
57
+ # Once required, dynamic scope tracking must remain enabled because all
58
+ # evaluators sharing this graph may reach the dynamic reference.
59
+ @dynamic_scope = !schemas.empty?
60
+ @default_dialect = dialect
61
+ @shareable = false
62
+ end
63
+
64
+ def compile(schema, base_uri: nil, dialect: @default_dialect)
65
+ roots = @compiled_roots.fetch(schema) { @compiled_roots[schema] = {} }
66
+ cache_key = [base_uri.to_s, dialect]
67
+ roots.fetch(cache_key) do
68
+ roots[cache_key] = compile_atomically do |changes|
69
+ root_dialect = dialect_for(schema, dialect, changes)
70
+ compile_document(schema, base_uri.to_s, root_dialect, changes)
71
+ end
72
+ end
73
+ end
74
+
75
+ def resolve(node, reference)
76
+ if (resolved = @resolved_refs&.[](node))&.key?(reference)
77
+ return resolved[reference]
78
+ end
79
+
80
+ if @shareable
81
+ raise ResolutionError, "reference #{reference.inspect} was not resolved before sharing"
82
+ end
83
+
84
+ target = resolve_uncached(node, reference)
85
+ ((@resolved_refs ||= {})[node] ||= {})[reference] = target
86
+ end
87
+
88
+ def dynamic_anchor(resource, name)
89
+ @dynamic_anchors.dig(resource, name)
90
+ end
91
+
92
+ def dynamic_scope?
93
+ @dynamic_scope
94
+ end
95
+
96
+ def make_shareable
97
+ return self if @shareable
98
+
99
+ @external_schemas.each_key do |uri|
100
+ index_external(strip_fragment(uri.to_s), @default_dialect)
101
+ end
102
+
103
+ index = 0
104
+ while index < nodes.length
105
+ node = nodes[index]
106
+ schema = node.schema
107
+ if schema.is_a?(Hash)
108
+ ["$ref", "$recursiveRef", "$dynamicRef"].each do |keyword|
109
+ resolve(node, schema[keyword]) if schema.key?(keyword)
110
+ end
111
+ end
112
+ index += 1
113
+ end
114
+
115
+ @resolved_refs ||= {}
116
+ @shareable = true
117
+ Ractor.make_shareable(self)
118
+ end
119
+
120
+ private def compilation_changes
121
+ {
122
+ resources: {},
123
+ resource_nodes: {},
124
+ uri_registry: {},
125
+ nodes: [],
126
+ document_locations: [],
127
+ dynamic_anchors: {},
128
+ custom_dialects: {},
129
+ requires_dynamic_scope: false
130
+ }
131
+ end
132
+
133
+ private def compile_atomically
134
+ changes = compilation_changes
135
+ result = yield changes
136
+ commit(changes)
137
+ result
138
+ end
139
+
140
+ private def commit(changes)
141
+ resources.update(changes[:resources])
142
+ changes[:resource_nodes].each do |resource, resource_nodes|
143
+ resource_nodes.each_value { |node| resource.add(node) }
144
+ end
145
+ uri_registry.update(changes[:uri_registry])
146
+ nodes.concat(changes[:nodes])
147
+ if @nodes_by_document_location
148
+ changes[:document_locations].each do |document_key, schema_path, node|
149
+ (@nodes_by_document_location[document_key] ||= {})[schema_path] = node
150
+ end
151
+ end
152
+ changes[:dynamic_anchors].each do |resource, anchors|
153
+ (@dynamic_anchors[resource] ||= {}).update(anchors)
154
+ end
155
+ (@custom_dialects ||= {}).update(changes[:custom_dialects]) unless changes[:custom_dialects].empty?
156
+ @dynamic_scope = true if changes[:requires_dynamic_scope]
157
+ end
158
+
159
+ private def resolve_uncached(node, reference)
160
+ uri = absolute_uri(node.base_uri, reference)
161
+ document_uri = strip_fragment(uri)
162
+ index_external(document_uri, node.dialect)
163
+
164
+ if (target = uri_registry[uri])
165
+ return target
166
+ end
167
+
168
+ resource = if node.resource.uri == document_uri
169
+ node.resource
170
+ else
171
+ resources[document_uri]
172
+ end
173
+ raise ResolutionError, "unresolvable reference #{reference.inspect}" unless resource
174
+
175
+ raw_fragment = fragment(uri)
176
+ return resource.root if raw_fragment.empty?
177
+
178
+ decoded = URI.decode_uri_component(raw_fragment)
179
+ unless decoded.start_with?("/")
180
+ raise ResolutionError, "unsupported plain-name fragment ##{raw_fragment}"
181
+ end
182
+
183
+ pointer = canonical_pointer(decoded)
184
+ return resource.node_at(pointer) if resource.node_at(pointer)
185
+
186
+ schema_path = "#{resource.root.schema_path}#{pointer}"
187
+ if (target = node_by_document_location(resource.root.document_key, schema_path))
188
+ return target
189
+ end
190
+
191
+ target = pointer_target(resource.root.schema, pointer)
192
+ compile_atomically do |changes|
193
+ compile_node(
194
+ target,
195
+ resource.root.base_uri,
196
+ resource.root.dialect,
197
+ schema_path,
198
+ pointer,
199
+ resource,
200
+ resource.root.document_key,
201
+ changes
202
+ )
203
+ end
204
+ rescue URI::Error
205
+ raise ResolutionError, "unresolvable reference #{reference.inspect}"
206
+ end
207
+
208
+ def node_at(uri)
209
+ uri_registry[uri.to_s]
210
+ end
211
+
212
+ private def compile_document(schema, retrieval_uri, dialect, changes)
213
+ document_key = retrieval_uri.empty? ? Object.new : retrieval_uri
214
+ root = compile_node(schema, retrieval_uri, dialect, "", "", nil, document_key, changes)
215
+ document_uri = strip_fragment(retrieval_uri)
216
+ register_resource(document_uri, root, changes) unless document_uri.empty? || resource_at(document_uri, changes)
217
+ register_uri_unless_present(retrieval_uri, root, changes) unless retrieval_uri.empty?
218
+ register_uri_unless_present(document_uri, root, changes) unless document_uri.empty?
219
+ root
220
+ end
221
+
222
+ private def compile_node(schema, inherited_base, dialect, schema_path, resource_path, resource, document_key, changes)
223
+ hash_schema = schema.is_a?(Hash)
224
+ if hash_schema && (schema.key?("$recursiveRef") || schema.key?("$dynamicRef"))
225
+ changes[:requires_dynamic_scope] = true
226
+ end
227
+ dialect = dialect_for(schema, dialect, changes) if hash_schema && schema.key?("$schema")
228
+ if hash_schema && dialect.format_assertion? && schema.key?("format") &&
229
+ Formats.resolve(schema["format"]).name.nil?
230
+ raise UnsupportedFormatError,
231
+ "unsupported format #{schema["format"].inspect} required by Format-Assertion vocabulary"
232
+ end
233
+ exclusive_ref = hash_schema && schema.key?("$ref") && !dialect.ref_siblings?
234
+ base = if hash_schema && !exclusive_ref && schema.key?("$id")
235
+ absolute_uri(inherited_base, schema["$id"])
236
+ else
237
+ inherited_base
238
+ end
239
+ base = inherited_base if base.empty?
240
+ starts_resource = hash_schema && schema.key?("$id") && !exclusive_ref &&
241
+ !base.empty? && fragment(base).empty?
242
+ resource_path = "" if starts_resource
243
+
244
+ node = SchemaNode.new(
245
+ schema: schema,
246
+ dialect: dialect,
247
+ base_uri: base,
248
+ schema_path: schema_path,
249
+ resource_path: resource_path,
250
+ document_key: document_key
251
+ )
252
+ changes[:nodes] << node
253
+ changes[:document_locations] << [document_key, schema_path, node]
254
+
255
+ if hash_schema && schema.key?("$id") && !exclusive_ref && !base.empty?
256
+ changes[:uri_registry][base] = node
257
+ if starts_resource
258
+ resource = register_resource(strip_fragment(base), node, changes)
259
+ end
260
+ end
261
+
262
+ resource ||= register_resource(strip_fragment(base), node, changes)
263
+ unless node.resource
264
+ node.resource = resource
265
+ (changes[:resource_nodes][resource] ||= {})[node.resource_path] = node unless node.equal?(resource.root)
266
+ end
267
+
268
+ if hash_schema && !exclusive_ref
269
+ register_anchor(node, resource, schema["$anchor"], changes) if schema.key?("$anchor")
270
+ if schema.key?("$dynamicAnchor")
271
+ register_anchor(node, resource, schema["$dynamicAnchor"], changes, dynamic: true)
272
+ end
273
+ end
274
+
275
+ dialect.each_subschema(schema) do |child_schema, segments|
276
+ child_schema_path = append_segments(schema_path, segments)
277
+ child_resource_path = if resource_path == schema_path
278
+ child_schema_path
279
+ else
280
+ append_segments(resource_path, segments)
281
+ end
282
+ child = compile_node(
283
+ child_schema,
284
+ base,
285
+ dialect,
286
+ child_schema_path,
287
+ child_resource_path,
288
+ resource,
289
+ document_key,
290
+ changes
291
+ )
292
+ node.add_child(*segments, child: child)
293
+ end
294
+ node.freeze
295
+ end
296
+
297
+ private def register_resource(uri, root, changes)
298
+ return Resource.new(uri, root) if uri.empty?
299
+
300
+ resource = resource_at(uri, changes)
301
+ return resource if resource && resource.root.equal?(root)
302
+ return resource if resource
303
+
304
+ changes[:resources][uri] = Resource.new(uri, root)
305
+ end
306
+
307
+ private def resource_at(uri, changes)
308
+ changes[:resources].fetch(uri) { resources[uri] }
309
+ end
310
+
311
+ private def register_uri_unless_present(uri, node, changes)
312
+ return if changes[:uri_registry].key?(uri) || uri_registry.key?(uri)
313
+
314
+ changes[:uri_registry][uri] = node
315
+ end
316
+
317
+ private def register_anchor(node, resource, name, changes, dynamic: false)
318
+ return unless name.is_a?(String) && !name.empty?
319
+
320
+ changes[:uri_registry]["#{resource.uri}##{name}"] = node
321
+ ((changes[:dynamic_anchors][resource] ||= {})[name] = node) if dynamic
322
+ end
323
+
324
+ private def node_by_document_location(document_key, schema_path)
325
+ unless @nodes_by_document_location
326
+ target = nodes.find { |node| node.document_key == document_key && node.schema_path == schema_path }
327
+ return unless target
328
+
329
+ @nodes_by_document_location = nodes.each_with_object({}) do |node, result|
330
+ (result[node.document_key] ||= {})[node.schema_path] = node
331
+ end
332
+ end
333
+ @nodes_by_document_location.dig(document_key, schema_path)
334
+ end
335
+
336
+ private def index_external(document_uri, fallback_dialect)
337
+ return if @indexed_external_schemas&.[](document_uri)
338
+
339
+ if (dialect = Dialect.resolve(document_uri)) && (meta_schema = MetaSchemas.resolve(document_uri))
340
+ compile_atomically do |changes|
341
+ compile_document(meta_schema, dialect.uri, dialect, changes)
342
+ end
343
+ (@indexed_external_schemas ||= {})[document_uri] = true
344
+ return
345
+ end
346
+ if @external_schemas.key?(document_uri)
347
+ external_schema = @external_schemas[document_uri]
348
+ compile_atomically do |changes|
349
+ dialect = dialect_for(external_schema, fallback_dialect, changes)
350
+ compile_document(external_schema, document_uri, dialect, changes)
351
+ end
352
+ (@indexed_external_schemas ||= {})[document_uri] = true
353
+ return
354
+ end
355
+
356
+ matches = @external_schemas.select do |external_uri, _schema|
357
+ strip_fragment(external_uri.to_s) == document_uri
358
+ end
359
+ compile_atomically do |changes|
360
+ matches.each do |external_uri, external_schema|
361
+ external_uri = external_uri.to_s
362
+ dialect = dialect_for(external_schema, fallback_dialect, changes)
363
+ compile_document(external_schema, external_uri, dialect, changes)
364
+ end
365
+ end
366
+ (@indexed_external_schemas ||= {})[document_uri] = true
367
+ end
368
+
369
+ private def dialect_for(schema, fallback, changes = nil)
370
+ return fallback unless schema.is_a?(Hash) && schema.key?("$schema")
371
+
372
+ uri = schema["$schema"].to_s
373
+ Dialect.resolve(uri) || custom_dialect(uri, fallback, changes) || fallback
374
+ end
375
+
376
+ private def custom_dialect(uri, fallback, changes)
377
+ meta_schema = @external_schemas[uri] || @external_schemas[uri.delete_suffix("#")]
378
+ return unless meta_schema.is_a?(Hash) && meta_schema["$vocabulary"].is_a?(Hash)
379
+
380
+ custom_dialects = changes ? changes[:custom_dialects] : (@custom_dialects ||= {})
381
+ return custom_dialects[uri] if custom_dialects.key?(uri)
382
+ return @custom_dialects[uri] if @custom_dialects&.key?(uri)
383
+
384
+ custom_dialects[uri] ||= begin
385
+ vocabulary = meta_schema["$vocabulary"]
386
+ validation = vocabulary.any? { |name, enabled| enabled && name.end_with?("/validation") }
387
+ format_assertion = vocabulary.any? { |name, _| name.end_with?("/format-assertion") }
388
+ keywords = if validation
389
+ fallback.keywords
390
+ else
391
+ fallback.keywords.except(*VALIDATION_KEYWORDS)
392
+ end
393
+ if format_assertion
394
+ keywords = keywords.merge("format" => Dialect::Keyword.new(mask: Dialect::STRING))
395
+ end
396
+ Dialect.new(
397
+ name: fallback.name,
398
+ uri: uri,
399
+ keywords: keywords,
400
+ ref_siblings: fallback.ref_siblings?,
401
+ format_assertion: format_assertion
402
+ )
403
+ end
404
+ end
405
+
406
+ VALIDATION_KEYWORDS = %w[
407
+ type enum const multipleOf maximum exclusiveMaximum minimum exclusiveMinimum
408
+ maxLength minLength pattern maxItems minItems uniqueItems maxContains minContains
409
+ maxProperties minProperties required dependentRequired
410
+ ].freeze
411
+ private_constant :VALIDATION_KEYWORDS
412
+
413
+ private def absolute_uri(base, identifier)
414
+ base = base.to_s
415
+ return base if identifier.nil?
416
+
417
+ identifier = identifier.to_s
418
+ return identifier if base.empty? || identifier.match?(/\A[A-Za-z][A-Za-z0-9+.-]*:/)
419
+ return strip_fragment(base) if identifier.empty?
420
+ return "#{strip_fragment(base)}#{identifier}" if identifier.start_with?("#")
421
+
422
+ URI.join(base, identifier).to_s
423
+ rescue URI::Error
424
+ begin
425
+ URI.join("resolve:///", base, identifier).to_s.delete_prefix("resolve:///")
426
+ rescue URI::Error
427
+ identifier
428
+ end
429
+ end
430
+
431
+ private def strip_fragment(uri)
432
+ index = uri.index("#")
433
+ index ? uri[0, index] : uri
434
+ end
435
+
436
+ private def fragment(uri)
437
+ index = uri.index("#")
438
+ index ? uri[(index + 1)..] : ""
439
+ end
440
+
441
+ private def append_segments(path, segments)
442
+ segments.reduce(path) do |result, segment|
443
+ encoded = segment.to_s
444
+ encoded = encoded.gsub("~", "~0") if encoded.include?("~")
445
+ encoded = encoded.gsub("/", "~1") if encoded.include?("/")
446
+ "#{result}/#{encoded}"
447
+ end
448
+ end
449
+
450
+ private def canonical_pointer(pointer)
451
+ return "" if pointer.empty?
452
+
453
+ tokens = pointer.split("/", -1).drop(1).map do |token|
454
+ decoded = token.gsub("~1", "/").gsub("~0", "~")
455
+ decoded.gsub("~", "~0").gsub("/", "~1")
456
+ end
457
+ "/#{tokens.join("/")}"
458
+ end
459
+
460
+ private def pointer_target(document, pointer)
461
+ pointer.split("/", -1).drop(1).reduce(document) do |current, token|
462
+ key = token.gsub("~1", "/").gsub("~0", "~")
463
+ if current.is_a?(Array)
464
+ unless key.match?(/\A(?:0|[1-9]\d*)\z/)
465
+ raise ResolutionError, "invalid JSON Pointer index #{key.inspect}"
466
+ end
467
+ current.fetch(key.to_i)
468
+ elsif current.is_a?(Hash)
469
+ current.fetch(key)
470
+ else
471
+ raise ResolutionError, "JSON Pointer traverses a scalar"
472
+ end
473
+ end
474
+ rescue IndexError
475
+ raise ResolutionError, "JSON Pointer target does not exist"
476
+ end
477
+ end
478
+ end
479
+
480
+ private_constant :Internal
481
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formats"
4
+
5
+ module Schemurai
6
+ module Internal
7
+ class SchemaNode
8
+ MISSING_SEGMENT = Object.new.freeze
9
+
10
+ attr_reader :schema, :dialect, :base_uri, :schema_path, :resource_path,
11
+ :keyword_mask, :document_key, :format
12
+ attr_accessor :resource
13
+
14
+ def initialize(schema:, dialect:, base_uri:, schema_path:, resource_path:, document_key:)
15
+ @schema = schema
16
+ @dialect = dialect
17
+ @base_uri = base_uri
18
+ @schema_path = schema_path
19
+ @resource_path = resource_path
20
+ @document_key = document_key
21
+ @keyword_mask = schema.is_a?(Hash) ? dialect.keyword_mask(schema) : 0
22
+ @format = Formats.resolve(schema["format"]) if schema.is_a?(Hash) && schema.key?("format")
23
+ @children = nil
24
+ end
25
+
26
+ def add_child(keyword, segment = MISSING_SEGMENT, child:)
27
+ children = (@children ||= {})
28
+ if segment.equal?(MISSING_SEGMENT)
29
+ children[keyword] = child
30
+ else
31
+ (children[keyword] ||= {})[segment] = child
32
+ end
33
+ end
34
+
35
+ def child(keyword, segment = MISSING_SEGMENT)
36
+ return unless @children
37
+ return @children[keyword] if segment.equal?(MISSING_SEGMENT)
38
+
39
+ @children.dig(keyword, segment)
40
+ end
41
+
42
+ def freeze
43
+ if @children
44
+ @children.each_value { |value| value.freeze if value.is_a?(Hash) }
45
+ @children.freeze
46
+ end
47
+ super
48
+ end
49
+
50
+ private_constant :MISSING_SEGMENT
51
+ end
52
+ end
53
+
54
+ private_constant :Internal
55
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schemurai
4
+ VERSION = "1.0.0"
5
+ end
data/lib/schemurai.rb ADDED
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schemurai/version"
4
+ require_relative "schemurai/formats"
5
+ require_relative "schemurai/schema_graph"
6
+ require_relative "schemurai/evaluator"
7
+
8
+ module Schemurai
9
+ class Error < StandardError; end
10
+ class ResolutionError < Error; end
11
+ class UnsupportedFormatError < Error; end
12
+
13
+ ValidationError = Data.define(:keyword, :instance_path, :schema_path, :message) do
14
+ def to_h
15
+ {keyword: keyword, instance_path: instance_path, schema_path: schema_path, message: message}
16
+ end
17
+ end
18
+
19
+ class Result
20
+ attr_reader :errors
21
+
22
+ def initialize(errors)
23
+ @errors = errors.freeze
24
+ end
25
+
26
+ def valid?
27
+ errors.empty?
28
+ end
29
+
30
+ alias_method :success?, :valid?
31
+ end
32
+
33
+ class SchemaRegistry
34
+ def initialize(schemas: {})
35
+ @graph = Internal::SchemaGraph.new(schemas: schemas)
36
+ @shareable = false
37
+ end
38
+
39
+ def compile(schema, base_uri: nil, content: false, format: false)
40
+ raise Error, "cannot compile schemas after the registry is made shareable" if @shareable
41
+
42
+ root = @graph.compile(schema, base_uri: base_uri)
43
+ Validator.new(@graph, root, content: content, format: format)
44
+ end
45
+
46
+ def make_shareable
47
+ return self if @shareable
48
+
49
+ @graph.make_shareable
50
+ @shareable = true
51
+ Ractor.make_shareable(self)
52
+ end
53
+
54
+ def shareable?
55
+ @shareable
56
+ end
57
+
58
+ def validator_for(uri, content: false, format: false)
59
+ raise Error, "make_shareable must be called before retrieving validators by URI" unless @shareable
60
+
61
+ root = @graph.node_at(uri)
62
+ raise ResolutionError, "unregistered schema URI #{uri.inspect}" unless root
63
+
64
+ Validator.new(@graph, root, content: content, format: format)
65
+ end
66
+ end
67
+
68
+ class Validator
69
+ def initialize(graph, root, content:, format:)
70
+ @evaluator = Internal::Evaluator.new(graph, root, content: content, format: format)
71
+ end
72
+
73
+ def validate(instance)
74
+ @evaluator.validate(instance)
75
+ end
76
+
77
+ def valid?(instance)
78
+ @evaluator.valid?(instance)
79
+ end
80
+ end
81
+
82
+ module_function def compile(schema, schemas: {}, base_uri: nil, content: false, format: false)
83
+ SchemaRegistry.new(schemas: schemas).compile(
84
+ schema,
85
+ base_uri: base_uri,
86
+ content: content,
87
+ format: format
88
+ )
89
+ end
90
+
91
+ module_function def validate(schema, instance, schemas: {}, base_uri: nil, content: false, format: false)
92
+ compile(
93
+ schema,
94
+ schemas: schemas,
95
+ base_uri: base_uri,
96
+ content: content,
97
+ format: format
98
+ ).validate(instance)
99
+ end
100
+
101
+ module_function def valid?(schema, instance, schemas: {}, base_uri: nil, content: false, format: false)
102
+ compile(
103
+ schema,
104
+ schemas: schemas,
105
+ base_uri: base_uri,
106
+ content: content,
107
+ format: format
108
+ ).valid?(instance)
109
+ end
110
+ end