autoparse 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,422 @@
1
+ # Copyright 2010 Google Inc
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+
16
+ require 'json'
17
+ require 'time'
18
+ require 'autoparse/inflection'
19
+ require 'addressable/uri'
20
+
21
+ module AutoParse
22
+ class Instance
23
+ def self.uri
24
+ return @uri ||= nil
25
+ end
26
+
27
+ def self.properties
28
+ return @properties ||= {}
29
+ end
30
+
31
+ def self.additional_properties_schema
32
+ return EMPTY_SCHEMA
33
+ end
34
+
35
+ def self.property_dependencies
36
+ return @property_dependencies ||= {}
37
+ end
38
+
39
+ def self.data
40
+ return @schema_data
41
+ end
42
+
43
+ def self.description
44
+ return @schema_data['description']
45
+ end
46
+
47
+ def self.validate_string_property(property_value, schema_data)
48
+ property_value = property_value.to_str rescue property_value
49
+ if !property_value.kind_of?(String)
50
+ return false
51
+ else
52
+ # TODO: implement more than type-checking
53
+ return true
54
+ end
55
+ end
56
+
57
+ def self.define_string_property(property_name, key, schema_data)
58
+ define_method(property_name) do
59
+ value = self[key] || schema_data['default']
60
+ if value != nil
61
+ if schema_data['format'] == 'byte'
62
+ Base64.decode64(value)
63
+ elsif schema_data['format'] == 'date-time'
64
+ Time.parse(value)
65
+ elsif schema_data['format'] == 'url'
66
+ Addressable::URI.parse(value)
67
+ elsif schema_data['format'] =~ /^u?int(32|64)$/
68
+ value.to_i
69
+ else
70
+ value
71
+ end
72
+ else
73
+ nil
74
+ end
75
+ end
76
+ define_method(property_name + '=') do |value|
77
+ if schema_data['format'] == 'byte'
78
+ self[key] = Base64.encode64(value)
79
+ elsif schema_data['format'] == 'date-time'
80
+ if value.respond_to?(:to_str)
81
+ value = Time.parse(value.to_str)
82
+ elsif !value.respond_to?(:xmlschema)
83
+ raise TypeError,
84
+ "Could not obtain RFC 3339 timestamp from #{value.class}."
85
+ end
86
+ self[key] = value.xmlschema
87
+ elsif schema_data['format'] == 'url'
88
+ # This effectively does limited URI validation.
89
+ self[key] = Addressable::URI.parse(value).to_str
90
+ elsif schema_data['format'] =~ /^u?int(32|64)$/
91
+ self[key] = value.to_s
92
+ elsif value.respond_to?(:to_str)
93
+ self[key] = value.to_str
94
+ elsif value.kind_of?(Symbol)
95
+ self[key] = value.to_s
96
+ else
97
+ raise TypeError,
98
+ "Expected String or Symbol, got #{value.class}."
99
+ end
100
+ end
101
+ end
102
+
103
+ def self.define_boolean_property(property_name, key, schema_data)
104
+ define_method(property_name) do
105
+ value = self[key] || schema_data['default']
106
+ case value.to_s.downcase
107
+ when 'true', 'yes', 'y', 'on', '1'
108
+ true
109
+ when 'false', 'no', 'n', 'off', '0'
110
+ false
111
+ when 'nil', 'null'
112
+ nil
113
+ else
114
+ raise TypeError,
115
+ "Expected boolean, got #{value.class}."
116
+ end
117
+ end
118
+ define_method(property_name + '=') do |value|
119
+ case value.to_s.downcase
120
+ when 'true', 'yes', 'y', 'on', '1'
121
+ self[key] = true
122
+ when 'false', 'no', 'n', 'off', '0'
123
+ self[key] = false
124
+ when 'nil', 'null'
125
+ self[key] = nil
126
+ else
127
+ raise TypeError, "Expected boolean, got #{value.class}."
128
+ end
129
+ end
130
+ end
131
+
132
+ def self.validate_number_property(property_value, schema_data)
133
+ return false if !property_value.kind_of?(Numeric)
134
+ # TODO: implement more than type-checking
135
+ return true
136
+ end
137
+
138
+ def self.define_number_property(property_name, key, schema_data)
139
+ define_method(property_name) do
140
+ Float(self[key] || schema_data['default'])
141
+ end
142
+ define_method(property_name + '=') do |value|
143
+ if value == nil
144
+ self[key] = value
145
+ else
146
+ self[key] = Float(value)
147
+ end
148
+ end
149
+ end
150
+
151
+ def self.validate_integer_property(property_value, schema_data)
152
+ return false if !property_value.kind_of?(Integer)
153
+ if schema_data['minimum'] && schema_data['exclusiveMinimum']
154
+ return false if property_value <= schema_data['minimum']
155
+ elsif schema_data['minimum']
156
+ return false if property_value < schema_data['minimum']
157
+ end
158
+ if schema_data['maximum'] && schema_data['exclusiveMaximum']
159
+ return false if property_value >= schema_data['maximum']
160
+ elsif schema_data['maximum']
161
+ return false if property_value > schema_data['maximum']
162
+ end
163
+ return true
164
+ end
165
+
166
+ def self.define_integer_property(property_name, key, schema_data)
167
+ define_method(property_name) do
168
+ Integer(self[key] || schema_data['default'])
169
+ end
170
+ define_method(property_name + '=') do |value|
171
+ if value == nil
172
+ self[key] = value
173
+ else
174
+ self[key] = Integer(value)
175
+ end
176
+ end
177
+ end
178
+
179
+ def self.validate_array_property(property_value, schema_data)
180
+ if property_value.respond_to?(:to_ary)
181
+ property_value = property_value.to_ary
182
+ else
183
+ return false
184
+ end
185
+ property_value.each do |item_value|
186
+ unless self.validate_property_value(item_value, schema_data['items'])
187
+ return false
188
+ end
189
+ end
190
+ return true
191
+ end
192
+
193
+ def self.define_array_property(property_name, key, schema_data)
194
+ define_method(property_name) do
195
+ # The default value of an empty Array obviates a mutator method.
196
+ value = self[key] || []
197
+ array = if value != nil && !value.respond_to?(:to_ary)
198
+ raise TypeError,
199
+ "Expected Array, got #{value.class}."
200
+ else
201
+ value.to_ary
202
+ end
203
+ if schema_data['items'] && schema_data['items']['$ref']
204
+ schema_name = schema_data['items']['$ref']
205
+ # FIXME: Vestigial bits need to be replaced with a more viable
206
+ # lookup system.
207
+ if AutoParse.schemas[schema_name]
208
+ schema_class = AutoParse.schemas[schema_name]
209
+ array.map! do |item|
210
+ schema_class.new(item)
211
+ end
212
+ else
213
+ raise ArgumentError,
214
+ "Could not find schema: #{schema_uri}."
215
+ end
216
+ end
217
+ array
218
+ end
219
+ end
220
+
221
+ def self.validate_object_property(property_value, schema_data, schema=nil)
222
+ if property_value.kind_of?(Instance)
223
+ return property_value.valid?
224
+ elsif schema != nil && schema.kind_of?(Class)
225
+ return schema.new(property_value).valid?
226
+ else
227
+ # This is highly ineffecient, but hard to avoid given the schema is
228
+ # anonymous.
229
+ schema = AutoParse.generate(schema_data)
230
+ return schema.new(property_value).valid?
231
+ end
232
+ end
233
+
234
+ def self.define_object_property(property_name, key, schema_data)
235
+ # TODO finish this up...
236
+ if schema_data['$ref']
237
+ schema_uri = self.uri + Addressable::URI.parse(schema_data['$ref'])
238
+ schema = AutoParse.schemas[schema_uri]
239
+ if schema == nil
240
+ raise ArgumentError,
241
+ "Could not find schema: #{schema_data['$ref']} " +
242
+ "Referenced schema must be parsed first."
243
+ end
244
+ else
245
+ # Anonymous schema
246
+ schema = AutoParse.generate(schema_data)
247
+ end
248
+ define_method(property_name) do
249
+ schema.new(self[key] || schema_data['default'])
250
+ end
251
+ end
252
+
253
+ def self.define_any_property(property_name, key, schema_data)
254
+ define_method(property_name) do
255
+ self[key] || schema_data['default']
256
+ end
257
+ define_method(property_name + '=') do |value|
258
+ self[key] = value
259
+ end
260
+ end
261
+
262
+ ##
263
+ # @api private
264
+ def self.validate_property_value(property_value, schema_data)
265
+ if property_value == nil && schema_data['required'] == true
266
+ return false
267
+ elsif property_value == nil
268
+ # Value was omitted, but not required. Still valid.
269
+ return true
270
+ end
271
+
272
+ # Verify property values
273
+ if schema_data['$ref']
274
+ schema_uri = self.uri + Addressable::URI.parse(schema_data['$ref'])
275
+ schema = AutoParse.schemas[schema_uri]
276
+ if schema == nil
277
+ raise ArgumentError,
278
+ "Could not find schema: #{schema_data['$ref']} " +
279
+ "Referenced schema must be parsed first."
280
+ end
281
+ schema_data = schema.data
282
+ end
283
+ case schema_data['type']
284
+ when 'string'
285
+ return false unless self.validate_string_property(
286
+ property_value, schema_data
287
+ )
288
+ when 'boolean'
289
+ return false unless self.validate_boolean_property(
290
+ property_value, schema_data
291
+ )
292
+ when 'number'
293
+ return false unless self.validate_number_property(
294
+ property_value, schema_data
295
+ )
296
+ when 'integer'
297
+ return false unless self.validate_integer_property(
298
+ property_value, schema_data
299
+ )
300
+ when 'array'
301
+ return false unless self.validate_array_property(
302
+ property_value, schema_data
303
+ )
304
+ when 'object'
305
+ return false unless self.validate_object_property(
306
+ property_value, schema_data
307
+ )
308
+ else
309
+ # Either type 'any' or we don't know what this is,
310
+ # default to anything goes. Validation of an 'any' property always
311
+ # succeeds.
312
+ end
313
+ return true
314
+ end
315
+
316
+ def initialize(data)
317
+ if self.class.data &&
318
+ self.class.data['type'] &&
319
+ self.class.data['type'] != 'object'
320
+ raise TypeError,
321
+ "Only schemas of type 'object' are instantiable."
322
+ end
323
+ if data.respond_to?(:to_hash)
324
+ data = data.to_hash
325
+ elsif data.respond_to?(:to_json)
326
+ data = JSON.parse(data.to_json)
327
+ else
328
+ raise TypeError,
329
+ 'Unable to parse. ' +
330
+ 'Expected data to respond to either :to_hash or :to_json.'
331
+ end
332
+ @data = data
333
+ end
334
+
335
+ def [](key)
336
+ return @data[key]
337
+ end
338
+
339
+ def []=(key, value)
340
+ return @data[key] = value
341
+ end
342
+
343
+ ##
344
+ # Validates the parsed data against the schema.
345
+ def valid?
346
+ unvalidated_fields = @data.keys.dup
347
+ for property_key, property_schema in self.class.properties
348
+ property_value = self[property_key]
349
+ if !self.class.validate_property_value(property_value, property_schema)
350
+ return false
351
+ end
352
+ if property_value == nil && property_schema['required'] != true
353
+ # Value was omitted, but not required. Still valid. Skip dependency
354
+ # checks.
355
+ next
356
+ end
357
+
358
+ # Verify property dependencies
359
+ property_dependencies = self.class.property_dependencies[property_key]
360
+ case property_dependencies
361
+ when String, Array
362
+ property_dependencies = [property_dependencies].flatten
363
+ for dependency_key in property_dependencies
364
+ dependency_value = self[dependency_key]
365
+ return false if dependency_value == nil
366
+ end
367
+ when Class
368
+ if property_dependencies.ancestors.include?(Instance)
369
+ dependency_instance = property_dependencies.new(property_value)
370
+ return false unless dependency_instance.valid?
371
+ else
372
+ raise TypeError,
373
+ "Expected schema Class, got #{property_dependencies.class}."
374
+ end
375
+ end
376
+ end
377
+ if self.class.additional_properties_schema == nil
378
+ # No additional properties allowed
379
+ return false unless unvalidated_fields.empty?
380
+ elsif self.class.additional_properties_schema != EMPTY_SCHEMA
381
+ # Validate all remaining fields against this schema
382
+
383
+ # Make sure tests don't pass prematurely
384
+ return false
385
+ end
386
+ if self.class.superclass && self.class.superclass != Instance &&
387
+ self.class.ancestors.first != Instance
388
+ # The spec actually only defined the 'extends' semantics as children
389
+ # must also validate aainst the parent.
390
+ return false unless self.class.superclass.new(@data).valid?
391
+ end
392
+ return true
393
+ end
394
+
395
+ def to_hash
396
+ return @data
397
+ end
398
+
399
+ def to_json
400
+ return JSON.generate(self.to_hash)
401
+ end
402
+
403
+ ##
404
+ # Returns a <code>String</code> representation of the schema instance.
405
+ #
406
+ # @return [String] The instance's state, as a <code>String</code>.
407
+ def inspect
408
+ if self.class.respond_to?(:description)
409
+ sprintf(
410
+ "#<%s:%#0x DESC:'%s'>",
411
+ self.class.to_s, self.object_id, self.class.description
412
+ )
413
+ else
414
+ sprintf("#<%s:%#0x>", self.class.to_s, self.object_id)
415
+ end
416
+ end
417
+ end
418
+
419
+ ##
420
+ # The empty schema accepts all JSON.
421
+ EMPTY_SCHEMA = Instance
422
+ end
@@ -0,0 +1,26 @@
1
+ # Copyright 2010 Google Inc
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # Used to prevent the class/module from being loaded more than once
16
+ unless defined? AutoParse::VERSION
17
+ module AutoParse
18
+ module VERSION
19
+ MAJOR = 0
20
+ MINOR = 1
21
+ TINY = 0
22
+
23
+ STRING = [MAJOR, MINOR, TINY].join('.')
24
+ end
25
+ end
26
+ end
data/lib/autoparse.rb ADDED
@@ -0,0 +1,203 @@
1
+ # Copyright 2010 Google Inc
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require 'autoparse/instance'
16
+ require 'autoparse/version'
17
+ require 'addressable/uri'
18
+
19
+ module AutoParse
20
+ def self.schemas
21
+ @schemas ||= {}
22
+ end
23
+
24
+ def self.generate(schema_data, uri=nil)
25
+ if schema_data["extends"]
26
+ super_uri = uri + Addressable::URI.parse(schema_data["extends"])
27
+ super_schema = self.schemas[super_uri]
28
+ if super_schema == nil
29
+ raise ArgumentError,
30
+ "Could not find schema to extend: #{schema_data["extends"]} " +
31
+ "Parent schema must be parsed before child schema."
32
+ end
33
+ else
34
+ super_schema = Instance
35
+ end
36
+ schema = Class.new(super_schema) do
37
+ @uri = Addressable::URI.parse(uri)
38
+ @uri.normalize! if @uri != nil
39
+ @schema_data = schema_data
40
+
41
+ def self.additional_properties_schema
42
+ # Override the superclass implementation so we're not always returning
43
+ # the empty schema.
44
+ return @additional_properties_schema
45
+ end
46
+
47
+ (@schema_data['properties'] || []).each do |(k, v)|
48
+ property_key, property_schema = k, v
49
+ property_name = INFLECTOR.underscore(property_key).gsub("-", "_")
50
+ property_super_schema = super_schema.properties[property_key]
51
+ if property_super_schema
52
+ # TODO: Not sure if this should be a recursive merge or not...
53
+ # TODO: Might need to raise an error if a schema is extended in
54
+ # a way that violates the requirement that all child instances also
55
+ # validate against the parent schema.
56
+ property_schema = property_super_schema.merge(property_schema)
57
+ end
58
+ self.properties[property_key] = property_schema
59
+ if property_schema['$ref']
60
+ schema_uri =
61
+ self.uri + Addressable::URI.parse(property_schema['$ref'])
62
+ schema = AutoParse.schemas[schema_uri]
63
+ if schema == nil
64
+ raise ArgumentError,
65
+ "Could not find schema: #{property_schema['$ref']} " +
66
+ "Referenced schema must be parsed first."
67
+ end
68
+ property_schema = schema.data
69
+ end
70
+ case property_schema['type']
71
+ when 'string'
72
+ define_string_property(
73
+ property_name, property_key, property_schema
74
+ )
75
+ when 'boolean'
76
+ define_boolean_property(
77
+ property_name, property_key, property_schema
78
+ )
79
+ when 'number'
80
+ define_number_property(
81
+ property_name, property_key, property_schema
82
+ )
83
+ when 'integer'
84
+ define_integer_property(
85
+ property_name, property_key, property_schema
86
+ )
87
+ when 'array'
88
+ define_array_property(
89
+ property_name, property_key, property_schema
90
+ )
91
+ when 'object'
92
+ define_object_property(
93
+ property_name, property_key, property_schema
94
+ )
95
+ else
96
+ # Either type 'any' or we don't know what this is,
97
+ # default to anything goes.
98
+ define_any_property(
99
+ property_name, property_key, property_schema
100
+ )
101
+ end
102
+ end
103
+
104
+ if schema_data['additionalProperties'] == true ||
105
+ schema_data['additionalProperties'] == nil
106
+ # Schema-less unknown properties are allowed.
107
+ @additional_properties_schema = EMPTY_SCHEMA
108
+ define_method('method_missing') do |method, *params, &block|
109
+ # We need to convert from Ruby calling style to JavaScript calling
110
+ # style. If this fails, attempt to use JavaScript calling style
111
+ # directly.
112
+
113
+ # We can't modify the method in-place because this affects the call
114
+ # to super.
115
+ stripped_method = method.to_s
116
+ assignment = false
117
+ if stripped_method[-1..-1] == '='
118
+ assignment = true
119
+ stripped_method[-1..-1] = ''
120
+ end
121
+ key = INFLECTOR.camelize(stripped_method)
122
+ key[0..0] = key[0..0].downcase
123
+ if self[key] != nil
124
+ value = self[key]
125
+ elsif self[stripped_method] != nil
126
+ key = stripped_method
127
+ value = self[stripped_method]
128
+ else
129
+ # Method not found.
130
+ super
131
+ end
132
+ # If additionalProperties is simply set to true, no parsing takes
133
+ # place and all values are treated as 'any'.
134
+ if assignment
135
+ new_value = params[0]
136
+ self[key] = new_value
137
+ else
138
+ value
139
+ end
140
+ end
141
+
142
+ elsif schema_data['additionalProperties']
143
+ # Unknown properties follow the supplied schema.
144
+ ap_schema = Schema.generate(schema_data['additionalProperties'])
145
+ @additional_properties_schema = ap_schema
146
+ define_method('method_missing') do |method, *params, &block|
147
+ # We need to convert from Ruby calling style to JavaScript calling
148
+ # style. If this fails, attempt to use JavaScript calling style
149
+ # directly.
150
+
151
+ # We can't modify the method in-place because this affects the call
152
+ # to super.
153
+ stripped_method = method.to_s
154
+ assignment = false
155
+ if stripped_method[-1..-1] == '='
156
+ assignment = true
157
+ stripped_method[-1..-1] = ''
158
+ end
159
+ key = INFLECTOR.camelize(stripped_method)
160
+ key[0..0] = key[0..0].downcase
161
+ if self[key] != nil
162
+ value = self[key]
163
+ elsif self[stripped_method] != nil
164
+ key = stripped_method
165
+ value = self[stripped_method]
166
+ else
167
+ # Method not found.
168
+ super
169
+ end
170
+ if assignment
171
+ # In the case of assignment, it's very likely the developer is
172
+ # passing in an unparsed Hash value. This value must be parsed.
173
+ # Unfortunately, we may accidentally reparse something that's
174
+ # already in a parsed state because Schema.new(Schema.new(data))
175
+ # is completely valid. This will cause performance issues if
176
+ # developers are careless, but since there's no good reason to
177
+ # do assignment on parsed objects, hopefully this should not
178
+ # cause problems often.
179
+ new_value = params[0]
180
+ self[key] = ap_schema.new(new_value)
181
+ else
182
+ ap_schema.new(value)
183
+ end
184
+ end
185
+ else
186
+ @additional_properties_schema = nil
187
+ end
188
+
189
+ if schema_data['dependencies']
190
+ for dependency_key, dependency_data in schema_data['dependencies']
191
+ if dependency_data.kind_of?(Hash)
192
+ dependency_data = AutoParse.generate(dependency_data)
193
+ end
194
+ self.property_dependencies[dependency_key] = dependency_data
195
+ end
196
+ end
197
+ end
198
+
199
+ # Register the new schema.
200
+ self.schemas[schema.uri] = schema
201
+ return schema
202
+ end
203
+ end