yaml-schema 1.2.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7079554145bf81aef3f0fb89dfa5ad5e0867336866989da48e9217cbae1f114
4
- data.tar.gz: e6bc2b714f98cce44632e17fc6b9d4d8bbaebea4583427897019ce8d28f81293
3
+ metadata.gz: df10b8348eda9fb9eb5d36af1119234e00b48a4d1e5ef7f104d9fade49f02493
4
+ data.tar.gz: 0d208c8b1883843586bd30c9de3cb5cafb6a65e8978eb495902d34e5c660462f
5
5
  SHA512:
6
- metadata.gz: 347fe6c4f3a01eefc61b4f6a18570893c472fa815f942b9ec57ac9ccd8abd6d7f06d73b0f757dd38dee5c9da5f6e01d8c4dd76e5e726d4b92aa0e4a966126f89
7
- data.tar.gz: 62a7ec295631d838f06c8932f4c1ae614650cfb635b1bd9bb2539425a53575e1bf87a9af748d1632e082c0f25dc93c7cc311173300c5bf47bfbf3cd11dbc6d41
6
+ metadata.gz: f345acb0e18034d923671d42507b3851a9735a41254e9bbd34caf2b6c9f89007e161fdf6b65844bd69528c40488db44ff341894aba73522622beae059d7c56c2
7
+ data.tar.gz: 847b358731e0a6acaa24964bed4d58849ccc3da6e6440b95ff143396e16f79adeadd58b0a1a00f1cbf7d61ce6ec7eaff231a18653a0693170f671d07d3abaf78
@@ -7,6 +7,8 @@ on:
7
7
  jobs:
8
8
  ruby-versions:
9
9
  uses: ruby/actions/.github/workflows/ruby_versions.yml@master
10
+ with:
11
+ engine: cruby
10
12
 
11
13
  test:
12
14
  needs: ruby-versions
data/README.md CHANGED
@@ -11,14 +11,14 @@ A library for validating YAML documents against a schema.
11
11
  ### Basic Validation
12
12
 
13
13
  ```ruby
14
- require 'yaml-schema'
14
+ require "yaml-schema"
15
15
 
16
16
  schema = {
17
- type: 'object',
18
- properties: {
19
- name: { type: 'string' },
20
- age: { type: 'integer' },
21
- active: { type: 'boolean' }
17
+ "type" => "object",
18
+ "properties" => {
19
+ "name" => { "type" => "string" },
20
+ "age" => { "type" => "integer" },
21
+ "active" => { "type" => "boolean" }
22
22
  }
23
23
  }
24
24
 
@@ -53,9 +53,9 @@ Specify multiple valid types for a field:
53
53
 
54
54
  ```ruby
