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
@@ -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
@@ -59,6 +59,7 @@ module Attributor
59
59
 
60
60
  # generates an example Collection
61
61
  # @return An Array of native type objects conforming to the specified member_type
62
+ # TODO: ALLOW to pass "values" for the members?...as values: {id: 1, ...}
62
63
  def self.example(context = nil, options: {})
63
64
  result = []
64
65
  size = options[:size] || (rand(3) + 1)
@@ -90,7 +91,7 @@ module Attributor
90
91
  elsif value.respond_to?(:to_a)
91
92
  loaded_value = value.to_a
92
93
  else
93
- raise Attributor::IncompatibleTypeError, context: context, value_type: value.class, type: self
94
+ raise Attributor::IncompatibleTypeError.new(context: context, value_type: value.class, type: self)
94
95
  end
95
96
 
96
97
  new(loaded_value.collect { |member| member_attribute.load(member, context) })
@@ -102,7 +103,7 @@ module Attributor
102
103
 
103
104
  def self.dump(values, **opts)
104
105
  return nil if values.nil?
105
- values.collect { |value| member_attribute.dump(value, opts) }
106
+ values.collect { |value| member_attribute.dump(value, **opts) }
106
107
  end
107
108
 
108
109
  def self.describe(shallow = false, example: nil)
@@ -116,11 +117,27 @@ module Attributor
116
117
  hash
117
118
  end
118
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
+ hash[:default] = opts[:default] if opts[:default]
129
+
130
+ #hash[:examples] = [ example.dump ] if example
131
+ member_example = example && example.first
132
+ hash[:items] = member_attribute.as_json_schema(example: member_example)
133
+ hash
134
+ end
135
+
119
136
  def self.constructable?
120
137
  true
121
138
  end
122
139
 
123
- def self.construct(constructor_block, options)
140
+ def self.construct(constructor_block, **options)
124
141
  member_options = (options[:member_options] || {}).clone
125
142
  if options.key?(:reference) && !member_options.key?(:reference)
126
143
  member_options[:reference] = options[:reference]
@@ -172,7 +189,7 @@ module Attributor
172
189
  end
173
190
 
174
191
  def dump(**opts)
175
- collect { |value| self.class.member_attribute.dump(value, opts) }
192
+ collect { |value| self.class.member_attribute.dump(value, **opts) }
176
193
  end
177
194
  end
178
195
  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
@@ -5,7 +5,9 @@ require_relative '../exceptions'
5
5
  require 'date'
6
6
 
7
7
  module Attributor
8
- class DateTime < Temporal
8
+ class DateTime
9
+ include Temporal
10
+
9
11
  def self.native_type
10
12
  ::DateTime
11
13
  end
@@ -24,8 +26,12 @@ module Attributor
24
26
  begin
25
27
  return ::DateTime.parse(value)
26
28
  rescue ArgumentError
27
- raise Attributor::DeserializationError, context: context, from: value.class, encoding: 'DateTime', value: value
29
+ raise Attributor::DeserializationError.new(context: context, from: value.class, encoding: 'DateTime', value: value)
28
30
  end
29
31
  end
32
+
33
+ def self.json_schema_string_format
34
+ :'date-time'
35
+ end
30
36
  end
31
37
  end
@@ -2,8 +2,9 @@
2
2
  # See: http://ruby-doc.org/core-2.1.0/Float.html
3
3
 
4
4
  module Attributor
5
+
5
6
  class Float
6
- include Type
7
+ include Numeric
7
8
 
8
9
  def self.native_type
9
10
  ::Float
@@ -22,8 +23,8 @@ module Attributor
22
23
  super
23
24
  end
24
25
 
25
- def self.family
26
- 'numeric'
26
+ def self.json_schema_type
27
+ :number
27
28
  end
28
29
  end
29
30
  end
@@ -16,7 +16,7 @@ module Attributor
16
16
  end
17
17
 
18
18
  class Hash
19
- MAX_EXAMPLE_DEPTH = 5
19
+ MAX_EXAMPLE_DEPTH = 10
20
20
  CIRCULAR_REFERENCE_MARKER = '...'.freeze
21
21
 
22
22
  include Container
@@ -82,7 +82,7 @@ module Attributor
82
82
  def self.attributes(**options, &key_spec)
83
83
  raise @error if @error
84
84
 
85
- keys(options, &key_spec)
85
+ keys(**options, &key_spec)
86
86
  end
87
87
 
88
88
  def self.keys(**options, &key_spec)
@@ -97,6 +97,13 @@ module Attributor
97
97
  @keys
98
98
  end
99
99
 
100
+ def self.requirements
101
+ if @saved_blocks.any?
102
+ definition
103
+ end
104
+ @requirements
105
+ end
106
+
100
107
  def self.definition
