jtd 0.1.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38fab09375c0357e0c329bca34011de7f04eb7da7af137eea1c986534b47b2c5
4
- data.tar.gz: b99fee5bda367e455c45b530ae828ed429d7b6fde00348c246d555139c42bab1
3
+ metadata.gz: f28bef81d86fe4efad4f6e1398ce5b1d7d462de7e99590297b14a7a92bf2b40d
4
+ data.tar.gz: 8f25271a45816681d57d13dcb1cd8436cfc93d5a16aeadfa7a1946b199bc489f
5
5
  SHA512:
6
- metadata.gz: c0da77011abe60b2fdd3ee667c057a7218c8494342063202ff11489417fd04a2fe104126d0c5e96f984b95e0ed86453fdd3df24355e0efe372d6efe09d293941
7
- data.tar.gz: 98c043d9b4bcd4943b5e11d8767f02125fb7754ec026d782210a937a0b0a60232e376d87e722f7b5045fe91965bda21eedd631b5877c77bd4b2830f90b822696
6
+ metadata.gz: 0bcc179000832d0d22833fec826df648f1b40d2cbc8610d138a479144260fedae3c507a28aa986ef9392be6a777f871607bf27bb99bbaee8486ca6f5538d7e55
7
+ data.tar.gz: 8749c6e7e26072097069e4977b7b817210a2f70815f4a6706d728ae4b629169429856eaa0c24ec11dcee171e196b4effcc1ce2185c9ae028eb6da24a2d5df9d6
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- jtd (0.1.1)
4
+ jtd (0.1.2)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/lib/jtd.rb CHANGED
@@ -1,6 +1,6 @@
1
- require "jtd/version"
1
+ require 'jtd/schema'
2
+ require 'jtd/validate'
3
+ require 'jtd/version'
2
4
 
3
5
  module JTD
4
- class Error < StandardError; end
5
- # Your code goes here...
6
6
  end
