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,116 @@
1
+ require 'ostruct'
2
+
3
+ module Attributor
4
+
5
+
6
+ class AttributeResolver
7
+ ROOT_PREFIX = '$'.freeze
8
+
9
+ class Data < ::Hash
10
+ include Hashie::Extensions::MethodReader
11
+ end
12
+
13
+ attr_reader :data
14
+
15
+ def initialize
16
+ @data = Data.new
17
+ end
18
+
19
+
20
+ # TODO: support collection queries
21
+ def query!(key_path, path_prefix=ROOT_PREFIX)
22
+ # If the incoming key_path is not an absolute path, append the given prefix
23
+ # NOTE: Need to index key_path by range here because Ruby 1.8 returns a
24
+ # FixNum for the ASCII code, not the actual character, when indexing by a number.
25
+ unless key_path[0..0] == ROOT_PREFIX
26
+ # TODO: prepend path_prefix to path_prefix if it did not include it? hm.
27
+ key_path = path_prefix + SEPARATOR + key_path
28
+ end
29
+
30
+ # Discard the initial element, which should always be ROOT_PREFIX at this point
31
+ _root, *path = key_path.split(SEPARATOR)
32
+
33
+ # Follow the hierarchy path to the requested node and return it
34
+ # Example path => ["instance", "ssh_key", "name"]
35
+ # Example @data => {"instance" => { "ssh_key" => { "name" => "foobar" } }}
36
+ result = path.inject(@data) do |hash, key|
37
+ return nil if hash.nil?
38
+ hash.send key
39
+ end
40
+ result
41
+ end
42
+
43
+
44
+ # Query for a certain key in the attribute hierarchy
45
+ #
46
+ # @param [String] key_path The name of the key to query and its path
47
+ # @param [String] path_prefix
48
+ #
49
+ # @return [String] The value of the specified attribute/key
50
+ #
51
+ def query(key_path,path_prefix=ROOT_PREFIX)
52
+ query!(key_path,path_prefix)
53
+ rescue NoMethodError => e
54
+ nil
55
+ end
56
+
57
+ def register(key_path, value)
58
+ if key_path.split(SEPARATOR).size > 1
59
+ raise AttributorException.new("can only register top-level attributes. got: #{key_path}")
60
+ end
61
+
62
+ @data[key_path] = value
63
+ end
64
+
65
+
66
+ # Checks that the the condition is met. This means the attribute identified
67
+ # by path_prefix and key_path satisfies the optional predicate, which when
68
+ # nil simply checks for existence.
69
+ #
70
+ # @param path_prefix [String]
71
+ # @param key_path [String]
72
+ # @param predicate [String|Regexp|Proc|NilClass]
73
+ #
74
+ # @returns [Boolean] True if :required_if condition is met, false otherwise
75
+ #
76
+ # @raise [AttributorException] When an unsupported predicate is passed
77
+ #
78
+ def check(path_prefix, key_path, predicate=nil)
79
+ value = self.query(key_path, path_prefix)
80
+
81
+ # we have a value, any value, which is good enough given no predicate
82
+ if !value.nil? && predicate.nil?
83
+ return true
84
+ end
85
+
86
+ case predicate
87
+ when ::String, ::Regexp, ::Integer, ::Float, ::DateTime, true, false
88
+ return predicate === value
89
+ when ::Proc
90
+ # Cannot use === here as above due to different behavior in Ruby 1.8
91
+ return predicate.call(value)
92
+ when nil
93
+ return !value.nil?
94
+ else
95
+ raise AttributorException.new("predicate not supported: #{predicate.inspect}")
96
+ end
97
+
98
+ end
99
+
100
+ # TODO: kill this when we also kill Taylor's IdentityMap.current
101
+ def self.current=(resolver)
102
+ Thread.current[:_attributor_attribute_resolver] = resolver
103
+ end
104
+
105
+
106
+ def self.current
107
+ if resolver = Thread.current[:_attributor_attribute_resolver]
108
+ return resolver
109
+ else
110
+ raise AttributorException, "No AttributeResolver set."
111
+ end
112
+ end
113
+
114
+ end
115
+
116
+ end
@@ -0,0 +1,106 @@
1
+ #Container of options and structure definition
2
+ module Attributor
3
+
4
+ # RULES FOR ATTRIBUTES
5
+ # The type of an attribute is:
6
+ # the specified type
7
+ # inferred from a reference type.
8
+ # it should always end up being an anonymous type, otherwise the Model class will explode
9
+ # Struct if a block is given
10
+
11
+ # The reference option for an attribute is passed if a block is given
12
+
13
+ class DSLCompiler
14
+
15
+ attr_accessor :options, :target
16
+
17
+ def initialize(target, **options)
18
+ @target = target
19
+ @options = options
20
+ end
21
+
22
+ def parse(*blocks)
23
+ blocks.push(Proc.new) if block_given?
24
+ blocks.each { |block| self.instance_eval(&block) }
25
+ self
26
+ end
27
+
28
+ def attributes
29
+ if target.respond_to?(:attributes)
30
+ target.attributes
31
+ else
32
+ target.keys
33
+ end
34
+ end
35
+
36
+ def attribute(name, attr_type=nil, **opts, &block)
37
+ raise AttributorException, "Attribute names must be symbols, got: #{name.inspect}" unless name.kind_of? ::Symbol
38
+ target.attributes[name] = define(name, attr_type, **opts, &block)
39
+ end
40
+
41
+ def key(name, attr_type=nil, **opts, &block)
42
+ unless name.kind_of?(options.fetch(:key_type, Attributor::Object).native_type)
43
+ raise "Invalid key: #{name.inspect}, must be instance of #{options[:key_type].native_type.name}"
44
+ end
45
+ target.keys[name] = define(name, attr_type, **opts, &block)
46
+ end
47
+
48
+ # Creates an Attributor:Attribute with given definition.
49
+ #
50
+ # @overload define(name, type, opts, &block)
51
+ # With an explicit type.
52
+ # @param [symbol] name describe name param
53
+ # @param [Attributor::Type] type describe type param
54
+ # @param [Hash] opts describe opts param
55
+ # @param [Block] block describe block param
56
+ # @example
57
+ # attribute :email, String, example: /[:email:]/
58
+ # @overload define(name, opts, &block)
59
+ # Assume a type of Attributor::Struct
60
+ # @param [symbol] name describe name param
61
+ # @param [Hash] opts describe opts param
62
+ # @param [Block] block describe block param
63
+ # @example
64
+ # attribute :address do
65
+ # attribute :number, String
66
+ # attribute :street, String
67
+ # attribute :city, String
68
+ # attribute :state, String
69
+ # end
70
+ # @api semiprivate
71
+ def define(name, attr_type=nil, **opts, &block)
72
+ if (existing_attribute = attributes[name])
73
+ if existing_attribute.attributes
74
+ existing_attribute.type.attributes(&block)
75
+ return existing_attribute
76
+ end
77
+ end
78
+
79
+ if (reference = self.options[:reference])
80
+ inherited_attribute = reference.attributes[name]
81
+ else
82
+ inherited_attribute = nil
83
+ end
84
+
85
+ if attr_type.nil?
86
+ if inherited_attribute
87
+ attr_type = inherited_attribute.type
88
+ # Only inherit opts if no explicit attr_type was given.
89
+ opts = inherited_attribute.options.merge(opts)
90
+ elsif block_given?
91
+ attr_type = Attributor::Struct
92
+ else
93
+ raise AttributorException, "type for attribute with name: #{name} could not be determined"
94
+ end
95
+ end
96
+
97
+ if block_given? && inherited_attribute
98
+ opts[:reference] = inherited_attribute.type
99
+ end
100
+
101
+ Attributor::Attribute.new(attr_type, opts, &block)
102
+ end
103
+
104
+
105
+ end
106
+ end
@@ -0,0 +1,38 @@
1
+ module Attributor
2
+ class AttributorException < ::StandardError
3
+ end
4
+
5
+ class LoadError < AttributorException
6
+ end
7
+
8
+ class IncompatibleTypeError < LoadError
9
+
10
+ def initialize(type:, value_type: , context: )
11
+ super "Type #{type} cannot load values of type #{value_type} while loading #{Attributor.humanize_context(context)}."
12
+ end
13
+ end
14
+
15
+ class CoercionError < LoadError
16
+ def initialize( context: , from: , to:, value: nil)
17
+ msg = "Error coercing from #{from} to #{to} while loading #{Attributor.humanize_context(context)}."
18
+ msg += " Received value #{Attributor.errorize_value(value)}" if value
19
+ super msg
20
+ end
21
+ end
22
+
23
+ class DeserializationError < LoadError
24
+ def initialize( context: , from:, encoding: , value: nil)
25
+ msg = "Error deserializing a #{from} using #{encoding} while loading #{Attributor.humanize_context(context)}."
26
+ msg += " Received value #{Attributor.errorize_value(value)}" if value
27
+ super msg
28
+ end
29
+ end
30
+
31
+ class DumpError < AttributorException
32
+ def initialize( context: , name: , type: , original_exception: )
33
+ msg = "Error while dumping attribute #{name} of type #{type} for context #{Attributor.humanize_context(context)} ."
34
+ msg << " Reason: #{original_exception}"
35
+ super msg
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,10 @@
1
+ require 'date'
2
+
3
+
4
+ class Randgen
5
+ DATE_TIME_EPOCH = ::DateTime.new(2015, 1, 1, 0, 0, 0)
6
+
7
+ def self.date
8
+ return DATE_TIME_EPOCH - rand(800)
9
+ end
10
+ end
@@ -0,0 +1,117 @@
1
+ # Will need eventually, but not right now:
2
+ # Hash
3
+ # Array
4
+ # CSV
5
+ # Ids
6
+
7
+
8
+ module Attributor
9
+
10
+ # It is the abstract base class to hold an attribute, both a leaf and a container (hash/Array...)
11
+ # TODO: should this be a mixin since it is an abstract class?
12
+ module Type
13
+
14
+ def self.included( klass )
15
+ klass.extend(ClassMethods)
16
+ end
17
+
18
+
19
+ module ClassMethods
20
+
21
+ # Generic decoding and coercion of the attribute.
22
+ def load(value,context=Attributor::DEFAULT_ROOT_CONTEXT, **options)
23
+ return nil if value.nil?
24
+ unless value.is_a?(self.native_type)
25
+ raise Attributor::IncompatibleTypeError, context: context, value_type: value.class, type: self
26
+ end
27
+
28
+ value
29
+ end
30
+
31
+ # Generic encoding of the attribute
32
+ def dump(value,**opts)
33
+ value
34
+ end
35
+
36
+ # TODO: refactor this to take just the options instead of the full attribute?
37
+ # TODO: delegate to subclass
38
+ def validate(value,context=Attributor::DEFAULT_ROOT_CONTEXT,attribute)
39
+ errors = []
40
+ attribute.options.each do |option, opt_definition|
41
+ case option
42
+ when :max
43
+ errors << "#{Attributor.humanize_context(context)} value (#{value}) is larger than the allowed max (#{opt_definition.inspect})" unless value <= opt_definition
44
+ when :min
45
+ errors << "#{Attributor.humanize_context(context)} value (#{value}) is smaller than the allowed min (#{opt_definition.inspect})" unless value >= opt_definition
46
+ when :regexp
47
+ errors << "#{Attributor.humanize_context(context)} value (#{value}) does not match regexp (#{opt_definition.inspect})" unless value =~ opt_definition
48
+ end
49
+ end
50
+ errors
51
+ end
52
+
53
+ # Default, overridable valid_type? function
54
+ def valid_type?(value)
55
+ return value.is_a?(native_type) if respond_to?(:native_type)
56
+
57
+ raise AttributorException.new("#{self} must implement #valid_type? or #native_type")
58
+ end
59
+
60
+ # Default, overridable example function
61
+ def example(context=nil, options:{})
62
+ raise AttributorException.new("#{self} must implement #example")
63
+ # return options[:example] if options.has_key? :example
64
+ # return options[:default] if options.has_key? :default
65
+ # if options.has_key? :values
66
+ # vals = options[:values]
67
+ # return vals[rand(vals.size)]
68
+ # end
69
+ # return nil
70
+ end
71
+
72
+
73
+ # HELPER FUNCTIONS
74
+
75
+
76
+ def check_option!(name, definition)
77
+ case name
78
+ when :min
79
+ raise AttributorException.new("Value for option :min does not implement '<='. Got: (#{definition.inspect})") unless definition.respond_to?(:<=)
80
+ when :max
81
+ raise AttributorException.new("Value for option :max does not implement '>='. Got(#{definition.inspect})") unless definition.respond_to?(:>=)
82
+ when :regexp
83
+ # could go for a respoind_to? :=~ here, but that seems overly... cute... and not useful.
84
+ raise AttributorException.new("Value for option :regexp is not a Regexp object. Got (#{definition.inspect})") unless definition.is_a? ::Regexp
85
+ else
86
+ return :unknown
87
+ end
88
+
89
+ return :ok
90
+ end
91
+
92
+
93
+ def generate_subcontext(context, subname)
94
+ context + [subname]
95
+ end
96
+
97
+ def dsl_compiler
98
+ DSLCompiler
99
+ end
100
+
101
+ # By default, non complex types will not have a DSL subdefinition this handles such case
102
+ def compile_dsl( options, block )
103
+ raise AttributorException.new("Basic structures cannot take extra block definitions") if block
104
+ # Simply create a DSL compiler to store the options, and not to parse any DSL
105
+ sub_definition=dsl_compiler.new( options )
106
+ return sub_definition
107
+ end
108
+
109
+ # Default describe for simple types...only their name (stripping the base attributor module)
110
+ def describe(root=false)
111
+ type_name = self.ancestors.find { |k| k.name && !k.name.empty? }.name
112
+ { :name => type_name.gsub( Attributor::MODULE_PREFIX_REGEX, '' ) }
113
+ end
114
+
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,26 @@
1
+ # Represents a plain old boolean type. TBD: can be nil?
2
+ #
3
+ require_relative '../exceptions'
4
+
5
+ module Attributor
6
+
7
+ class Boolean
8
+ include Type
9
+
10
+ def self.valid_type?(value)
11
+ value == true || value == false
12
+ end
13
+
14
+ def self.example(context=nil, options: {})
15
+ [true, false].sample
16
+ end
17
+
18
+ def self.load(value,context=Attributor::DEFAULT_ROOT_CONTEXT, **options)
19
+ raise CoercionError, context: context, from: value.class, to: self, value: value if value.is_a?(::Float)
20
+ return false if [ false, 'false', 'FALSE', '0', 0, 'f', 'F' ].include?(value)
21
+ return true if [ true, 'true', 'TRUE', '1', 1, 't', 'T' ].include?(value)
22
+ raise CoercionError, context: context, from: value.class, to: self
23
+ end
24
+ end
25
+ end
26
+
@@ -0,0 +1,135 @@
1
+ # Represents an unordered collection of attributes
2
+ #
3
+
4
+ module Attributor
5
+
6
+ class Collection
7
+ include Container
8
+
9
+ # @param type [Attributor::Type] optional, defines the type of all collection members
10
+ # @return anonymous class with specified type of collection members
11
+ #
12
+ # @example Collection.of(Integer)
13
+ #
14
+ def self.of(type)
15
+ resolved_type = Attributor.resolve_type(type)
16
+ unless resolved_type.ancestors.include?(Attributor::Type)
17
+ raise Attributor::AttributorException.new("Collections can only have members that are Attributor::Types")
18
+ end
19
+ Class.new(self) do
20
+ @member_type = resolved_type
21
+ end
22
+ end
23
+
24
+ def self.native_type
25
+ return ::Array
26
+ end
27
+
28
+ def self.member_type
29
+ @member_type ||= Attributor::Object
30
+ end
31
+
32
+ def self.member_attribute
33
+ @member_attribute ||= begin
34
+ self.construct(nil,{})
35
+ @member_attribute
36
+ end
37
+ end
38
+
39
+
40
+ # generates an example Collection
41
+ # @return An Array of native type objects conforming to the specified member_type
42
+ def self.example(context=nil, options: {})
43
+ result = []
44
+ size = rand(3) + 1
45
+ context ||= ["Collection-#{result.object_id}"]
46
+
47
+ size.times do |i|
48
+ subcontext = context + ["at(#{i})"]
49
+ result << self.member_attribute.example(subcontext)
50
+ end
51
+
52
+ result
53
+ end
54
+
55
+
56
+ # The incoming value should be an array here, so the only decoding that we need to do
57
+ # is from the members (if there's an :member_type defined option).
58
+ def self.load(value,context=Attributor::DEFAULT_ROOT_CONTEXT, **options)
59
+ if value.nil?
60
+ return nil
61
+ elsif value.is_a?(Enumerable)
62
+ loaded_value = value
63
+ elsif value.is_a?(::String)
64
+ loaded_value = decode_string(value,context)
65
+ else
66
+ raise Attributor::IncompatibleTypeError, context: context, value_type: value.class, type: self
67
+ end
68
+
69
+ return loaded_value.collect { |member| self.member_attribute.load(member,context) }
70
+ end
71
+
72
+
73
+ def self.decode_string(value,context)
74
+ decode_json(value,context)
75
+ end
76
+
77
+
78
+ def self.dump(values, **opts)
79
+ return nil if values.nil?
80
+ values.collect { |value| member_attribute.dump(value,opts) }
81
+ end
82
+
83
+ def self.describe(shallow=false)
84
+ #puts "Collection: #{self.type}"
85
+ hash = super(shallow)
86
+ hash[:options] = {} unless hash[:options]
87
+ hash[:options][:member_attribute] = self.member_attribute.describe
88
+ hash
89
+ end
90
+
91
+ def self.construct(constructor_block, options)
92
+
93
+ member_options = (options[:member_options] || {} ).clone
94
+ if options.has_key?(:reference) && !member_options.has_key?(:reference)
95
+ member_options[:reference] = options[:reference]
96
+ end
97
+
98
+ # create the member_attribute, passing in our member_type and whatever constructor_block is.
99
+ # that in turn will call construct on the type if applicable.
100
+ @member_attribute = Attributor::Attribute.new self.member_type, member_options, &constructor_block
101
+
102
+ # overwrite our type with whatever type comes out of the attribute
103
+ @member_type = @member_attribute.type
104
+
105
+ return self
106
+ end
107
+
108
+
109
+ def self.check_option!(name, definition)
110
+ # TODO: support more options like :max_size
111
+ case name
112
+ when :reference
113
+ when :member_options
114
+ else
115
+ return :unknown
116
+ end
117
+
118
+ :ok
119
+ end
120
+
121
+ # @param values [Array] Array of values to validate
122
+ def self.validate(values, context=Attributor::DEFAULT_ROOT_CONTEXT, attribute=nil)
123
+ values.each_with_index.collect do |value, i|
124
+ subcontext = context + ["at(#{i})"]
125
+ self.member_attribute.validate(value, subcontext)
126
+ end.flatten.compact
127
+ end
128
+
129
+ def self.validate_options( value, context, attribute )
130
+ errors = []
131
+ errors
132
+ end
133
+
134
+ end
135
+ end