jtd 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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: