jtd 0.1.1 → 0.1.6
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/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
|
+
[](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:
|