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.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/CHANGELOG.md +52 -0
- data/Gemfile +3 -0
- data/Guardfile +12 -0
- data/LICENSE +22 -0
- data/README.md +62 -0
- data/Rakefile +28 -0
- data/attributor.gemspec +40 -0
- data/lib/attributor.rb +89 -0
- data/lib/attributor/attribute.rb +271 -0
- data/lib/attributor/attribute_resolver.rb +116 -0
- data/lib/attributor/dsl_compiler.rb +106 -0
- data/lib/attributor/exceptions.rb +38 -0
- data/lib/attributor/extensions/randexp.rb +10 -0
- data/lib/attributor/type.rb +117 -0
- data/lib/attributor/types/boolean.rb +26 -0
- data/lib/attributor/types/collection.rb +135 -0
- data/lib/attributor/types/container.rb +42 -0
- data/lib/attributor/types/csv.rb +10 -0
- data/lib/attributor/types/date_time.rb +36 -0
- data/lib/attributor/types/file_upload.rb +11 -0
- data/lib/attributor/types/float.rb +27 -0
- data/lib/attributor/types/hash.rb +337 -0
- data/lib/attributor/types/ids.rb +26 -0
- data/lib/attributor/types/integer.rb +63 -0
- data/lib/attributor/types/model.rb +316 -0
- data/lib/attributor/types/object.rb +19 -0
- data/lib/attributor/types/string.rb +25 -0
- data/lib/attributor/types/struct.rb +50 -0
- data/lib/attributor/types/tempfile.rb +36 -0
- data/lib/attributor/version.rb +3 -0
- data/spec/attribute_resolver_spec.rb +227 -0
- data/spec/attribute_spec.rb +597 -0
- data/spec/attributor_spec.rb +25 -0
- data/spec/dsl_compiler_spec.rb +130 -0
- data/spec/spec_helper.rb +30 -0
- data/spec/support/models.rb +81 -0
- data/spec/support/types.rb +21 -0
- data/spec/type_spec.rb +134 -0
- data/spec/types/boolean_spec.rb +85 -0
- data/spec/types/collection_spec.rb +286 -0
- data/spec/types/container_spec.rb +49 -0
- data/spec/types/csv_spec.rb +17 -0
- data/spec/types/date_time_spec.rb +90 -0
- data/spec/types/file_upload_spec.rb +6 -0
- data/spec/types/float_spec.rb +78 -0
- data/spec/types/hash_spec.rb +372 -0
- data/spec/types/ids_spec.rb +32 -0
- data/spec/types/integer_spec.rb +151 -0
- data/spec/types/model_spec.rb +401 -0
- data/spec/types/string_spec.rb +55 -0
- data/spec/types/struct_spec.rb +189 -0
- data/spec/types/tempfile_spec.rb +6 -0
- 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,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,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
|