json_schemer 1.0.3 → 2.4.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +7 -8
  3. data/CHANGELOG.md +96 -0
  4. data/Gemfile.lock +23 -14
  5. data/README.md +343 -20
  6. data/json_schemer.gemspec +8 -3
  7. data/lib/json_schemer/configuration.rb +31 -0
  8. data/lib/json_schemer/content.rb +18 -0
  9. data/lib/json_schemer/draft201909/meta.rb +320 -0
  10. data/lib/json_schemer/draft201909/vocab/applicator.rb +104 -0
  11. data/lib/json_schemer/draft201909/vocab/core.rb +45 -0
  12. data/lib/json_schemer/draft201909/vocab.rb +31 -0
  13. data/lib/json_schemer/draft202012/meta.rb +364 -0
  14. data/lib/json_schemer/draft202012/vocab/applicator.rb +382 -0
  15. data/lib/json_schemer/draft202012/vocab/content.rb +52 -0
  16. data/lib/json_schemer/draft202012/vocab/core.rb +160 -0
  17. data/lib/json_schemer/draft202012/vocab/format_annotation.rb +23 -0
  18. data/lib/json_schemer/draft202012/vocab/format_assertion.rb +23 -0
  19. data/lib/json_schemer/draft202012/vocab/meta_data.rb +30 -0
  20. data/lib/json_schemer/draft202012/vocab/unevaluated.rb +104 -0
  21. data/lib/json_schemer/draft202012/vocab/validation.rb +290 -0
  22. data/lib/json_schemer/draft202012/vocab.rb +105 -0
  23. data/lib/json_schemer/draft4/meta.rb +161 -0
  24. data/lib/json_schemer/draft4/vocab/validation.rb +38 -0
  25. data/lib/json_schemer/draft4/vocab.rb +18 -0
  26. data/lib/json_schemer/draft6/meta.rb +172 -0
  27. data/lib/json_schemer/draft6/vocab.rb +16 -0
  28. data/lib/json_schemer/draft7/meta.rb +183 -0
  29. data/lib/json_schemer/draft7/vocab/validation.rb +69 -0
  30. data/lib/json_schemer/draft7/vocab.rb +30 -0
  31. data/lib/json_schemer/errors.rb +1 -0
  32. data/lib/json_schemer/format/duration.rb +23 -0
  33. data/lib/json_schemer/format/json_pointer.rb +18 -0
  34. data/lib/json_schemer/format.rb +127 -106
  35. data/lib/json_schemer/keyword.rb +56 -0
  36. data/lib/json_schemer/location.rb +25 -0
  37. data/lib/json_schemer/openapi.rb +38 -0
  38. data/lib/json_schemer/openapi30/document.rb +1672 -0
  39. data/lib/json_schemer/openapi30/meta.rb +34 -0
  40. data/lib/json_schemer/openapi30/vocab/base.rb +18 -0
  41. data/lib/json_schemer/openapi30/vocab.rb +12 -0
  42. data/lib/json_schemer/openapi31/document.rb +1557 -0
  43. data/lib/json_schemer/openapi31/meta.rb +136 -0
  44. data/lib/json_schemer/openapi31/vocab/base.rb +127 -0
  45. data/lib/json_schemer/openapi31/vocab.rb +18 -0
  46. data/lib/json_schemer/output.rb +56 -0
  47. data/lib/json_schemer/resources.rb +24 -0
  48. data/lib/json_schemer/result.rb +242 -0
  49. data/lib/json_schemer/schema.rb +433 -0
  50. data/lib/json_schemer/version.rb +1 -1
  51. data/lib/json_schemer.rb +235 -32
  52. metadata +119 -18
  53. data/lib/json_schemer/schema/base.rb +0 -677
  54. data/lib/json_schemer/schema/draft4.json +0 -149
  55. data/lib/json_schemer/schema/draft4.rb +0 -44
  56. data/lib/json_schemer/schema/draft6.json +0 -155
  57. data/lib/json_schemer/schema/draft6.rb +0 -25
  58. data/lib/json_schemer/schema/draft7.json +0 -172
  59. data/lib/json_schemer/schema/draft7.rb +0 -32
