attributor 5.2.0 → 5.6

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 (53) hide show
  1. checksums.yaml +5 -5
  2. data/.travis.yml +4 -3
  3. data/CHANGELOG.md +146 -140
  4. data/attributor.gemspec +4 -5
  5. data/lib/attributor.rb +16 -2
  6. data/lib/attributor/attribute.rb +42 -9
  7. data/lib/attributor/extras/field_selector.rb +4 -0
  8. data/lib/attributor/families/numeric.rb +19 -6
  9. data/lib/attributor/families/temporal.rb +16 -9
  10. data/lib/attributor/hash_dsl_compiler.rb +6 -6
  11. data/lib/attributor/type.rb +27 -4
  12. data/lib/attributor/types/bigdecimal.rb +7 -2
  13. data/lib/attributor/types/boolean.rb +7 -2
  14. data/lib/attributor/types/class.rb +2 -2
  15. data/lib/attributor/types/collection.rb +24 -5
  16. data/lib/attributor/types/container.rb +3 -3
  17. data/lib/attributor/types/csv.rb +5 -1
  18. data/lib/attributor/types/date.rb +9 -3
  19. data/lib/attributor/types/date_time.rb +8 -2
  20. data/lib/attributor/types/float.rb +4 -3
  21. data/lib/attributor/types/hash.rb +82 -18
  22. data/lib/attributor/types/integer.rb +7 -1
  23. data/lib/attributor/types/model.rb +2 -2
  24. data/lib/attributor/types/object.rb +5 -0
  25. data/lib/attributor/types/polymorphic.rb +3 -2
  26. data/lib/attributor/types/string.rb +20 -1
  27. data/lib/attributor/types/struct.rb +1 -1
  28. data/lib/attributor/types/symbol.rb +5 -0
  29. data/lib/attributor/types/tempfile.rb +4 -0
  30. data/lib/attributor/types/time.rb +7 -3
  31. data/lib/attributor/types/uri.rb +9 -1
  32. data/lib/attributor/version.rb +1 -1
  33. data/spec/attribute_spec.rb +42 -7
  34. data/spec/dsl_compiler_spec.rb +7 -7
  35. data/spec/extras/field_selector/field_selector_spec.rb +9 -0
  36. data/spec/hash_dsl_compiler_spec.rb +2 -2
  37. data/spec/support/integers.rb +7 -0
  38. data/spec/type_spec.rb +1 -1
  39. data/spec/types/bigdecimal_spec.rb +8 -0
  40. data/spec/types/boolean_spec.rb +10 -0
  41. data/spec/types/class_spec.rb +0 -1
  42. data/spec/types/collection_spec.rb +16 -0
  43. data/spec/types/date_spec.rb +9 -0
  44. data/spec/types/date_time_spec.rb +9 -0
  45. data/spec/types/float_spec.rb +8 -0
  46. data/spec/types/hash_spec.rb +127 -9
  47. data/spec/types/integer_spec.rb +10 -1
  48. data/spec/types/model_spec.rb +14 -3
  49. data/spec/types/string_spec.rb +10 -0
  50. data/spec/types/temporal_spec.rb +5 -1
  51. data/spec/types/time_spec.rb +9 -0
  52. data/spec/types/uri_spec.rb +9 -0
  53. metadata +21 -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,40 @@ 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
+ if the_default = self.options[:default]
165
+ the_object = the_default.is_a?(Proc) ? the_default.call : the_default
166
+ description[:default] = the_object.is_a?(Attributor::Dumpable) ? the_object.dump : the_object
167
+ end
168
+ #TODO description[:title] = "TODO: do we want to use a title??..."
169
+
170
+ # Change the reference option to the actual class name.
171
+ if ( reference = self.options[:reference] )
172
+ description[:'x-reference'] = reference.name
173
+ end
174
+
175
+ # TODO: not sure if that's correct (we used to get it from the described hash...
176
+ description[:example] = self.dump(example) if example
177
+
178
+ description
179
+ end
180
+
181
+ def example(context=nil, parent: nil, values:{})
182
+ raise ArgumentError, "attribute example cannot take a context of type String" if (context.is_a? ::String )
151
183
  if context
152
184
  ctx = Attributor.humanize_context(context)
153
185
  seed, = Digest::SHA1.digest(ctx).unpack('QQ')
@@ -158,7 +190,8 @@ module Attributor
158
190
 
159
191
  if options.key? :example
160
192
  loaded = example_from_options(parent, context)
161
- errors = validate(loaded, context)
193
+ # Only validate the type, if the proc-generated example is "complex" (has attributes)
194
+ errors = loaded.class.respond_to?(:attributes) ? validate_type(loaded, context) : validate(loaded, context)
162
195
  raise AttributorException, "Error generating example for #{Attributor.humanize_context(context)}. Errors: #{errors.inspect}" if errors.any?
163
196
  return loaded
164
197
  end
@@ -166,7 +199,7 @@ module Attributor
166
199
  return options[:values].pick if options.key? :values
167
200
 
168
201
  if type.respond_to?(:attributes)
169
- type.example(context, values)
202
+ type.example(context, **values)
170
203
  else
171
204
  type.example(context, options: options)
172
205
  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
@@ -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
@@ -1,13 +1,14 @@
1
1
  require 'bigdecimal'
2
2
 
3
3
  module Attributor
4
- class BigDecimal < Numeric
4
+ class BigDecimal
5
+ include Numeric
5
6
  def self.native_type
6
7
  ::BigDecimal
7
8
  end
8
9
 
9
10
  def self.example(_context = nil, options: {})
10
- ::BigDecimal.new("#{/\d{3}/.gen}.#{/\d{3}/.gen}")
11
+ BigDecimal("#{/\d{3}/.gen}.#{/\d{3}/.gen}")
11
12
  end
12
13
 
13
14
  def self.load(value, _context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
@@ -16,5 +17,9 @@ module Attributor
16
17
  return BigDecimal(value, 10) if value.is_a?(::Float)
17
18
  BigDecimal(value)
18
19
  end
20
+
21
+ def self.json_schema_type
22
+ :number
23
+ end
19
24
  end
20
25
  end
@@ -17,14 +17,19 @@ module Attributor
17
17
  def self.load(value, context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
18
18
  return nil if value.nil?
19
19
 
20
- raise CoercionError, context: context, from: value.class, to: self, value: value if value.is_a?(::Float)
20
+ raise CoercionError.new(context: context, from: value.class, to: self, value: value) if value.is_a?(::Float)
21
21
  return false if [false, 'false', 'FALSE', '0', 0, 'f', 'F'].include?(value)
22
22
  return true if [true, 'true', 'TRUE', '1', 1, 't', 'T'].include?(value)
23
- raise CoercionError, context: context, from: value.class, to: self
23
+ raise CoercionError.new(context: context, from: value.class, to: self)
24
24
  end
25
25
 
26
26
  def self.family
27
27
  'boolean'
28
28
  end
29
+
30
+ def self.json_schema_type
31
+ :boolean
32
+ end
33
+
29
34
  end
30
35
  end
@@ -1,4 +1,4 @@
1
- require 'active_support'
1
+ require 'active_support/core_ext/string/inflections'
2
2
 
3
3
  require_relative '../exceptions'
4
4
 
@@ -16,7 +16,7 @@ module Attributor
16
16
 
17
17
  # Must be given a String object or nil
18
18
  unless value.is_a?(::String) || value.nil?
19
- raise IncompatibleTypeError, context: context, value_type: value.class, type: self
19
+ raise IncompatibleTypeError.new(context: context, value_type: value.class, type: self)
20
20
  end
21
21
 
22
22
  value = '::' + value if value[0..1] != '::'
@@ -51,7 +51,7 @@ module Attributor
51
51
 
52
52
  def self.member_attribute
53
53
  @member_attribute ||= begin
54
- construct(nil, {})
54
+ construct(nil)
55
55
 
56
56
  @member_attribute
57
57
  end
@@ -91,7 +91,7 @@ module Attributor
91
91
  elsif value.respond_to?(:to_a)
92
92
  loaded_value = value.to_a
93
93
  else
94
- raise Attributor::IncompatibleTypeError, context: context, value_type: value.class, type: self
94
+ raise Attributor::IncompatibleTypeError.new(context: context, value_type: value.class, type: self)
95
95
  end
96
96
 
97
97
  new(loaded_value.collect { |member| member_attribute.load(member, context) })
@@ -103,7 +103,7 @@ module Attributor
103
103
 
104
104
  def self.dump(values, **opts)
105
105
  return nil if values.nil?
106
- values.collect { |value| member_attribute.dump(value, opts) }
106
+ values.collect { |value| member_attribute.dump(value, **opts) }
107
107
  end
108
108
 
109
109
  def self.describe(shallow = false, example: nil)
@@ -117,11 +117,30 @@ module Attributor
117
117
  hash
118
118
  end
119
119
 
120
+ def self.json_schema_type
121
+ :array
122
+ end
123
+
124
+ def self.as_json_schema( shallow: false, example: nil, attribute_options: {} )
125
+ hash = super
126
+ opts = self.options.merge( attribute_options )
127
+ hash[:description] = opts[:description] if opts[:description]
128
+ if the_default = opts[:default]
129
+ the_object = the_default.is_a?(Proc) ? the_default.call : the_default
130
+ hash[:description] = the_object.is_a?(Attributor::Dumpable) ? the_object.dump : the_object
131
+ end
132
+
133
+ #hash[:examples] = [ example.dump ] if example
134
+ member_example = example && example.first
135
+ hash[:items] = member_attribute.as_json_schema(example: member_example)
136
+ hash
137
+ end
138
+
120
139
  def self.constructable?
121
140
  true
122
141
  end
123
142
 
124
- def self.construct(constructor_block, options)
143
+ def self.construct(constructor_block, **options)
125
144
  member_options = (options[:member_options] || {}).clone
126
145
  if options.key?(:reference) && !member_options.key?(:reference)
127
146
  member_options[:reference] = options[:reference]
@@ -173,7 +192,7 @@ module Attributor
173
192
  end
174
193
 
175
194
  def dump(**opts)
176
- collect { |value| self.class.member_attribute.dump(value, opts) }
195
+ collect { |value| self.class.member_attribute.dump(value, **opts) }
177
196
  end
178
197
  end
179
198
  end
@@ -19,17 +19,17 @@ module Attributor
19
19
  # @return [Array] a normal Ruby Array
20
20
  #
21
21
  def decode_json(value, context = Attributor::DEFAULT_ROOT_CONTEXT)
22
- raise Attributor::DeserializationError, context: context, from: value.class, encoding: 'JSON', value: value unless value.is_a? ::String
22
+ raise Attributor::DeserializationError.new(context: context, from: value.class, encoding: 'JSON', value: value) unless value.is_a? ::String
23
23
 
24
24
  # attempt to parse as JSON
25
25
  parsed_value = JSON.parse(value)
26
26
  unless valid_type?(parsed_value)
27
- raise Attributor::CoercionError, context: context, from: parsed_value.class, to: name, value: parsed_value
27
+ raise Attributor::CoercionError.new(context: context, from: parsed_value.class, to: name, value: parsed_value)
28
28
  end
29
29
 
30
30
  parsed_value
31
31
  rescue JSON::JSONError
32
- raise Attributor::DeserializationError, context: context, from: value.class, encoding: 'JSON', value: value
32
+ raise Attributor::DeserializationError.new(context: context, from: value.class, encoding: 'JSON', value: value)
33
33
  end
34
34
  end
35
35
  end
@@ -9,7 +9,7 @@ module Attributor
9
9
  when ::String
10
10
  values
11
11
  when ::Array
12
- values.collect { |value| member_attribute.dump(value, opts).to_s }.join(',')
12
+ values.collect { |value| member_attribute.dump(value, **opts).to_s }.join(',')
13
13
  when nil
14
14
  nil
15
15
  else
@@ -37,5 +37,9 @@ module Attributor
37
37
  def self.family
38
38
  Collection.family
39
39
  end
40
+
41
+ def self.json_schema_type
42
+ :string
43
+ end
40
44
  end
41
45
  end
@@ -1,7 +1,9 @@
1
1
  require 'date'
2
2
 
3
3
  module Attributor
4
- class Date < Temporal
4
+ class Date
5
+ include Temporal
6
+
5
7
  def self.native_type
6
8
  ::Date
7
9
  end
@@ -21,11 +23,15 @@ module Attributor
21
23
  begin
22
24
  return ::Date.parse(value)
23
25
  rescue ArgumentError
24
- raise Attributor::DeserializationError, context: context, from: value.class, encoding: 'Date', value: value
26
+ raise Attributor::DeserializationError.new(context: context, from: value.class, encoding: 'Date', value: value)
25
27
  end
26
28
  else
27
- raise CoercionError, context: context, from: value.class, to: self, value: value
29
+ raise CoercionError.new(context: context, from: value.class, to: self, value: value)
28
30
  end
29
31
  end
32
+
33
+ def self.json_schema_string_format
34
+ :date
35
+ end
30
36
  end
31
37
  end