attributor 5.5 → 6.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 30cd524aa1a60bb34487915e2201e20b075f8eaf
4
- data.tar.gz: 4a9776a1a772b00636a46caec55f583aa75d54f4
2
+ SHA256:
3
+ metadata.gz: 1a27077cb0a67b6ab8fdde19c88a993ba648c6deb38d5a83766df49b7199338d
4
+ data.tar.gz: 99bf9996744d757817fd167fa510b462fab6ee1dd3b52992594d06768966bd53
5
5
  SHA512:
6
- metadata.gz: a5a4832825c92b5530352b626ae281c134aa215f167c633a7912b22cee2927fde696d07b92bfd6159b0e88db4dc69b19b38a289181cb523e0c961005608e4f86
7
- data.tar.gz: a44680f8509acf463ac0c4a22a872db8456300745d01b5c4a31a597bd17760d995e5d6ce26120d998f8df5c4c06fe2d11e7e00c35959f6c987860059425af663
6
+ metadata.gz: e91ad1a7759be0e56c66f53bafbe6d22f1a2ce724fc777ade2649987d6bf19d184eb0fc5f43d00dcbf2310bbae72359199f34d25c38aa65cc69f38cc28d33a55
7
+ data.tar.gz: 947764564827d2d05e967f60ccc100d42eedf219fddab8ffff56bb8f61c0264c67e9bdd77d3a3585b99d80e5e54f22ef2a6e3424c3ba83549df0932874b0c0af
data/CHANGELOG.md CHANGED
@@ -1,6 +1,23 @@
1
1
  # Attributor Changelog
2
2
 
3
3
  ## next
4
+ - added support for enum's out of values in json_schema generation
5
+
6
+ ## 6.1 (1/7/2022)
7
+ - added support for enum's out of values in json_schema generation
8
+
9
+ ## 6.0 (22/11/2021)
10
+ - removed `required_if` support and all of the necessary code.
11
+ - changed the semantics of the `required:` option in attributes, to really mean if the "key" is required to be passed in or not (i.e., check if the key is null, not if its value is null)
12
+ - Introduced a new option`null: true|false` to allow for the value of an attribute to be nullable or not when the attribute is passed in.
13
+ * The default behavior for an attribute nullability currently `null: false` (but it can be easily changed by overriding the `Attributor::Attribute.default_for_null` function to return `true`)
14
+ ## 5.7 (1/7/2021)
15
+
16
+ - added `custom_option` to Attributor::Attribute class, accepting a name and Attribute arguments that will be used to validate the option value(s) provided.
17
+
18
+ ## 5.6 (11/02/2020)
19
+
20
+ - Small fixes for dumping JSON-schema default values if they're Proc's or dumpable objects
4
21
 
5
22
  ## 5.5 (21/08/2020)
6
23
 
@@ -17,18 +17,30 @@ module Attributor
17
17
  FakeParent
18
18
  end
19
19
  end
20
+
20
21
  # It is the abstract base class to hold an attribute, both a leaf and a container (hash/Array...)
21
22
  # TODO: should this be a mixin since it is an abstract class?
22
23
  class Attribute
23
24
  attr_reader :type, :options
24
25
 
26
+ @custom_options = {}
27
+
28
+ class << self
29
+ attr_accessor :custom_options
30
+ end
31
+
32
+ def self.custom_option(name, attr_type, options = {}, &block)
33
+ if TOP_LEVEL_OPTIONS.include?(name) || INTERNAL_OPTIONS.include?(name)
34
+ raise ArgumentError, "can not define custom_option with name #{name.inspect}, it is reserved by Attributor"
35
+ end
36
+ self.custom_options[name] = Attributor::Attribute.new(attr_type, options, &block)
37
+ end
38
+
25
39
  # @options: metadata about the attribute
26
40
  # @block: code definition for struct attributes (nil for predefined types or leaf/simple types)
27
41
  def initialize(type, options = {}, &block)
28
42
  @type = Attributor.resolve_type(type, options, block)
29
-
30
- @options = options
31
- @options = @type.options.merge(@options) if @type.respond_to?(:options)
43
+ @options = @type.respond_to?(:options) ? @type.options.merge(options) : options
32
44
 
33
45
  check_options!
34
46
  end
@@ -82,18 +94,16 @@ module Attributor
82
94
 
83
95
  def validate_type(value, context)
84
96
  # delegate check to type subclass if it exists
85
- unless type.valid_type?(value)
86
- msg = "Attribute #{Attributor.humanize_context(context)} received value: "
87
- msg += "#{Attributor.errorize_value(value)} is of the wrong type "
88
- msg += "(got: #{value.class.name}, expected: #{type.name})"
89
- return [msg]
90
- end
91
- []
97
+ return [] if value.nil? || type.valid_type?(value)
98
+
99
+ msg = "Attribute #{Attributor.humanize_context(context)} received value: "
100
+ msg += "#{Attributor.errorize_value(value)} is of the wrong type "
101
+ msg += "(got: #{value.class.name}, expected: #{type.name})"
102
+ [msg]
92
103
  end
93
104
 
94
- TOP_LEVEL_OPTIONS = [:description, :values, :default, :example, :required, :required_if, :custom_data].freeze
105
+ TOP_LEVEL_OPTIONS = [:description, :values, :default, :example, :required, :null, :custom_data].freeze
95
106
  INTERNAL_OPTIONS = [:dsl_compiler, :dsl_compiler_options].freeze # Options we don't want to expose when describing attributes
96
- JSON_SCHEMA_UNSUPPORTED_OPTIONS = [ :required, :required_if ].freeze
97
107
  def describe(shallow=true, example: nil)
98
108
  description = { }
99
109
  # Clone the common options
@@ -161,7 +171,10 @@ module Attributor
161
171
 
162
172
  description[:description] = self.options[:description] if self.options[:description]
163
173
  description[:enum] = self.options[:values] if self.options[:values]
164
- description[:default] = self.options[:default] if self.options[:default]
174
+ if the_default = self.options[:default]
175
+ the_object = the_default.is_a?(Proc) ? the_default.call : the_default
176
+ description[:default] = the_object.is_a?(Attributor::Dumpable) ? the_object.dump : the_object
177
+ end
165
178
  #TODO description[:title] = "TODO: do we want to use a title??..."
166
179
 
167
180
  # Change the reference option to the actual class name.
@@ -172,6 +185,11 @@ module Attributor
172
185
  # TODO: not sure if that's correct (we used to get it from the described hash...
173
186
  description[:example] = self.dump(example) if example
174
187
 
188
+ # add custom options as x-optionname
189
+ self.class.custom_options.each do |name, _|
190
+ description["x-#{name}".to_sym] = self.options[name] if self.options.key?(name)
191
+ end
192
+
175
193
  description
176
194
  end
177
195
 
@@ -206,78 +224,38 @@ module Attributor
206
224
  type.attributes if @type_has_attributes ||= type.respond_to?(:attributes)
207
225
  end
208
226
 
227
+ # Default value for a non-specified null: option
228
+ def self.default_for_null
229
+ false
230
+ end
231
+
232
+ # It is only nullable if there is an explicit null: true (or if it's not passed/set, and the default is true)
233
+ def self.nullable_attribute?(options)
234
+ !options.key?(:null) ? default_for_null : options[:null]
235
+ end
236
+
209
237
  # Validates stuff and checks dependencies
210
238
  def validate(object, context = Attributor::DEFAULT_ROOT_CONTEXT)
211
239
  raise "INVALID CONTEXT!! #{context}" unless context
212
240
  # Validate any requirements, absolute or conditional, and return.
213
241
 
214
- if object.nil? # == Attributor::UNSET
215
- # With no value, we can only validate whether that is acceptable or not and return.
216
- # Beyond that, no further validation should be done.
217
- return validate_missing_value(context)
218
- end
219
-
220
- # TODO: support validation for other types of conditional dependencies based on values of other attributes
221
-
222
- errors = validate_type(object, context)
223
-
224
- # End validation if we don't even have the proper type to begin with
225
- return errors if errors.any?
226
-
227
- if options[:values] && !options[:values].include?(object)
228
- errors << "Attribute #{Attributor.humanize_context(context)}: #{Attributor.errorize_value(object)} is not within the allowed values=#{options[:values].inspect} "
229
- end
230
-
231
- errors + type.validate(object, context, self)
232
- end
233
-
234
- def validate_missing_value(context)
235
- raise "INVALID CONTEXT!!! (got: #{context.inspect})" unless context.is_a? Enumerable
236
-
237
- # Missing attribute was required if :required option was set
238
- return ["Attribute #{Attributor.humanize_context(context)} is required"] if options[:required]
239
-
240
- # Missing attribute was not required if :required_if (and :required)
241
- # option was NOT set
242
- requirement = options[:required_if]
243
- return [] unless requirement
244
-
245
- case requirement
246
- when ::String
247
- key_path = requirement
248
- predicate = nil
249
- when ::Hash
250
- # TODO: support multiple dependencies?
251
- key_path = requirement.keys.first
252
- predicate = requirement.values.first
242
+ errors = []
243
+ if object.nil? && !self.class.nullable_attribute?(options)
244
+ errors << "Attribute #{Attributor.humanize_context(context)} is not nullable"
253
245
  else
254
- # should never get here if the option validation worked...
255
- raise AttributorException, "unknown type of dependency: #{requirement.inspect} for #{Attributor.humanize_context(context)}"
256
- end
257
-
258
- # chop off the last part
259
- requirement_context = context[0..-2]
260
- requirement_context_string = requirement_context.join(Attributor::SEPARATOR)
246
+ errors.push *validate_type(object, context)
261
247
 
262
- # FIXME: we're having to reconstruct a string context just to use the resolver...smell.
263
- if AttributeResolver.current.check(requirement_context_string, key_path, predicate)
264
- message = "Attribute #{Attributor.humanize_context(context)} is required when #{key_path} "
265
-
266
- # give a hint about what the full path for a relative key_path would be
267
- unless key_path[0..0] == Attributor::AttributeResolver::ROOT_PREFIX
268
- message << "(for #{Attributor.humanize_context(requirement_context)}) "
248
+ # If the value is null we skip value validation:
249
+ # a) If null wasn't allowed, it would have failed above.
250
+ # b) If null was allowed, we always allow that as a valid value
251
+ if !object.nil? && options[:values] && !options[:values].include?(object)
252
+ errors << "Attribute #{Attributor.humanize_context(context)}: #{Attributor.errorize_value(object)} is not within the allowed values=#{options[:values].inspect} "
269
253
  end
270
-
271
- message << if predicate
272
- "matches #{predicate.inspect}."
273
- else
274
- 'is present.'
275
- end
276
-
277
- [message]
278
- else
279
- []
280
254
  end
255
+
256
+ return errors if errors.any?
257
+
258
+ object.nil? ? errors : errors + type.validate(object, context, self)
281
259
  end
282
260
 
283
261
  def check_options!
@@ -293,6 +271,8 @@ module Attributor
293
271
 
294
272
  # TODO: override in type subclass
295
273
  def check_option!(name, definition)
274
+ return check_custom_option(name, definition) if self.class.custom_options.include? name
275
+
296
276
  case name
297
277
  when :values
298
278
  raise AttributorException, "Allowed set of values requires an array. Got (#{definition})" unless definition.is_a? ::Array
@@ -304,9 +284,8 @@ module Attributor
304
284
  when :required
305
285
  raise AttributorException, 'Required must be a boolean' unless definition == true || definition == false
306
286
  raise AttributorException, 'Required cannot be enabled in combination with :default' if definition == true && options.key?(:default)
307
- when :required_if
308
- raise AttributorException, 'Required_if must be a String, a Hash definition or a Proc' unless definition.is_a?(::String) || definition.is_a?(::Hash) || definition.is_a?(::Proc)
309
- raise AttributorException, 'Required_if cannot be specified together with :required' if options[:required]
287
+ when :null
288
+ raise AttributorException, 'Null must be a boolean' unless definition == true || definition == false
310
289
  when :example
311
290
  unless definition.is_a?(::Regexp) || definition.is_a?(::String) || definition.is_a?(::Array) || definition.is_a?(::Proc) || definition.nil? || type.valid_type?(definition)
312
291
  raise AttributorException, "Invalid example type (got: #{definition.class.name}). It must always match the type of the attribute (except if passing Regex that is allowed for some types)"
@@ -319,5 +298,14 @@ module Attributor
319
298
 
320
299
  :ok # passes
321
300
  end
301
+
302
+ def check_custom_option(name, definition)
303
+ attribute = self.class.custom_options.fetch(name)
304
+
305
+ errors = attribute.validate(definition)
306
+ raise AttributorException, "Custom option #{name.inspect} is invalid: #{errors.inspect}" if errors.any?
307
+
308
+ :ok
309
+ end
322
310
  end
323
311
  end
@@ -35,30 +35,31 @@ module Attributor
35
35
  rest = attr_names - keys
36
36
  unless rest.empty?
37
37
  rest.each do |attr|
38
- result.push "Key #{attr} is required for #{Attributor.humanize_context(context)}."
38
+ sub_context = Attributor::Hash.generate_subcontext(context, attr)
39
+ result.push "Attribute #{Attributor.humanize_context(sub_context)} is required."
39
40
  end
40
41
  end
41
42
  when :exactly
42
43
  included = attr_names & keys
43
44
  unless included.size == number
44
- result.push "Exactly #{number} of the following keys #{attr_names} are required for #{Attributor.humanize_context(context)}. Found #{included.size} instead: #{included.inspect}"
45
+ result.push "Exactly #{number} of the following attributes #{attr_names} are required for #{Attributor.humanize_context(context)}. Found #{included.size} instead: #{included.inspect}"
45
46
  end
46
47
  when :at_most
47
48
  rest = attr_names & keys
48
49
  if rest.size > number
49
50
  found = rest.empty? ? 'none' : rest.inspect
50
- result.push "At most #{number} keys out of #{attr_names} can be passed in for #{Attributor.humanize_context(context)}. Found #{found}"
51
+ result.push "At most #{number} attributes out of #{attr_names} can be passed in for #{Attributor.humanize_context(context)}. Found #{found}"
51
52
  end
52
53
  when :at_least
53
54
  rest = attr_names & keys
54
55
  if rest.size < number
55
56
  found = rest.empty? ? 'none' : rest.inspect
56
- result.push "At least #{number} keys out of #{attr_names} are required to be passed in for #{Attributor.humanize_context(context)}. Found #{found}"
57
+ result.push "At least #{number} attributes out of #{attr_names} are required to be passed in for #{Attributor.humanize_context(context)}. Found #{found}"
57
58
  end
58
59
  when :exclusive
59
60
  intersection = attr_names & keys
60
61
  if intersection.size > 1
61
- result.push "keys #{intersection.inspect} are mutually exclusive for #{Attributor.humanize_context(context)}."
62
+ result.push "Attributes #{intersection.inspect} are mutually exclusive for #{Attributor.humanize_context(context)}."
62
63
  end
63
64
  end
64
65
  result
@@ -135,6 +135,9 @@ module Attributor
135
135
  if hash[:type] == :string && the_format = json_schema_string_format
136
136
  hash[:format] = the_format
137
137
  end
138
+ # Common options
139
+ hash[:enum] = attribute_options[:values] if attribute_options[:values]
140
+
138
141
  hash
139
142
  end
140
143
 
@@ -125,7 +125,10 @@ module Attributor
125
125
  hash = super
126
126
  opts = self.options.merge( attribute_options )
127
127
  hash[:description] = opts[:description] if opts[:description]
128
- hash[:default] = opts[:default] if opts[:default]
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
129
132
 
130
133
  #hash[:examples] = [ example.dump ] if example
131
134
  member_example = example && example.first
@@ -426,7 +426,6 @@ module Attributor
426
426
  unless object.is_a?(self)
427
427
  raise ArgumentError, "#{name} can not validate object of type #{object.class.name} for #{Attributor.humanize_context(context)}."
428
428
  end
429
-
430
429
  object.validate(context)
431
430
  end
432
431
 
@@ -514,7 +513,6 @@ module Attributor
514
513
  end
515
514
  end
516
515
  # TODO: minProperties and maxProperties and patternProperties
517
- # TODO: map our required_if (and possible our above requirements 'at_least...' to json schema dependencies)
518
516
  hash
519
517
  end
520
518
 
@@ -613,6 +611,12 @@ module Attributor
613
611
  context = [context] if context.is_a? ::String
614
612
 
615
613
  if self.class.keys.any?
614
+ extra_keys = @contents.keys - self.class.keys.keys
615
+ if extra_keys.any? && !self.class.options[:allow_extra]
616
+ return extra_keys.collect do |k|
617
+ "#{Attributor.humanize_context(context)} can not have key: #{k.inspect}"
618
+ end
619
+ end
616
620
  self.validate_keys(context)
617
621
  else
618
622
  self.validate_generic(context)
@@ -622,30 +626,34 @@ module Attributor
622
626
  end
623
627
 
624
628
  def validate_keys(context)
625
- extra_keys = @contents.keys - self.class.keys.keys
626
- if extra_keys.any? && !self.class.options[:allow_extra]
627
- return extra_keys.collect do |k|
628
- "#{Attributor.humanize_context(context)} can not have key: #{k.inspect}"
629
- end
630
- end
631
-
632
629
  errors = []
633
- keys_with_values = []
630
+ keys_provided = []
634
631
 
635
632
  self.class.keys.each do |key, attribute|
636
633
  sub_context = self.class.generate_subcontext(context, key)
637
634
 
638
- value = @contents[key]
639
- keys_with_values << key unless value.nil?
635
+ value = _get_attr(key)
636
+ keys_provided << key if @contents.key?(key)
640
637
 
641
638
  if value.respond_to?(:validating) # really, it's a thing with sub-attributes
642
639
  next if value.validating
