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 +4 -4
- data/README.md +36 -0
- data/lib/yaml-schema/compiler.rb +538 -0
- data/lib/yaml-schema/runtime.rb +82 -0
- data/lib/yaml-schema.rb +117 -67
- data/test/compiler_test.rb +210 -0
- data/test/validator_test.rb +152 -2
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: df10b8348eda9fb9eb5d36af1119234e00b48a4d1e5ef7f104d9fade49f02493
|
|
4
|
+
data.tar.gz: 0d208c8b1883843586bd30c9de3cb5cafb6a65e8978eb495902d34e5c660462f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
data/test/validator_test.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|