101
108
  opts = {
102
109
  key_type: @key_type,
@@ -104,7 +111,7 @@ module Attributor
104
111
  }.merge(@options)
105
112
 
106
113
  blocks = @saved_blocks.shift(@saved_blocks.size)
107
- compiler = dsl_class.new(self, opts)
114
+ compiler = dsl_class.new(self, **opts)
108
115
  compiler.parse(*blocks)
109
116
 
110
117
  if opts[:case_insensitive_load] == true
@@ -164,15 +171,27 @@ module Attributor
164
171
  raise Attributor::AttributorException, ":case_insensitive_load may not be used with keys of type #{key_type.name}"
165
172
  end
166
173
 
167
- keys(options, &constructor_block)
174
+ keys(**options, &constructor_block)
168
175
  self
169
176
  end
170
177
 
178
+
179
+
171
180
  def self.example_contents(context, parent, **values)
172
181
  hash = ::Hash.new
173
182
  example_depth = context.size
174
-
175
- keys.each do |sub_attribute_name, sub_attribute|
183
+ # Be smart about what attributes to use for the example: i.e. have into account complex requirements
184
+ # that might have been defined in the hash like at_most(1).of ..., exactly(2).of ...etc.
185
+ # But play it safe and default to the previous behavior in case there is any error processing them
186
+ # ( that is until the SmartAttributeSelector class isn't fully tested and ready for prime time)
187
+ begin
188
+ stack = SmartAttributeSelector.new( requirements.map(&:describe), keys.keys , values)
189
+ selected = stack.process
190
+ rescue => e
191
+ selected = keys.keys
192
+ end
193
+
194
+ keys.select{|n,attr| selected.include? n}.each do |sub_attribute_name, sub_attribute|
176
195
  if sub_attribute.attributes
177
196
  # TODO: add option to raise an exception in this case?
178
197
  next if example_depth > MAX_EXAMPLE_DEPTH
@@ -202,7 +221,7 @@ module Attributor
202
221
  result = new
203
222
  result.extend(ExampleMixin)
204
223
 
205
- result.lazy_attributes = example_contents(context, result, values)
224
+ result.lazy_attributes = example_contents(context, result, **values)
206
225
  else
207
226
  hash = ::Hash.new
208
227
 
@@ -243,11 +262,11 @@ module Attributor
243
262
  end
244
263
 
245
264
  def self.load(value, context = Attributor::DEFAULT_ROOT_CONTEXT, recurse: false, **_options)
246
- context = Array(context)
247
265
 
248
266
  return value if value.is_a?(self)
249
267
  return nil if value.nil? && !recurse
250
268
 
269
+ context = Array(context)
251
270
  loaded_value = self.parse(value, context)
252
271
 
253
272
  return from_hash(loaded_value, context, recurse: recurse) if keys.any?
@@ -263,10 +282,12 @@ module Attributor
263
282
  value
264
283
  elsif value.is_a?(::String)
265
284
  decode_json(value, context)
266
- elsif value.respond_to?(:to_hash)
285
+ elsif value.respond_to?(:to_h)
286
+ value.to_h
287
+ elsif value.respond_to?(:to_hash) # Deprecate this in lieu of to_h only?
267
288
  value.to_hash
268
289
  else
269
- raise Attributor::IncompatibleTypeError, context: context, value_type: value.class, type: self
290
+ raise Attributor::IncompatibleTypeError.new(context: context, value_type: value.class, type: self)
270
291
  end
271
292
  end
272
293
 
@@ -282,6 +303,10 @@ module Attributor
282
303
  context + ["key(#{key_name.inspect})"]
283
304
  end
284
305
 
306
+ def to_h
307
+ Attributor.recursive_to_h(@contents)
308
+ end
309
+
285
310
  def generate_subcontext(context, key_name)
286
311
  self.class.generate_subcontext(context, key_name)
287
312
  end
@@ -412,11 +437,11 @@ module Attributor
412
437
 
413
438
  if keys.any?
414
439
  # Spit keys if it's the root or if it's an anonymous structures
415
- if !shallow || name.nil?
416
- required_names = []
440
+ if ( !shallow || self.name == nil)
441
+ required_names_from_attr = []
417
442
  # FIXME: change to :keys when the praxis doc browser supports displaying those
418
- hash[:attributes] = keys.each_with_object({}) do |(sub_name, sub_attribute), sub_attributes|
419
- required_names << sub_name if sub_attribute.options[:required] == true
443
+ hash[:attributes] = self.keys.each_with_object({}) do |(sub_name, sub_attribute), sub_attributes|
444
+ required_names_from_attr << sub_name if sub_attribute.options[:required] == true
420
445
  sub_example = example.get(sub_name) if example
421
446
  sub_attributes[sub_name] = sub_attribute.describe(true, example: sub_example)
422
447
  end
@@ -424,23 +449,79 @@ module Attributor
424
449
  described_req = req.describe(shallow)
425
450
  if described_req[:type] == :all
426
451
  # Add the names of the attributes that have the required flag too
427
- described_req[:attributes] |= required_names
428
- required_names = []
452
+ described_req[:attributes] |= required_names_from_attr
453
+ required_names_from_attr = []
429
454
  end
430
455
  list << described_req
431
456
  end
432
457
  # Make sure we create an :all requirement, if there wasn't one so we can add the required: true attributes
433
- unless required_names.empty?
434
- hash[:requirements] << { type: :all, attributes: required_names }
458
+ unless required_names_from_attr.empty?
459
+ hash[:requirements] << {type: :all, attributes: required_names_from_attr }
435
460
  end
436
461
  end
437
462
  else
438
463
  hash[:value] = { type: value_type.describe(true) }
464
+ hash[:example] = example if example
465
+ end
466
+
467
+ hash
468
+ end
469
+
470
+ def self.as_json_schema( shallow: false, example: nil, attribute_options: {} )
471
+ hash = super
472
+ opts = self.options.merge( attribute_options )
473
+
474
+ if key_type
475
+ hash[:'x-key_type'] = key_type.as_json_schema
476
+ end
477
+
478
+ if self.keys.any?
479
+ # Spit keys if it's the root or if it's an anonymous structures
480
+ if ( !shallow || self.name == nil)
481
+ required_names_from_attr = []
482
+ # FIXME: change to :keys when the praxis doc browser supports displaying those
483
+ hash[:properties] = self.keys.each_with_object({}) do |(sub_name, sub_attribute), sub_attributes|
484
+ required_names_from_attr << sub_name if sub_attribute.options[:required] == true
485
+ sub_example = example.get(sub_name) if example
486
+ sub_attributes[sub_name] = sub_attribute.as_json_schema(shallow: true, example: sub_example)
487
+ end
488
+
489
+ # Expose the more complex requirements to in the x-tended attribute
490
+ extended_requirements = self.requirements.each_with_object([]) do |req, list|
491
+ described_req = req.describe(shallow)
492
+ if described_req[:type] == :all
493
+ # Add the names of the attributes that have the required flag too
494
+ described_req[:attributes] |= required_names_from_attr
495
+ required_names_from_attr = []
496
+ end
497
+ list << described_req
498
+ end
499
+ all = extended_requirements.find{|r| r[:type] == :all }
500
+ if ( all && !all[:attributes].empty? )
501
+ hash[:required] = all[:attributes]
502
+ end
503
+ hash[:'x-requirements'] = extended_requirements unless extended_requirements.empty?
504
+ end
505
+ else
506
+ hash[:'x-value_type'] = value_type.as_json_schema(shallow:true)
439
507
  end
440
508
 
509
+ if opts[:allow_extra]
510
+ hash[:additionalProperties] = if value_type == Attributor::Object
511
+ true
512
+ else
513
+ value_type.as_json_schema(shallow: true)
514
+ end
515
+ end
516
+ # TODO: minProperties and maxProperties and patternProperties
517
+ # TODO: map our required_if (and possible our above requirements 'at_least...' to json schema dependencies)
441
518
  hash
442
519
  end
443
520
 
521
+ def self.json_schema_type
522
+ :object
523
+ end
524
+
444
525
  # TODO: Think about the format of the subcontexts to use: let's use .at(key.to_s)
445
526
  attr_reader :contents
446
527
 
@@ -528,6 +609,7 @@ module Attributor
528
609
  end
529
610
 
530
611
  def validate(context = Attributor::DEFAULT_ROOT_CONTEXT)
612
+ @validating = true
531
613
  context = [context] if context.is_a? ::String
532
614
 
533
615
  if self.class.keys.any?
@@ -535,6 +617,8 @@ module Attributor
535
617
  else
536
618
  self.validate_generic(context)
537
619
  end
620
+ ensure
621
+ @validating = false
538
622
  end
539
623
 
540
624
  def validate_keys(context)
@@ -587,12 +671,12 @@ module Attributor
587
671
  @dumping = true
588
672
 
589
673
  contents.each_with_object({}) do |(k, v), hash|
590
- k = key_attribute.dump(k, opts)
674
+ k = key_attribute.dump(k, **opts)
591
675
 
592
676
  v = if (attribute_for_value = self.class.keys[k])
593
- attribute_for_value.dump(v, opts)
677
+ attribute_for_value.dump(v, **opts)
594
678
  else
595
- value_attribute.dump(v, opts)
679
+ value_attribute.dump(v, **opts)
596
680
  end
597
681
 
598
682
  hash[k] = v