praxis 0.21 → 2.0.pre.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +8 -15
  3. data/CHANGELOG.md +328 -299
  4. data/CONTRIBUTING.md +4 -4
  5. data/README.md +11 -9
  6. data/lib/api_browser/app/js/directives/attribute_table.js +2 -1
  7. data/lib/api_browser/app/js/directives/conditional_requirements.js +13 -0
  8. data/lib/api_browser/app/js/directives/type_placeholder.js +10 -1
  9. data/lib/api_browser/app/js/factories/normalize_attributes.js +4 -2
  10. data/lib/api_browser/app/js/factories/template_for.js +5 -2
  11. data/lib/api_browser/app/js/filters/has_requirement.js +14 -0
  12. data/lib/api_browser/app/js/filters/tag_requirement.js +13 -0
  13. data/lib/api_browser/app/sass/praxis.scss +11 -0
  14. data/lib/api_browser/app/views/action.html +2 -2
  15. data/lib/api_browser/app/views/directives/attribute_description/member_options.html +2 -2
  16. data/lib/api_browser/app/views/directives/attribute_table.html +1 -1
  17. data/lib/api_browser/app/views/type.html +1 -1
  18. data/lib/api_browser/app/views/type/details.html +2 -2
  19. data/lib/api_browser/app/views/types/embedded/array.html +2 -0
  20. data/lib/api_browser/app/views/types/embedded/default.html +3 -1
  21. data/lib/api_browser/app/views/types/embedded/requirements.html +6 -0
  22. data/lib/api_browser/app/views/types/embedded/single_req.html +9 -0
  23. data/lib/api_browser/app/views/types/embedded/struct.html +14 -2
  24. data/lib/api_browser/app/views/types/standalone/array.html +1 -1
  25. data/lib/api_browser/app/views/types/standalone/struct.html +2 -1
  26. data/lib/api_browser/package.json +1 -1
  27. data/lib/praxis.rb +9 -3
  28. data/lib/praxis/action_definition.rb +1 -1
  29. data/lib/praxis/action_definition/headers_dsl_compiler.rb +1 -1
  30. data/lib/praxis/application.rb +1 -9
  31. data/lib/praxis/bootloader.rb +1 -4
  32. data/lib/praxis/config.rb +1 -1
  33. data/lib/praxis/dispatcher.rb +10 -6
  34. data/lib/praxis/docs/generator.rb +2 -1
  35. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +180 -0
  36. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +273 -0
  37. data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +125 -0
  38. data/lib/praxis/extensions/field_selection.rb +1 -9
  39. data/lib/praxis/extensions/field_selection/active_record_query_selector.rb +51 -0
  40. data/lib/praxis/extensions/field_selection/sequel_query_selector.rb +61 -0
  41. data/lib/praxis/extensions/rails_compat.rb +2 -0
  42. data/lib/praxis/extensions/rails_compat/request_methods.rb +19 -0
  43. data/lib/praxis/handlers/xml.rb +1 -1
  44. data/lib/praxis/mapper/active_model_compat.rb +98 -0
  45. data/lib/praxis/mapper/resource.rb +242 -0
  46. data/lib/praxis/mapper/selector_generator.rb +149 -0
  47. data/lib/praxis/mapper/sequel_compat.rb +76 -0
  48. data/lib/praxis/media_type_identifier.rb +2 -1
  49. data/lib/praxis/middleware_app.rb +20 -2
  50. data/lib/praxis/multipart/parser.rb +14 -2
  51. data/lib/praxis/notifications.rb +1 -1
  52. data/lib/praxis/plugins/mapper_plugin.rb +64 -0
  53. data/lib/praxis/plugins/rails_plugin.rb +104 -0
  54. data/lib/praxis/request.rb +7 -1
  55. data/lib/praxis/request_superclassing.rb +11 -0
  56. data/lib/praxis/resource_definition.rb +5 -5
  57. data/lib/praxis/response.rb +1 -1
  58. data/lib/praxis/route.rb +1 -1
  59. data/lib/praxis/routing_config.rb +1 -1
  60. data/lib/praxis/trait.rb +1 -1
  61. data/lib/praxis/types/media_type_common.rb +2 -2
  62. data/lib/praxis/types/multipart.rb +1 -1
  63. data/lib/praxis/types/multipart_array.rb +2 -2
  64. data/lib/praxis/types/multipart_array/part_definition.rb +1 -1
  65. data/lib/praxis/version.rb +1 -1
  66. data/praxis.gemspec +14 -13
  67. data/spec/functional_spec.rb +4 -7
  68. data/spec/praxis/action_definition_spec.rb +1 -1
  69. data/spec/praxis/application_spec.rb +1 -1
  70. data/spec/praxis/collection_spec.rb +3 -2
  71. data/spec/praxis/config_spec.rb +2 -2
  72. data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +106 -0
  73. data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +147 -0
  74. data/spec/praxis/extensions/field_selection/support/spec_resources_active_model.rb +130 -0
  75. data/spec/praxis/extensions/field_selection/support/spec_resources_sequel.rb +106 -0
  76. data/spec/praxis/handlers/xml_spec.rb +2 -2
  77. data/spec/praxis/mapper/resource_spec.rb +169 -0
  78. data/spec/praxis/mapper/selector_generator_spec.rb +293 -0
  79. data/spec/praxis/media_type_spec.rb +0 -10
  80. data/spec/praxis/middleware_app_spec.rb +29 -9
  81. data/spec/praxis/request_stages/action_spec.rb +8 -1
  82. data/spec/praxis/response_definition_spec.rb +7 -4
  83. data/spec/praxis/response_spec.rb +1 -1
  84. data/spec/praxis/responses/internal_server_error_spec.rb +2 -2
  85. data/spec/praxis/responses/validation_error_spec.rb +2 -2
  86. data/spec/praxis/router_spec.rb +1 -1
  87. data/spec/spec_app/app/controllers/instances.rb +1 -1
  88. data/spec/spec_app/config/environment.rb +3 -21
  89. data/spec/spec_helper.rb +11 -15
  90. data/spec/support/be_deep_equal_matcher.rb +39 -0
  91. data/spec/support/spec_resources.rb +124 -0
  92. data/tasks/thor/templates/generator/empty_app/Gemfile +3 -3
  93. metadata +102 -77
  94. data/.ruby-version +0 -1
  95. data/lib/praxis/extensions/mapper_selectors.rb +0 -16
  96. data/lib/praxis/media_type_collection.rb +0 -127
  97. data/lib/praxis/plugins/praxis_mapper_plugin.rb +0 -246
  98. data/lib/praxis/stats.rb +0 -113
  99. data/spec/praxis/media_type_collection_spec.rb +0 -157
  100. data/spec/praxis/plugins/praxis_mapper_plugin_spec.rb +0 -142
  101. data/spec/praxis/stats_spec.rb +0 -9
  102. data/spec/spec_app/app/models/person.rb +0 -3
