attributor 5.1.0 → 5.5

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 +4 -4
  2. data/.travis.yml +4 -3
  3. data/CHANGELOG.md +145 -135
  4. data/attributor.gemspec +5 -6
  5. data/lib/attributor.rb +17 -2
  6. data/lib/attributor/attribute.rb +39 -9
  7. data/lib/attributor/dsl_compiler.rb +17 -9
  8. data/lib/attributor/exceptions.rb +5 -0
  9. data/lib/attributor/extras/field_selector.rb +4 -0
  10. data/lib/attributor/families/numeric.rb +19 -6
  11. data/lib/attributor/families/temporal.rb +16 -9
  12. data/lib/attributor/hash_dsl_compiler.rb +6 -6
  13. data/lib/attributor/smart_attribute_selector.rb +149 -0
  14. data/lib/attributor/type.rb +27 -4
  15. data/lib/attributor/types/bigdecimal.rb +7 -2
  16. data/lib/attributor/types/boolean.rb +7 -2
  17. data/lib/attributor/types/class.rb +2 -2
  18. data/lib/attributor/types/collection.rb +22 -5
  19. data/lib/attributor/types/container.rb +3 -3
  20. data/lib/attributor/types/csv.rb +5 -1
  21. data/lib/attributor/types/date.rb +9 -3
  22. data/lib/attributor/types/date_time.rb +8 -2
  23. data/lib/attributor/types/float.rb +4 -3
  24. data/lib/attributor/types/hash.rb +105 -21
  25. data/lib/attributor/types/integer.rb +7 -1
  26. data/lib/attributor/types/model.rb +2 -2
  27. data/lib/attributor/types/object.rb +5 -0
  28. data/lib/attributor/types/polymorphic.rb +3 -2
  29. data/lib/attributor/types/string.rb +20 -1
  30. data/lib/attributor/types/struct.rb +1 -1
  31. data/lib/attributor/types/symbol.rb +5 -0
  32. data/lib/attributor/types/tempfile.rb +4 -0
  33. data/lib/attributor/types/time.rb +7 -3
  34. data/lib/attributor/types/uri.rb +9 -1
  35. data/lib/attributor/version.rb +1 -1
  36. data/spec/attribute_spec.rb +42 -7
  37. data/spec/dsl_compiler_spec.rb +16 -6
  38. data/spec/extras/field_selector/field_selector_spec.rb +9 -0
  39. data/spec/hash_dsl_compiler_spec.rb +2 -2
  40. data/spec/smart_attribute_selector_spec.rb +272 -0
  41. data/spec/support/integers.rb +7 -0
  42. data/spec/type_spec.rb +1 -1
  43. data/spec/types/bigdecimal_spec.rb +8 -0
  44. data/spec/types/boolean_spec.rb +10 -0
  45. data/spec/types/class_spec.rb +0 -1
  46. data/spec/types/collection_spec.rb +16 -0
  47. data/spec/types/date_spec.rb +9 -0
  48. data/spec/types/date_time_spec.rb +9 -0
  49. data/spec/types/float_spec.rb +8 -0
  50. data/spec/types/hash_spec.rb +181 -9
  51. data/spec/types/integer_spec.rb +10 -1
  52. data/spec/types/model_spec.rb +14 -3
  53. data/spec/types/string_spec.rb +10 -0
  54. data/spec/types/temporal_spec.rb +5 -1
  55. data/spec/types/time_spec.rb +9 -0
  56. data/spec/types/uri_spec.rb +9 -0
  57. metadata +24 -34
@@ -93,12 +93,12 @@ module Attributor
93
93
 
94
94
  TOP_LEVEL_OPTIONS = [:description, :values, :default, :example, :required, :required_if, :custom_data].freeze
95
95
  INTERNAL_OPTIONS = [:dsl_compiler, :dsl_compiler_options].freeze # Options we don't want to expose when describing attributes
96
-
97
- def describe(shallow = true, example: nil)
98
- description = {}
96
+ JSON_SCHEMA_UNSUPPORTED_OPTIONS = [ :required, :required_if ].freeze
97
+ def describe(shallow=true, example: nil)
98
+ description = { }
99
99
  # Clone the common options
100
100
  TOP_LEVEL_OPTIONS.each do |option_name|
101
- description[option_name] = options[option_name] if options.key? option_name
101
+ description[option_name] = self.describe_option(option_name) if self.options.has_key? option_name
102
102
  end
103
103
 
104
104
  # Make sure this option definition is not mistaken for the real generated example
@@ -109,7 +109,7 @@ module Attributor
109
109
  special_options = options.keys - TOP_LEVEL_OPTIONS - INTERNAL_OPTIONS
110
110
  description[:options] = {} unless special_options.empty?
111
111
  special_options.each do |opt_name|
112
- description[:options][opt_name] = options[opt_name]
112
+ description[:options][opt_name] = self.describe_option(opt_name)
113
113
  end
114
114
  # Change the reference option to the actual class name.
115
115
  if (reference = options[:reference])
@@ -146,8 +146,37 @@ module Attributor
146
146
  load(generated, context)
147
147
  end
148
148
 
149
- def example(context = nil, parent: nil, values: {})
150
- raise ArgumentError, 'attribute example cannot take a context of type String' if context.is_a? ::String
149
+ def describe_option( option_name )
150
+ self.type.describe_option( option_name, self.options[option_name] )
151
+ end
152
+
153
+ # FiXME: pass and utilize the "shallow" parameter
154
+ #required
155
+ #options
156
+ #type
157
+ #example
158
+ # UTILIZE THIS SITE! http://jsonschema.net/#/
159
+ def as_json_schema(shallow: true, example: nil)
160
+ description = self.type.as_json_schema(shallow: shallow, example: example, attribute_options: self.options )
161
+
162
+ description[:description] = self.options[:description] if self.options[:description]
163
+ description[:enum] = self.options[:values] if self.options[:values]
164
+ description[:default] = self.options[:default] if self.options[:default]
165
+ #TODO description[:title] = "TODO: do we want to use a title??..."
166
+
167
+ # Change the reference option to the actual class name.
168
+ if ( reference = self.options[:reference] )
169
+ description[:'x-reference'] = reference.name
170
+ end
171
+
172
+ # TODO: not sure if that's correct (we used to get it from the described hash...
173
+ description[:example] = self.dump(example) if example
174
+
175
+ description
176
+ end
177
+
178
+ def example(context=nil, parent: nil, values:{})
179
+ raise ArgumentError, "attribute example cannot take a context of type String" if (context.is_a? ::String )
151
180
  if context
152
181
  ctx = Attributor.humanize_context(context)
153
182
  seed, = Digest::SHA1.digest(ctx).unpack('QQ')
@@ -158,7 +187,8 @@ module Attributor
158
187
 
159
188
  if options.key? :example
160
189
  loaded = example_from_options(parent, context)
161
- errors = validate(loaded, context)
190
+ # Only validate the type, if the proc-generated example is "complex" (has attributes)
191
+ errors = loaded.class.respond_to?(:attributes) ? validate_type(loaded, context) : validate(loaded, context)
162
192
  raise AttributorException, "Error generating example for #{Attributor.humanize_context(context)}. Errors: #{errors.inspect}" if errors.any?
163
193
  return loaded
164
194
  end
@@ -166,7 +196,7 @@ module Attributor
166
196
  return options[:values].pick if options.key? :values
167
197
 
168
198
  if type.respond_to?(:attributes)
169
- type.example(context, values)
199
+ type.example(context, **values)
170
200
  else
171
201
  type.example(context, options: options)
172
202
  end
@@ -78,6 +78,8 @@ module Attributor
78
78
  # end
79
79
  # @api semiprivate
80
80
  def define(name, attr_type = nil, **opts, &block)
81
+ example_given = opts.key? :example
82
+
81
83
  # add to existing attribute if present
82
84
  if (existing_attribute = attributes[name])
83
85
  if existing_attribute.attributes
@@ -86,27 +88,33 @@ module Attributor
86
88
  end
87
89
  end
88
90
 
89
- # determine inherited attribute
90
- inherited_attribute = nil
91
- if (reference = options[:reference])
92
- if (inherited_attribute = reference.attributes[name])
91
+ # determine inherited type (giving preference to the direct attribute options)
92
+ inherited_type = opts[:reference]
93
+ unless inherited_type
94
+ reference = options[:reference]
95
+ if reference && reference.respond_to?(:attributes) && reference.attributes.key?(name)
96
+ inherited_attribute = reference.attributes[name]
93
97
  opts = inherited_attribute.options.merge(opts) unless attr_type
94
- opts[:reference] = inherited_attribute.type if block_given?
98
+ inherited_type = inherited_attribute.type
99
+ opts[:reference] = inherited_type if block_given?
95
100
  end
96
101
  end
97
102
 
98
103
  # determine attribute type to use
99
104
  if attr_type.nil?
100
105
  if block_given?
101
- attr_type = if inherited_attribute && inherited_attribute.type < Attributor::Collection
106
+ # Don't inherit explicit examples if we've redefined the structure
107
+ # (but preserve the direct example if given here)
108
+ opts.delete :example unless example_given
109
+ attr_type = if inherited_type && inherited_type < Attributor::Collection
102
110
  # override the reference to be the member_attribute's type for collections
103
- opts[:reference] = inherited_attribute.type.member_attribute.type
111
+ opts[:reference] = inherited_type.member_attribute.type
104
112
  Attributor::Collection.of(Struct)
105
113
  else
106
114
  Attributor::Struct
107
115
  end
108
- elsif inherited_attribute
109
- attr_type = inherited_attribute.type
116
+ elsif inherited_type
117
+ attr_type = inherited_type
110
118
  else
111
119
  raise AttributorException, "type for attribute with name: #{name} could not be determined"
112
120
  end
@@ -34,4 +34,9 @@ module Attributor
34
34
  super msg
35
35
  end
36
36
  end
37
+
38
+ # Thrown from SmartAttributeSelector when the requirements of attributes are certainly unfeasible
39
+ class UnfeasibleRequirementsError < AttributorException
40
+ end
41
+
37
42
  end
@@ -12,6 +12,10 @@ module Attributor
12
12
 
13
13
  include Attributor::Type
14
14
 
15
+ def self.json_schema_type
16
+ :string
17
+ end
18
+
15
19
  def self.native_type
16
20
  ::Hash
17
21
  end
@@ -1,15 +1,28 @@
1
1
  # Abstract type for the 'numeric' family
2
2
 
3
3
  module Attributor
4
- class Numeric
4
+ module Numeric
5
+ extend ActiveSupport::Concern
5
6
  include Type
6
7
 
7
- def self.native_type
8
- raise NotImplementedError
9
- end
8
+ module ClassMethods
9
+
10
+ def native_type
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def family
15
+ 'numeric'
16
+ end
10
17
 
11
- def self.family
12
- 'numeric'
18
+ def as_json_schema( shallow: false, example: nil, attribute_options: {} )
19
+ h = super
20
+ opts = ( self.respond_to?(:options) ) ? self.options.merge( attribute_options ) : attribute_options
21
+ h[:minimum] = opts[:min] if opts[:min]
22
+ h[:maximum] = opts[:max] if opts[:max]
23
+ # We're not explicitly setting false to exclusiveMinimum and exclusiveMaximum (as that's the default)
24
+ h
25
+ end
13
26
  end
14
27
  end
15
28
  end
@@ -1,19 +1,26 @@
1
1
  # Abstract type for the 'temporal' family
2
2
 
3
3
  module Attributor
4
- class Temporal
4
+ module Temporal
5
+ extend ActiveSupport::Concern
5
6
  include Type
6
7
 
7
- def self.native_type
8
- raise NotImplementedError
9
- end
8
+ module ClassMethods
9
+ def native_type
10
+ raise NotImplementedError
11
+ end
10
12
 
11
- def self.family
12
- 'temporal'
13
- end
13
+ def family
14
+ 'temporal'
15
+ end
16
+
17
+ def dump(value, **_opts)
18
+ value && value.iso8601
19
+ end
14
20
 
15
- def self.dump(value, **_opts)
16
- value && value.iso8601
21
+ def json_schema_type
22
+ :string
23
+ end
17
24
  end
18
25
  end
19
26
  end
@@ -89,31 +89,31 @@ module Attributor
89
89
  end
90
90
 
91
91
  def all(*attr_names, **opts)
92
- req = Requirement.new(options.merge(opts).merge(all: attr_names))
92
+ req = Requirement.new(**options.merge(opts).merge(all: attr_names))
93
93
  target.add_requirement req
94
94
  req
95
95
  end
96
96
 
97
97
  def at_most(number)
98
- req = Requirement.new(options.merge(at_most: number))
98
+ req = Requirement.new(**options.merge(at_most: number))
99
99
  target.add_requirement req
100
100
  req
101
101
  end
102
102
 
103
103
  def at_least(number)
104
- req = Requirement.new(options.merge(at_least: number))
104
+ req = Requirement.new(**options.merge(at_least: number))
105
105
  target.add_requirement req
106
106
  req
107
107
  end
108
108
 
109
109
  def exactly(number)
110
- req = Requirement.new(options.merge(exactly: number))
110
+ req = Requirement.new(**options.merge(exactly: number))
111
111
  target.add_requirement req
112
112
  req
113
113
  end
114
114
 
115
115
  def exclusive(*attr_names, **opts)
116
- req = Requirement.new(options.merge(opts).merge(exclusive: attr_names))
116
+ req = Requirement.new(**options.merge(opts).merge(exclusive: attr_names))
117
117
  target.add_requirement req
118
118
  req
119
119
  end
@@ -132,7 +132,7 @@ module Attributor
132
132
  _requirements_dsl
133
133
  end
134
134
  else
135
- _requirements_dsl.all(*spec, opts)
135
+ _requirements_dsl.all(*spec, **opts)
136
136
  end
137
137
  end
138
138
  end
@@ -0,0 +1,149 @@
1
+ module Attributor
2
+ class SmartAttributeSelector
3
+ attr_accessor :reqs, :accepted, :banned, :remaining
4
+ attr_reader :reqs, :accepted, :banned, :remaining, :keys_with_values
5
+
6
+ def initialize( reqs , attributes, values)
7
+ @reqs = reqs.dup
8
+ @accepted = []
9
+ @banned = []
10
+ @remaining = attributes.dup
11
+ @keys_with_values = values.each_with_object([]){|(k,v),populated| populated.push(k) unless v == nil}
12
+ end
13
+
14
+ def process
15
+ process_required
16
+ process_exclusive
17
+ process_exactly
18
+ process_at_least
19
+ process_at_most
20
+ # Just add the ones that haven't been explicitly rejected
21
+ self.accepted += self.remaining
22
+ self.remaining = []
23
+ self.accepted.uniq!
24
+ self.accepted
25
+ end
26
+
27
+ def process_required
28
+ self.reqs = reqs.each_with_object([]) do |req, rest|
29
+ if req[:type] == :all
30
+ self.accepted += req[:attributes]
31
+ self.remaining -= req[:attributes]
32
+ else
33
+ rest.push req
34
+ end
35
+ end
36
+ end
37
+
38
+ def process_exclusive
39
+ self.reqs = reqs.each_with_object([]) do |req, rest|
40
+ if req[:type] == :exclusive ||
41
+ (req[:type] == :exactly && req[:count] == 1 ) ||
42
+ (req[:type] == :at_most && req[:count] == 1 )
43
+ process_exclusive_set(Array.new(req[:attributes]))
44
+ else
45
+ rest.push req
46
+ end
47
+ end
48
+ end
49
+
50
+ def process_at_least
51
+ self.reqs = reqs.each_with_object([]) do |req, rest|
52
+ if req[:type] == :at_least
53
+ process_at_least_set(Array.new(req[:attributes]), req[:count])
54
+ else
55
+ rest.push req
56
+ end
57
+ end
58
+ end
59
+
60
+ def process_at_most
61
+ self.reqs = reqs.each_with_object([]) do |req, rest|
62
+ if req[:type] == :at_most && req[:count] > 1 # count=1 is already handled in exclusive
63
+ process_at_most_set(Array.new(req[:attributes]), req[:count])
64
+ else
65
+ rest.push req
66
+ end
67
+ end
68
+ end
69
+
70
+ def process_exactly
71
+ self.reqs = reqs.each_with_object([]) do |req, rest|
72
+ if req[:type] == :exactly && req[:count] > 1 # count=1 is already handled in exclusive
73
+ process_exactly_set(Array.new(req[:attributes]), req[:count])
74
+ else
75
+ rest.push req
76
+ end
77
+ end
78
+ end
79
+
80
+ #################
81
+
82
+ def process_exclusive_set( exclusive_set )
83
+ feasible = exclusive_set - banned # available ones to pick (that are not banned)
84
+ # Try to favor attributes that come in with some values, otherwise get the first feasible one
85
+ preferred = feasible & keys_with_values
86
+ pick = (preferred.size == 0 ? feasible : preferred).first
87
+
88
+ if pick
89
+ self.accepted.push( pick )
90
+ else
91
+ raise UnfeasibleRequirementsError unless exclusive_set.empty?
92
+ end
93
+ self.banned += (feasible - [pick])
94
+ self.remaining -= exclusive_set
95
+ end
96
+
97
+ def process_at_least_set( at_least_set, count)
98
+ feasible = at_least_set - banned # available ones to pick (that are not banned)
99
+ preferred = (feasible & keys_with_values)[0,count]
100
+ # Add more if not enough
101
+ pick = if preferred.size < count
102
+ preferred + (feasible - preferred)[0,count-preferred.size]
103
+ else
104
+ preferred
105
+ end
106
+
107
+ unless pick.size == count
108
+ raise UnfeasibleRequirementsError
109
+ end
110
+ self.accepted += pick
111
+ self.remaining -= pick
112
+ end
113
+
114
+ def process_at_most_set( set, count)
115
+ ceil=(count+1)/2
116
+ feasible = set - banned # available ones to pick (that are not banned)
117
+ preferred = (feasible & keys_with_values)[0,ceil]
118
+
119
+ pick = if preferred.size < ceil
120
+ preferred + (feasible - preferred)[0,(ceil)-preferred.size]
121
+ else
122
+ preferred
123
+ end
124
+
125
+ self.accepted += pick
126
+ self.remaining -= pick
127
+ end
128
+
129
+ def process_exactly_set( set, count)
130
+ feasible = set - banned # available ones to pick (that are not banned)
131
+ preferred = (feasible & keys_with_values)[0,count]
132
+
133
+ pick = if preferred.size < count
134
+ preferred + (feasible - preferred)[0,count-preferred.size]
135
+ else
136
+ preferred
137
+ end
138
+
139
+ unless pick.size == count
140
+ raise UnfeasibleRequirementsError
141
+ end
142
+
143
+ self.accepted += pick
144
+ self.remaining -= pick
145
+ end
146
+
147
+
148
+ end
149
+ end
@@ -2,9 +2,7 @@ module Attributor
2
2
  # It is the abstract base class to hold an attribute, both a leaf and a container (hash/Array...)