@@ -0,0 +1,433 @@
1
+ # frozen_string_literal: true
2
+ module JSONSchemer
3
+ class Schema
4
+ Context = Struct.new(:instance, :dynamic_scope, :adjacent_results, :short_circuit, :access_mode) do
5
+ def original_instance(instance_location)
6
+ Hana::Pointer.parse(Location.resolve(instance_location)).reduce(instance) do |obj, token|
7
+ if obj.is_a?(Array)
8
+ obj.fetch(token.to_i)
9
+ elsif !obj.key?(token) && obj.key?(token.to_sym)
10
+ obj.fetch(token.to_sym)
11
+ else
12
+ obj.fetch(token)
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ extend Forwardable
19
+ include Output
20
+
21
+ SCHEMA_KEYWORD_CLASS = Draft202012::Vocab::Core::Schema
22
+ VOCABULARY_KEYWORD_CLASS = Draft202012::Vocab::Core::Vocabulary
23
+ ID_KEYWORD_CLASS = Draft202012::Vocab::Core::Id
24
+ UNKNOWN_KEYWORD_CLASS = Draft202012::Vocab::Core::UnknownKeyword
25
+ NOT_KEYWORD_CLASS = Draft202012::Vocab::Applicator::Not
26
+ PROPERTIES_KEYWORD_CLASS = Draft202012::Vocab::Applicator::Properties
27
+
28
+ NET_HTTP_REF_RESOLVER = proc { |uri| JSON.parse(Net::HTTP.get(uri)) }
29
+ RUBY_REGEXP_RESOLVER = proc { |pattern| Regexp.new(pattern) }
30
+ ECMA_REGEXP_RESOLVER = proc { |pattern| Regexp.new(EcmaRegexp.ruby_equivalent(pattern)) }
31
+
32
+ DEFAULT_PROPERTY_DEFAULT_RESOLVER = proc do |instance, property, results_with_tree_validity|
33
+ results_with_tree_validity = results_with_tree_validity.select(&:last) unless results_with_tree_validity.size == 1
34
+ annotations = results_with_tree_validity.to_set { |result, _tree_valid| result.annotation }
35
+ if annotations.size == 1
36
+ instance[property] = annotations.first.clone
37
+ true
38
+ else
39
+ false
40
+ end
41
+ end
42
+ SYMBOL_PROPERTY_DEFAULT_RESOLVER = proc do |instance, property, results_with_tree_validity|
43
+ DEFAULT_PROPERTY_DEFAULT_RESOLVER.call(instance, property.to_sym, results_with_tree_validity)
44
+ end
45
+
46
+ attr_accessor :base_uri, :meta_schema, :keywords, :keyword_order
47
+ attr_reader :value, :parent, :root, :configuration, :parsed
48
+ def_delegators :@configuration, :vocabulary, :format, :formats, :content_encodings, :content_media_types, :before_property_validation, :after_property_validation, :insert_property_defaults
49
+ def_delegator :@configuration, :keywords, :custom_keywords
50
+
51
+ def initialize(
52
+ value,
53
+ parent = nil,
54
+ root = self,
55
+ keyword = nil,
56
+ configuration: JSONSchemer.configuration,
57
+ base_uri: configuration.base_uri,
58
+ meta_schema: configuration.meta_schema,
59
+ vocabulary: configuration.vocabulary,
60
+ format: configuration.format,
61
+ formats: configuration.formats,
62
+ content_encodings: configuration.content_encodings,
63
+ content_media_types: configuration.content_media_types,
64
+ keywords: configuration.keywords,
65
+ before_property_validation: configuration.before_property_validation,
66
+ after_property_validation: configuration.after_property_validation,
67
+ insert_property_defaults: configuration.insert_property_defaults,
68
+ property_default_resolver: configuration.property_default_resolver,
69
+ ref_resolver: configuration.ref_resolver,
70
+ regexp_resolver: configuration.regexp_resolver,
71
+ output_format: configuration.output_format,
72
+ resolve_enumerators: configuration.resolve_enumerators,
73
+ access_mode: configuration.access_mode
74
+ )
75
+ @value = deep_stringify_keys(value)
76
+ @parent = parent
77
+ @root = root
78
+ @keyword = keyword
79
+ @schema = self
80
+ @base_uri = base_uri
81
+ @meta_schema = meta_schema
82
+ @configuration = Configuration.new(
83
+ :base_uri => base_uri,
84
+ :meta_schema => meta_schema,
85
+ :vocabulary => vocabulary,
86
+ :format => format,
87
+ :formats => formats,
88
+ :content_encodings => content_encodings,
89
+ :content_media_types => content_media_types,
90
+ :keywords => keywords,
91
+ :before_property_validation => Array(before_property_validation),
92
+ :after_property_validation => Array(after_property_validation),
93
+ :insert_property_defaults => insert_property_defaults,
94
+ :property_default_resolver => property_default_resolver,
95
+ :ref_resolver => ref_resolver,
96
+ :regexp_resolver => regexp_resolver,
97
+ :output_format => output_format,
98
+ :resolve_enumerators => resolve_enumerators,
99
+ :access_mode => access_mode
100
+ )
101
+ @parsed = parse
102
+ end
103
+
104
+ def valid?(instance, **options)
105
+ validate(instance, :output_format => 'flag', **options).fetch('valid')
106
+ end
107
+
108
+ def validate(instance, output_format: @configuration.output_format, resolve_enumerators: @configuration.resolve_enumerators, access_mode: @configuration.access_mode)
109
+ instance_location = Location.root
110
+ context = Context.new(instance, [], nil, (!insert_property_defaults && output_format == 'flag'), access_mode)
111
+ result = validate_instance(deep_stringify_keys(instance), instance_location, root_keyword_location, context)
112
+ if insert_property_defaults && result.insert_property_defaults(context, &property_default_resolver)
113
+ result = validate_instance(deep_stringify_keys(instance), instance_location, root_keyword_location, context)
114
+ end
115
+ output = result.output(output_format)
116
+ resolve_enumerators!(output) if resolve_enumerators
117
+ output
118
+ end
119
+
120
+ def valid_schema?(**options)
121
+ meta_schema.valid?(value, **options)
122
+ end
123
+
124
+ def validate_schema(**options)
125
+ meta_schema.validate(value, **options)
126
+ end
127
+
128
+ def ref(value)
129
+ root.resolve_ref(URI.join(base_uri, value))
130
+ end
131
+
132
+ def validate_instance(instance, instance_location, keyword_location, context)
133
+ context.dynamic_scope.push(self)
134
+ original_adjacent_results = context.adjacent_results
135
+ adjacent_results = context.adjacent_results = {}
136
+ short_circuit = context.short_circuit
137
+
138
+ begin
139
+ return result(instance, instance_location, keyword_location, false) if value == false
140
+ return result(instance, instance_location, keyword_location, true) if value == true || value.empty?
141
+
142
+ valid = true
143
+ nested = []
144
+
145
+ parsed.each do |keyword, keyword_instance|
146
+ next unless keyword_result = keyword_instance.validate(instance, instance_location, join_location(keyword_location, keyword), context)
147
+ valid &&= keyword_result.valid
148
+ return result(instance, instance_location, keyword_location, false) if short_circuit && !valid
149
+ nested << keyword_result
150
+ adjacent_results[keyword_instance.class] = keyword_result
151
+ end
152
+
153
+ if root.custom_keywords.any?
154
+ resolved_instance_location = Location.resolve(instance_location)
155
+ root.custom_keywords.each do |custom_keyword, callable|
156
+ if value.key?(custom_keyword)
157
+ [*callable.call(instance, value, resolved_instance_location)].each do |custom_keyword_result|
158
+ custom_keyword_valid = custom_keyword_result == true
159
+ valid &&= custom_keyword_valid
160
+ type = custom_keyword_result.is_a?(String) ? custom_keyword_result : custom_keyword
161
+ details = { 'keyword' => custom_keyword, 'result' => custom_keyword_result }
162
+ nested << result(instance, instance_location, keyword_location, custom_keyword_valid, :type => type, :details => details)
163
+ end
164
+ end
165
+ end
166
+ end
167
+
168
+ result(instance, instance_location, keyword_location, valid, nested)
169
+ ensure
170
+ context.dynamic_scope.pop
171
+ context.adjacent_results = original_adjacent_results
172
+ end
173
+ end
174
+
175
+ def resolve_ref(uri)
176
+ pointer = ''
177
+ if Format.valid_json_pointer?(uri.fragment)
178
+ pointer = URI.decode_www_form_component(uri.fragment)
179
+ uri.fragment = nil
180
+ end
181
+
182
+ lexical_resources = resources.fetch(:lexical)
183
+ schema = lexical_resources[uri]
184
+
185
+ if !schema && uri.fragment.nil?
186
+ empty_fragment_uri = uri.dup
187
+ empty_fragment_uri.fragment = ''
188
+ schema = lexical_resources[empty_fragment_uri]
189
+ end
190
+
191
+ unless schema
192
+ location_independent_identifier = uri.fragment
193
+ uri.fragment = nil
194
+ remote_schema = JSONSchemer.schema(
195
+ ref_resolver.call(uri) || raise(InvalidRefResolution, uri.to_s),
196
+ :configuration => configuration,
197
+ :base_uri => uri,
198
+ :meta_schema => meta_schema,
199
+ :ref_resolver => ref_resolver,
200
+ :regexp_resolver => regexp_resolver
201
+ )
202
+ remote_uri = remote_schema.base_uri.dup
203
+ remote_uri.fragment = location_independent_identifier if location_independent_identifier
204
+ schema = remote_schema.resources.fetch(:lexical).fetch(remote_uri)
205
+ end
206
+
207
+ schema = Hana::Pointer.parse(pointer).reduce(schema) do |obj, token|
208
+ obj.fetch(token)
209
+ rescue IndexError
210
+ raise InvalidRefPointer, pointer
211
+ end
212
+
213
+ schema = schema.parsed_schema if schema.is_a?(Keyword)
214
+ raise InvalidRefPointer, pointer unless schema.is_a?(Schema)
215
+
216
+ schema
217
+ end
218
+
219
+ def resolve_regexp(pattern)
220
+ regexp_resolver.call(pattern) || raise(InvalidRegexpResolution, pattern)
221
+ end
222
+
223
+ def bundle
224
+ return value unless value.is_a?(Hash)
225
+
226
+ id_keyword = meta_schema.id_keyword
227
+ defs_keyword = meta_schema.defs_keyword
228
+
229
+ compound_document = value.dup
230
+ compound_document[id_keyword] = base_uri.to_s
231
+ compound_document['$schema'] = meta_schema.base_uri.to_s
232
+ embedded_resources = compound_document[defs_keyword] = (compound_document[defs_keyword]&.dup || {})
233
+
234
+ if compound_document.key?('$ref') && meta_schema.keywords.fetch('$ref').exclusive?
235
+ compound_document['allOf'] = (compound_document['allOf']&.dup || [])
236
+ compound_document['allOf'] << { '$ref' => compound_document.delete('$ref') }
237
+ end
238
+
239
+ values = [self]
240
+ while value = values.shift
241
+ case value
242
+ when Schema
243
+ values << value.parsed
244
+ when Keyword
245
+ if value.respond_to?(:ref_uri) && value.respond_to?(:ref_schema)
246
+ ref_uri = value.ref_uri.dup
247
+ ref_uri.fragment = nil
248
+ ref_id = ref_uri.to_s
249
+ ref_schema = value.ref_schema.root
250
+
251
+ next if ref_schema == root || embedded_resources.key?(ref_id)
252
+
253
+ embedded_resource = ref_schema.value.dup
254
+ embedded_resource[id_keyword] = ref_id
255
+ embedded_resource['$schema'] = ref_schema.meta_schema.base_uri.to_s
256
+ embedded_resources[ref_id] = embedded_resource
257
+
258
+ values << ref_schema
259
+ else
260
+ values << value.parsed
261
+ end
262
+ when Hash
263
+ values.concat(value.values)
264
+ when Array
265
+ values.concat(value)
266
+ end
267
+ end
268
+
269
+ compound_document
270
+ end
271
+
272
+ def absolute_keyword_location
273
+ # using `equal?` because `URI::Generic#==` is slow
274
+ @absolute_keyword_location ||= if !parent || (!parent.schema.base_uri.equal?(base_uri) && (base_uri.fragment.nil? || base_uri.fragment.empty?))
275
+ absolute_keyword_location_uri = base_uri.dup
276
+ absolute_keyword_location_uri.fragment = ''
277
+ absolute_keyword_location_uri.to_s
278
+ elsif keyword
279
+ "#{parent.absolute_keyword_location}/#{fragment_encode(escaped_keyword)}"
280
+ else
281
+ parent.absolute_keyword_location
282
+ end
283
+ end
284
+
285
+ def schema_pointer
286
+ @schema_pointer ||= if !parent
287
+ ''
288
+ elsif keyword
289
+ "#{parent.schema_pointer}/#{escaped_keyword}"
290
+ else
291
+ parent.schema_pointer
292
+ end
293
+ end
294
+
295
+ def error_key
296
+ '^'
297
+ end
298
+
299
+ def fetch(key)
300
+ parsed.fetch(key)
301
+ end
302
+
303
+ def fetch_format(format, *args, &block)
304
+ if meta_schema == self
305
+ formats.fetch(format, *args, &block)
306
+ else
307
+ formats.fetch(format) { meta_schema.fetch_format(format, *args, &block) }
308
+ end
309
+ end
310
+
311
+ def fetch_content_encoding(content_encoding, *args, &block)
312
+ if meta_schema == self
313
+ content_encodings.fetch(content_encoding, *args, &block)
314
+ else
315
+ content_encodings.fetch(content_encoding) { meta_schema.fetch_content_encoding(content_encoding, *args, &block) }
316
+ end
317
+ end
318
+
319
+ def fetch_content_media_type(content_media_type, *args, &block)
320
+ if meta_schema == self
321
+ content_media_types.fetch(content_media_type, *args, &block)
322
+ else
323
+ content_media_types.fetch(content_media_type) { meta_schema.fetch_content_media_type(content_media_type, *args, &block) }
324
+ end
325
+ end
326
+
327
+ def id_keyword
328
+ @id_keyword ||= (keywords.key?('$id') ? '$id' : 'id')
329
+ end
330
+
331
+ def defs_keyword
332
+ @defs_keyword ||= (keywords.key?('$defs') ? '$defs' : 'definitions')
333
+ end
334
+
335
+ def resources
336
+ @resources ||= { :lexical => Resources.new, :dynamic => Resources.new }
337
+ end
338
+
339
+ def error(formatted_instance_location:, **options)
340
+ if value == false && parent&.respond_to?(:false_schema_error)
341
+ parent.false_schema_error(:formatted_instance_location => formatted_instance_location, **options)
342
+ else
343
+ "value at #{formatted_instance_location} does not match schema"
344
+ end
345
+ end
346
+
347
+ def ref_resolver
348
+ @ref_resolver ||= @configuration.ref_resolver == 'net/http' ? CachedResolver.new(&NET_HTTP_REF_RESOLVER) : @configuration.ref_resolver
349
+ end
350
+
351
+ def regexp_resolver
352
+ @regexp_resolver ||= case @configuration.regexp_resolver
353
+ when 'ecma'
354
+ CachedResolver.new(&ECMA_REGEXP_RESOLVER)
355
+ when 'ruby'
356
+ CachedResolver.new(&RUBY_REGEXP_RESOLVER)
357
+ else
358
+ @configuration.regexp_resolver
359
+ end
360
+ end
361
+
362
+ def inspect
363
+ "#<#{self.class.name} @value=#{@value.inspect} @parent=#{@parent.inspect} @keyword=#{@keyword.inspect}>"
364
+ end
365
+
366
+ private
367
+
368
+ def parse
369
+ @parsed = {}
370
+
371
+ if value.is_a?(Hash) && value.key?('$schema')
372
+ @parsed['$schema'] = SCHEMA_KEYWORD_CLASS.new(value.fetch('$schema'), self, '$schema')
373
+ elsif meta_schema.is_a?(String)
374
+ SCHEMA_KEYWORD_CLASS.new(meta_schema, self, '$schema')
375
+ end
376
+
377
+ if value.is_a?(Hash) && value.key?('$vocabulary')
378
+ @parsed['$vocabulary'] = VOCABULARY_KEYWORD_CLASS.new(value.fetch('$vocabulary'), self, '$vocabulary')
379
+ elsif vocabulary
380
+ VOCABULARY_KEYWORD_CLASS.new(vocabulary, self, '$vocabulary')
381
+ end
382
+
383
+ keywords = meta_schema.keywords
384
+ exclusive_ref = value.is_a?(Hash) && value.key?('$ref') && keywords.fetch('$ref').exclusive?
385
+
386
+ if root == self && (!value.is_a?(Hash) || !value.key?(meta_schema.id_keyword) || exclusive_ref)
387
+ ID_KEYWORD_CLASS.new(base_uri, self, meta_schema.id_keyword)
388
+ end
389
+
390
+ if exclusive_ref
391
+ @parsed['$ref'] = keywords.fetch('$ref').new(value.fetch('$ref'), self, '$ref')
392
+ defs_keyword = meta_schema.defs_keyword
393
+ if value.key?(defs_keyword) && keywords.key?(defs_keyword)
394
+ @parsed[defs_keyword] = keywords.fetch(defs_keyword).new(value.fetch(defs_keyword), self, defs_keyword)
395
+ end
396
+ elsif value.is_a?(Hash)
397
+ keyword_order = meta_schema.keyword_order
398
+ last = keywords.size
399
+
400
+ value.sort do |(keyword_a, _value_a), (keyword_b, _value_b)|
401
+ keyword_order.fetch(keyword_a, last) <=> keyword_order.fetch(keyword_b, last)
402
+ end.each do |keyword, value|
403
+ @parsed[keyword] ||= keywords.fetch(keyword, UNKNOWN_KEYWORD_CLASS).new(value, self, keyword)
404
+ end
405
+ end
406
+
407
+ @parsed
408
+ end
409
+
410
+ def root_keyword_location
411
+ @root_keyword_location ||= Location.root
412
+ end
413
+
414
+ def property_default_resolver
415
+ @property_default_resolver ||= if @configuration.property_default_resolver
416
+ @configuration.property_default_resolver
417
+ else
418
+ insert_property_defaults == :symbol ? SYMBOL_PROPERTY_DEFAULT_RESOLVER : DEFAULT_PROPERTY_DEFAULT_RESOLVER
419
+ end
420
+ end
421
+
422
+ def resolve_enumerators!(output)
423
+ case output
424
+ when Hash
425
+ output.transform_values! { |value| resolve_enumerators!(value) }
426
+ when Enumerator
427
+ output.map { |value| resolve_enumerators!(value) }
428
+ else
429
+ output
430
+ end
431
+ end
432
+ end
433
+ end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module JSONSchemer
3
- VERSION = '1.0.3'
3
+ VERSION = '2.4.0'
4
4
  end