643
640
  end
644
-
645
- errors.concat attribute.validate(value, sub_context)
641
+ # Isn't this handled by the requirements validation? NO! we might want to combine
642
+ if attribute.options[:required] && !@contents.key?(key)
643
+ errors.concat ["Attribute #{Attributor.humanize_context(sub_context)} is required."]
644
+ end
645
+ if @contents[key].nil?
646
+ if !Attribute.nullable_attribute?(attribute.options) && @contents.key?(key)
647
+ errors.concat ["Attribute #{Attributor.humanize_context(sub_context)} is not nullable."]
648
+ end
649
+ # No need to validate the attribute further if the key wasn't passed...(or we would get nullable errors etc..cause the attribute has no
650
+ # context if its containing key was even passed (and there might not be a containing key for a top level attribute anyways))
651
+ else
652
+ errors.concat attribute.validate(value, sub_context)
653
+ end
646
654
  end
647
655
  self.class.requirements.each do |requirement|
648
- validation_errors = requirement.validate(keys_with_values, context)
656
+ validation_errors = requirement.validate(keys_provided, context)
649
657
  errors.concat(validation_errors) unless validation_errors.empty?
650
658
  end
651
659
  errors
@@ -661,7 +669,7 @@ module Attributor
661
669
 
662
670
  unless value_type == Attributor::Object
663
671
  sub_context = context + ["value(#{value.inspect})"]
664
- errors.concat value_attribute.validate(value, sub_context)
672
+ errors.concat value_attribute.validate(value, sub_context)
665
673
  end
666
674
  end
667
675
  end
@@ -129,27 +129,9 @@ module Attributor
129
129
  @validating = true
130
130
 
131
131
  context = [context] if context.is_a? ::String
132
- keys_with_values = []
133
- errors = []
134
-
135
- self.class.attributes.each do |sub_attribute_name, sub_attribute|
136
- sub_context = self.class.generate_subcontext(context, sub_attribute_name)
137
-
138
- value = __send__(sub_attribute_name)
139
- keys_with_values << sub_attribute_name unless value.nil?
140
-
141
- if value.respond_to?(:validating) # really, it's a thing with sub-attributes
142
- next if value.validating
143
- end
144
-
145
- errors.concat sub_attribute.validate(value, sub_context)
146
- end
147
- self.class.requirements.each do |req|
148
- validation_errors = req.validate(keys_with_values, context)
149
- errors.concat(validation_errors) unless validation_errors.empty?
150
- end
151
-
152
- errors
132
+ # Use the common, underlying attribute validation of the hash (which will use our _get_attr)
133
+ # to know how to retrieve a value from a model (instead of a hash)
134
+ validate_keys(context)
153
135
  ensure
154
136
  @validating = false
155
137
  end
@@ -198,4 +180,10 @@ module Attributor
198
180
  @dumping = false
199
181
  end
200
182
  end
183
+
184
+ # Override the generic way to get a value from an instance (models need to call the method)
185
+ def _get_attr(k)
186
+ __send__(k)
187
+ end
188
+
201
189
  end
@@ -1,3 +1,3 @@
1
1
  module Attributor
2
- VERSION = '5.5'.freeze
2
+ VERSION = '6.1'.freeze
3
3
  end
data/lib/attributor.rb CHANGED
@@ -13,7 +13,6 @@ module Attributor
13
13
  require_relative 'attributor/type'
14
14
  require_relative 'attributor/dsl_compiler'
15
15
  require_relative 'attributor/hash_dsl_compiler'
16
- require_relative 'attributor/attribute_resolver'
17
16
  require_relative 'attributor/smart_attribute_selector'
18
17
 
19
18
  require_relative 'attributor/example_mixin'
@@ -22,7 +21,8 @@ module Attributor
22
21
 
23
22
  # hierarchical separator string for composing human readable attributes
24
23
  SEPARATOR = '.'.freeze
25
- DEFAULT_ROOT_CONTEXT = ['$'].freeze
24
+ ROOT_PREFIX = '$'.freeze
25
+ DEFAULT_ROOT_CONTEXT = [ROOT_PREFIX].freeze
26
26
 
27
27
  # @param type [Class] The class of the type to resolve
28
28
  #
@@ -56,10 +56,6 @@ module Attributor
56
56
 
57
57
  context = Array(context) if context.is_a? ::String
58
58
 
59
- unless context.is_a? Enumerable
60
- raise "INVALID CONTEXT!!! (got: #{context.inspect})"
61
- end
62
-
63
59
  begin
64
60
  return context.join('.')
65
61
  rescue e