@@ -0,0 +1,258 @@
1
+ module JTD
2
+ class Schema
3
+ attr_accessor *%i[
4
+ metadata
5
+ nullable
6
+ definitions
7
+ ref
8
+ type
9
+ enum
10
+ elements
11
+ properties
12
+ optional_properties
13
+ additional_properties
14
+ values
15
+ discriminator
16
+ mapping
17
+ ]
18
+
19
+ def self.from_hash(hash)
20
+ # Raising this error early makes for a much clearer error for the
21
+ # relatively common case of something that was expected to be an object
22
+ # (Hash), but was something else instead.
23
+ raise TypeError.new("expected hash, got: #{hash}") unless hash.is_a?(Hash)
24
+
25
+ illegal_keywords = hash.keys - KEYWORDS
26
+ unless illegal_keywords.empty?
27
+ raise TypeError.new("illegal schema keywords: #{illegal_keywords}")
28
+ end
29
+
30
+ s = Schema.new
31
+
32
+ if hash['metadata']
33
+ s.metadata = hash['metadata']
34
+ end
35
+
36
+ unless hash['nullable'].nil?
37
+ s.nullable = hash['nullable']
38
+ end
39
+
40
+ if hash['definitions']
41
+ s.definitions = Hash[hash['definitions'].map { |k, v| [k, from_hash(v) ]}]
42
+ end
43
+
44
+ s.ref = hash['ref']
45
+ s.type = hash['type']
46
+ s.enum = hash['enum']
47
+
48
+ if hash['elements']
49
+ s.elements = from_hash(hash['elements'])
50
+ end
51
+
52
+ if hash['properties']
53
+ s.properties = Hash[hash['properties'].map { |k, v| [k, from_hash(v) ]}]
54
+ end
55
+
56
+ if hash['optionalProperties']
57
+ s.optional_properties = Hash[hash['optionalProperties'].map { |k, v| [k, from_hash(v) ]}]
58
+ end
59
+
60
+ unless hash['additionalProperties'].nil?
61
+ s.additional_properties = hash['additionalProperties']
62
+ end
63
+
64
+ if hash['values']
65
+ s.values = from_hash(hash['values'])
66
+ end
67
+
68
+ s.discriminator = hash['discriminator']
69
+
70
+ if hash['mapping']
71
+ s.mapping = Hash[hash['mapping'].map { |k, v| [k, from_hash(v) ]}]
72
+ end
73
+
74
+ s
75
+ end
76
+
77
+ def verify(root = self)
78
+ self.check_type('metadata', [Hash])
79
+ self.check_type('nullable', [TrueClass, FalseClass])
80
+ self.check_type('definitions', [Hash])
81
+ self.check_type('ref', [String])
82
+ self.check_type('type', [String])
83
+ self.check_type('enum', [Array])
84
+ self.check_type('elements', [Schema])
85
+ self.check_type('properties', [Hash])
86
+ self.check_type('optional_properties', [Hash])
87
+ self.check_type('additional_properties', [TrueClass, FalseClass])
88
+ self.check_type('values', [Schema])
89
+ self.check_type('discriminator', [String])
90
+ self.check_type('mapping', [Hash])
91
+
92
+ form_signature = [
93
+ !!ref,
94
+ !!type,
95
+ !!enum,
96
+ !!elements,
97
+ !!properties,
98
+ !!optional_properties,
99
+ !!additional_properties,
100
+ !!values,
101
+ !!discriminator,
102
+ !!mapping,
103
+ ]
104
+
105
+ unless VALID_FORMS.include?(form_signature)
106
+ raise ArgumentError.new("invalid schema form: #{self}")
107
+ end
108
+
109
+ if root != self && definitions && definitions.any?
110
+ raise ArgumentError.new("non-root definitions: #{definitions}")
111
+ end
112
+
113
+ if ref
114
+ if !root.definitions || !root.definitions.key?(ref)
115
+ raise ArgumentError.new("ref to non-existent definition: #{ref}")
116
+ end
117
+ end
118
+
119
+ if type && !TYPES.include?(type)
120
+ raise ArgumentError.new("invalid type: #{type}")
121
+ end
122
+
123
+ if enum
124
+ if enum.empty?
125
+ raise ArgumentError.new("enum must not be empty: #{self}")
126
+ end
127
+
128
+ if enum.any? { |v| !v.is_a?(String) }
129
+ raise ArgumentError.new("enum must contain only strings: #{enum}")
130
+ end
131
+
132
+ if enum.size != enum.uniq.size
133
+ raise ArgumentError.new("enum must not contain duplicates: #{enum}")
134
+ end
135
+ end
136
+
137
+ if properties && optional_properties
138
+ shared_keys = properties.keys & optional_properties.keys
139
+ if shared_keys.any?
140
+ raise ArgumentError.new("properties and optional_properties share keys: #{shared_keys}")
141
+ end
142
+ end
143
+
144
+ if mapping
145
+ mapping.values.each do |s|
146
+ if s.form != :properties
147
+ raise ArgumentError.new("mapping values must be of properties form: #{s}")
148
+ end
149
+
150
+ if s.nullable
151
+ raise ArgumentError.new("mapping values must not be nullable: #{s}")
152
+ end
153
+
154
+ contains_discriminator = ArgumentError.new("mapping values must not contain discriminator (#{discriminator}): #{s}")
155
+
156
+ if s.properties && s.properties.key?(discriminator)
157
+ raise contains_discriminator
158
+ end
159
+
160
+ if s.optional_properties && s.optional_properties.key?(discriminator)
161
+ raise contains_discriminator
162
+ end
163
+ end
164
+ end
165
+
166
+ definitions.values.each { |s| s.verify(root) } if definitions
167
+ elements.verify(root) if elements
168
+ properties.values.each { |s| s.verify(root) } if properties
169
+ optional_properties.values.each { |s| s.verify(root) } if optional_properties
170
+ values.verify(root) if values
171
+ mapping.values.each { |s| s.verify(root) } if mapping
172
+
173
+ self
174
+ end
175
+
176
+ def form
177
+ return :ref if ref
178
+ return :type if type
179
+ return :enum if enum
180
+ return :elements if elements
181
+ return :properties if properties || optional_properties
182
+ return :values if values
183
+ return :discriminator if discriminator
184
+
185
+ :empty
186
+ end
187
+
188
+ private
189
+
190
+ KEYWORDS = %w[
191
+ metadata
192
+ nullable
193
+ definitions
194
+ ref
195
+ type
196
+ enum
197
+ elements
198
+ properties
199
+ optionalProperties
200
+ additionalProperties
201
+ values
202
+ discriminator
203
+ mapping
204
+ ]
205
+
206
+ private_constant :KEYWORDS
207
+
208
+ TYPES = %w[
209
+ boolean
210
+ int8
211
+ uint8
212
+ int16
213
+ uint16
214
+ int32
215
+ uint32
216
+ float32
217
+ float64
218
+ string
219
+ timestamp
220
+ ]
221
+
222
+ private_constant :TYPES
223
+
224
+ VALID_FORMS = [
225
+ # Empty form
226
+ [false, false, false, false, false, false, false, false, false, false],
227
+ # Ref form
228
+ [true, false, false, false, false, false, false, false, false, false],
229
+ # Type form
230
+ [false, true, false, false, false, false, false, false, false, false],
231
+ # Enum form
232
+ [false, false, true, false, false, false, false, false, false, false],
233
+ # Elements form
234
+ [false, false, false, true, false, false, false, false, false, false],
235
+ # Properties form -- properties or optional properties or both, and
236
+ # never additional properties on its own
237
+ [false, false, false, false, true, false, false, false, false, false],
238
+ [false, false, false, false, false, true, false, false, false, false],
239
+ [false, false, false, false, true, true, false, false, false, false],
240
+ [false, false, false, false, true, false, true, false, false, false],
241
+ [false, false, false, false, false, true, true, false, false, false],
242
+ [false, false, false, false, true, true, true, false, false, false],
243
+ # Values form
244
+ [false, false, false, false, false, false, false, true, false, false],
245
+ # Discriminator form
246
+ [false, false, false, false, false, false, false, false, true, true],
247
+ ]
248
+
249
+ private_constant :VALID_FORMS
250
+
251
+ def check_type(key, classes)
252
+ val = self.send(key)
253
+ unless val.nil? || classes.include?(val.class)
254
+ raise TypeError.new("#{key} must be one of #{classes}, got: #{val}")
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,293 @@
1
+ require 'time'
2
+
3
+ module JTD
4
+ def self.validate(schema, instance, options = ValidationOptions.new)
5
+ state = ValidationState.new
6
+ state.options = options
7
+ state.root_schema = schema
8
+ state.instance_tokens = []
9
+ state.schema_tokens = [[]]
10
+ state.errors = []
11
+
12
+ begin
13
+ validate_with_state(state, schema, instance)
14
+ rescue MaxErrorsReachedError
15
+ # This is just a dummy error to immediately stop validation. We swallow
16
+ # the error here, and return the abridged set of errors.
17
+ end
18
+
19
+ state.errors
20
+ end
21
+
22
+ class ValidationOptions
23
+ attr_accessor :max_depth, :max_errors
24
+
25
+ def initialize(max_depth: 0, max_errors: 0)
26
+ @max_depth = max_depth
27
+ @max_errors = max_errors
28
+ end
29
+ end
30
+
31
+ class ValidationError < Struct.new(:instance_path, :schema_path)
32
+ def self.from_hash(hash)
33
+ instance_path = hash['instancePath']
34
+ schema_path = hash['schemaPath']
35
+
36
+ ValidationError.new(instance_path, schema_path)
37
+ end
38
+ end
39
+
40
+ class MaxDepthExceededError < StandardError
41
+ def initialize(msg = 'max depth exceeded during JTD::validate')
42
+ super
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ class ValidationState
49
+ attr_accessor :options, :root_schema, :instance_tokens, :schema_tokens, :errors
50
+
51
+ def push_instance_token(token)
52
+ instance_tokens << token
53
+ end
54
+
55
+ def pop_instance_token
56
+ instance_tokens.pop
57
+ end
58
+
59
+ def push_schema_token(token)
60
+ schema_tokens.last << token
61
+ end
62
+
63
+ def pop_schema_token
64
+ schema_tokens.last.pop
65
+ end
66
+
67
+ def push_error
68
+ errors << ValidationError.new(instance_tokens.clone, schema_tokens.last.clone)
69
+
70
+ raise MaxErrorsReachedError.new if errors.size == options.max_errors
71
+ end
72
+ end
73
+
74
+ private_constant :ValidationState
75
+
76
+ class MaxErrorsReachedError < StandardError
77
+ end
78
+
79
+ private_constant :MaxErrorsReachedError
80
+
81
+ def self.validate_with_state(state, schema, instance, parent_tag = nil)
82
+ return if schema.nullable && instance.nil?
83
+
84
+ case schema.form
85
+ when :ref
86
+ state.schema_tokens << ['definitions', schema.ref]
87
+ p state.schema_tokens.length, state.options, state.options.max_depth
88
+ raise MaxDepthExceededError.new if state.schema_tokens.length == state.options.max_depth
89
+
90
+ validate_with_state(state, state.root_schema.definitions[schema.ref], instance)
91
+ state.schema_tokens.pop
92
+
93
+ when :type
94
+ state.push_schema_token('type')
95
+
96
+ case schema.type
97
+ when 'boolean'
98
+ state.push_error unless instance == true || instance == false
99
+ when 'float32', 'float64'
100
+ state.push_error unless instance.is_a?(Numeric)
101
+ when 'int8'
102
+ validate_int(state, instance, -128, 127)
103
+ when 'uint8'
104
+ validate_int(state, instance, 0, 255)
105
+ when 'int16'
106
+ validate_int(state, instance, -32_768, 32_767)
107
+ when 'uint16'
108
+ validate_int(state, instance, 0, 65_535)
109
+ when 'int32'
110
+ validate_int(state, instance, -2_147_483_648, 2_147_483_647)
111
+ when 'uint32'
112
+ validate_int(state, instance, 0, 4_294_967_295)
113
+ when 'string'
114
+ state.push_error unless instance.is_a?(String)
115
+ when 'timestamp'
116
+ begin
117
+ DateTime.rfc3339(instance)
118
+ rescue TypeError, ArgumentError
119
+ state.push_error
120
+ end
121
+ end
122
+
123
+ state.pop_schema_token
124
+
125
+ when :enum
126
+ state.push_schema_token('enum')
127
+ state.push_error unless schema.enum.include?(instance)
128
+ state.pop_schema_token
129
+
130
+ when :elements
131
+ state.push_schema_token('elements')
132
+
133
+ if instance.is_a?(Array)
134
+ instance.each_with_index do |sub_instance, index|
135
+ state.push_instance_token(index.to_s)
136
+ validate_with_state(state, schema.elements, sub_instance)
137
+ state.pop_instance_token
138
+ end
139
+ else
140
+ state.push_error
141
+ end
142
+
143
+ state.pop_schema_token
144
+
145
+ when :properties
146
+ # The properties form is a little weird. The JSON Typedef spec always
147
+ # works out so that the schema path points to a part of the schema that
148
+ # really exists, and there's no guarantee that a schema of the properties
149
+ # form has the properties keyword.
150
+ #
151
+ # To deal with this, we handle the "instance isn't even an object" case
152
+ # separately.
153
+ unless instance.is_a?(Hash)
154
+ if schema.properties
155
+ state.push_schema_token('properties')
156
+ else
157
+ state.push_schema_token('optionalProperties')
158
+ end
159
+
160
+ state.push_error
161
+ state.pop_schema_token
162
+
163
+ return
164
+ end
165
+
166
+ # Check the required properties.
167
+ if schema.properties
168
+ state.push_schema_token('properties')
169
+
170
+ schema.properties.each do |property, sub_schema|
171
+ state.push_schema_token(property)
172
+
173
+ if instance.key?(property)
174
+ state.push_instance_token(property)
175
+ validate_with_state(state, sub_schema, instance[property])
176
+ state.pop_instance_token
177
+ else
178
+ state.push_error
179
+ end
180
+
181
+ state.pop_schema_token
182
+ end
183
+
184
+ state.pop_schema_token
185
+ end
186
+
187
+ # Check the optional properties. This is almost identical to the previous
188
+ # case, except we don't raise an error if the property isn't present on
189
+ # the instance.
190
+ if schema.optional_properties
191
+ state.push_schema_token('optionalProperties')
192
+
193
+ schema.optional_properties.each do |property, sub_schema|
194
+ state.push_schema_token(property)
195
+
196
+ if instance.key?(property)
197
+ state.push_instance_token(property)
198
+ validate_with_state(state, sub_schema, instance[property])
199
+ state.pop_instance_token
200
+ end
201
+
202
+ state.pop_schema_token
203
+ end
204
+
205
+ state.pop_schema_token
206
+ end
207
+
208
+ # Check for unallowed additional properties.
209
+ unless schema.additional_properties
210
+ properties = (schema.properties || {}).keys
211
+ optional_properties = (schema.optional_properties || {}).keys
212
+ parent_tags = [parent_tag]
213
+
214
+ additional_keys = instance.keys - properties - optional_properties - parent_tags
215
+ additional_keys.each do |property|
216
+ state.push_instance_token(property)
217
+ state.push_error
218
+ state.pop_instance_token
219
+ end
220
+ end
221
+
222
+ when :values
223
+ state.push_schema_token('values')
224
+
225
+ if instance.is_a?(Hash)
226
+ instance.each do |property, sub_instance|
227
+ state.push_instance_token(property)
228
+ validate_with_state(state, schema.values, sub_instance)
229
+ state.pop_instance_token
230
+ end
231
+ else
232
+ state.push_error
233
+ end
234
+
235
+ state.pop_schema_token
236
+
237
+ when :discriminator
238
+ unless instance.is_a?(Hash)
239
+ state.push_schema_token('discriminator')
240
+ state.push_error
241
+ state.pop_schema_token
242
+
243
+ return
244
+ end
245
+
246
+ unless instance.key?(schema.discriminator)
247
+ state.push_schema_token('discriminator')
248
+ state.push_error
249
+ state.pop_schema_token
250
+
251
+ return
252
+ end
253
+
254
+ unless instance[schema.discriminator].is_a?(String)
255
+ state.push_schema_token('discriminator')
256
+ state.push_instance_token(schema.discriminator)
257
+ state.push_error
258
+ state.pop_instance_token
259
+ state.pop_schema_token
260
+
261
+ return
262
+ end
263
+
264
+ unless schema.mapping.key?(instance[schema.discriminator])
265
+ state.push_schema_token('mapping')
266
+ state.push_instance_token(schema.discriminator)
267
+ state.push_error
268
+ state.pop_instance_token
269
+ state.pop_schema_token
270
+
271
+ return
272
+ end
273
+
274
+ sub_schema = schema.mapping[instance[schema.discriminator]]
275
+
276
+ state.push_schema_token('mapping')
277
+ state.push_schema_token(instance[schema.discriminator])
278
+ validate_with_state(state, sub_schema, instance, schema.discriminator)
279
+ state.pop_schema_token
280
+ state.pop_schema_token
281
+ end
282
+ end
283
+
284
+ def self.validate_int(state, instance, min, max)
285
+ if instance.is_a?(Numeric)
286
+ if instance.modulo(1).nonzero? || instance < min || instance > max
287
+ state.push_error
288
+ end
289
+ else
290
+ state.push_error
291
+ end
292
+ end
293
+ end
@@ -1,3 +1,3 @@
1
1
  module JTD
2
- VERSION = "0.1.1"
2
+ VERSION = "0.1.2"
3
3
  end
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.1
4
+ version: 0.1.2
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-18 00:00:00.000000000 Z
11
+ date: 2020-09-19 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: