praxis 0.21 → 2.0.pre.3
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 +8 -15
- data/CHANGELOG.md +328 -299
- data/CONTRIBUTING.md +4 -4
- data/README.md +11 -9
- data/lib/api_browser/app/js/directives/attribute_table.js +2 -1
- data/lib/api_browser/app/js/directives/conditional_requirements.js +13 -0
- data/lib/api_browser/app/js/directives/type_placeholder.js +10 -1
- data/lib/api_browser/app/js/factories/normalize_attributes.js +4 -2
- data/lib/api_browser/app/js/factories/template_for.js +5 -2
- data/lib/api_browser/app/js/filters/has_requirement.js +14 -0
- data/lib/api_browser/app/js/filters/tag_requirement.js +13 -0
- data/lib/api_browser/app/sass/praxis.scss +11 -0
- data/lib/api_browser/app/views/action.html +2 -2
- data/lib/api_browser/app/views/directives/attribute_description/member_options.html +2 -2
- data/lib/api_browser/app/views/directives/attribute_table.html +1 -1
- data/lib/api_browser/app/views/type.html +1 -1
- data/lib/api_browser/app/views/type/details.html +2 -2
- data/lib/api_browser/app/views/types/embedded/array.html +2 -0
- data/lib/api_browser/app/views/types/embedded/default.html +3 -1
- data/lib/api_browser/app/views/types/embedded/requirements.html +6 -0
- data/lib/api_browser/app/views/types/embedded/single_req.html +9 -0
- data/lib/api_browser/app/views/types/embedded/struct.html +14 -2
- data/lib/api_browser/app/views/types/standalone/array.html +1 -1
- data/lib/api_browser/app/views/types/standalone/struct.html +2 -1
- data/lib/api_browser/package.json +1 -1
- data/lib/praxis.rb +9 -3
- data/lib/praxis/action_definition.rb +1 -1
- data/lib/praxis/action_definition/headers_dsl_compiler.rb +1 -1
- data/lib/praxis/application.rb +1 -9
- data/lib/praxis/bootloader.rb +1 -4
- data/lib/praxis/config.rb +1 -1
- data/lib/praxis/dispatcher.rb +10 -6
- data/lib/praxis/docs/generator.rb +2 -1
- data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +180 -0
- data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +273 -0
- data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +125 -0
- data/lib/praxis/extensions/field_selection.rb +1 -9
- data/lib/praxis/extensions/field_selection/active_record_query_selector.rb +51 -0
- data/lib/praxis/extensions/field_selection/sequel_query_selector.rb +61 -0
- data/lib/praxis/extensions/rails_compat.rb +2 -0
- data/lib/praxis/extensions/rails_compat/request_methods.rb +19 -0
- data/lib/praxis/handlers/xml.rb +1 -1
- data/lib/praxis/mapper/active_model_compat.rb +98 -0
- data/lib/praxis/mapper/resource.rb +242 -0
- data/lib/praxis/mapper/selector_generator.rb +149 -0
- data/lib/praxis/mapper/sequel_compat.rb +76 -0
- data/lib/praxis/media_type_identifier.rb +2 -1
- data/lib/praxis/middleware_app.rb +20 -2
- data/lib/praxis/multipart/parser.rb +14 -2
- data/lib/praxis/notifications.rb +1 -1
- data/lib/praxis/plugins/mapper_plugin.rb +64 -0
- data/lib/praxis/plugins/rails_plugin.rb +104 -0
- data/lib/praxis/request.rb +7 -1
- data/lib/praxis/request_superclassing.rb +11 -0
- data/lib/praxis/resource_definition.rb +5 -5
- data/lib/praxis/response.rb +1 -1
- data/lib/praxis/route.rb +1 -1
- data/lib/praxis/routing_config.rb +1 -1
- data/lib/praxis/trait.rb +1 -1
- data/lib/praxis/types/media_type_common.rb +2 -2
- data/lib/praxis/types/multipart.rb +1 -1
- data/lib/praxis/types/multipart_array.rb +2 -2
- data/lib/praxis/types/multipart_array/part_definition.rb +1 -1
- data/lib/praxis/version.rb +1 -1
- data/praxis.gemspec +14 -13
- data/spec/functional_spec.rb +4 -7
- data/spec/praxis/action_definition_spec.rb +1 -1
- data/spec/praxis/application_spec.rb +1 -1
- data/spec/praxis/collection_spec.rb +3 -2
- data/spec/praxis/config_spec.rb +2 -2
- data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +106 -0
- data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +147 -0
- data/spec/praxis/extensions/field_selection/support/spec_resources_active_model.rb +130 -0
- data/spec/praxis/extensions/field_selection/support/spec_resources_sequel.rb +106 -0
- data/spec/praxis/handlers/xml_spec.rb +2 -2
- data/spec/praxis/mapper/resource_spec.rb +169 -0
- data/spec/praxis/mapper/selector_generator_spec.rb +293 -0
- data/spec/praxis/media_type_spec.rb +0 -10
- data/spec/praxis/middleware_app_spec.rb +29 -9
- data/spec/praxis/request_stages/action_spec.rb +8 -1
- data/spec/praxis/response_definition_spec.rb +7 -4
- data/spec/praxis/response_spec.rb +1 -1
- data/spec/praxis/responses/internal_server_error_spec.rb +2 -2
- data/spec/praxis/responses/validation_error_spec.rb +2 -2
- data/spec/praxis/router_spec.rb +1 -1
- data/spec/spec_app/app/controllers/instances.rb +1 -1
- data/spec/spec_app/config/environment.rb +3 -21
- data/spec/spec_helper.rb +11 -15
- data/spec/support/be_deep_equal_matcher.rb +39 -0
- data/spec/support/spec_resources.rb +124 -0
- data/tasks/thor/templates/generator/empty_app/Gemfile +3 -3
- metadata +102 -77
- data/.ruby-version +0 -1
- data/lib/praxis/extensions/mapper_selectors.rb +0 -16
- data/lib/praxis/media_type_collection.rb +0 -127
- data/lib/praxis/plugins/praxis_mapper_plugin.rb +0 -246
- data/lib/praxis/stats.rb +0 -113
- data/spec/praxis/media_type_collection_spec.rb +0 -157
- data/spec/praxis/plugins/praxis_mapper_plugin_spec.rb +0 -142
- data/spec/praxis/stats_spec.rb +0 -9
- data/spec/spec_app/app/models/person.rb +0 -3
data/lib/praxis/dispatcher.rb
CHANGED
|
@@ -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?
|
|
@@ -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
|