attributor 2.1.0

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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +4 -0
  5. data/CHANGELOG.md +52 -0
  6. data/Gemfile +3 -0
  7. data/Guardfile +12 -0
  8. data/LICENSE +22 -0
  9. data/README.md +62 -0
  10. data/Rakefile +28 -0
  11. data/attributor.gemspec +40 -0
  12. data/lib/attributor.rb +89 -0
  13. data/lib/attributor/attribute.rb +271 -0
  14. data/lib/attributor/attribute_resolver.rb +116 -0
  15. data/lib/attributor/dsl_compiler.rb +106 -0
  16. data/lib/attributor/exceptions.rb +38 -0
  17. data/lib/attributor/extensions/randexp.rb +10 -0
  18. data/lib/attributor/type.rb +117 -0
  19. data/lib/attributor/types/boolean.rb +26 -0
  20. data/lib/attributor/types/collection.rb +135 -0
  21. data/lib/attributor/types/container.rb +42 -0
  22. data/lib/attributor/types/csv.rb +10 -0
  23. data/lib/attributor/types/date_time.rb +36 -0
  24. data/lib/attributor/types/file_upload.rb +11 -0
  25. data/lib/attributor/types/float.rb +27 -0
  26. data/lib/attributor/types/hash.rb +337 -0
  27. data/lib/attributor/types/ids.rb +26 -0
  28. data/lib/attributor/types/integer.rb +63 -0
  29. data/lib/attributor/types/model.rb +316 -0
  30. data/lib/attributor/types/object.rb +19 -0
  31. data/lib/attributor/types/string.rb +25 -0
  32. data/lib/attributor/types/struct.rb +50 -0
  33. data/lib/attributor/types/tempfile.rb +36 -0
  34. data/lib/attributor/version.rb +3 -0
  35. data/spec/attribute_resolver_spec.rb +227 -0
  36. data/spec/attribute_spec.rb +597 -0
  37. data/spec/attributor_spec.rb +25 -0
  38. data/spec/dsl_compiler_spec.rb +130 -0
  39. data/spec/spec_helper.rb +30 -0
  40. data/spec/support/models.rb +81 -0
  41. data/spec/support/types.rb +21 -0
  42. data/spec/type_spec.rb +134 -0
  43. data/spec/types/boolean_spec.rb +85 -0
  44. data/spec/types/collection_spec.rb +286 -0
  45. data/spec/types/container_spec.rb +49 -0
  46. data/spec/types/csv_spec.rb +17 -0
  47. data/spec/types/date_time_spec.rb +90 -0
  48. data/spec/types/file_upload_spec.rb +6 -0
  49. data/spec/types/float_spec.rb +78 -0
  50. data/spec/types/hash_spec.rb +372 -0
  51. data/spec/types/ids_spec.rb +32 -0
  52. data/spec/types/integer_spec.rb +151 -0
  53. data/spec/types/model_spec.rb +401 -0
  54. data/spec/types/string_spec.rb +55 -0
  55. data/spec/types/struct_spec.rb +189 -0
  56. data/spec/types/tempfile_spec.rb +6 -0
  57. metadata +348 -0