@@ -76,8 +76,17 @@ module Praxis
76
76
  @action = action
77
77
  @request = request
78
78
 
79
- payload = {request: request, response: nil}
79
+ payload = {request: request, response: nil, controller: @controller}
80
80
 
81
+ instrumented_dispatch( payload )
82
+
83
+ ensure
84
+ @controller = nil
85
+ @action = nil
86
+ @request = nil
87
+ end
88
+
89
+ def instrumented_dispatch( payload )
81
90
  Notifications.instrument 'praxis.request.all'.freeze, payload do
82
91
  begin
83
92
  # the response stage must be the final stage in the list
@@ -100,13 +109,8 @@ module Praxis
100
109
  @application.error_handler.handle!(request, e)
101
110
  end
102
111
  end
103
- ensure
104
- @controller = nil
105
- @action = nil
106
- @request = nil
107
112
  end
108
113
 
109
-
110
114
  # TODO: fix for multithreaded environments
111
115
  def reset_cache!
112
116
  return unless Praxis::Blueprint.caching_enabled?
@@ -20,7 +20,8 @@ module Praxis
20
20
  Attributor::Integer,
21
21
  Attributor::Object,
22
22
  Attributor::String,
23
- Attributor::Symbol
23
+ Attributor::Symbol,
24
+ Attributor::URI,
24
25
  ]).freeze
25
26
 
26
27
 
