jtd 0.1.1 → 0.1.6
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +183 -0
- data/lib/jtd.rb +4 -3
- data/lib/jtd/schema.rb +291 -0
- data/lib/jtd/validate.rb +363 -0
- data/lib/jtd/version.rb +2 -1
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 48c509443ff1c585b82b2dad7e6177cb427b1109fb5f4561b8d13f5aea367732
|
4
|
+
data.tar.gz: ed30da3978bda75cdac5a726ccaec03516035e7b352f3d3a923719c934ba227f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 211758c89db305e4e6b1fdafdb27043f0cee0c233bd14e358eeef901a76d1c06a732404e4d13ff4e00220b23d4ee577ae10f17c61ac167ff38a5910e51002245
|
7
|
+
data.tar.gz: 90bde12a921a518fe982cfc9fce5b2dd2610b2165bd891e1c450ef2dbc4b6094d32107bdc0f40621168c09f3a11814360d9659c6ed20c28acce093f6fcc853a7
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -1 +1,184 @@
|
|
1
1
|
# jtd: JSON Validation for Ruby
|
2
|
+
|
3
|
+
[![Gem](https://img.shields.io/gem/v/jtd)](https://rubygems.org/gems/jtd)
|
4
|
+
|
5
|
+
`jtd` is a Ruby implementation of [JSON Type Definition][jtd], a schema language
|
6
|
+
for JSON. `jtd` primarily gives you two things:
|
7
|
+
|
8
|
+
1. Validating input data against JSON Typedef schemas.
|
9
|
+
2. A Ruby representation of JSON Typedef schemas.
|
10
|
+
|
11
|
+
With this package, you can add JSON Typedef-powered validation to your
|
12
|
+
application, or you can build your own tooling on top of JSON Type Definition.
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
You can install this package with `gem`:
|
17
|
+
|
18
|
+
```bash
|
19
|
+
gem install jtd
|
20
|
+
```
|
21
|
+
|
22
|
+
## Documentation
|
23
|
+
|
24
|
+
Detailed API documentation is available online at:
|
25
|
+
|
26
|
+
https://rubydoc.info/gems/jtd/JTD
|
27
|
+
|
28
|
+
For more high-level documentation about JSON Typedef in general, or JSON Typedef
|
29
|
+
in combination with Python in particular, see:
|
30
|
+
|
31
|
+
* [The JSON Typedef Website][jtd]
|
32
|
+
* ["Validating JSON in Ruby with JSON Typedef"][jtd-ruby-validation]
|
33
|
+
|
34
|
+
## Basic Usage
|
35
|
+
|
36
|
+
> For a more detailed tutorial and guidance on how to integrate `jtd` in your
|
37
|
+
> application, see ["Validating JSON in Ruby with JSON
|
38
|
+
> Typedef"][jtd-ruby-validation] in the JSON Typedef docs.
|
39
|
+
|
40
|
+
Here's an example of how you can use this package to validate JSON data against
|
41
|
+
a JSON Typedef schema:
|
42
|
+
|
43
|
+
```ruby
|
44
|
+
require 'jtd'
|
45
|
+
|
46
|
+
schema = JTD::Schema.from_hash({
|
47
|
+
'properties' => {
|
48
|
+
'name' => { 'type' => 'string' },
|
49
|
+
'age' => { 'type' => 'uint32' },
|
50
|
+
'phones' => {
|
51
|
+
'elements' => {
|
52
|
+
'type' => 'string'
|
53
|
+
}
|
54
|
+
}
|
55
|
+
}
|
56
|
+
})
|
57
|
+
|
58
|
+
# JTD::validate returns an array of validation errors. If there were no problems
|
59
|
+
# with the input, it returns an empty array.
|
60
|
+
|
61
|
+
# Outputs: []
|
62
|
+
p JTD::validate(schema, {
|
63
|
+
'name' => 'John Doe',
|
64
|
+
'age' => 43,
|
65
|
+
'phones' => ['+44 1234567', '+44 2345678'],
|
66
|
+
})
|
67
|
+
|
68
|
+
# This next input has three problems with it:
|
69
|
+
#
|
70
|
+
# 1. It's missing "name", which is a required property.
|
71
|
+
# 2. "age" is a string, but it should be an integer.
|
72
|
+
# 3. "phones[1]" is a number, but it should be a string.
|
73
|
+
#
|
74
|
+
# Each of those errors corresponds to one of the errors returned by validate.
|
75
|
+
|
76
|
+
# Outputs:
|
77
|
+
#
|
78
|
+
# [
|
79
|
+
# #<struct JTD::ValidationError
|
80
|
+
# instance_path=[],
|
81
|
+
# schema_path=["properties", "name"]
|
82
|
+
# >,
|
83
|
+
# #<struct JTD::ValidationError
|
84
|
+
# instance_path=["age"],
|
85
|
+
# schema_path=["properties", "age", "type"]
|
86
|
+
# >,
|
87
|
+
# #<struct JTD::ValidationError
|
88
|
+
# instance_path=["phones", "1"],
|
89
|
+
# schema_path=["properties", "phones", "elements", "type"]
|
90
|
+
# >
|
91
|
+
# ]
|
92
|
+
p JTD::validate(schema, {
|
93
|
+
'age' => '43',
|
94
|
+
'phones' => ['+44 1234567', 442345678],
|
95
|
+
})
|
96
|
+
```
|
97
|
+
|
98
|
+
## Advanced Usage: Limiting Errors Returned
|
99
|
+
|
100
|
+
By default, `JTD::validate` returns every error it finds. If you just care about
|
101
|
+
whether there are any errors at all, or if you can't show more than some number
|
102
|
+
of errors, then you can get better performance out of `JTD::validate` using the
|
103
|
+
`max_errors` option.
|
104
|
+
|
105
|
+
For example, taking the same example from before, but limiting it to 1 error, we
|
106
|
+
get:
|
107
|
+
|
108
|
+
```python
|
109
|
+
# Outputs:
|
110
|
+
#
|
111
|
+
# [#<struct JTD::ValidationError instance_path=[], schema_path=["properties", "name"]>]
|
112
|
+
options = JTD::ValidationOptions.new(max_errors: 1)
|
113
|
+
p JTD::validate(schema, {
|
114
|
+
'age' => '43',
|
115
|
+
'phones' => ['+44 1234567', 442345678],
|
116
|
+
}, options)
|
117
|
+
```
|
118
|
+
|
119
|
+
## Advanced Usage: Handling Untrusted Schemas
|
120
|
+
|
121
|
+
If you want to run `jtd` against a schema that you don't trust, then you should:
|
122
|
+
|
123
|
+
1. Ensure the schema is well-formed, using the `#verify` method on
|
124
|
+
`JTD::Schema`. That will check things like making sure all `ref`s have
|
125
|
+
corresponding definitions.
|
126
|
+
|
127
|
+
2. Call `JTD::validate` with the `max_depth` option. JSON Typedef lets you write
|
128
|
+
recursive schemas -- if you're evaluating against untrusted schemas, you
|
129
|
+
might go into an infinite loop when evaluating against a malicious input,
|
130
|
+
such as this one:
|
131
|
+
|
132
|
+
```json
|
133
|
+
{
|
134
|
+
"ref": "loop",
|
135
|
+
"definitions": {
|
136
|
+
"loop": {
|
137
|
+
"ref": "loop"
|
138
|
+
}
|
139
|
+
}
|
140
|
+
}
|
141
|
+
```
|
142
|
+
|
143
|
+
The `max_depth` option tells `JTD::validate` how many `ref`s to follow
|
144
|
+
recursively before giving up and raising `JTD::MaxDepthExceededError`.
|
145
|
+
|
146
|
+
Here's an example of how you can use `jtd` to evaluate data against an untrusted
|
147
|
+
schema:
|
148
|
+
|
149
|
+
```ruby
|
150
|
+
require 'jtd'
|
151
|
+
|
152
|
+
# validate_untrusted returns true if `data` satisfies `schema`, and false if it
|
153
|
+
# does not. Throws an error if `schema` is invalid, or if validation goes in an
|
154
|
+
# infinite loop.
|
155
|
+
def validate_untrusted(schema, data)
|
156
|
+
schema.verify()
|
157
|
+
|
158
|
+
# You should tune max_depth to be high enough that most legitimate schemas
|
159
|
+
# evaluate without errors, but low enough that an attacker cannot cause a
|
160
|
+
# denial of service attack.
|
161
|
+
options = JTD::ValidationOptions.new(max_depth: 32)
|
162
|
+
JTD::validate(schema, data, options).empty?
|
163
|
+
end
|
164
|
+
|
165
|
+
# Returns true
|
166
|
+
validate_untrusted(JTD::Schema.from_hash({ 'type' => 'string' }), 'foo')
|
167
|
+
|
168
|
+
# Returns false
|
169
|
+
validate_untrusted(JTD::Schema.from_hash({ 'type' => 'string' }), nil)
|
170
|
+
|
171
|
+
# Raises ArgumentError (invalid type: nonsense)
|
172
|
+
validate_untrusted(JTD::Schema.from_hash({ 'type' => 'nonsense' }), 'foo')
|
173
|
+
|
174
|
+
# Raises JTD::MaxDepthExceededError (max depth exceeded during JTD::validate)
|
175
|
+
validate_untrusted(JTD::Schema.from_hash({
|
176
|
+
'definitions' => {
|
177
|
+
'loop' => { 'ref' => 'loop' },
|
178
|
+
},
|
179
|
+
'ref' => 'loop',
|
180
|
+
}), nil)
|
181
|
+
```
|
182
|
+
|
183
|
+
[jtd]: https://jsontypedef.com
|
184
|
+
[jtd-ruby-validation]: https://jsontypedef.com/docs/ruby/validation
|
data/lib/jtd.rb
CHANGED
data/lib/jtd/schema.rb
ADDED
@@ -0,0 +1,291 @@
|
|
1
|
+
module JTD
|
2
|
+
# Represents a JSON Type Definition schema.
|
3
|
+
class Schema
|
4
|
+
attr_accessor *%i[
|
5
|
+
metadata
|
6
|
+
nullable
|
7
|
+
definitions
|
8
|
+
ref
|
9
|
+
type
|
10
|
+
enum
|
11
|
+
elements
|
12
|
+
properties
|
13
|
+
optional_properties
|
14
|
+
additional_properties
|
15
|
+
values
|
16
|
+
discriminator
|
17
|
+
mapping
|
18
|
+
]
|
19
|
+
|
20
|
+
# Constructs a Schema from a Hash like the kind produced by JSON#parse.
|
21
|
+
#
|
22
|
+
# In other words, #from_hash is meant to be used to convert some parsed JSON
|
23
|
+
# into a Schema.
|
24
|
+
#
|
25
|
+
# If hash isn't a Hash or contains keys that are illegal for JSON Type
|
26
|
+
# Definition, then #from_hash will raise a TypeError.
|
27
|
+
#
|
28
|
+
# If the properties of hash are not of the correct type for a JSON Type
|
29
|
+
# Definition schema (for example, if the "elements" property of hash is
|
30
|
+
# non-nil, but not a hash), then #from_hash may raise a NoMethodError.
|
31
|
+
def self.from_hash(hash)
|
32
|
+
# Raising this error early makes for a much clearer error for the
|
33
|
+
# relatively common case of something that was expected to be an object
|
34
|
+
# (Hash), but was something else instead.
|
35
|
+
raise TypeError.new("expected hash, got: #{hash}") unless hash.is_a?(Hash)
|
36
|
+
|
37
|
+
illegal_keywords = hash.keys - KEYWORDS
|
38
|
+
unless illegal_keywords.empty?
|
39
|
+
raise TypeError.new("illegal schema keywords: #{illegal_keywords}")
|
40
|
+
end
|
41
|
+
|
42
|
+
s = Schema.new
|
43
|
+
|
44
|
+
if hash['metadata']
|
45
|
+
s.metadata = hash['metadata']
|
46
|
+
end
|
47
|
+
|
48
|
+
unless hash['nullable'].nil?
|
49
|
+
s.nullable = hash['nullable']
|
50
|
+
end
|
51
|
+
|
52
|
+
if hash['definitions']
|
53
|
+
s.definitions = Hash[hash['definitions'].map { |k, v| [k, from_hash(v) ]}]
|
54
|
+
end
|
55
|
+
|
56
|
+
s.ref = hash['ref']
|
57
|
+
s.type = hash['type']
|
58
|
+
s.enum = hash['enum']
|
59
|
+
|
60
|
+
if hash['elements']
|
61
|
+
s.elements = from_hash(hash['elements'])
|
62
|
+
end
|
63
|
+
|
64
|
+
if hash['properties']
|
65
|
+
s.properties = Hash[hash['properties'].map { |k, v| [k, from_hash(v) ]}]
|
66
|
+
end
|
67
|
+
|
68
|
+
if hash['optionalProperties']
|
69
|
+
s.optional_properties = Hash[hash['optionalProperties'].map { |k, v| [k, from_hash(v) ]}]
|
70
|
+
end
|
71
|
+
|
72
|
+
unless hash['additionalProperties'].nil?
|
73
|
+
s.additional_properties = hash['additionalProperties']
|
74
|
+
end
|
75
|
+
|
76
|
+
if hash['values']
|
77
|
+
s.values = from_hash(hash['values'])
|
78
|
+
end
|
79
|
+
|
80
|
+
s.discriminator = hash['discriminator']
|
81
|
+
|
82
|
+
if hash['mapping']
|
83
|
+
s.mapping = Hash[hash['mapping'].map { |k, v| [k, from_hash(v) ]}]
|
84
|
+
end
|
85
|
+
|
86
|
+
s
|
87
|
+
end
|
88
|
+
|
89
|
+
# Raises a TypeError or ArgumentError if the Schema is not correct according
|
90
|
+
# to the JSON Type Definition specification.
|
91
|
+
#
|
92
|
+
# See the JSON Type Definition specification for more details, but a high
|
93
|
+
# level #verify checks such things as:
|
94
|
+
#
|
95
|
+
# 1. Making sure each of the attributes of the Schema are of the right type,
|
96
|
+
# 2. The Schema uses a valid combination of JSON Type Definition keywords,
|
97
|
+
# 3. The Schema isn't ambiguous or unsatisfiable.
|
98
|
+
# 4. The Schema doesn't make references to nonexistent definitions.
|
99
|
+
#
|
100
|
+
# If root is specified, then that root is assumed to contain the schema
|
101
|
+
# being verified. By default, the Schema is considered its own root, which
|
102
|
+
# is usually the desired behavior.
|
103
|
+
def verify(root = self)
|
104
|
+
self.check_type('metadata', [Hash])
|
105
|
+
self.check_type('nullable', [TrueClass, FalseClass])
|
106
|
+
self.check_type('definitions', [Hash])
|
107
|
+
self.check_type('ref', [String])
|
108
|
+
self.check_type('type', [String])
|
109
|
+
self.check_type('enum', [Array])
|
110
|
+
self.check_type('elements', [Schema])
|
111
|
+
self.check_type('properties', [Hash])
|
112
|
+
self.check_type('optional_properties', [Hash])
|
113
|
+
self.check_type('additional_properties', [TrueClass, FalseClass])
|
114
|
+
self.check_type('values', [Schema])
|
115
|
+
self.check_type('discriminator', [String])
|
116
|
+
self.check_type('mapping', [Hash])
|
117
|
+
|
118
|
+
form_signature = [
|
119
|
+
!!ref,
|
120
|
+
!!type,
|
121
|
+
!!enum,
|
122
|
+
!!elements,
|
123
|
+
!!properties,
|
124
|
+
!!optional_properties,
|
125
|
+
!!additional_properties,
|
126
|
+
!!values,
|
127
|
+
!!discriminator,
|
128
|
+
!!mapping,
|
129
|
+
]
|
130
|
+
|
131
|
+
unless VALID_FORMS.include?(form_signature)
|
132
|
+
raise ArgumentError.new("invalid schema form: #{self}")
|
133
|
+
end
|
134
|
+
|
135
|
+
if root != self && definitions && definitions.any?
|
136
|
+
raise ArgumentError.new("non-root definitions: #{definitions}")
|
137
|
+
end
|
138
|
+
|
139
|
+
if ref
|
140
|
+
if !root.definitions || !root.definitions.key?(ref)
|
141
|
+
raise ArgumentError.new("ref to non-existent definition: #{ref}")
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
if type && !TYPES.include?(type)
|
146
|
+
raise ArgumentError.new("invalid type: #{type}")
|
147
|
+
end
|
148
|
+
|
149
|
+
if enum
|
150
|
+
if enum.empty?
|
151
|
+
raise ArgumentError.new("enum must not be empty: #{self}")
|
152
|
+
end
|
153
|
+
|
154
|
+
if enum.any? { |v| !v.is_a?(String) }
|
155
|
+
raise ArgumentError.new("enum must contain only strings: #{enum}")
|
156
|
+
end
|
157
|
+
|
158
|
+
if enum.size != enum.uniq.size
|
159
|
+
raise ArgumentError.new("enum must not contain duplicates: #{enum}")
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
if properties && optional_properties
|
164
|
+
shared_keys = properties.keys & optional_properties.keys
|
165
|
+
if shared_keys.any?
|
166
|
+
raise ArgumentError.new("properties and optional_properties share keys: #{shared_keys}")
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
if mapping
|
171
|
+
mapping.values.each do |s|
|
172
|
+
if s.form != :properties
|
173
|
+
raise ArgumentError.new("mapping values must be of properties form: #{s}")
|
174
|
+
end
|
175
|
+
|
176
|
+
if s.nullable
|
177
|
+
raise ArgumentError.new("mapping values must not be nullable: #{s}")
|
178
|
+
end
|
179
|
+
|
180
|
+
contains_discriminator = ArgumentError.new("mapping values must not contain discriminator (#{discriminator}): #{s}")
|
181
|
+
|
182
|
+
if s.properties && s.properties.key?(discriminator)
|
183
|
+
raise contains_discriminator
|
184
|
+
end
|
185
|
+
|
186
|
+
if s.optional_properties && s.optional_properties.key?(discriminator)
|
187
|
+
raise contains_discriminator
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
definitions.values.each { |s| s.verify(root) } if definitions
|
193
|
+
elements.verify(root) if elements
|
194
|
+
properties.values.each { |s| s.verify(root) } if properties
|
195
|
+
optional_properties.values.each { |s| s.verify(root) } if optional_properties
|
196
|
+
values.verify(root) if values
|
197
|
+
mapping.values.each { |s| s.verify(root) } if mapping
|
198
|
+
|
199
|
+
self
|
200
|
+
end
|
201
|
+
|
202
|
+
# Returns the form that the schema takes on.
|
203
|
+
#
|
204
|
+
# The return value will be one of :empty, :ref:, :type, :enum, :elements,
|
205
|
+
# :properties, :values, or :discriminator.
|
206
|
+
#
|
207
|
+
# If the schema is not well-formed, i.e. calling #verify on it raises an
|
208
|
+
# error, then the return value of #form is not well-defined.
|
209
|
+
def form
|
210
|
+
return :ref if ref
|
211
|
+
return :type if type
|
212
|
+
return :enum if enum
|
213
|
+
return :elements if elements
|
214
|
+
return :properties if properties || optional_properties
|
215
|
+
return :values if values
|
216
|
+
return :discriminator if discriminator
|
217
|
+
|
218
|
+
:empty
|
219
|
+
end
|
220
|
+
|
221
|
+
private
|
222
|
+
|
223
|
+
KEYWORDS = %w[
|
224
|
+
metadata
|
225
|
+
nullable
|
226
|
+
definitions
|
227
|
+
ref
|
228
|
+
type
|
229
|
+
enum
|
230
|
+
elements
|
231
|
+
properties
|
232
|
+
optionalProperties
|
233
|
+
additionalProperties
|
234
|
+
values
|
235
|
+
discriminator
|
236
|
+
mapping
|
237
|
+
]
|
238
|
+
|
239
|
+
private_constant :KEYWORDS
|
240
|
+
|
241
|
+
TYPES = %w[
|
242
|
+
boolean
|
243
|
+
int8
|
244
|
+
uint8
|
245
|
+
int16
|
246
|
+
uint16
|
247
|
+
int32
|
248
|
+
uint32
|
249
|
+
float32
|
250
|
+
float64
|
251
|
+
string
|
252
|
+
timestamp
|
253
|
+
]
|
254
|
+
|
255
|
+
private_constant :TYPES
|
256
|
+
|
257
|
+
VALID_FORMS = [
|
258
|
+
# Empty form
|
259
|
+
[false, false, false, false, false, false, false, false, false, false],
|
260
|
+
# Ref form
|
261
|
+
[true, false, false, false, false, false, false, false, false, false],
|
262
|
+
# Type form
|
263
|
+
[false, true, false, false, false, false, false, false, false, false],
|
264
|
+
# Enum form
|
265
|
+
[false, false, true, false, false, false, false, false, false, false],
|
266
|
+
# Elements form
|
267
|
+
[false, false, false, true, false, false, false, false, false, false],
|
268
|
+
# Properties form -- properties or optional properties or both, and
|
269
|
+
# never additional properties on its own
|
270
|
+
[false, false, false, false, true, false, false, false, false, false],
|
271
|
+
[false, false, false, false, false, true, false, false, false, false],
|
272
|
+
[false, false, false, false, true, true, false, false, false, false],
|
273
|
+
[false, false, false, false, true, false, true, false, false, false],
|
274
|
+
[false, false, false, false, false, true, true, false, false, false],
|
275
|
+
[false, false, false, false, true, true, true, false, false, false],
|
276
|
+
# Values form
|
277
|
+
[false, false, false, false, false, false, false, true, false, false],
|
278
|
+
# Discriminator form
|
279
|
+
[false, false, false, false, false, false, false, false, true, true],
|
280
|
+
]
|
281
|
+
|
282
|
+
private_constant :VALID_FORMS
|
283
|
+
|
284
|
+
def check_type(key, classes)
|
285
|
+
val = self.send(key)
|
286
|
+
unless val.nil? || classes.include?(val.class)
|
287
|
+
raise TypeError.new("#{key} must be one of #{classes}, got: #{val}")
|
288
|
+
end
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
data/lib/jtd/validate.rb
ADDED
@@ -0,0 +1,363 @@
|
|
1
|
+
require 'time'
|
2
|
+
|
3
|
+
module JTD
|
4
|
+
# Validates +instance+ against +schema+ according to the JSON Type Definition
|
5
|
+
# specification.
|
6
|
+
#
|
7
|
+
# Returns a list of ValidationError. If there are no validation errors, then
|
8
|
+
# the returned list will be empty.
|
9
|
+
#
|
10
|
+
# By default, all errors are returned, and an unlimited number of references
|
11
|
+
# will be followed. If you are running #validate against schemas that may
|
12
|
+
# return a lot of errors, or which may contain circular references, then this
|
13
|
+
# can cause performance issues or stack overflows.
|
14
|
+
#
|
15
|
+
# To mitigate this risk, consider using +options+, which must be an instance
|
16
|
+
# of ValidationOptions, to limit the number of errors returned or references
|
17
|
+
# followed.
|
18
|
+
#
|
19
|
+
# If ValidationOptions#max_depth is reached, then #validate will raise a
|
20
|
+
# MaxDepthExceededError.
|
21
|
+
#
|
22
|
+
# The return value of #validate is not well-defined if the schema is not
|
23
|
+
# valid, i.e. Schema#verify raises an error.
|
24
|
+
def self.validate(schema, instance, options = ValidationOptions.new)
|
25
|
+
state = ValidationState.new
|
26
|
+
state.options = options
|
27
|
+
state.root_schema = schema
|
28
|
+
state.instance_tokens = []
|
29
|
+
state.schema_tokens = [[]]
|
30
|
+
state.errors = []
|
31
|
+
|
32
|
+
begin
|
33
|
+
validate_with_state(state, schema, instance)
|
34
|
+
rescue MaxErrorsReachedError
|
35
|
+
# This is just a dummy error to immediately stop validation. We swallow
|
36
|
+
# the error here, and return the abridged set of errors.
|
37
|
+
end
|
38
|
+
|
39
|
+
state.errors
|
40
|
+
end
|
41
|
+
|
42
|
+
# Options you can pass to JTD::validate.
|
43
|
+
class ValidationOptions
|
44
|
+
# The maximum number of references to follow before aborting validation. You
|
45
|
+
# can use this to prevent a stack overflow when validating schemas that
|
46
|
+
# potentially have infinite loops, such as this one:
|
47
|
+
#
|
48
|
+
# {
|
49
|
+
# "definitions": {
|
50
|
+
# "loop": { "ref": "loop" }
|
51
|
+
# },
|
52
|
+
# "ref": "loop"
|
53
|
+
# }
|
54
|
+
#
|
55
|
+
# The default value for +max_depth+ is 0, which indicates that no max depth
|
56
|
+
# should be imposed at all.
|
57
|
+
attr_accessor :max_depth
|
58
|
+
|
59
|
+
# The maximum number of errors to return. You can use this to have
|
60
|
+
# JTD::validate have better performance if you don't have any use for errors
|
61
|
+
# beyond a certain count.
|
62
|
+
#
|
63
|
+
# For instance, if all you care about is whether or not there are any
|
64
|
+
# validation errors at all, you can set +max_errors+ to 1. If you're
|
65
|
+
# presenting validation errors in an interface that can't show more than 5
|
66
|
+
# errors, set +max_errors+ to 5.
|
67
|
+
#
|
68
|
+
# The default value for +max_errors+ is 0, which indicates that all errors
|
69
|
+
# will be returned.
|
70
|
+
attr_accessor :max_errors
|
71
|
+
|
72
|
+
# Construct a new set of ValidationOptions with the given +max_depth+ and
|
73
|
+
# +max_errors+.
|
74
|
+
#
|
75
|
+
# See the documentation for +max_depth+ and +max_errors+ for what their
|
76
|
+
# default values of 0 mean.
|
77
|
+
def initialize(max_depth: 0, max_errors: 0)
|
78
|
+
@max_depth = max_depth
|
79
|
+
@max_errors = max_errors
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Represents a single JSON Type Definition validation error.
|
84
|
+
#
|
85
|
+
# ValidationError does not extend StandardError; it is not a Ruby exception.
|
86
|
+
# It is a plain old Ruby object.
|
87
|
+
#
|
88
|
+
# Every ValidationError has two attributes:
|
89
|
+
#
|
90
|
+
# * +instance_path+ is an array of strings. It represents the path to the part
|
91
|
+
# of the +instance+ passed to JTD::validate that was rejected.
|
92
|
+
#
|
93
|
+
# * +schema_path+ is an array of strings. It represents the path to the part
|
94
|
+
# of the +schema+ passed to JTD::validate that rejected the instance at
|
95
|
+
# +instance_path+.
|
96
|
+
class ValidationError < Struct.new(:instance_path, :schema_path)
|
97
|
+
|
98
|
+
# Constructs a new ValidationError from the standard JSON representation of
|
99
|
+
# a validation error in JSON Type Definition.
|
100
|
+
def self.from_hash(hash)
|
101
|
+
instance_path = hash['instancePath']
|
102
|
+
schema_path = hash['schemaPath']
|
103
|
+
|
104
|
+
ValidationError.new(instance_path, schema_path)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Error raised from JTD::validate if the number of references followed exceeds
|
109
|
+
# ValidationOptions#max_depth.
|
110
|
+
class MaxDepthExceededError < StandardError
|
111
|
+
# Constructs a new MaxDepthExceededError.
|
112
|
+
def initialize(msg = 'max depth exceeded during JTD::validate')
|
113
|
+
super
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
class ValidationState # :nodoc:
|
120
|
+
attr_accessor :options, :root_schema, :instance_tokens, :schema_tokens, :errors
|
121
|
+
|
122
|
+
def push_instance_token(token)
|
123
|
+
instance_tokens << token
|
124
|
+
end
|
125
|
+
|
126
|
+
def pop_instance_token
|
127
|
+
instance_tokens.pop
|
128
|
+
end
|
129
|
+
|
130
|
+
def push_schema_token(token)
|
131
|
+
schema_tokens.last << token
|
132
|
+
end
|
133
|
+
|
134
|
+
def pop_schema_token
|
135
|
+
schema_tokens.last.pop
|
136
|
+
end
|
137
|
+
|
138
|
+
def push_error
|
139
|
+
errors << ValidationError.new(instance_tokens.clone, schema_tokens.last.clone)
|
140
|
+
|
141
|
+
raise MaxErrorsReachedError.new if errors.size == options.max_errors
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
private_constant :ValidationState
|
146
|
+
|
147
|
+
class MaxErrorsReachedError < StandardError # :nodoc:
|
148
|
+
end
|
149
|
+
|
150
|
+
private_constant :MaxErrorsReachedError
|
151
|
+
|
152
|
+
def self.validate_with_state(state, schema, instance, parent_tag = nil)
|
153
|
+
return if schema.nullable && instance.nil?
|
154
|
+
|
155
|
+
case schema.form
|
156
|
+
when :ref
|
157
|
+
state.schema_tokens << ['definitions', schema.ref]
|
158
|
+
raise MaxDepthExceededError.new if state.schema_tokens.length == state.options.max_depth
|
159
|
+
|
160
|
+
validate_with_state(state, state.root_schema.definitions[schema.ref], instance)
|
161
|
+
state.schema_tokens.pop
|
162
|
+
|
163
|
+
when :type
|
164
|
+
state.push_schema_token('type')
|
165
|
+
|
166
|
+
case schema.type
|
167
|
+
when 'boolean'
|
168
|
+
state.push_error unless instance == true || instance == false
|
169
|
+
when 'float32', 'float64'
|
170
|
+
state.push_error unless instance.is_a?(Numeric)
|
171
|
+
when 'int8'
|
172
|
+
validate_int(state, instance, -128, 127)
|
173
|
+
when 'uint8'
|
174
|
+
validate_int(state, instance, 0, 255)
|
175
|
+
when 'int16'
|
176
|
+
validate_int(state, instance, -32_768, 32_767)
|
177
|
+
when 'uint16'
|
178
|
+
validate_int(state, instance, 0, 65_535)
|
179
|
+
when 'int32'
|
180
|
+
validate_int(state, instance, -2_147_483_648, 2_147_483_647)
|
181
|
+
when 'uint32'
|
182
|
+
validate_int(state, instance, 0, 4_294_967_295)
|
183
|
+
when 'string'
|
184
|
+
state.push_error unless instance.is_a?(String)
|
185
|
+
when 'timestamp'
|
186
|
+
begin
|
187
|
+
DateTime.rfc3339(instance)
|
188
|
+
rescue TypeError, ArgumentError
|
189
|
+
state.push_error
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
state.pop_schema_token
|
194
|
+
|
195
|
+
when :enum
|
196
|
+
state.push_schema_token('enum')
|
197
|
+
state.push_error unless schema.enum.include?(instance)
|
198
|
+
state.pop_schema_token
|
199
|
+
|
200
|
+
when :elements
|
201
|
+
state.push_schema_token('elements')
|
202
|
+
|
203
|
+
if instance.is_a?(Array)
|
204
|
+
instance.each_with_index do |sub_instance, index|
|
205
|
+
state.push_instance_token(index.to_s)
|
206
|
+
validate_with_state(state, schema.elements, sub_instance)
|
207
|
+
state.pop_instance_token
|
208
|
+
end
|
209
|
+
else
|
210
|
+
state.push_error
|
211
|
+
end
|
212
|
+
|
213
|
+
state.pop_schema_token
|
214
|
+
|
215
|
+
when :properties
|
216
|
+
# The properties form is a little weird. The JSON Typedef spec always
|
217
|
+
# works out so that the schema path points to a part of the schema that
|
218
|
+
# really exists, and there's no guarantee that a schema of the properties
|
219
|
+
# form has the properties keyword.
|
220
|
+
#
|
221
|
+
# To deal with this, we handle the "instance isn't even an object" case
|
222
|
+
# separately.
|
223
|
+
unless instance.is_a?(Hash)
|
224
|
+
if schema.properties
|
225
|
+
state.push_schema_token('properties')
|
226
|
+
else
|
227
|
+
state.push_schema_token('optionalProperties')
|
228
|
+
end
|
229
|
+
|
230
|
+
state.push_error
|
231
|
+
state.pop_schema_token
|
232
|
+
|
233
|
+
return
|
234
|
+
end
|
235
|
+
|
236
|
+
# Check the required properties.
|
237
|
+
if schema.properties
|
238
|
+
state.push_schema_token('properties')
|
239
|
+
|
240
|
+
schema.properties.each do |property, sub_schema|
|
241
|
+
state.push_schema_token(property)
|
242
|
+
|
243
|
+
if instance.key?(property)
|
244
|
+
state.push_instance_token(property)
|
245
|
+
validate_with_state(state, sub_schema, instance[property])
|
246
|
+
state.pop_instance_token
|
247
|
+
else
|
248
|
+
state.push_error
|
249
|
+
end
|
250
|
+
|
251
|
+
state.pop_schema_token
|
252
|
+
end
|
253
|
+
|
254
|
+
state.pop_schema_token
|
255
|
+
end
|
256
|
+
|
257
|
+
# Check the optional properties. This is almost identical to the previous
|
258
|
+
# case, except we don't raise an error if the property isn't present on
|
259
|
+
# the instance.
|
260
|
+
if schema.optional_properties
|
261
|
+
state.push_schema_token('optionalProperties')
|
262
|
+
|
263
|
+
schema.optional_properties.each do |property, sub_schema|
|
264
|
+
state.push_schema_token(property)
|
265
|
+
|
266
|
+
if instance.key?(property)
|
267
|
+
state.push_instance_token(property)
|
268
|
+
validate_with_state(state, sub_schema, instance[property])
|
269
|
+
state.pop_instance_token
|
270
|
+
end
|
271
|
+
|
272
|
+
state.pop_schema_token
|
273
|
+
end
|
274
|
+
|
275
|
+
state.pop_schema_token
|
276
|
+
end
|
277
|
+
|
278
|
+
# Check for unallowed additional properties.
|
279
|
+
unless schema.additional_properties
|
280
|
+
properties = (schema.properties || {}).keys
|
281
|
+
optional_properties = (schema.optional_properties || {}).keys
|
282
|
+
parent_tags = [parent_tag]
|
283
|
+
|
284
|
+
additional_keys = instance.keys - properties - optional_properties - parent_tags
|
285
|
+
additional_keys.each do |property|
|
286
|
+
state.push_instance_token(property)
|
287
|
+
state.push_error
|
288
|
+
state.pop_instance_token
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
when :values
|
293
|
+
state.push_schema_token('values')
|
294
|
+
|
295
|
+
if instance.is_a?(Hash)
|
296
|
+
instance.each do |property, sub_instance|
|
297
|
+
state.push_instance_token(property)
|
298
|
+
validate_with_state(state, schema.values, sub_instance)
|
299
|
+
state.pop_instance_token
|
300
|
+
end
|
301
|
+
else
|
302
|
+
state.push_error
|
303
|
+
end
|
304
|
+
|
305
|
+
state.pop_schema_token
|
306
|
+
|
307
|
+
when :discriminator
|
308
|
+
unless instance.is_a?(Hash)
|
309
|
+
state.push_schema_token('discriminator')
|
310
|
+
state.push_error
|
311
|
+
state.pop_schema_token
|
312
|
+
|
313
|
+
return
|
314
|
+
end
|
315
|
+
|
316
|
+
unless instance.key?(schema.discriminator)
|
317
|
+
state.push_schema_token('discriminator')
|
318
|
+
state.push_error
|
319
|
+
state.pop_schema_token
|
320
|
+
|
321
|
+
return
|
322
|
+
end
|
323
|
+
|
324
|
+
unless instance[schema.discriminator].is_a?(String)
|
325
|
+
state.push_schema_token('discriminator')
|
326
|
+
state.push_instance_token(schema.discriminator)
|
327
|
+
state.push_error
|
328
|
+
state.pop_instance_token
|
329
|
+
state.pop_schema_token
|
330
|
+
|
331
|
+
return
|
332
|
+
end
|
333
|
+
|
334
|
+
unless schema.mapping.key?(instance[schema.discriminator])
|
335
|
+
state.push_schema_token('mapping')
|
336
|
+
state.push_instance_token(schema.discriminator)
|
337
|
+
state.push_error
|
338
|
+
state.pop_instance_token
|
339
|
+
state.pop_schema_token
|
340
|
+
|
341
|
+
return
|
342
|
+
end
|
343
|
+
|
344
|
+
sub_schema = schema.mapping[instance[schema.discriminator]]
|
345
|
+
|
346
|
+
state.push_schema_token('mapping')
|
347
|
+
state.push_schema_token(instance[schema.discriminator])
|
348
|
+
validate_with_state(state, sub_schema, instance, schema.discriminator)
|
349
|
+
state.pop_schema_token
|
350
|
+
state.pop_schema_token
|
351
|
+
end
|
352
|
+
end
|
353
|
+
|
354
|
+
def self.validate_int(state, instance, min, max)
|
355
|
+
if instance.is_a?(Numeric)
|
356
|
+
if instance.modulo(1).nonzero? || instance < min || instance > max
|
357
|
+
state.push_error
|
358
|
+
end
|
359
|
+
else
|
360
|
+
state.push_error
|
361
|
+
end
|
362
|
+
end
|
363
|
+
end
|
data/lib/jtd/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jtd
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.6
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ulysse Carion
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-09-
|
11
|
+
date: 2020-09-21 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description:
|
14
14
|
email:
|
@@ -32,6 +32,8 @@ files:
|
|
32
32
|
- bin/setup
|
33
33
|
- jtd.gemspec
|
34
34
|
- lib/jtd.rb
|
35
|
+
- lib/jtd/schema.rb
|
36
|
+
- lib/jtd/validate.rb
|
35
37
|
- lib/jtd/version.rb
|
36
38
|
homepage: https://github.com/jsontypedef/json-typedef-ruby
|
37
39
|
licenses:
|