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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +206 -0
- data/lib/schemurai/dialect.rb +118 -0
- data/lib/schemurai/dialect_keywords.rb +91 -0
- data/lib/schemurai/dialects/draft2019_09.rb +27 -0
- data/lib/schemurai/dialects/draft2020_12.rb +27 -0
- data/lib/schemurai/dialects/draft7.rb +27 -0
- data/lib/schemurai/evaluation.rb +46 -0
- data/lib/schemurai/evaluator.rb +857 -0
- data/lib/schemurai/formats.rb +403 -0
- data/lib/schemurai/meta_schemas/draft2019_09.rb +48 -0
- data/lib/schemurai/meta_schemas/draft2020_12.rb +48 -0
- data/lib/schemurai/meta_schemas/draft7.rb +121 -0
- data/lib/schemurai/meta_schemas.rb +28 -0
- data/lib/schemurai/schema_graph.rb +481 -0
- data/lib/schemurai/schema_node.rb +55 -0
- data/lib/schemurai/version.rb +5 -0
- data/lib/schemurai.rb +110 -0
- metadata +76 -0
|
@@ -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
|
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
|