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.
- checksums.yaml +4 -4
- data/.travis.yml +4 -3
- data/CHANGELOG.md +145 -135
- data/attributor.gemspec +5 -6
- data/lib/attributor.rb +17 -2
- data/lib/attributor/attribute.rb +39 -9
- data/lib/attributor/dsl_compiler.rb +17 -9
- data/lib/attributor/exceptions.rb +5 -0
- data/lib/attributor/extras/field_selector.rb +4 -0
- data/lib/attributor/families/numeric.rb +19 -6
- data/lib/attributor/families/temporal.rb +16 -9
- data/lib/attributor/hash_dsl_compiler.rb +6 -6
- data/lib/attributor/smart_attribute_selector.rb +149 -0
- data/lib/attributor/type.rb +27 -4
- data/lib/attributor/types/bigdecimal.rb +7 -2
- data/lib/attributor/types/boolean.rb +7 -2
- data/lib/attributor/types/class.rb +2 -2
- data/lib/attributor/types/collection.rb +22 -5
- data/lib/attributor/types/container.rb +3 -3
- data/lib/attributor/types/csv.rb +5 -1
- data/lib/attributor/types/date.rb +9 -3
- data/lib/attributor/types/date_time.rb +8 -2
- data/lib/attributor/types/float.rb +4 -3
- data/lib/attributor/types/hash.rb +105 -21
- data/lib/attributor/types/integer.rb +7 -1
- data/lib/attributor/types/model.rb +2 -2
- data/lib/attributor/types/object.rb +5 -0
- data/lib/attributor/types/polymorphic.rb +3 -2
- data/lib/attributor/types/string.rb +20 -1
- data/lib/attributor/types/struct.rb +1 -1
- data/lib/attributor/types/symbol.rb +5 -0
- data/lib/attributor/types/tempfile.rb +4 -0
- data/lib/attributor/types/time.rb +7 -3
- data/lib/attributor/types/uri.rb +9 -1
- data/lib/attributor/version.rb +1 -1
- data/spec/attribute_spec.rb +42 -7
- data/spec/dsl_compiler_spec.rb +16 -6
- data/spec/extras/field_selector/field_selector_spec.rb +9 -0
- data/spec/hash_dsl_compiler_spec.rb +2 -2
- data/spec/smart_attribute_selector_spec.rb +272 -0
- data/spec/support/integers.rb +7 -0
- data/spec/type_spec.rb +1 -1
- data/spec/types/bigdecimal_spec.rb +8 -0
- data/spec/types/boolean_spec.rb +10 -0
- data/spec/types/class_spec.rb +0 -1
- data/spec/types/collection_spec.rb +16 -0
- data/spec/types/date_spec.rb +9 -0
- data/spec/types/date_time_spec.rb +9 -0
- data/spec/types/float_spec.rb +8 -0
- data/spec/types/hash_spec.rb +181 -9
- data/spec/types/integer_spec.rb +10 -1
- data/spec/types/model_spec.rb +14 -3
- data/spec/types/string_spec.rb +10 -0
- data/spec/types/temporal_spec.rb +5 -1
- data/spec/types/time_spec.rb +9 -0
- data/spec/types/uri_spec.rb +9 -0
- metadata +24 -34
data/lib/attributor/attribute.rb
CHANGED
@@ -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
|
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] =
|
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] =
|
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
|
150
|
-
|
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
|
-
|
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
|
-
|
91
|
-
|
92
|
-
|
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
|
-
|
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
|
-
|
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] =
|
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
|
109
|
-
attr_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
|
@@ -1,15 +1,28 @@
|
|
1
1
|
# Abstract type for the 'numeric' family
|
2
2
|
|
3
3
|
module Attributor
|
4
|
-
|
4
|
+
module Numeric
|
5
|
+
extend ActiveSupport::Concern
|
5
6
|
include Type
|
6
7
|
|
7
|
-
|
8
|
-
|
9
|
-
|
8
|
+
module ClassMethods
|
9
|
+
|
10
|
+
def native_type
|
11
|
+
raise NotImplementedError
|
12
|
+
end
|
13
|
+
|
14
|
+
def family
|
15
|
+
'numeric'
|
16
|
+
end
|
10
17
|
|
11
|
-
|
12
|
-
|
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
|
-
|
4
|
+
module Temporal
|
5
|
+
extend ActiveSupport::Concern
|
5
6
|
include Type
|
6
7
|
|
7
|
-
|
8
|
-
|
9
|
-
|
8
|
+
module ClassMethods
|
9
|
+
def native_type
|
10
|
+
raise NotImplementedError
|
11
|
+
end
|
10
12
|
|
11
|
-
|
12
|
-
|
13
|
-
|
13
|
+
def family
|
14
|
+
'temporal'
|
15
|
+
end
|
16
|
+
|
17
|
+
def dump(value, **_opts)
|
18
|
+
value && value.iso8601
|
19
|
+
end
|
14
20
|
|
15
|
-
|
16
|
-
|
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
|
data/lib/attributor/type.rb
CHANGED
@@ -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
|
-
|
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
|
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
|