@@ -0,0 +1,42 @@
1
+ module Attributor
2
+ module Container
3
+ # Module for types that can contain subtypes. Collection.of(?) or Hash.of(?)
4
+ def self.included(klass)
5
+ klass.module_eval do
6
+ include Attributor::Type
7
+ end
8
+ klass.extend(ClassMethods)
9
+ end
10
+
11
+ module ClassMethods
12
+
13
+ def decode_string(value, context=Attributor::DEFAULT_ROOT_CONTEXT)
14
+ raise "#{self.name}.decode_string is not implemented. (when decoding #{Attributor.humanize_context(context)})"
15
+ end
16
+
17
+ # Decode JSON string that encapsulates an array
18
+ #
19
+ # @param value [String] JSON string
20
+ # @return [Array] a normal Ruby Array
21
+ #
22
+ def decode_json(value, context=Attributor::DEFAULT_ROOT_CONTEXT)
23
+ raise Attributor::DeserializationError, context: context, from: value.class, encoding: "JSON" , value: value unless value.kind_of? ::String
24
+
25
+ # attempt to parse as JSON
26
+ parsed_value = JSON.parse(value)
27
+
28
+ if self.valid_type?(parsed_value)
29
+ value = parsed_value
30
+ else
31
+ raise Attributor::CoercionError, context: context, from: parsed_value.class, to: self.name, value: parsed_value
32
+ end
33
+ return value
34
+
35
+ rescue JSON::JSONError => e
36
+ raise Attributor::DeserializationError, context: context, from: value.class, encoding: "JSON" , value: value
37
+ end
38
+
39
+ end
40
+
41
+ end
42
+ end
@@ -0,0 +1,10 @@
1
+ module Attributor
2
+
3
+ class CSV < Collection
4
+
5
+ def self.decode_string(value,context)
6
+ value.split(',')
7
+ end
8
+
9
+ end
10
+ end
@@ -0,0 +1,36 @@
1
+ # Represents a plain old boolean type. TBD: can be nil?
2
+ #
3
+ require_relative '../exceptions'
4
+ require 'date'
5
+
6
+ module Attributor
7
+
8
+ class DateTime
9
+ include Type
10
+
11
+ def self.native_type
12
+ return ::DateTime
13
+ end
14
+
15
+ def self.example(context=nil, options: {})
16
+ return self.load(/[:date:]/.gen, context)
17
+ end
18
+
19
+ def self.load(value,context=Attributor::DEFAULT_ROOT_CONTEXT, **options)
20
+ # We assume that if the value is already in the right type, we've decoded it already
21
+ return value if value.is_a?(self.native_type)
22
+ return value.to_datetime if value.is_a?(::Time)
23
+ return nil unless value.is_a?(::String)
24
+ # TODO: we should be able to convert not only from String but Time...etc
25
+ # Else, we'll decode it from String.
26
+ begin
27
+ return ::DateTime.parse(value)
28
+ rescue ArgumentError => e
29
+ raise Attributor::DeserializationError, context: context, from: value.class, encoding: "DateTime" , value: value
30
+ end
31
+ end
32
+
33
+ end
34
+
35
+ end
36
+
@@ -0,0 +1,11 @@
1
+ module Attributor
2
+ class FileUpload < Attributor::Model
3
+ attributes do
4
+ attribute :name, String
5
+ attribute :filename, String
6
+ attribute :type, String
7
+ attribute :tempfile, Tempfile
8
+ attribute :head, String
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,27 @@
1
+ # Float objects represent inexact real numbers using the native architecture's double-precision floating point representation.
2
+ # See: http://ruby-doc.org/core-2.1.0/Float.html
3
+ #
4
+ module Attributor
5
+
6
+ class Float
7
+ include Type
8
+
9
+ def self.native_type
10
+ return ::Float
11
+ end
12
+
13
+ def self.example(context=nil, options: {})
14
+ min = options[:min].to_f || 0.0
15
+ max = options[:max].to_f || Math.PI
16
+
17
+ rand * (max-min) + min
18
+ end
19
+
20
+ def self.load(value,context=Attributor::DEFAULT_ROOT_CONTEXT, **options)
21
+ Float(value)
22
+ rescue TypeError
23
+ super
24
+ end
25
+
26
+ end
27
+ end
@@ -0,0 +1,337 @@
1
+ module Attributor
2
+ class Hash
3
+ extend Forwardable
4
+
5
+ include Container
6
+ include Enumerable
7
+
8
+ class << self
9
+ attr_reader :key_type, :value_type, :options
10
+ attr_reader :value_attribute
11
+ attr_reader :key_attribute
12
+ end
13
+
14
+ @key_type = Object
15
+ @value_type = Object
16
+
17
+
18
+ @key_attribute = Attribute.new(@key_type)
19
+ @value_attribute = Attribute.new(@value_type)
20
+
21
+ @saved_blocks = []
22
+ @options = {}
23
+ @keys = {}
24
+
25
+
26
+ def self.inherited(klass)
27
+ k = self.key_type
28
+ v = self.value_type
29
+
30
+ klass.instance_eval do
31
+ @saved_blocks = []
32
+ @options = {}
33
+ @keys = {}
34
+ @key_type = k
35
+ @value_type = v
36
+ @key_attribute = Attribute.new(@key_type)
37
+ @value_attribute = Attribute.new(@value_type)
38
+ end
39
+ end
40
+
41
+ def self.keys(**options, &key_spec)
42
+ if block_given?
43
+ @saved_blocks << key_spec
44
+ @options.merge!(options)
45
+ elsif @saved_blocks.any?
46
+ self.definition
47
+ end
48
+ @keys
49
+ end
50
+
51
+ def self.definition
52
+ opts = {
53
+ :key_type => @key_type,
54
+ :value_type => @value_type
55
+ }.merge(@options)
56
+
57
+ blocks = @saved_blocks.shift(@saved_blocks.size)
58
+ compiler = dsl_class.new(self, opts)
59
+ compiler.parse(*blocks)
60
+ end
61
+
62
+ def self.dsl_class
63
+ @options[:dsl_compiler] || DSLCompiler
64
+ end
65
+
66
+ def self.native_type
67
+ self
68
+ end
69
+
70
+ def self.valid_type?(type)
71
+ type.kind_of?(self) || type.kind_of?(::Hash)
72
+ end
73
+
74
+ # @example Hash.of(key: String, value: Integer)
75
+ def self.of(key: @key_type, value: @value_type)
76
+ if key
77
+ resolved_key_type = Attributor.resolve_type(key)
78
+ unless resolved_key_type.ancestors.include?(Attributor::Type)
79
+ raise Attributor::AttributorException.new("Hashes only support key types that are Attributor::Types. Got #{resolved_key_type.name}")
80
+ end
81
+
82
+ end
83
+
84
+ if value
85
+ resolved_value_type = Attributor.resolve_type(value)
86
+ unless resolved_value_type.ancestors.include?(Attributor::Type)
87
+ raise Attributor::AttributorException.new("Hashes only support value types that are Attributor::Types. Got #{resolved_value_type.name}")
88
+ end
89
+ end
90
+
91
+
92
+
93
+
94
+ Class.new(self) do
95
+ @key_type = resolved_key_type
96
+ @value_type = resolved_value_type
97
+
98
+ @key_attribute = Attribute.new(@key_type)
99
+ @value_attribute = Attribute.new(@value_type)
100
+ @concrete = true
101
+ @keys = {}
102
+ end
103
+ end
104
+
105
+
106
+ def self.construct(constructor_block, **options)
107
+ return self if constructor_block.nil?
108
+
109
+ unless @concrete
110
+ return self.of(key:self.key_type, value: self.value_type)
111
+ .construct(constructor_block,**options)
112
+ end
113
+
114
+ self.keys(options, &constructor_block)
115
+ self
116
+ end
117
+
118
+
119
+ def self.example(context=nil, options: {})
120
+ return self.new if (key_type == Object && value_type == Object)
121
+
122
+ hash = ::Hash.new
123
+ # Let's not bother to generate any hash contents if there's absolutely no type defined
124
+
125
+ if self.keys.any?
126
+ self.keys.each do |sub_name, sub_attribute|
127
+ subcontext = context + ["at(#{sub_name})"]
128
+ hash[sub_name] = sub_attribute.example(subcontext)
129
+ end
130
+ else
131
+
132
+ size = rand(3) + 1
133
+
134
+ context ||= ["#{Hash}-#{rand(10000000)}"]
135
+ context = Array(context)
136
+
137
+ size.times do |i|
138
+ example_key = key_type.example(context + ["at(#{i})"])
139
+ subcontext = context + ["at(#{example_key})"]
140
+ hash[example_key] = value_type.example(subcontext)
141
+ end
142
+ end
143
+
144
+ self.new(hash)
145
+ end
146
+
147
+
148
+ def self.dump(value, **opts)
149
+ return nil if value.nil?
150
+ return super if (@key_type == Object && @value_type == Object )
151
+
152
+ value.each_with_object({}) do |(k,v),hash|
153
+ k = key_type.dump(k,opts) if @key_type
154
+ v = value_type.dump(v,opts) if @value_type
155
+ hash[k] = v
156
+ end
157
+ end
158
+
159
+ def self.check_option!(name, definition)
160
+ case name
161
+ when :key_type
162
+ :ok
163
+ when :value_type
164
+ :ok
165
+ when :reference
166
+ :ok # FIXME ... actually do something smart
167
+ else
168
+ :unknown
169
+ end
170
+ end
171
+
172
+
173
+ def self.load(value,context=Attributor::DEFAULT_ROOT_CONTEXT, **options)
174
+ if value.nil?
175
+ return nil
176
+ elsif value.is_a?(self)
177
+ return value
178
+ elsif value.kind_of?(Attributor::Hash)
179
+ loaded_value = value.contents
180
+ elsif value.is_a?(::Hash)
181
+ loaded_value = value
182
+ elsif value.is_a?(::String)
183
+ loaded_value = decode_json(value,context)
184
+ else
185
+ raise Attributor::IncompatibleTypeError, context: context, value_type: value.class, type: self
186
+ end
187
+
188
+ return self.from_hash(loaded_value,context) if self.keys.any?
189
+ return self.new(loaded_value) if (key_type == Object && value_type == Object)
190
+
191
+ loaded_value.each_with_object(self.new) do| (k, v), obj |
192
+ obj[self.key_type.load(k,context)] = self.value_type.load(v,context)
193
+ end
194
+
195
+ end
196
+
197
+ def self.generate_subcontext(context, key_name)
198
+ context + ["get(#{key_name.inspect})"]
199
+ end
200
+
201
+ def self.from_hash(object,context)
202
+ hash = self.new
203
+
204
+ object.each do |k,v|
205
+ hash_key = @key_type.load(k)
206
+
207
+ hash_attribute = self.keys.fetch(hash_key) do
208
+ raise AttributorException, "Unknown key received: #{k.inspect} while loading #{Attributor.humanize_context(context)}"
209
+ end
210
+
211
+ sub_context = self.generate_subcontext(context,hash_key)
212
+ hash[hash_key] = hash_attribute.load(v, sub_context)
213
+ end
214
+
215
+ self.keys.each do |key_name, attribute|
216
+ next if hash.key?(key_name)
217
+ sub_context = self.generate_subcontext(context,key_name)
218
+ hash[key_name] = attribute.load(nil, sub_context)
219
+ end
220
+
221
+ hash
222
+ end
223
+
224
+
225
+ def self.validate(object,context=Attributor::DEFAULT_ROOT_CONTEXT,_attribute)
226
+ context = [context] if context.is_a? ::String
227
+
228
+ unless object.kind_of?(self)
229
+ raise ArgumentError, "#{self.name} can not validate object of type #{object.class.name} for #{Attributor.humanize_context(context)}."
230
+ end
231
+
232
+ object.validate(context)
233
+ end
234
+
235
+ def self.describe(shallow=false)
236
+ hash = super
237
+
238
+ if key_type
239
+ hash[:key] = {type: key_type.describe}
240
+ end
241
+
242
+ if self.keys.any?
243
+ # Spit keys if it's the root or if it's an anonymous structures
244
+ if ( !shallow || self.name == nil) && self.keys.any?
245
+ # FIXME: change to :keys when the praxis doc browser supports displaying those. or josep's demo is over.
246
+ hash[:keys] = self.keys.each_with_object({}) do |(sub_name, sub_attribute), sub_attributes|
247
+ sub_attributes[sub_name] = sub_attribute.describe(true)
248
+ end
249
+ end
250
+ else
251
+ hash[:value] = {type: value_type.describe(true)}
252
+ end
253
+
254
+ hash
255
+ end
256
+
257
+ # TODO: chance value_type and key_type to be attributes?
258
+ # TODO: add a validate, which simply validates that the incoming keys and values are of the right type.
259
+ # Think about the format of the subcontexts to use: let's use .at(key.to_s)
260
+ attr_reader :contents
261
+ def_delegators :@contents, :[], :[]=, :each, :size, :keys, :key?, :values, :empty?
262
+
263
+ def initialize(contents={})
264
+ @contents = contents
265
+
266
+
267
+ end
268
+
269
+ def key_type
270
+ self.class.key_type
271
+ end
272
+
273
+ def value_type
274
+ self.class.value_type
275
+ end
276
+
277
+
278
+ def key_attribute
279
+ self.class.key_attribute
280
+ end
281
+
282
+ def value_attribute
283
+ self.class.value_attribute
284
+ end
285
+
286
+
287
+ def ==(other)
288
+ contents == other || (other.respond_to?(:contents) ? contents == other.contents : false)
289
+ end
290
+
291
+ def validate(context=Attributor::DEFAULT_ROOT_CONTEXT)
292
+ context = [context] if context.is_a? ::String
293
+
294
+ if self.class.keys.any?
295
+ extra_keys = @contents.keys - self.class.keys.keys
296
+ if extra_keys.any?
297
+ return extra_keys.collect do |k|
298
+ "#{Attributor.humanize_context(context)} can not have key: #{k.inspect}"
299
+ end
300
+ end
301
+
302
+ self.class.keys.each_with_object(Array.new) do |(key, attribute), errors|
303
+ sub_context = self.class.generate_subcontext(context,key)
304
+
305
+ value = @contents[key]
306
+
307
+ if value.respond_to?(:validating) # really, it's a thing with sub-attributes
308
+ next if value.validating
309
+ end
310
+
311
+ errors.push *attribute.validate(value, sub_context)
312
+ end
313
+ else
314
+ @contents.each_with_object(Array.new) do |(key, value), errors|
315
+ # FIXME: the sub contexts and error messages don't really make sense here
316
+ unless key_type == Attributor::Object
317
+ sub_context = context + ["key(#{key.inspect})"]
318
+ errors.push *key_attribute.validate(key, sub_context)
319
+ end
320
+
321
+ unless value_type == Attributor::Object
322
+ sub_context = context + ["value(#{value.inspect})"]
323
+ errors.push *value_attribute.validate(value, sub_context)
324
+ end
325
+ end
326
+ end
327
+ end
328
+
329
+ def dump(*args)
330
+ @contents.each_with_object(::Hash.new) do |(k,v), hash|
331
+ hash[k] = self.class.value_type.dump(v)
332
+ end
333
+ end
334
+
335
+ end
336
+
337
+ end