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,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Senko
6
+ class Compiler
7
+ ResolvedRef = Struct.new(:schema, :keyword_location, :base_uri, :scope, keyword_init: true)
8
+
9
+ class RefResolver
10
+ ROOT_SCOPE = :__senko_root__
11
+ BUILTIN_METASCHEMA_URIS = [
12
+ 'https://json-schema.org/draft/2020-12/schema',
13
+ 'https://json-schema.org/draft/2020-12/schema#',
14
+ 'https://json-schema.org/draft/2019-09/schema',
15
+ 'https://json-schema.org/draft/2019-09/schema#',
16
+ 'http://json-schema.org/draft-07/schema',
17
+ 'http://json-schema.org/draft-07/schema#',
18
+ 'http://json-schema.org/draft-06/schema',
19
+ 'http://json-schema.org/draft-06/schema#',
20
+ 'http://json-schema.org/draft-04/schema',
21
+ 'http://json-schema.org/draft-04/schema#'
22
+ ].freeze
23
+
24
+ def initialize(root_schema, schemas: {}, ref_resolver: nil)
25
+ @root_schema = root_schema
26
+ @schemas = {}
27
+ schemas.each do |uri, schema|
28
+ @schemas[uri.to_s] = registered_schema(uri.to_s, schema)
29
+ end
30
+ @ref_resolver = ref_resolver
31
+ @resources = {}
32
+ @locations = {}
33
+ @schema_scopes = {}
34
+ @resource_scopes = {}
35
+ @anchors = {}
36
+ @dynamic_anchors = {}
37
+ @base_by_location = {}
38
+ @scope_base = {}
39
+ index_schema(@root_schema, '', root_base_uri, ROOT_SCOPE)
40
+ index_registered_schemas
41
+ end
42
+
43
+ def resolve(uri, from: '', scope: ROOT_SCOPE)
44
+ uri = uri.to_s
45
+ base_uri = base_for(from, scope)
46
+ absolute = absolute_ref(uri, base_uri)
47
+ if absolute.empty? || absolute == '#'
48
+ return ResolvedRef.new(schema: @root_schema, keyword_location: '', base_uri: root_base_uri, scope: ROOT_SCOPE)
49
+ end
50
+
51
+ base, fragment = split_fragment(absolute)
52
+ schema, target_scope = schema_for_base(base)
53
+ unless fragment
54
+ return ResolvedRef.new(
55
+ schema: schema,
56
+ keyword_location: location_for_schema(schema),
57
+ base_uri: base,
58
+ scope: target_scope
59
+ )
60
+ end
61
+
62
+ resolve_fragment(schema, fragment, base, target_scope)
63
+ end
64
+
65
+ def resolve_dynamic(uri, from: '', scope: ROOT_SCOPE)
66
+ uri = uri.to_s
67
+ base_uri = base_for(from, scope)
68
+ absolute = absolute_ref(uri, base_uri)
69
+ base, fragment = split_fragment(absolute)
70
+ anchor = URI::DEFAULT_PARSER.unescape(fragment.to_s)
71
+
72
+ if !anchor.empty? && !anchor.start_with?('/') && @dynamic_anchors.key?([base, anchor])
73
+ return @dynamic_anchors.fetch([base, anchor])
74
+ end
75
+
76
+ resolve(uri, from: from, scope: scope)
77
+ end
78
+
79
+ def base_uri_for(location, scope)
80
+ base_for(location, scope)
81
+ end
82
+
83
+ def dynamic_anchors_for(base_uri)
84
+ @dynamic_anchors.each_with_object({}) do |((base, anchor), resolved), result|
85
+ result[anchor] = resolved if base == base_uri
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def base_for(location, scope)
92
+ @base_by_location.fetch([scope, location]) { @scope_base.fetch(scope, root_base_uri) }
93
+ end
94
+
95
+ def root_base_uri
96
+ schema_id = @root_schema.is_a?(Hash) ? @root_schema['$id'].to_s : ''
97
+ split_fragment(schema_id).first.to_s
98
+ end
99
+
100
+ def absolute_ref(uri, base_uri)
101
+ return uri if uri.empty?
102
+ return "#{base_uri}#{uri}" if uri.start_with?('#') && !base_uri.to_s.empty?
103
+ return uri if uri.start_with?('#')
104
+ return uri if uri.match?(/\A[a-z][a-z0-9+\-.]*:/i)
105
+ return uri if base_uri.to_s.empty?
106
+
107
+ URI.join(base_uri, uri).to_s
108
+ rescue URI::InvalidURIError
109
+ uri
110
+ end
111
+
112
+ def schema_for_base(base_uri)
113
+ return [@root_schema, ROOT_SCOPE] if base_uri.to_s.empty?
114
+ return [@resources[base_uri], @resource_scopes.fetch(base_uri)] if @resources.key?(base_uri)
115
+ if @schemas.key?(base_uri)
116
+ return [@schemas[base_uri],
117
+ @schema_scopes.fetch(@schemas[base_uri].object_id, base_uri)]
118
+ end
119
+
120
+ schema = fetch_remote_schema(base_uri)
121
+ if schema
122
+ @schemas[base_uri] = schema
123
+ index_schema(schema, '', base_uri, base_uri)
124
+ return [schema, @schema_scopes.fetch(schema.object_id, base_uri)]
125
+ end
126
+
127
+ raise UnresolvableRefError, "unable to resolve ref #{base_uri.inspect}"
128
+ end
129
+
130
+ def fetch_remote_schema(uri)
131
+ return nil unless @ref_resolver
132
+
133
+ deep_stringify(@ref_resolver.call(uri))
134
+ end
135
+
136
+ def registered_schema(uri, schema)
137
+ return builtin_metaschema(uri) if schema == true && BUILTIN_METASCHEMA_URIS.include?(uri)
138
+
139
+ deep_stringify(schema)
140
+ end
141
+
142
+ def builtin_metaschema(uri)
143
+ {
144
+ '$id' => split_fragment(uri).first,
145
+ '$schema' => 'https://json-schema.org/draft/2020-12/schema',
146
+ 'type' => %w[object boolean],
147
+ 'properties' => {
148
+ 'type' => {
149
+ 'anyOf' => [
150
+ { 'enum' => json_type_names },
151
+ { 'type' => 'array', 'items' => { 'enum' => json_type_names } }
152
+ ]
153
+ },
154
+ 'enum' => { 'type' => 'array' },
155
+ 'const' => true,
156
+ 'multipleOf' => { 'type' => 'number', 'exclusiveMinimum' => 0 },
157
+ 'maximum' => { 'type' => 'number' },
158
+ 'exclusiveMaximum' => { 'type' => 'number' },
159
+ 'minimum' => { 'type' => 'number' },
160
+ 'exclusiveMinimum' => { 'type' => 'number' },
161
+ 'maxLength' => non_negative_integer_schema,
162
+ 'minLength' => non_negative_integer_schema,
163
+ 'pattern' => { 'type' => 'string' },
164
+ 'maxItems' => non_negative_integer_schema,
165
+ 'minItems' => non_negative_integer_schema,
166
+ 'uniqueItems' => { 'type' => 'boolean' },
167
+ 'maxContains' => non_negative_integer_schema,
168
+ 'minContains' => non_negative_integer_schema,
169
+ 'maxProperties' => non_negative_integer_schema,
170
+ 'minProperties' => non_negative_integer_schema,
171
+ 'required' => string_array_schema,
172
+ '$defs' => schema_map_schema,
173
+ 'definitions' => schema_map_schema,
174
+ 'properties' => schema_map_schema,
175
+ 'patternProperties' => schema_map_schema,
176
+ 'dependentRequired' => {
177
+ 'type' => 'object',
178
+ 'additionalProperties' => string_array_schema
179
+ },
180
+ 'dependentSchemas' => schema_map_schema,
181
+ 'propertyNames' => { '$ref' => '#' },
182
+ 'items' => { '$ref' => '#' },
183
+ 'additionalItems' => { '$ref' => '#' },
184
+ 'additionalProperties' => { '$ref' => '#' },
185
+ 'contains' => { '$ref' => '#' },
186
+ 'not' => { '$ref' => '#' },
187
+ 'if' => { '$ref' => '#' },
188
+ 'then' => { '$ref' => '#' },
189
+ 'else' => { '$ref' => '#' },
190
+ 'unevaluatedItems' => { '$ref' => '#' },
191
+ 'unevaluatedProperties' => { '$ref' => '#' },
192
+ 'allOf' => schema_array_schema,
193
+ 'anyOf' => schema_array_schema,
194
+ 'oneOf' => schema_array_schema,
195
+ 'prefixItems' => schema_array_schema
196
+ },
197
+ 'additionalProperties' => true
198
+ }
199
+ end
200
+
201
+ def json_type_names
202
+ %w[null boolean object array number string integer]
203
+ end
204
+
205
+ def non_negative_integer_schema
206
+ { 'type' => 'integer', 'minimum' => 0 }
207
+ end
208
+
209
+ def string_array_schema
210
+ { 'type' => 'array', 'items' => { 'type' => 'string' } }
211
+ end
212
+
213
+ def schema_array_schema
214
+ { 'type' => 'array', 'items' => { '$ref' => '#' } }
215
+ end
216
+
217
+ def schema_map_schema
218
+ { 'type' => 'object', 'additionalProperties' => { '$ref' => '#' } }
219
+ end
220
+
221
+ def split_fragment(uri)
222
+ index = uri.index('#')
223
+ return [uri, nil] unless index
224
+
225
+ [uri[0...index], uri[(index + 1)..]]
226
+ end
227
+
228
+ def resolve_fragment(schema, fragment, base_uri, scope)
229
+ decoded = URI::DEFAULT_PARSER.unescape(fragment.to_s)
230
+ if decoded.empty?
231
+ return ResolvedRef.new(
232
+ schema: schema,
233
+ keyword_location: location_for_schema(schema),
234
+ base_uri: base_uri,
235
+ scope: scope
236
+ )
237
+ end
238
+ return resolve_pointer(schema, decoded, base_uri, scope) if decoded.start_with?('/')
239
+
240
+ resolve_anchor(schema, decoded, base_uri, scope)
241
+ end
242
+
243
+ def resolve_pointer(schema, pointer, base_uri, scope)
244
+ current = schema
245
+ location = location_for_schema(schema)
246
+
247
+ pointer.split('/', -1)[1..].each do |raw_token|
248
+ token = unescape_pointer_token(raw_token)
249
+ current = pointer_child(current, token, pointer)
250
+ location = join_pointer(location, token)
251
+ end
252
+
253
+ ResolvedRef.new(schema: current, keyword_location: location, base_uri: base_uri, scope: scope)
254
+ end
255
+
256
+ def pointer_child(current, token, pointer)
257
+ if current.is_a?(Hash)
258
+ raise UnresolvableRefError, "unable to resolve JSON Pointer #{pointer.inspect}" unless current.key?(token)
259
+
260
+ current[token]
261
+ elsif current.is_a?(Array)
262
+ index = Integer(token, exception: false)
263
+ unless index && index >= 0 && index < current.length
264
+ raise UnresolvableRefError,
265
+ "unable to resolve JSON Pointer #{pointer.inspect}"
266
+ end
267
+
268
+ current[index]
269
+ else
270
+ raise UnresolvableRefError, "unable to resolve JSON Pointer #{pointer.inspect}"
271
+ end
272
+ end
273
+
274
+ def resolve_anchor(schema, anchor, base_uri, scope)
275
+ found = @anchors[[base_uri, anchor]] || @anchors[['', anchor]]
276
+ return found if found
277
+
278
+ found = find_anchor(schema, anchor, location_for_schema(schema), base_uri, scope)
279
+ raise UnresolvableRefError, "unable to resolve anchor #{anchor.inspect}" unless found
280
+
281
+ found
282
+ end
283
+
284
+ def find_anchor(value, anchor, location, base_uri, scope)
285
+ if value.is_a?(Hash)
286
+ if value['$anchor'] == anchor || value['$dynamicAnchor'] == anchor
287
+ return ResolvedRef.new(schema: value, keyword_location: location, base_uri: base_uri, scope: scope)
288
+ end
289
+
290
+ value.each do |key, child|
291
+ found = find_anchor(child, anchor, join_pointer(location, key), base_uri, scope)
292
+ return found if found
293
+ end
294
+ elsif value.is_a?(Array)
295
+ value.each_with_index do |child, index|
296
+ found = find_anchor(child, anchor, join_pointer(location, index.to_s), base_uri, scope)
297
+ return found if found
298
+ end
299
+ end
300
+
301
+ nil
302
+ end
303
+
304
+ def index_registered_schemas
305
+ @schemas.each do |uri, schema|
306
+ scope = split_fragment(uri).first
307
+ index_schema(schema, '', scope, scope)
308
+ end
309
+ end
310
+
311
+ def index_schema(value, location, current_base, scope)
312
+ return unless value.is_a?(Hash)
313
+
314
+ @scope_base[scope] ||= current_base
315
+ next_base = schema_base_uri(value, current_base)
316
+ @base_by_location[[scope, location]] = next_base
317
+ if !next_base.to_s.empty? && (location.empty? || value.key?('$id'))
318
+ @resources[next_base] = value
319
+ @resource_scopes[next_base] = scope
320
+ end
321
+ @locations[value.object_id] = location
322
+ @schema_scopes[value.object_id] = scope
323
+ index_anchor(value, '$anchor', @anchors, location, next_base, scope)
324
+ index_anchor(value, '$dynamicAnchor', @dynamic_anchors, location, next_base, scope)
325
+
326
+ schema_children(value).each do |child_location, child|
327
+ index_schema(child, join_pointer(location, *Array(child_location)), next_base, scope)
328
+ end
329
+ end
330
+
331
+ def schema_children(schema)
332
+ children = []
333
+ schema.each do |key, child|
334
+ key = key.to_s
335
+ if schema_value_keyword?(key)
336
+ children << [key, child] if child.is_a?(Hash)
337
+ elsif schema_array_keyword?(key) && child.is_a?(Array)
338
+ child.each_with_index { |item, index| children << [[key, index.to_s], item] if item.is_a?(Hash) }
339
+ elsif schema_map_keyword?(key) && child.is_a?(Hash)
340
+ child.each { |name, item| children << [[key, name.to_s], item] if item.is_a?(Hash) }
341
+ elsif key == 'dependencies' && child.is_a?(Hash)
342
+ child.each { |name, item| children << [[key, name.to_s], item] if item.is_a?(Hash) }
343
+ end
344
+ end
345
+ children
346
+ end
347
+
348
+ def schema_value_keyword?(key)
349
+ %w[
350
+ items additionalItems additionalProperties propertyNames contains not if then else
351
+ unevaluatedProperties unevaluatedItems
352
+ ].include?(key)
353
+ end
354
+
355
+ def schema_array_keyword?(key)
356
+ %w[allOf anyOf oneOf prefixItems].include?(key)
357
+ end
358
+
359
+ def schema_map_keyword?(key)
360
+ %w[properties patternProperties $defs definitions dependentSchemas].include?(key)
361
+ end
362
+
363
+ def index_anchor(schema, keyword, index, location, base_uri, scope)
364
+ anchor = schema[keyword]
365
+ return unless anchor.is_a?(String)
366
+
367
+ index[[base_uri, anchor]] =
368
+ ResolvedRef.new(schema: schema, keyword_location: location, base_uri: base_uri, scope: scope)
369
+ end
370
+
371
+ def schema_base_uri(schema, current_base)
372
+ return current_base unless schema.key?('$id')
373
+
374
+ id = schema['$id'].to_s
375
+ resolved = current_base.to_s.empty? ? id : URI.join(current_base, id).to_s
376
+ split_fragment(resolved).first
377
+ rescue URI::InvalidURIError
378
+ id
379
+ end
380
+
381
+ def location_for_schema(schema)
382
+ @locations.fetch(schema.object_id, '')
383
+ end
384
+
385
+ def deep_stringify(value)
386
+ case value
387
+ when Hash
388
+ value.each_with_object({}) do |(key, child), result|
389
+ result[key.to_s] = deep_stringify(child)
390
+ end
391
+ when Array
392
+ value.map { |child| deep_stringify(child) }
393
+ else
394
+ value
395
+ end
396
+ end
397
+
398
+ def unescape_pointer_token(token)
399
+ token.gsub('~1', '/').gsub('~0', '~')
400
+ end
401
+
402
+ def join_pointer(base, *tokens)
403
+ tokens.reduce(base) do |memo, token|
404
+ "#{memo}/#{token.to_s.gsub('~', '~0').gsub('/', '~1')}"
405
+ end
406
+ end
407
+ end
408
+ end
409
+ end