3
3
  # TODO: should this be a mixin since it is an abstract class?
4
4
  module Type
5
- def self.included(klass)
6
- klass.extend(ClassMethods)
7
- end
5
+ extend ActiveSupport::Concern
8
6
 
9
7
  module ClassMethods
10
8
  # Does this type support the generation of subtypes?
@@ -29,7 +27,7 @@ module Attributor
29
27
  def load(value, context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
30
28
  return nil if value.nil?
31
29
  unless value.is_a?(native_type)
32
- raise Attributor::IncompatibleTypeError, context: context, value_type: value.class, type: self
30
+ raise Attributor::IncompatibleTypeError.new(context: context, value_type: value.class, type: self)
33
31
  end
34
32
 
35
33
  value
@@ -124,6 +122,31 @@ module Attributor
124
122
  def family
125
123
  'any'
126
124
  end
125
+
126
+ # Default no format in case it's a string type
127
+ def json_schema_string_format
128
+ nil
129
+ end
130
+
131
+ def as_json_schema( shallow: false, example: nil, attribute_options: {} )
132
+ type_name = self.ancestors.find { |k| k.name && !k.name.empty? }.name
133
+ hash = { type: json_schema_type, 'x-type_name': type_name.gsub( Attributor::MODULE_PREFIX_REGEX, '' )}
134
+ # Add a format, if the type has defined
135
+ if hash[:type] == :string && the_format = json_schema_string_format
136
+ hash[:format] = the_format
137
+ end
138
+ hash
139
+ end
140
+
141
+ def describe_option( option_name, option_value )
142
+ return case option_name
143
+ when :description
144
+ option_value
145
+ else
146
+ option_value # By default, describing an option returns the hash with the specification
147
+ end
148
+ end
149
+
127
150
  end
128
151
  end
129
152
  end