yaml-schema 1.2.1 → 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: 162c65cfdc841b1a41db7d8f5d865b51a145361bfd1623723d73ad8c8fd7030a
4
- data.tar.gz: 6a64e47154f5ed2c8910c588add2c5ff4bceeadea4240ccd3469b52277f5fe9f
3
+ metadata.gz: df10b8348eda9fb9eb5d36af1119234e00b48a4d1e5ef7f104d9fade49f02493
4
+ data.tar.gz: 0d208c8b1883843586bd30c9de3cb5cafb6a65e8978eb495902d34e5c660462f
5
5
  SHA512:
6
- metadata.gz: 1223fa2441b84f33b57c1f826658de489b2d36a4d76de7dd9770832f4a5c12e815aeabbc242d557a76c6f074ab1d344610e1131dd9ea86144ded4c54ac740e23
7
- data.tar.gz: 3c6758c8c5efec97b10d02ade2bc25c8fe9f7f4301b01602333c0178bd57804f4db3fbb24c390139a8e12981a88a85de72686d1efc628e1ddd086ca8e4b7952c
6
+ metadata.gz: f345acb0e18034d923671d42507b3851a9735a41254e9bbd34caf2b6c9f89007e161fdf6b65844bd69528c40488db44ff341894aba73522622beae059d7c56c2
7
+ data.tar.gz: 847b358731e0a6acaa24964bed4d58849ccc3da6e6440b95ff143396e16f79adeadd58b0a1a00f1cbf7d61ce6ec7eaff231a18653a0693170f671d07d3abaf78
data/README.md CHANGED
@@ -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
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YAMLSchema
4
+ ##
5
+ # Support code shared by YAMLSchema::Validator and compiled validators.
6
+ #
7
+ # The code between the runtime markers is also copied verbatim into
8
+ # stand-alone validators generated by YAMLSchema::Compiler.to_ruby, so it
9
+ # must not refer to anything else in this library.
10
+ module Runtime
11
+ # :runtime-begin:
12
+ # Taken from http://yaml.org/type/timestamp.html
13
+ TIME = /^-?\d{4}-\d{1,2}-\d{1,2}(?:[Tt]|\s+)\d{1,2}:\d\d:\d\d(?:\.\d*)?(?:\s*(?:Z|[-+]\d{1,2}:?(?:\d\d)?))?$/
14
+
15
+ # Taken from http://yaml.org/type/float.html
16
+ # Base 60, [-+]inf and NaN are handled separately
17
+ FLOAT = /^(?:[-+]?([0-9][0-9_,]*)?\.[0-9]*([eE][-+][0-9]+)?(?# base 10))$/x
18
+
19
+ # Taken from http://yaml.org/type/int.html and modified to ensure at least one numerical symbol exists
20
+ INTEGER_STRICT = /^(?:[-+]?0b[_]*[0-1][0-1_]* (?# base 2)
21
+ |[-+]?0[_]*[0-7][0-7_]* (?# base 8)
22
+ |[-+]?(0|[1-9][0-9_]*) (?# base 10)
23
+ |[-+]?0x[_]*[0-9a-fA-F][0-9a-fA-F_]* (?# base 16))$/x
24
+
25
+ private
26
+
27
+ # Tokenize +string+ returning the Ruby object
28
+ def extract_type string
29
+ return :null if string.empty?
30
+ # Check for a String type, being careful not to get caught by hash keys, hex values, and
31
+ # special floats (e.g., -.inf).
32
+ if string.match?(%r{^[^\d.:-]?[[:alpha:]_\s!@#$%\^&*(){}<>|/\\~;=]+}) || string.match?(/\n/)
33
+ return :string if string.length > 5
34
+
35
+ if string.match?(/^[^ytonf~]/i)
36
+ :string
37
+ elsif string == '~' || string.match?(/^null$/i)
38
+ :null
39
+ elsif string.match?(/^(yes|true|on)$/i)
40
+ :boolean
41
+ elsif string.match?(/^(no|false|off)$/i)
42
+ :boolean
43
+ else
44
+ :string
45
+ end
46
+ elsif string.match?(TIME)
47
+ :time
48
+ elsif string.match?(/^\d{4}-(?:1[012]|0\d|\d)-(?:[12]\d|3[01]|0\d|\d)$/)
49
+ :date
50
+ elsif string.match?(/^\+?\.inf$/i)
51
+ :float
52
+ elsif string.match?(/^-\.inf$/i)
53
+ :float
54
+ elsif string.match?(/^\.nan$/i)
55
+ :float
56
+ elsif string.match?(/^:./)
57
+ :symbol
58
+ elsif string.match?(/^[-+]?[0-9][0-9_]*(:[0-5]?[0-9]){1,2}$/)
59
+ :sexagesimal
60
+ elsif string.match?(/^[-+]?[0-9][0-9_]*(:[0-5]?[0-9]){1,2}\.[0-9_]*$/)
61
+ :sexagesimal
62
+ elsif string.match?(FLOAT)
63
+ if string.match?(/\A[-+]?\.\Z/)
64
+ :string
65
+ else
66
+ :float
67
+ end
68
+ elsif string.match?(INTEGER_STRICT)
69
+ :integer
70
+ else
71
+ :string
72
+ end
73
+ end
74
+
75
+ def make_error klass, msg, path
76
+ ex = klass.new msg + " path: /#{path.join("/")}"
77
+ ex.set_backtrace caller
78
+ ex
79
+ end
80
+ # :runtime-end:
81
+ end
82
+ end
data/lib/yaml-schema.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml-schema/runtime"
4
+
3
5
  module YAMLSchema
4
- VERSION = "1.2.1"
6
+ VERSION = "1.3.0"
5
7
 
6
8
  class Pointer
7
9
  include Enumerable
@@ -63,6 +65,8 @@ module YAMLSchema
63
65
  end
64
66
 
65
67
  class Validator
68
+ include Runtime
69
+
66
70
  class Exception < StandardError; end
67
71
  class UnexpectedType < Exception; end
68
72
  class UnexpectedProperty < Exception; end
@@ -95,10 +99,13 @@ module YAMLSchema
95
99
 
96
100
  INSTANCE = new.freeze
97
101
 
102
+ VALID_TYPES = %w[object array string integer float boolean null date time symbol].freeze
103
+
98
104
  ##
99
105
  # Given a particular schema, validate that the node conforms to the
100
106
  # schema. Raises an exception if it is invalid
101
107
  def validate schema, node, aliases: true
108
+ validate_schema!(schema)
102
109
  val = _validate(schema["type"], schema, node, Valid, {}, [], aliases)
103
110
  if val.exception
104
111
  raise val
@@ -111,6 +118,7 @@ module YAMLSchema
111
118
  # Given a particular schema, validate that the node conforms to the
112
119
  # schema. Returns an error object if the node is invalid, otherwise false.
113
120
  def invalid? schema, node, aliases: true
121
+ validate_schema!(schema)
114
122
  res = _validate(schema["type"], schema, node, Valid, {}, [], aliases)
115
123
  if Valid == res
116
124
  false
@@ -119,12 +127,112 @@ module YAMLSchema
119
127
  end
120
128
  end
121
129
 
130
+ ##
131
+ # Validates a schema definition, raising InvalidSchema if it is malformed.
132
+ def validate_schema! schema, path = ""
133
+ raise InvalidSchema, "schema must be a Hash#{_sp path}" unless Hash === schema
134
+
135
+ type = schema["type"]
136
+ raise InvalidSchema, "schema must have a \"type\" key#{_sp path}" unless type
137
+
138
+ types = Array === type ? type : [type]
139
+ types.each do |t|
140
+ raise InvalidSchema, "type must be a String, got #{t.class}#{_sp path}" unless String === t
141
+ raise InvalidSchema, "unknown type #{t.dump}#{_sp path}" unless VALID_TYPES.include?(t)
142
+ end
143
+
144
+ if schema["tag"]
145
+ raise InvalidSchema, "\"tag\" must be a String#{_sp path}" unless String === schema["tag"]
146
+ end
147
+
148
+ if schema["pattern"]
149
+ raise InvalidSchema, "\"pattern\" must be a Regexp#{_sp path}" unless Regexp === schema["pattern"]
150
+ end
151
+
152
+ types.each do |t|
153
+ case t
154
+ when "object"
155
+ _validate_object_schema(schema, path)
156
+ when "array"
157
+ _validate_array_schema(schema, path)
158
+ else
159
+ _validate_scalar_schema(schema, path)
160
+ end
161
+ end
162
+ end
163
+
122
164
  private
123
165
 
124
- def make_error klass, msg, path
125
- ex = klass.new msg + " path: /#{path.join("/")}"
126
- ex.set_backtrace caller
127
- ex
166
+ def _sp path
167
+ path.empty? ? "" : " at #{path}"
168
+ end
169
+
170
+ def _validate_object_schema schema, path
171
+ if schema["properties"]
172
+ raise InvalidSchema, "\"properties\" must be a Hash#{_sp path}" unless Hash === schema["properties"]
173
+ schema["properties"].each do |key, sub|
174
+ raise InvalidSchema, "property name must be a String#{_sp path}" unless String === key
175
+ validate_schema!(sub, "#{path}/#{key}")
176
+ end
177
+ elsif schema["items"]
178
+ # items-based object (homogeneous key-value)
179
+ else
180
+ raise InvalidSchema, "objects must specify \"items\" or \"properties\"#{_sp path}"
181
+ end
182
+
183
+ if schema["items"] && !schema["properties"]
184
+ raise InvalidSchema, "\"items\" must be a Hash#{_sp path}" unless Hash === schema["items"]
185
+ validate_schema!(schema["items"], "#{path}/items")
186
+ end
187
+
188
+ if schema["additionalProperties"]
189
+ raise InvalidSchema, "\"additionalProperties\" must be a Hash#{_sp path}" unless Hash === schema["additionalProperties"]
190
+ validate_schema!(schema["additionalProperties"], "#{path}/additionalProperties")
191
+ end
192
+
193
+ if schema["propertyNames"]
194
+ raise InvalidSchema, "\"propertyNames\" must be a Hash#{_sp path}" unless Hash === schema["propertyNames"]
195
+ validate_schema!(schema["propertyNames"], "#{path}/propertyNames") if schema["propertyNames"]["type"]
196
+ end
197
+
198
+ if schema["required"]
199
+ raise InvalidSchema, "\"required\" must be an Array#{_sp path}" unless Array === schema["required"]
200
+ schema["required"].each do |r|
201
+ raise InvalidSchema, "\"required\" entries must be Strings#{_sp path}" unless String === r
202
+ end
203
+ end
204
+ end
205
+
206
+ def _validate_array_schema schema, path
207
+ if schema["items"]
208
+ raise InvalidSchema, "\"items\" must be a Hash#{_sp path}" unless Hash === schema["items"]
209
+ validate_schema!(schema["items"], "#{path}/items")
210
+ elsif schema["prefixItems"]
211
+ raise InvalidSchema, "\"prefixItems\" must be an Array#{_sp path}" unless Array === schema["prefixItems"]
212
+ schema["prefixItems"].each_with_index do |sub, i|
213
+ validate_schema!(sub, "#{path}/prefixItems/#{i}")
214
+ end
215
+ else
216
+ raise InvalidSchema, "arrays must specify \"items\" or \"prefixItems\"#{_sp path}"
217
+ end
218
+
219
+ if schema["minItems"]
220
+ raise InvalidSchema, "\"minItems\" must be an Integer#{_sp path}" unless Integer === schema["minItems"]
221
+ end
222
+
223
+ if schema["maxItems"]
224
+ raise InvalidSchema, "\"maxItems\" must be an Integer#{_sp path}" unless Integer === schema["maxItems"]
225
+ end
226
+ end
227
+
228
+ def _validate_scalar_schema schema, path
229
+ if schema["minLength"]
230
+ raise InvalidSchema, "\"minLength\" must be an Integer#{_sp path}" unless Integer === schema["minLength"]
231
+ end
232
+
233
+ if schema["maxLength"]
234
+ raise InvalidSchema, "\"maxLength\" must be an Integer#{_sp path}" unless Integer === schema["maxLength"]
235
+ end
128
236
  end
129
237
 
130
238
  def _validate type, schema, node, valid, aliases, path, allow_aliases
@@ -235,7 +343,8 @@ module YAMLSchema
235
343
  valid = _validate sub_schema["type"], sub_schema, item, valid, aliases, path + [i], allow_aliases
236
344
  }
237
345
  elsif schema["prefixItems"]
238
- node.children.each_with_index { |item, i|
346
+ # Items beyond the end of prefixItems are not validated
347
+ node.children.first(schema["prefixItems"].length).each_with_index { |item, i|
239
348
  sub_schema = schema["prefixItems"][i]
240
349
  valid = _validate sub_schema["type"], sub_schema, item, valid, aliases, path + [i], allow_aliases
241
350
  }
@@ -300,66 +409,7 @@ module YAMLSchema
300
409
 
301
410
  valid
302
411
  end
303
-
304
- # Taken from http://yaml.org/type/timestamp.html
305
- TIME = /^-?\d{4}-\d{1,2}-\d{1,2}(?:[Tt]|\s+)\d{1,2}:\d\d:\d\d(?:\.\d*)?(?:\s*(?:Z|[-+]\d{1,2}:?(?:\d\d)?))?$/
306
-
307
- # Taken from http://yaml.org/type/float.html
308
- # Base 60, [-+]inf and NaN are handled separately
309
- FLOAT = /^(?:[-+]?([0-9][0-9_,]*)?\.[0-9]*([eE][-+][0-9]+)?(?# base 10))$/x
310
-
311
- # Taken from http://yaml.org/type/int.html and modified to ensure at least one numerical symbol exists
312
- INTEGER_STRICT = /^(?:[-+]?0b[_]*[0-1][0-1_]* (?# base 2)
313
- |[-+]?0[_]*[0-7][0-7_]* (?# base 8)
314
- |[-+]?(0|[1-9][0-9_]*) (?# base 10)
315
- |[-+]?0x[_]*[0-9a-fA-F][0-9a-fA-F_]* (?# base 16))$/x
316
-
317
- # Tokenize +string+ returning the Ruby object
318
- def extract_type string
319
- return :null if string.empty?
320
- # Check for a String type, being careful not to get caught by hash keys, hex values, and
321
- # special floats (e.g., -.inf).
322
- if string.match?(%r{^[^\d.:-]?[[:alpha:]_\s!@#$%\^&*(){}<>|/\\~;=]+}) || string.match?(/\n/)
323
- return :string if string.length > 5
324
-
325
- if string.match?(/^[^ytonf~]/i)
326
- :string
327
- elsif string == '~' || string.match?(/^null$/i)
328
- :null
329
- elsif string.match?(/^(yes|true|on)$/i)
330
- :boolean
331
- elsif string.match?(/^(no|false|off)$/i)
332
- :boolean
333
- else
334
- :string
335
- end
336
- elsif string.match?(TIME)
337
- :time
338
- elsif string.match?(/^\d{4}-(?:1[012]|0\d|\d)-(?:[12]\d|3[01]|0\d|\d)$/)
339
- :date
340
- elsif string.match?(/^\+?\.inf$/i)
341
- :float
342
- elsif string.match?(/^-\.inf$/i)
343
- :float
344
- elsif string.match?(/^\.nan$/i)
345
- :float
346
- elsif string.match?(/^:./)
347
- :symbol
348
- elsif string.match?(/^[-+]?[0-9][0-9_]*(:[0-5]?[0-9]){1,2}$/)
349
- :sexagesimal
350
- elsif string.match?(/^[-+]?[0-9][0-9_]*(:[0-5]?[0-9]){1,2}\.[0-9_]*$/)
351
- :sexagesimal
352
- elsif string.match?(FLOAT)
353
- if string.match?(/\A[-+]?\.\Z/)
354
- :string
355
- else
356
- :float
357
- end
358
- elsif string.match?(INTEGER_STRICT)
359
- :integer
360
- else
361
- :string
362
- end
363
- end
364
412
  end
365
413
  end
414
+
415
+ require "yaml-schema/compiler"
@@ -0,0 +1,210 @@
1
+ require "minitest/autorun"
2
+ require "yaml-schema"
3
+ require "psych"
4
+ require "tmpdir"
5
+ require "open3"
6
+ require "rbconfig"
7
+
8
+ module YAMLSchema
9
+ class CompilerTest < Minitest::Test
10
+ SCHEMAS = [
11
+ { "type" => "string" },
12
+ { "type" => "string", "minLength" => 2, "maxLength" => 4, "pattern" => /\A[a-z]+\z/ },
13
+ { "type" => "integer" },
14
+ { "type" => "integer", "pattern" => /\A1/ },
15
+ { "type" => "float" },
16
+ { "type" => "boolean" },
17
+ { "type" => "null" },
18
+ { "type" => "date" },
19
+ { "type" => "time" },
20
+ { "type" => "symbol" },
21
+ { "type" => ["null", "string"] },
22
+ { "type" => ["integer", "boolean", "string"] },
23
+ { "type" => "array", "items" => { "type" => "integer" } },
24
+ { "type" => "array", "items" => { "type" => "string" }, "minItems" => 1, "maxItems" => 2 },
25
+ { "type" => "array", "prefixItems" => [{ "type" => "string" }, { "type" => "integer" }] },
26
+ { "type" => "object", "items" => { "type" => "integer" } },
27
+ { "type" => "object", "tag" => "!thing", "items" => { "type" => "string" } },
28
+ { "type" => ["null", "object"], "tag" => "!thing", "items" => { "type" => "string" } },
29
+ {
30
+ "type" => "object",
31
+ "properties" => {
32
+ "name" => { "type" => "string" },
33
+ "age" => { "type" => ["null", "integer"] },
34
+ "tags" => { "type" => "array", "items" => { "type" => "string" } },
35
+ },
36
+ "required" => ["name", "age"],
37
+ },
38
+ {
39
+ "type" => "object",
40
+ "properties" => { "a" => { "type" => "integer" } },
41
+ "additionalProperties" => { "type" => "boolean" },
42
+ "propertyNames" => { "pattern" => /\A[a-z]\z/, "maxLength" => 1 },
43
+ },
44
+ {
45
+ "type" => "object",
46
+ "properties" => {
47
+ "users" => {
48
+ "type" => "array",
49
+ "items" => {
50
+ "type" => "object",
51
+ "properties" => {
52
+ "name" => { "type" => "string" },
53
+ "meta" => { "type" => "object", "items" => { "type" => ["string", "integer"] } },
54
+ },
55
+ "required" => ["name"],
56
+ },
57
+ },
58
+ },
59
+ },
60
+ ]
61
+
62
+ DOCUMENTS = [
63
+ "foo", "'foo'", "\"123\"", "123", "0x1f", "1.5", ".inf", "true", "yes",
64
+ "", "~", "null", "2024-01-02", "2024-01-02 10:11:12", ":sym", "abcdef",
65
+ "!str 123", "!thing foo", "ab", "a1",
66
+ "- 1\n- 2", "- foo\n- 1", "- foo\n- bar\n- baz", "[]", "- a\n- 1\n- extra",
67
+ "a: 1", "a: true\nb: false", "a: 1\na: true", "ab: 1", "1: 1",
68
+ "!thing\na: b", "!other\na: b", "{}",
69
+ "name: bob\nage: 1", "name: bob", "name: bob\nage:\ntags:\n- x", "name: bob\nage: 1\nextra: 1",
70
+ "name: 1\nage: 1", "age: x",
71
+ "users:\n- name: a\n meta:\n x: 1\n y: z\n- name: b", "users:\n- meta: {}",
72
+ "users:\n- name: a\n meta:\n x: [1]",
73
+ "a: &x 1\nb: *x", "x: &x\n- 1\ny: *x",
74
+ ]
75
+
76
+ def outcome
77
+ [:ok, yield]
78
+ rescue Validator::Exception => e
79
+ [e.class, e.message]
80
+ rescue => e
81
+ [:crash, e.class]
82
+ end
83
+
84
+ # Stand-alone validators define their own exception classes, so compare
85
+ # exceptions by their unqualified names.
86
+ def loose_outcome
87
+ [:ok, yield]
88
+ rescue => e
89
+ # An exception raised without a message uses its class name as the message
90
+ message = e.message == e.class.name ? :default : e.message
91
+ [e.class.name.split("::").last, message]
92
+ end
93
+
94
+ def standalone_validator schema
95
+ mod = Module.new
96
+ mod.module_eval Compiler.to_ruby(schema, class_name: "Validator", standalone: true)
97
+ mod::Validator
98
+ end
99
+
100
+ def test_compiled_matches_interpreter
101
+ SCHEMAS.each do |schema|
102
+ compiled = Compiler.compile(schema)
103
+ DOCUMENTS.each do |yaml|
104
+ node = Psych.parse("---\n#{yaml}").children.first
105
+ [true, false].each do |aliases|
106
+ expected = outcome { Validator.validate(schema, node, aliases: aliases) }
107
+ actual = outcome { compiled.validate(node, aliases: aliases) }
108
+ assert_equal expected, actual, "schema=#{schema.inspect} yaml=#{yaml.inspect} aliases=#{aliases}"
109
+
110
+ expected = outcome { e = Validator.new.invalid?(schema, node, aliases: aliases); e && [e.class, e.message] }
111
+ actual = outcome { e = compiled.invalid?(node, aliases: aliases); e && [e.class, e.message] }
112
+ assert_equal expected, actual, "invalid? schema=#{schema.inspect} yaml=#{yaml.inspect}"
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ def test_standalone_matches_interpreter
119
+ SCHEMAS.each do |schema|
120
+ standalone = standalone_validator(schema)
121
+ refute_operator standalone, :<, CompiledValidator
122
+
123
+ DOCUMENTS.each do |yaml|
124
+ node = Psych.parse("---\n#{yaml}").children.first
125
+ [true, false].each do |aliases|
126
+ expected = loose_outcome { Validator.validate(schema, node, aliases: aliases) }
127
+ actual = loose_outcome { standalone.validate(node, aliases: aliases) }
128
+ assert_equal expected, actual, "schema=#{schema.inspect} yaml=#{yaml.inspect} aliases=#{aliases}"
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ def test_standalone_runs_without_yaml_schema
135
+ schema = { "type" => "object", "properties" => { "age" => { "type" => "integer" } } }
136
+ script = <<~'RUBY'
137
+ require "psych"
138
+ load ARGV.fetch(0)
139
+ abort "yaml-schema was loaded" if defined?(YAMLSchema)
140
+ puts AgeValidator.validate(Psych.parse("age: 1").children.first)
141
+ begin
142
+ AgeValidator.validate(Psych.parse("age: x").children.first)
143
+ rescue AgeValidator::UnexpectedValue => e
144
+ puts e.message
145
+ end
146
+ RUBY
147
+
148
+ Dir.mktmpdir do |dir|
149
+ path = File.join(dir, "age_validator.rb")
150
+ File.write path, Compiler.to_ruby(schema, class_name: "AgeValidator", standalone: true)
151
+
152
+ # Clear RUBYOPT so bundler/setup doesn't load the gemspec (which requires yaml-schema)
153
+ env = { "RUBYOPT" => nil, "RUBYLIB" => nil }
154
+ out, err, status = Open3.capture3(env, RbConfig.ruby, "--disable-gems", "-e", script, path)
155
+ assert status.success?, err
156
+ assert_equal "true\nexpected integer, got string path: /age\n", out
157
+ end
158
+ end
159
+
160
+ def test_validate_and_invalid?
161
+ v = Compiler.compile({ "type" => "array", "items" => { "type" => "integer" } })
162
+ assert v.validate(Psych.parse("- 1").children.first)
163
+ refute v.invalid?(Psych.parse("- 1").children.first)
164
+
165
+ node = Psych.parse("- 1\n- foo").children.first
166
+ err = v.invalid?(node)
167
+ assert_kind_of Validator::UnexpectedValue, err
168
+ assert_match %r{path: /1}, err.message
169
+ assert_raises(Validator::UnexpectedValue) { v.validate(node) }
170
+ end
171
+
172
+ def test_invalid_schema_raises_at_compile_time
173
+ assert_raises(Validator::InvalidSchema) { Compiler.compile({ "type" => "nope" }) }
174
+ end
175
+
176
+ def test_custom_node_info
177
+ info = Object.new
178
+ def info.read_tag(node); node.tag == "!aaron" ? "test" : node.tag; end
179
+ v = Compiler.compile({ "type" => "object", "tag" => "test", "properties" => { "foo" => { "type" => "string" } } })
180
+ node = Psych.parse("--- !aaron\nfoo: bar").children.first
181
+ assert v.new(info).validate(node)
182
+ assert_raises(Validator::UnexpectedTag) { v.validate(node) }
183
+ end
184
+
185
+ def test_extra_prefix_items_are_allowed
186
+ v = Compiler.compile({ "type" => "array", "prefixItems" => [{ "type" => "string" }] })
187
+ assert v.validate(Psych.parse("- a\n- 1").children.first)
188
+ end
189
+
190
+ def test_to_ruby
191
+ schema = { "type" => "object", "properties" => { "name" => { "type" => "string", "pattern" => %r{\A/\w+\z}i } } }
192
+ src = Compiler.to_ruby(schema, class_name: "GeneratedPersonValidator")
193
+
194
+ Dir.mktmpdir do |dir|
195
+ path = File.join(dir, "generated_person_validator.rb")
196
+ File.write path, src
197
+ load path
198
+ end
199
+
200
+ assert_operator GeneratedPersonValidator, :<, CompiledValidator
201
+ assert GeneratedPersonValidator.validate(Psych.parse("name: /Bob").children.first)
202
+ assert_raises(Validator::InvalidPattern) do
203
+ GeneratedPersonValidator.validate(Psych.parse("name: bob").children.first)
204
+ end
205
+ ensure
206
+ YAMLSchema.send(:remove_const, :GeneratedPersonValidator) if YAMLSchema.const_defined?(:GeneratedPersonValidator, false)
207
+ Object.send(:remove_const, :GeneratedPersonValidator) if Object.const_defined?(:GeneratedPersonValidator, false)
208
+ end
209
+ end
210
+ end
@@ -322,7 +322,10 @@ module YAMLSchema
322
322
  assert_raises UnexpectedTag do
323
323
  Validator.validate({
324
324
  "type" => "object",
325
- "tag" => "aaron"
325
+ "tag" => "aaron",
326
+ "properties" => {
327
+ "foo" => { "type" => "string" }
328
+ }
326
329
  }, ast.children.first)
327
330
  end
328
331
  end
@@ -332,7 +335,10 @@ module YAMLSchema
332
335
  ex = assert_raises UnexpectedTag do
333
336
  Validator.validate({
334
337
  "type" => "object",
335
- "tag" => "aaron"
338
+ "tag" => "aaron",
339
+ "properties" => {
340
+ "foo" => { "type" => "string" }
341
+ }
336
342
  }, ast.children.first)
337
343
  end
338
344
  assert_match(/lolol/, ex.message)
@@ -351,6 +357,9 @@ module YAMLSchema
351
357
  ex = assert_raises UnexpectedType do
352
358
  Validator.validate({
353
359
  "type" => "object",
360
+ "properties" => {
361
+ "foo" => { "type" => "string" }
362
+ }
354
363
  }, ast.children.first)
355
364
  end
356
365
  assert_match(/Mapping/, ex.message)
@@ -359,6 +368,7 @@ module YAMLSchema
359
368
  ex = assert_raises UnexpectedType do
360
369
  Validator.validate({
361
370
  "type" => "array",
371
+ "items" => { "type" => "string" }
362
372
  }, ast.children.first)
363
373
  end
364
374
  assert_match(/Sequence/, ex.message)
@@ -468,6 +478,17 @@ module YAMLSchema
468
478
  end
469
479
  end
470
480
 
481
+ def test_extra_prefix_items_are_allowed
482
+ ast = Psych.parse("- foo\n- 1\n- extra")
483
+ assert Validator.validate({
484
+ "type" => "array",
485
+ "prefixItems" => [
486
+ { "type" => "string" },
487
+ { "type" => "integer" },
488
+ ]
489
+ }, ast.children.first)
490
+ end
491
+
471
492
  def test_invalid_string
472
493
  ast = Psych.parse("1")
473
494
  assert_raises UnexpectedValue do
@@ -731,5 +752,134 @@ bar: *1
731
752
  }, ast.children.first)
732
753
  end
733
754
  end
755
+
756
+ class SchemaValidationTest < Minitest::Test
757
+ def test_missing_type
758
+ assert_raises InvalidSchema do
759
+ Validator.validate({}, Psych.parse("foo").children.first)
760
+ end
761
+ end
762
+
763
+ def test_invalid_type_value
764
+ assert_raises InvalidSchema do
765
+ Validator.validate({ "type" => 42 }, Psych.parse("foo").children.first)
766
+ end
767
+ end
768
+
769
+ def test_unknown_type
770
+ assert_raises InvalidSchema do
771
+ Validator.validate({ "type" => "map" }, Psych.parse("foo").children.first)
772
+ end
773
+ end
774
+
775
+ def test_object_without_properties_or_items
776
+ assert_raises InvalidSchema do
777
+ Validator.validate({
778
+ "type" => "object",
779
+ }, Psych.parse("foo: bar").children.first)
780
+ end
781
+ end
782
+
783
+ def test_array_without_items_or_prefix
784
+ assert_raises InvalidSchema do
785
+ Validator.validate({
786
+ "type" => "array",
787
+ }, Psych.parse("- foo").children.first)
788
+ end
789
+ end
790
+
791
+ def test_invalid_required_type
792
+ assert_raises InvalidSchema do
793
+ Validator.validate({
794
+ "type" => "object",
795
+ "properties" => { "foo" => { "type" => "string" } },
796
+ "required" => "foo",
797
+ }, Psych.parse("foo: bar").children.first)
798
+ end
799
+ end
800
+
801
+ def test_invalid_min_length_type
802
+ assert_raises InvalidSchema do
803
+ Validator.validate({
804
+ "type" => "string",
805
+ "minLength" => "5",
806
+ }, Psych.parse("foo").children.first)
807
+ end
808
+ end
809
+
810
+ def test_invalid_max_items_type
811
+ assert_raises InvalidSchema do
812
+ Validator.validate({
813
+ "type" => "array",
814
+ "items" => { "type" => "string" },
815
+ "maxItems" => "2",
816
+ }, Psych.parse("- foo").children.first)
817
+ end
818
+ end
819
+
820
+ def test_invalid_pattern_type
821
+ assert_raises InvalidSchema do
822
+ Validator.validate({
823
+ "type" => "string",
824
+ "pattern" => "foo",
825
+ }, Psych.parse("foo").children.first)
826
+ end
827
+ end
828
+
829
+ def test_invalid_tag_type
830
+ assert_raises InvalidSchema do
831
+ Validator.validate({
832
+ "type" => "object",
833
+ "tag" => 42,
834
+ "properties" => { "foo" => { "type" => "string" } },
835
+ }, Psych.parse("foo: bar").children.first)
836
+ end
837
+ end
838
+
839
+ def test_invalid_nested_schema
840
+ assert_raises InvalidSchema do
841
+ Validator.validate({
842
+ "type" => "object",
843
+ "properties" => {
844
+ "foo" => { "type" => "bogus" }
845
+ }
846
+ }, Psych.parse("foo: bar").children.first)
847
+ end
848
+ end
849
+
850
+ def test_invalid_prefix_items_type
851
+ assert_raises InvalidSchema do
852
+ Validator.validate({
853
+ "type" => "array",
854
+ "prefixItems" => { "type" => "string" },
855
+ }, Psych.parse("- foo").children.first)
856
+ end
857
+ end
858
+
859
+ def test_invalid_additional_properties_type
860
+ assert_raises InvalidSchema do
861
+ Validator.validate({
862
+ "type" => "object",
863
+ "properties" => { "foo" => { "type" => "string" } },
864
+ "additionalProperties" => "string",
865
+ }, Psych.parse("foo: bar").children.first)
866
+ end
867
+ end
868
+
869
+ def test_valid_union_types
870
+ ast = Psych.parse("foo")
871
+ assert Validator.validate({
872
+ "type" => ["null", "string"],
873
+ }, ast.children.first)
874
+ end
875
+
876
+ def test_invalid_union_type_entry
877
+ assert_raises InvalidSchema do
878
+ Validator.validate({
879
+ "type" => ["string", "bogus"],
880
+ }, Psych.parse("foo").children.first)
881
+ end
882
+ end
883
+ end
734
884
  end
735
885
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yaml-schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aaron Patterson
@@ -65,6 +65,9 @@ files:
65
65
  - README.md
66
66
  - Rakefile
67
67
  - lib/yaml-schema.rb
68
+ - lib/yaml-schema/compiler.rb
69
+ - lib/yaml-schema/runtime.rb
70
+ - test/compiler_test.rb
68
71
  - test/pointer_test.rb
69
72
  - test/validator_test.rb
70
73
  - yaml-schema.gemspec
@@ -86,9 +89,10 @@ required_rubygems_version: !ruby/object:Gem::Requirement
86
89
  - !ruby/object:Gem::Version
87
90
  version: '0'
88
91
  requirements: []
89
- rubygems_version: 4.0.6
92
+ rubygems_version: 4.0.20
90
93
  specification_version: 4
91
94
  summary: Validate YAML against a schema
92
95
  test_files:
96
+ - test/compiler_test.rb
93
97
  - test/pointer_test.rb
94
98
  - test/validator_test.rb