55
55
  schema = {
56
- type: 'object',
57
- properties: {
58
- value: { type: ['null', 'string'] }
56
+ "type" => "object",
57
+ "properties" => {
58
+ "value" => { "type" => ["null", "string"] }
59
59
  }
60
60
  }
61
61
  ```
@@ -64,17 +64,17 @@ schema = {
64
64
 
65
65
  ```ruby
66
66
  schema = {
67
- type: 'object',
68
- properties: {
69
- users: {
70
- type: 'array',
71
- items: {
72
- type: 'object',
73
- properties: {
74
- name: { type: 'string' },
75
- tags: {
76
- type: 'array',
77
- items: { type: 'string' }
67
+ "type" => "object",
68
+ "properties" => {
69
+ "users" => {
70
+ "type" => "array",
71
+ "items" => {
72
+ "type" => "object",
73
+ "properties" => {
74
+ "name" => { "type" => "string" },
75
+ "tags" => {
76
+ "type" => "array",
77
+ "items" => { "type" => "string" }
78
78
  }
79
79
  }
80
80
  }
@@ -83,6 +83,42 @@ schema = {
83
83
  }
84
84
  ```
85
85
 
86
+ ### Compiled Validators
87
+
88
+ If you validate many documents against the same schema, you can compile the
89
+ schema into a validator class specialized for it, rather than interpreting
90
+ the schema on every call:
91
+
92
+ ```ruby
93
+ PersonValidator = YAMLSchema::Compiler.compile(schema)
94
+
95
+ PersonValidator.validate(node) # raises on error, returns true otherwise
96
+ PersonValidator.invalid?(node) # returns the error, or false
97
+ PersonValidator.validate(node, aliases: false)
98
+ PersonValidator.new(node_info).validate(node)
99
+ ```
100
+
101
+ Compiled validators raise the same exceptions with the same messages as
102
+ `YAMLSchema::Validator`. The schema itself is validated at compile time.
103
+
104
+ You can also generate Ruby source and check it into your project:
105
+
106
+ ```ruby
107
+ File.write "person_validator.rb",
108
+ YAMLSchema::Compiler.to_ruby(schema, class_name: "PersonValidator")
109
+ ```
110
+
111
+ Pass `standalone: true` to generate a validator that doesn't depend on
112
+ yaml-schema at all. Stand-alone validators define their own exception classes
113
+ (`PersonValidator::UnexpectedType`, etc., all subclasses of
114
+ `PersonValidator::Exception`) instead of raising `YAMLSchema::Validator`
115
+ exceptions:
116
+
117
+ ```ruby
118
+ File.write "person_validator.rb",
119
+ YAMLSchema::Compiler.to_ruby(schema, class_name: "PersonValidator", standalone: true)
120
+ ```
121
+
86
122
  ## Error Handling
87
123
 
88
124
  Validation errors include detailed path information:
@@ -0,0 +1,538 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YAMLSchema
4
+ ##
5
+ # Base class for validators generated by YAMLSchema::Compiler. Subclasses
6
+ # implement a private +_validate_root+ method that returns +nil+ when the
7
+ # node is valid, or an exception object describing the first error.
8
+ class CompiledValidator
9
+ UnexpectedType = Validator::UnexpectedType
10
+ UnexpectedProperty = Validator::UnexpectedProperty
11
+ UnexpectedTag = Validator::UnexpectedTag
12
+ UnexpectedValue = Validator::UnexpectedValue
13
+ UnexpectedAlias = Validator::UnexpectedAlias
14
+ InvalidString = Validator::InvalidString
15
+ InvalidPattern = Validator::InvalidPattern
16
+ MissingRequiredField = Validator::MissingRequiredField
17
+
18
+ ##
19
+ # Validate +node+, raising an exception if it is invalid.
20
+ def self.validate node, aliases: true
21
+ self::INSTANCE.validate node, aliases: aliases
22
+ end
23
+
24
+ ##
25
+ # Validate +node+, returning the error if it is invalid, otherwise false.
26
+ def self.invalid? node, aliases: true
27
+ self::INSTANCE.invalid? node, aliases: aliases
28
+ end
29
+
30
+ def initialize node_info = Validator::NodeInfo
31
+ @node_info = node_info
32
+ end
33
+
34
+ ##
35
+ # Validate +node+, raising an exception if it is invalid.
36
+ def validate node, aliases: true
37
+ err = _validate_root(node, {}, [], aliases)
38
+ raise err if err
39
+ true
40
+ end
41
+
42
+ ##
43
+ # Validate +node+, returning the error if it is invalid, otherwise false.
44
+ def invalid? node, aliases: true
45
+ _validate_root(node, {}, [], aliases) || false
46
+ end
47
+
48
+ include Runtime
49
+ end
50
+
51
+ ##
52
+ # Compiles a schema into a Ruby class specialized for validating that
53
+ # schema. The generated class behaves like YAMLSchema::Validator, but
54
+ # doesn't need to interpret the schema at validation time:
55
+ #
56
+ # validator = YAMLSchema::Compiler.compile(schema)
57
+ # validator.validate(node)
58
+ #
59
+ # The generated source can also be written to a file:
60
+ #
61
+ # File.write "my_validator.rb", YAMLSchema::Compiler.to_ruby(schema, class_name: "MyValidator")
62
+ class Compiler
63
+ EMPTY_SCHEMA = {}.freeze # :nodoc:
64
+
65
+ ##
66
+ # Compile +schema+ and return a new validator class.
67
+ def self.compile schema
68
+ klass = Class.new(CompiledValidator)
69
+ klass.class_eval new(schema).body, "(yaml-schema compiled validator)", 1
70
+ klass
71
+ end
72
+
73
+ ##
74
+ # Compile +schema+ and return Ruby source code defining a validator
75
+ # class named +class_name+.
76
+ #
77
+ # By default the generated class inherits from
78
+ # YAMLSchema::CompiledValidator and requires this library at runtime.
79
+ # Pass <tt>standalone: true</tt> to generate a class with no dependency
80
+ # on yaml-schema. Stand-alone validators define their own exception
81
+ # classes (e.g. <tt>MyValidator::UnexpectedType</tt>) rather than
82
+ # raising YAMLSchema::Validator exceptions.
83
+ def self.to_ruby schema, class_name:, standalone: false
84
+ body = new(schema).body
85
+
86
+ if standalone
87
+ body = standalone_support + "\n" + body
88
+ header = ""
89
+ superclass = ""
90
+ else
91
+ header = "\nrequire \"yaml-schema\"\n"
92
+ superclass = " < YAMLSchema::CompiledValidator"
93
+ end
94
+
95
+ <<~RUBY
96
+ # frozen_string_literal: true
97
+ # Generated by yaml-schema #{VERSION}. Do not edit.
98
+ #{header}
99
+ class #{class_name}#{superclass}
100
+ #{body.gsub(/^(?=.)/, " ")}end
101
+ RUBY
102
+ end
103
+
104
+ STANDALONE_API = <<~'RUBY' # :nodoc:
105
+ class Exception < StandardError; end
106
+ class UnexpectedType < Exception; end
107
+ class UnexpectedProperty < Exception; end
108
+ class UnexpectedTag < Exception; end
109
+ class UnexpectedValue < Exception; end
110
+ class UnexpectedAlias < Exception; end
111
+ class InvalidString < Exception; end
112
+ class InvalidPattern < Exception; end
113
+ class MissingRequiredField < Exception; end
114
+
115
+ module NodeInfo
116
+ def self.read_tag node
117
+ node.tag
118
+ end
119
+ end
120
+
121
+ ##
122
+ # Validate +node+, raising an exception if it is invalid.
123
+ def self.validate node, aliases: true
124
+ INSTANCE.validate node, aliases: aliases
125
+ end
126
+
127
+ ##
128
+ # Validate +node+, returning the error if it is invalid, otherwise false.
129
+ def self.invalid? node, aliases: true
130
+ INSTANCE.invalid? node, aliases: aliases
131
+ end
132
+
133
+ def initialize node_info = NodeInfo
134
+ @node_info = node_info
135
+ end
136
+
137
+ ##
138
+ # Validate +node+, raising an exception if it is invalid.
139
+ def validate node, aliases: true
140
+ err = _validate_root(node, {}, [], aliases)
141
+ raise err if err
142
+ true
143
+ end
144
+
145
+ ##
146
+ # Validate +node+, returning the error if it is invalid, otherwise false.
147
+ def invalid? node, aliases: true
148
+ _validate_root(node, {}, [], aliases) || false
149
+ end
150
+ RUBY
151
+
152
+ # Source for everything a stand-alone validator needs: exception
153
+ # classes, the public API, and the shared runtime support code.
154
+ def self.standalone_support # :nodoc:
155
+ src = File.read(File.join(__dir__, "runtime.rb"))
156
+ runtime = src[/^[ \t]*# :runtime-begin:\n(.*?)^[ \t]*# :runtime-end:\n/m, 1] or
157
+ raise "runtime markers not found"
158
+ width = runtime.scan(/^[ \t]*(?=\S)/).map(&:length).min
159
+ runtime = runtime.gsub(/^[ \t]{#{width}}/, "")
160
+
161
+ STANDALONE_API + "\npublic\n\n" + runtime + "\npublic\n"
162
+ end
163
+
164
+ def initialize schema
165
+ Validator::INSTANCE.validate_schema!(schema)
166
+ @schema = schema
167
+ @methods = []
168
+ @constants = []
169
+ @names = {}.compare_by_identity
170
+ @key_names = {}.compare_by_identity # schemas forced to "string" (property names)
171
+ @counter = 0
172
+ end
173
+
174
+ ##
175
+ # Ruby source for the body of the validator class.
176
+ def body
177
+ root = schema_method(@schema, "")
178
+ src = +""
179
+ @constants.each { |c| src << c << "\n" }
180
+ src << "\n" unless @constants.empty?
181
+ src << "private\n\n"
182
+ src << "def _validate_root(node, aliases, path, allow_aliases)\n"
183
+ src << " #{root}(node, aliases, path, allow_aliases)\n"
184
+ src << "end\n"
185
+ @methods.each { |m| src << "\n" << m }
186
+ src << "\npublic\n\nINSTANCE = new.freeze\n"
187
+ src
188
+ end
189
+
190
+ private
191
+
192
+ def lit str
193
+ str.dump
194
+ end
195
+
196
+ def next_name prefix
197
+ @counter += 1
198
+ "#{prefix}#{@counter}"
199
+ end
200
+
201
+ def indent code, n
202
+ pad = " " * n
203
+ code.gsub(/^(?=.)/, pad)
204
+ end
205
+
206
+ # Returns the name of a method that fully validates a node against
207
+ # +schema+ (alias resolution, tag lookup, and type checks). Schemas are
208
+ # memoized by identity so shared sub-schemas share a method.
209
+ def schema_method schema, path, forced_type = nil
210
+ memo = forced_type ? @key_names : @names
211
+ return memo[schema] if memo.key?(schema)
212
+
213
+ name = memo[schema] = next_name("_v")
214
+ type = forced_type || schema["type"]
215
+
216
+ src = +""
217
+ src << "# #{path.empty? ? "/" : path}\n"
218
+ src << "def #{name}(node, aliases, path, allow_aliases)\n"
219
+ src << indent(<<~RUBY, 2)
220
+ if node.anchor
221
+ if node.alias?
222
+ raise UnexpectedAlias unless allow_aliases
223
+ node = aliases[node.anchor]
224
+ else
225
+ aliases[node.anchor] = node
226
+ end
227
+ end
228
+
229
+ tag = @node_info.read_tag(node)
230
+ RUBY
231
+
232
+ if Array === type
233
+ # Unions return the first success, or the last failure
234
+ type.each_with_index do |t, i|
235
+ tname = "#{name}_#{t}"
236
+ @methods << "def #{tname}(node, tag, aliases, path, allow_aliases)\n" +
237
+ indent(type_body(t, schema, path), 2) +
238
+ "end\n"
239
+ if i == type.length - 1
240
+ src << " #{tname}(node, tag, aliases, path, allow_aliases)\n"
241
+ else
242
+ src << " return unless #{tname}(node, tag, aliases, path, allow_aliases)\n"
243
+ end
244
+ end
245
+ else
246
+ src << indent(type_body(type, schema, path), 2)
247
+ end
248
+ src << "end\n"
249
+
250
+ @methods << src
251
+ name
252
+ end
253
+
254
+ # Code for validating +node+ (already alias-resolved, with +tag+ read)
255
+ # as +type+. Returns an error with +return+, and evaluates to nil on
256
+ # success.
257
+ def type_body type, schema, path
258
+ src = +tag_check(type, schema)
259
+
260
+ case type
261
+ when "object" then src << object_body(schema, path)
262
+ when "array" then src << array_body(schema, path)
263
+ else src << scalar_body(type, schema)
264
+ end
265
+
266
+ src << "nil\n"
267
+ end
268
+
269
+ def tag_check type, schema
270
+ expected = schema["tag"] if type == "object"
271
+
272
+ if expected
273
+ <<~RUBY
274
+ unless tag == #{lit expected}
275
+ if node.tag
276
+ return make_error(UnexpectedTag, #{lit "expected tag #{expected.dump}, but got "} + node.tag.dump, path)
277
+ else
278
+ return make_error(UnexpectedTag, #{lit "expected tag #{expected.dump}, but none specified"}, path)
279
+ end
280
+ end
281
+
282
+ RUBY
283
+ else
284
+ <<~RUBY
285
+ if tag
286
+ return make_error(UnexpectedTag, "expected no tag, but got " + node.tag.dump, path)
287
+ end
288
+
289
+ RUBY
290
+ end
291
+ end
292
+
293
+ # Validate a child node, pushing +segment+ on to the path for the
294
+ # duration of the call.
295
+ def child_call meth, node_expr, segment_expr
296
+ <<~RUBY
297
+ path.push(#{segment_expr})
298
+ err = #{meth}(#{node_expr}, aliases, path, allow_aliases)
299
+ path.pop
300
+ return err if err
301
+ RUBY
302
+ end
303
+
304
+ def object_body schema, path
305
+ src = +<<~RUBY
306
+ unless node.mapping?
307
+ return make_error(UnexpectedType, "expected Mapping, got " + node.class.name.dump, path)
308
+ end
309
+
310
+ children = node.children
311
+ len = children.length
312
+ i = 0
313
+ RUBY
314
+
315
+ if schema["properties"]
316
+ properties = schema["properties"]
317
+ key_meth = schema_method(schema["propertyNames"] || EMPTY_SCHEMA, "#{path}/propertyNames", "string")
318
+
319
+ unknown = if schema["additionalProperties"]
320
+ meth = schema_method(schema["additionalProperties"], "#{path}/additionalProperties")
321
+ child_call(meth, "val", "key.value")
322
+ else
323
+ <<~RUBY
324
+ path.push(key.value)
325
+ err = make_error(UnexpectedProperty, "unknown property " + key.value.dump, path)
326
+ path.pop
327
+ return err
328
+ RUBY
329
+ end
330
+
331
+ seen = properties.keys.each_with_index.map { |_, idx| "seen_#{idx}" }
332
+ seen.each { |s| src << "#{s} = false\n" }
333
+
334
+ src << <<~RUBY
335
+ while i < len
336
+ key = children[i]
337
+ val = children[i + 1]
338
+ i += 2
339
+
340
+ err = #{key_meth}(key, aliases, path, allow_aliases)
341
+ return err if err
342
+
343
+ RUBY
344
+
345
+ if properties.empty?
346
+ src << indent(unknown, 2)
347
+ else
348
+ src << " case key.value\n"
349
+ properties.each_with_index do |(name, sub), idx|
350
+ meth = schema_method(sub, "#{path}/properties/#{name}")
351
+ src << " when #{lit name}\n"
352
+ src << " if #{seen[idx]}\n"
353
+ src << indent(unknown, 6)
354
+ src << " else\n"
355
+ src << " #{seen[idx]} = true\n"
356
+ src << indent(child_call(meth, "val", lit(name)), 6)
357
+ src << " end\n"
358
+ end
359
+ src << " else\n"
360
+ src << indent(unknown, 4)
361
+ src << " end\n"
362
+ end
363
+ src << "end\n"
364
+
365
+ required = Array(schema["required"])
366
+ checks = properties.keys.each_with_index.select { |name, _| required.include?(name) }
367
+ unless checks.empty?
368
+ src << "\nmissing = nil\n"
369
+ checks.each do |name, idx|
370
+ src << "(missing ||= []) << #{lit name.dump} unless #{seen[idx]}\n"
371
+ end
372
+ src << <<~RUBY
373
+ if missing
374
+ return make_error(MissingRequiredField, "missing fields " + missing.join(" "), path)
375
+ end
376
+ RUBY
377
+ end
378
+ else
379
+ key_meth = schema_method(EMPTY_SCHEMA, "#{path}/propertyNames", "string")
380
+ meth = schema_method(schema["items"], "#{path}/items")
381
+ src << <<~RUBY
382
+ while i < len
383
+ key = children[i]
384
+ val = children[i + 1]
385
+ i += 2
386
+
387
+ err = #{key_meth}(key, aliases, path, allow_aliases)
388
+ return err if err
389
+
390
+ #{indent(child_call(meth, "val", "key.value"), 2)}end
391
+ RUBY
392
+ end
393
+
394
+ src
395
+ end
396
+
397
+ def array_body schema, path
398
+ src = +<<~RUBY
399
+ unless node.sequence?
400
+ return make_error(UnexpectedType, "expected Sequence, got " + node.class.name.dump, path)
401
+ end
402
+
403
+ children = node.children
404
+ len = children.length
405
+
406
+ RUBY
407
+
408
+ if max = schema["maxItems"]
409
+ src << <<~RUBY
410
+ if len > #{max}
411
+ return make_error(UnexpectedValue, #{lit "expected maximum #{max} items, but found "} + len.to_s, path)
412
+ end
413
+
414
+ RUBY
415
+ end
416
+
417
+ if min = schema["minItems"]
418
+ src << <<~RUBY
419
+ if len < #{min}
420
+ return make_error(UnexpectedValue, #{lit "expected minimum #{min} items, but found "} + len.to_s, path)
421
+ end
422
+
423
+ RUBY
424
+ end
425
+
426
+ if schema["items"]
427
+ meth = schema_method(schema["items"], "#{path}/items")
428
+ src << <<~RUBY
429
+ i = 0
430
+ while i < len
431
+ #{indent(child_call(meth, "children[i]", "i"), 2)} i += 1
432
+ end
433
+ RUBY
434
+ else
435
+ schema["prefixItems"].each_with_index do |sub, idx|
436
+ meth = schema_method(sub, "#{path}/prefixItems/#{idx}")
437
+ src << "if len > #{idx}\n"
438
+ src << indent(child_call(meth, "children[#{idx}]", idx.to_s), 2)
439
+ src << "end\n"
440
+ end
441
+ end
442
+
443
+ src
444
+ end
445
+
446
+ def pattern_constant pattern
447
+ name = "PATTERN_#{@constants.length}"
448
+ @constants << "#{name} = Regexp.new(#{lit pattern.source}, #{pattern.options})"
449
+ name
450
+ end
451
+
452
+ def scalar_body type, schema
453
+ src = +<<~RUBY
454
+ unless node.scalar?
455
+ return make_error(UnexpectedType, "expected Scalar, got " + node.class.name.dump, path)
456
+ end
457
+
458
+ value = node.value
459
+ RUBY
460
+
461
+ pattern = schema["pattern"]
462
+
463
+ if type == "string"
464
+ src << <<~RUBY
465
+ unless node.quoted || node.tag == "!str"
466
+ found = extract_type(value)
467
+ if found != :string
468
+ return make_error(UnexpectedValue, "expected string, got " + found.to_s, path)
469
+ end
470
+ end
471
+ RUBY
472
+
473
+ if max = schema["maxLength"]
474
+ src << <<~RUBY
475
+ if value.bytesize > #{max}
476
+ return make_error(InvalidString, #{lit "expected string length to be <= #{max}"}, path)
477
+ end
478
+ RUBY
479
+ end
480
+
481
+ if min = schema["minLength"]
482
+ src << <<~RUBY
483
+ if value.bytesize < #{min}
484
+ return make_error(InvalidString, #{lit "expected string length to be >= #{min}"}, path)
485
+ end
486
+ RUBY
487
+ end
488
+
489
+ if pattern
490
+ src << <<~RUBY
491
+ unless value.match?(#{pattern_constant(pattern)})
492
+ return make_error(InvalidPattern, "expected string '" + value.dump + #{lit "' to match #{pattern}"}, path)
493
+ end
494
+ RUBY
495
+ end
496
+ else
497
+ src << <<~RUBY
498
+ if node.quoted
499
+ return make_error(UnexpectedValue, #{lit "expected #{type}, got string"}, path)
500
+ end
501
+ RUBY
502
+
503
+ if type == "null"
504
+ src << <<~RUBY
505
+ unless value == ""
506
+ return make_error(UnexpectedValue, "expected empty string, got " + value.dump, path)
507
+ end
508
+ RUBY
509
+ else
510
+ if pattern
511
+ src << <<~RUBY
512
+ unless value.match?(#{pattern_constant(pattern)})
513
+ return make_error(InvalidPattern, "expected '" + value.dump + #{lit "' to match #{pattern}"}, path)
514
+ end
515
+ RUBY
516
+ end
517
+
518
+ if type == "boolean"
519
+ src << <<~RUBY
520
+ unless value == "false" || value == "true"
521
+ return make_error(UnexpectedValue, "expected 'true' or 'false' for boolean", path)
522
+ end
523
+ RUBY
524
+ else
525
+ src << <<~RUBY
526
+ found = extract_type(value)
527
+ unless found == #{type.to_sym.inspect}
528
+ return make_error(UnexpectedValue, #{lit "expected #{type}, got "} + found.to_s, path)
529
+ end
530
+ RUBY
531
+ end
532
+ end
533
+ end
534
+
535
+ src
536
+ end
537
+ end
538
+ end