@@ -0,0 +1,180 @@
1
+ module Praxis
2
+ module Extensions
3
+ class ActiveRecordFilterQueryBuilder
4
+ attr_reader :query, :table, :model
5
+
6
+ # Abstract class, which needs to be used by subclassing it through the .for method, to set the mapping of attributes
7
+ class << self
8
+ def for(definition)
9
+ Class.new(self) do
10
+ @attr_to_column = case definition
11
+ when Hash
12
+ definition
13
+ when Array
14
+ definition.each_with_object({}) { |item, hash| hash[item.to_sym] = item }
15
+ else
16
+ raise "Cannot use FilterQueryBuilder.of without passing an array or a hash (Got: #{definition.class.name})"
17
+ end
18
+ class << self
19
+ attr_reader :attr_to_column
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ # Base query to build upon
26
+ def initialize(query: , model:)
27
+ @query = query
28
+ @table = model.table_name
29
+ @last_join_alias = model.table_name
30
+ @alias_counter = 0;
31
+ end
32
+
33
+ def pick_alias( name )
34
+ @alias_counter += 1
35
+ "#{name}#{@alias_counter}"
36
+ end
37
+
38
+ def build_clause(filters)
39
+ filters.each do |item|
40
+ attr = item[:name]
41
+ spec = item[:specs]
42
+ column_name = attr_to_column[attr]
43
+ raise "Filtering by #{attr} not allowed (no mapping found)" unless column_name
44
+ if column_name.is_a?(Proc)
45
+ bindings = column_name.call(spec)
46
+ # A hash of bindings, consisting of a key with column name and a value to the query value
47
+ bindings.each do|col,val|
48
+ assoc_or_field, *rest = col.to_s.split('.')
49
+ expand_binding(column_name: assoc_or_field, rest: rest, op: spec[:op], value: val, use_this_name_for_clause: @last_join_alias)
50
+ end
51
+ else
52
+ assoc_or_field, *rest = column_name.to_s.split('.')
53
+ expand_binding(column_name: assoc_or_field, rest: rest, **spec, use_this_name_for_clause: @last_join_alias)
54
+ end
55
+ end
56
+ query
57
+ end
58
+
59
+ # TODO: Support more relationship types (including things like polymorphic..etc)
60
+ def do_join(query, assoc , source_alias, table_alias)
61
+ reflection = query.reflections[assoc.to_s]
62
+ do_join_reflection( query, reflection, source_alias, table_alias )
63
+ end
64
+
65
+ def do_join_reflection( query, reflection, source_alias, table_alias )
66
+ c = query.connection
67
+ case reflection
68
+ when ActiveRecord::Reflection::BelongsToReflection
69
+ join_clause = "INNER JOIN %s as %s ON %s.%s = %s.%s " % \
70
+ [c.quote_table_name(reflection.klass.table_name),
71
+ c.quote_table_name(table_alias),
72
+ c.quote_table_name(table_alias),
73
+ c.quote_column_name(reflection.association_primary_key),
74
+ c.quote_table_name(source_alias),
75
+ c.quote_column_name(reflection.association_foreign_key)
76
+ ]
77
+ query.joins(join_clause)
78
+ when ActiveRecord::Reflection::HasManyReflection
79
+ # join_clause = "INNER JOIN #{reflection.klass.table_name} as #{table_alias} ON" + \
80
+ # " \"#{source_alias}\".\"id\" = \"#{table_alias}\".\"#{reflection.foreign_key}\" "
81
+ join_clause = "INNER JOIN %s as %s ON %s.%s = %s.%s " % \
82
+ [c.quote_table_name(reflection.klass.table_name),
83
+ c.quote_table_name(table_alias),
84
+ c.quote_table_name(source_alias),
85
+ c.quote_column_name(reflection.active_record.primary_key),
86
+ c.quote_table_name(table_alias),
87
+ c.quote_column_name(reflection.foreign_key)
88
+ ]
89
+
90
+ if reflection.type # && reflection.options[:as]....
91
+ # addition = " AND \"#{table_alias}\".\"#{reflection.type}\" = \'#{reflection.active_record.class_name}\'"
92
+ addition = " AND %s.%s = %s" % \
93
+ [ c.quote_table_name(table_alias),
94
+ c.quote_table_name(reflection.type),
95
+ c.quote(reflection.active_record.class_name)]
96
+
97
+ join_clause += addition
98
+ end
99
+ query.joins(join_clause)
100
+ when ActiveRecord::Reflection::ThroughReflection
101
+ #puts "TODO: choose different alias (based on matching table type...)"
102
+ talias = pick_alias(reflection.through_reflection.table_name)
103
+ salias = source_alias
104
+
105
+ query = do_join_reflection(query, reflection.through_reflection, salias, talias)
106
+ #puts "TODO: choose different alias ?????????"
107
+ salias = talias
108
+
109
+ through_model = reflection.through_reflection.klass
110
+ through_assoc = reflection.name
111
+ final_reflection = reflection.source_reflection
112
+
113
+ do_join_reflection(query, final_reflection, salias, table_alias)
114
+ else
115
+ raise "Joins for this association type are currently UNSUPPORTED: #{reflection.inspect}"
116
+ end
117
+ end
118
+
119
+ def expand_binding(column_name:,rest: , op:,value:, use_this_name_for_clause: column_name)
120
+ unless rest.empty?
121
+ joined_alias = pick_alias(column_name)
122
+ @query = do_join(query, column_name, @last_join_alias, joined_alias)
123
+ saved_join_alias = @last_join_alias
124
+ @last_join_alias = joined_alias
125
+ new_column_name, *new_rest = rest
126
+ expand_binding(column_name: new_column_name, rest: new_rest, op: op, value: value, use_this_name_for_clause: joined_alias)
127
+ @last_join_alias = saved_join_alias
128
+ else
129
+ column_name = "#{use_this_name_for_clause}.#{column_name}"
130
+ add_clause(column_name: column_name, op: op, value: value)
131
+ end
132
+ end
133
+
134
+ def attr_to_column
135
+ # Class method defined by the subclassing Class (using .for)
136
+ self.class.attr_to_column
137
+ end
138
+
139
+ # Private to try to funnel all column names through `build_clause` that restricts
140
+ # the attribute names better (to allow more difficult SQL injections )
141
+ private def add_clause(column_name:, op:, value:)
142
+ likeval = get_like_value(value)
143
+ @query = case op
144
+ when '='
145
+ if likeval
146
+ query.where("#{column_name} LIKE ?", likeval)
147
+ else
148
+ query.where(column_name => value)
149
+ end
150
+ when '!='
151
+ if likeval
152
+ query.where("#{column_name} NOT LIKE ?", likeval)
153
+ else
154
+ query.where.not(column_name => value)
155
+ end
156
+ when '>'
157
+ query.where("#{column_name} > ?", value)
158
+ when '<'
159
+ query.where("#{column_name} < ?", value)
160
+ when '>='
161
+ query.where("#{column_name} >= ?", value)
162
+ when '<='
163
+ query.where("#{column_name} <= ?", value)
164
+ else
165
+ raise "Unsupported Operator!!! #{op}"
166
+ end
167
+ end
168
+
169
+ # Returns nil if the value was not a fuzzzy pattern
170
+ def get_like_value(value)
171
+ if value.is_a?(String) && (value[-1] == '*' || value[0] == '*')
172
+ likeval = value.dup
173
+ likeval[-1] = '%' if value[-1] == '*'
174
+ likeval[0] = '%' if value[0] == '*'
175
+ likeval
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+ # rubocop:disable all
3
+ #
4
+ # Attributor type to define and handlea simple language to express filtering attributes in listings.
5
+ # Commonly used in a query string parameter value for listing calls.
6
+ #
7
+ # The type allows you to restrict the allowable fields (and their types) based on an existing Mediatype.
8
+ # It also alows you to define exacly what fields (from that MediaType) are allowed, an what operations are
9
+ # supported for each of them. Includes most in/equalities and fuzzy matching options(i.e., leading/trailing `*` )
10
+ #
11
+ # Example syntax: `status=open&time>2001-1-1&name=*Bar`
12
+ #
13
+ # Example use and definition of the type:
14
+ # attribute :filters,
15
+ # Types::FilteringParams.for(MediaTypes::MyType) do
16
+ # filter 'user.id', using: ['=', '!=']
17
+ # filter 'name', using: ['=', '!=']
18
+ # filter 'children.created_at', using: ['>', '>=', '<', '<=']
19
+ # filter 'display_name', using: ['=', '!='], fuzzy: true
20
+ # end
21
+
22
+ module Praxis
23
+ module Extensions
24
+ module AttributeFiltering
25
+ class FilteringParams
26
+ include Attributor::Type
27
+ include Attributor::Dumpable
28
+
29
+ # This DSL allows to define which attributes are allowed in the filters, and with which operators
30
+ class DSLCompiler < Attributor::DSLCompiler
31
+ # "account.id": { operators: ["=", "!="] },
32
+ # name: { operators: ["=", "!="], fuzzy_match: true },
33
+ # start_date: { operators: ["!=", ">=", "<=", "=", "<", ">"] }
34
+ #
35
+ def filter(name, using: nil, fuzzy: false)
36
+ target.add_filter(name.to_sym, operators: Set.new(using), fuzzy: fuzzy)
37
+ end
38
+ end
39
+
40
+ VALUE_REGEX = /[^,&]*/
41
+ AVAILABLE_OPERATORS = Set.new(['!=', '>=', '<=', '=', '<', '>']).freeze
42
+ FILTER_REGEX = /(?<attribute>([^=!><])+)(?<operator>!=|>=|<=|=|<|>)(?<value>#{VALUE_REGEX}(,#{VALUE_REGEX})*)/
43
+
44
+ # Abstract class, which needs to be used by subclassing it through the .for method, to set the allowed filters
45
+ # definition should be a hash, keyed by field name, which contains a hash that can have two pieces of metadata
46
+ # :operators => an array of operators allowed (if empty, means all)
47
+ # :value_type => a type class which the value should match
48
+ # :fuzzy_match => weather or not we allow a "like" type query (for prefix or suffix matching)
49
+ class << self
50
+ attr_reader :media_type
51
+ attr_reader :allowed_filters
52
+
53
+ def for(media_type, **_opts)
54
+ unless media_type < Praxis::MediaType
55
+ raise ArgumentError, "Invalid type: #{media_type.name} for Filters. " \
56
+ 'Must be a subclass of MediaType'
57
+ end
58
+
59
+ ::Class.new(self) do
60
+ @media_type = media_type
61
+ @allowed_filters = {}
62
+ end
63
+ end
64
+
65
+ def add_filter(name, operators:, fuzzy:)
66
+ components = name.to_s.split('.').map(&:to_sym)
67
+ attribute, enclosing_type = find_filter_attribute(components, media_type)
68
+ raise 'Invalid set of operators passed' unless AVAILABLE_OPERATORS.superset?(operators)
69
+
70
+ @allowed_filters[name] = {
71
+ value_type: attribute.type,
72
+ operators: operators,
73
+ fuzzy_match: fuzzy
74
+ }
75
+ end
76
+ end
77
+
78
+ attr_reader :parsed_array
79
+
80
+ def self.native_type
81
+ self
82
+ end
83
+
84
+ def self.name
85
+ 'Praxis::Types::FilteringParams'
86
+ end
87
+
88
+ def self.display_name
89
+ 'Filtering'
90
+ end
91
+
92
+ def self.family
93
+ 'string'
94
+ end
95
+
96
+ def self.constructable?
97
+ true
98
+ end
99
+
100
+ def self.construct(definition, **options)
101
+ return self if definition.nil?
102
+
103
+ DSLCompiler.new(self, options).parse(*definition)
104
+ self
105
+ end
106
+
107
+ def self.find_filter_attribute(name_components, type)
108
+ type = type.member_type if type < Attributor::Collection
109
+ first, *rest = name_components
110
+ first_attr = type.attributes[first]
111
+ unless first_attr
112
+ raise "Error, you've requested to filter by field #{first} which does not exist in the #{type.name} mediatype!\n"
113
+ end
114
+
115
+ return find_filter_attribute(rest, first_attr.type) if rest.present?
116
+
117
+ [first_attr, type] # Return the attribute and associated enclosing type
118
+ end
119
+
120
+ def self.example(_context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
121
+ fields = if media_type
122
+ mt_example = media_type.example
123
+ pickable_fields = mt_example.object.keys & allowed_filters.keys
124
+ pickable_fields.sample(2).each_with_object([]) do |filter_name, arr|
125
+ op = allowed_filters[filter_name][:operators].to_a.sample(1).first
126
+
127
+ # Switch this to pick the right example attribute from the mt example
128
+ filter_components = filter_name.to_s.split('.').map(&:to_sym)
129
+ mapped_attribute, _enclosing_type = find_filter_attribute(filter_components, media_type)
130
+ unless mapped_attribute
131
+ raise "filter with name #{filter_name} does not correspond to an existing field inside " \
132
+ " MediaType #{media_type.name}"
133
+ end
134
+ attr_example = filter_components.inject(mt_example) do |last, name|
135
+ # we can safely do sends, since we've verified the components are valid
136
+ last.send(name)
137
+ end
138
+ arr << "#{filter_name}#{op}#{attr_example}"
139
+ end.join('&')
140
+ else
141
+ 'name=Joe&date>2017-01-01'
142
+ end
143
+ load(fields)
144
+ end
145
+
146
+ def self.validate(value, context = Attributor::DEFAULT_ROOT_CONTEXT, _attribute = nil)
147
+ instance = load(value, context)
148
+ instance.validate(context)
149
+ end
150
+
151
+ def self.load(filters, _context = Attributor::DEFAULT_ROOT_CONTEXT, **_options)
152
+ return filters if filters.is_a?(native_type)
153
+ return new if filters.nil?
154
+ parsed = filters.split('&').each_with_object([]) do |filter_string, arr|
155
+ match = FILTER_REGEX.match(filter_string)
156
+ values = CGI.unescape(match[:value]).split(',')
157
+ value = if values.size > 1
158
+ multimatch = true
159
+ values
160
+ else
161
+ multimatch = false
162
+ values.first
163
+ end
164
+
165
+ attr_name = match[:attribute].to_sym
166
+ # TODO: we should coerce values if there's a mediatype defined?
167
+ coerced = if media_type
168
+ filter_components = attr_name.to_s.split('.').map(&:to_sym)
169
+ attr, _enclosing_type = find_filter_attribute(filter_components, media_type)
170
+ if multimatch
171
+ attr_coll = Attributor::Collection.of(attr.type)
172
+ attr_coll.load(value)
173
+ else
174
+ attr.load(value)
175
+ end
176
+ else
177
+ value
178
+ end
179
+ arr.push(name: attr_name, specs: { op: match[:operator], value: coerced } )
180
+ end
181
+ new(parsed)
182
+ end
183
+
184
+ def self.dump(value, **_opts)
185
+ load(value).dump
186
+ end
187
+
188
+ def self.describe(_root = false, example: nil)
189
+ hash = super
190
+ if allowed_filters
191
+ hash[:filters] = allowed_filters.each_with_object({}) do |(name, spec), accum|
192
+ accum[name] = { operators: spec[:operators].to_a }
193
+ accum[name][:fuzzy] = true if spec[:fuzzy_match]
194
+ end
195
+ end
196
+
197
+ hash
198
+ end
199
+
200
+ def initialize(parsed = [])
201
+ @parsed_array = parsed
202
+ end
203
+
204
+ def validate(_context = Attributor::DEFAULT_ROOT_CONTEXT)
205
+ parsed_array.each_with_object([]) do |item, errors|
206
+ attr_name = item[:name]
207
+ specs = item[:specs]
208
+ attr_filters = allowed_filters[attr_name]
209
+ unless attr_filters
210
+ errors << "Filtering by #{attr_name} is not allowed. You can filter by #{allowed_filters.keys.map(&:to_s).join(', ')}"
211
+ next
212
+ end
213
+ allowed_operators = attr_filters[:operators]
214
+ unless allowed_operators.include?(specs[:op])
215
+ errors << "Operator #{specs[:op]} not allowed for filter #{attr_name}"
216
+ end
217
+ value_type = attr_filters[:value_type]
218
+ value = specs[:value]
219
+ if value_type && !value_type.valid_type?(value)
220
+ # Allow a collection of values of the right type for multimatch (if operators are = or !=)
221
+ if ['=','!='].include?(specs[:op])
222
+ coll_type = Attributor::Collection.of(value_type)
223
+ if !coll_type.valid_type?(value)
224
+ errors << "Invalid type in filter/s value for #{attr_name} " +\
225
+ "(one or more of the multiple matches in #{value} are not a #{value_type.name.split('::').last})"
226
+ end
227
+ else
228
+ errors << "Invalid type in filter value for #{attr_name} (#{value} using '#{specs[:op]}' is not a #{value_type.name.split('::').last})"
229
+ end
230
+ end
231
+
232
+ next unless value_type == Attributor::String
233
+ unless value.empty?
234
+ fuzzy_match = attr_filters[:fuzzy_match]
235
+ if (value[-1] == '*' || value[0] == '*') && !fuzzy_match
236
+ errors << "Fuzzy matching for #{attr_name} is not allowed (yet '*' was found in the value)"
237
+ end
238
+ end
239
+ end
240
+ end
241
+
242
+ # Dump back string parseable form
243
+ def dump
244
+ parsed_array.each_with_object([]) do |item, arr|
245
+ field = item[:name]
246
+ spec = item[:specs]
247
+ arr << "#{field}#{spec[:op]}#{spec[:value]}"
248
+ end.join('&')
249
+ end
250
+
251
+ def each
252
+ parsed_array&.each do |filter|
253
+ yield filter
254
+ end
255
+ end
256
+
257
+ def allowed_filters
258
+ # Class method defined by the subclassing Class (using .for)
259
+ self.class.allowed_filters
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
265
+
266
+ # Alias it to a much shorter and sweeter name in the Types namespace.
267
+ module Praxis
268
+ module Types
269
+ FilteringParams = Praxis::Extensions::AttributeFiltering::FilteringParams
270
+ end
271
+ end
272
+
273
+ # rubocop:enable all