praxis 0.21 → 0.22.pre.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (91) hide show
  1. checksums.yaml +5 -5
  2. data/.travis.yml +20 -12
  3. data/CHANGELOG.md +24 -0
  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 +8 -6
  28. data/lib/praxis/action_definition.rb +9 -7
  29. data/lib/praxis/api_definition.rb +44 -27
  30. data/lib/praxis/api_general_info.rb +3 -2
  31. data/lib/praxis/application.rb +139 -20
  32. data/lib/praxis/bootloader.rb +2 -4
  33. data/lib/praxis/bootloader_stages/environment.rb +0 -13
  34. data/lib/praxis/controller.rb +2 -0
  35. data/lib/praxis/dispatcher.rb +16 -10
  36. data/lib/praxis/docs/generator.rb +20 -9
  37. data/lib/praxis/docs/link_builder.rb +1 -1
  38. data/lib/praxis/error_handler.rb +5 -5
  39. data/lib/praxis/extensions/attribute_filtering.rb +28 -0
  40. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +180 -0
  41. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +273 -0
  42. data/lib/praxis/extensions/attribute_filtering/query_builder.rb +39 -0
  43. data/lib/praxis/extensions/field_selection.rb +3 -0
  44. data/lib/praxis/extensions/field_selection/active_record_query_selector.rb +57 -0
  45. data/lib/praxis/extensions/field_selection/sequel_query_selector.rb +65 -0
  46. data/lib/praxis/extensions/rails_compat.rb +2 -0
  47. data/lib/praxis/extensions/rails_compat/request_methods.rb +19 -0
  48. data/lib/praxis/extensions/rendering.rb +1 -1
  49. data/lib/praxis/file_group.rb +1 -1
  50. data/lib/praxis/middleware_app.rb +26 -6
  51. data/lib/praxis/multipart/parser.rb +14 -2
  52. data/lib/praxis/multipart/part.rb +5 -3
  53. data/lib/praxis/plugins/praxis_mapper_plugin.rb +2 -2
  54. data/lib/praxis/plugins/rails_plugin.rb +104 -0
  55. data/lib/praxis/request.rb +8 -9
  56. data/lib/praxis/request_stages/response.rb +3 -2
  57. data/lib/praxis/request_superclassing.rb +11 -0
  58. data/lib/praxis/resource_definition.rb +14 -10
  59. data/lib/praxis/response.rb +6 -7
  60. data/lib/praxis/response_definition.rb +7 -5
  61. data/lib/praxis/response_template.rb +4 -3
  62. data/lib/praxis/responses/http.rb +0 -36
  63. data/lib/praxis/responses/internal_server_error.rb +3 -12
  64. data/lib/praxis/responses/multipart_ok.rb +4 -11
  65. data/lib/praxis/responses/validation_error.rb +1 -10
  66. data/lib/praxis/router.rb +3 -3
  67. data/lib/praxis/tasks/api_docs.rb +10 -2
  68. data/lib/praxis/tasks/routes.rb +1 -0
  69. data/lib/praxis/version.rb +1 -1
  70. data/praxis.gemspec +4 -5
  71. data/spec/functional_spec.rb +4 -6
  72. data/spec/praxis/action_definition_spec.rb +26 -15
  73. data/spec/praxis/api_definition_spec.rb +13 -8
  74. data/spec/praxis/api_general_info_spec.rb +3 -8
  75. data/spec/praxis/application_spec.rb +13 -7
  76. data/spec/praxis/middleware_app_spec.rb +24 -10
  77. data/spec/praxis/request_spec.rb +17 -7
  78. data/spec/praxis/request_stages/validate_spec.rb +1 -1
  79. data/spec/praxis/resource_definition_spec.rb +12 -10
  80. data/spec/praxis/response_definition_spec.rb +22 -5
  81. data/spec/praxis/response_spec.rb +12 -5
  82. data/spec/praxis/responses/internal_server_error_spec.rb +4 -7
  83. data/spec/praxis/responses/validation_error_spec.rb +2 -2
  84. data/spec/praxis/router_spec.rb +8 -4
  85. data/spec/spec_app/config.ru +1 -6
  86. data/spec/spec_helper.rb +3 -3
  87. data/tasks/thor/templates/generator/empty_app/Gemfile +3 -3
  88. metadata +36 -32
  89. data/.ruby-version +0 -1
  90. data/lib/praxis/stats.rb +0 -113
  91. data/spec/praxis/stats_spec.rb +0 -9
@@ -20,6 +20,8 @@ module Praxis
20
20
  end
21
21
 
22
22
  definition.controller = self
23
+ # `implements` should only be processed while the application initializes/setup
24
+ # So we will use the `.instance` function to get the "current" application instance
23
25
  Application.instance.controllers << self
24
26
  end
25
27
 
@@ -28,11 +28,13 @@ module Praxis
28
28
  @deferred_callbacks[:after] << [conditions, block]
29
29
  end
30
30
 
31
- def self.current(thread: Thread.current, application: Application.instance)
31
+ # Typically, this is only called from the router, and the app will always be known.
32
+ # But we'll leave the application param as optional if we know there is a dispatcher in the thread
33
+ def self.current(thread: Thread.current, application: nil)
32
34
  thread[:praxis_dispatcher] ||= self.new(application: application)
33
35
  end
34
36
 
35
- def initialize(application: Application.instance)
37
+ def initialize(application:)
36
38
  @stages = []
37
39
  @application = application
38
40
  setup_stages!
@@ -76,8 +78,17 @@ module Praxis
76
78
  @action = action
77
79
  @request = request
78
80
 
79
- payload = {request: request, response: nil}
81
+ payload = {request: request, response: nil, controller: @controller}
80
82
 
83
+ instrumented_dispatch( payload )
84
+
85
+ ensure
86
+ @controller = nil
87
+ @action = nil
88
+ @request = nil
89
+ end
90
+
91
+ def instrumented_dispatch( payload )
81
92
  Notifications.instrument 'praxis.request.all'.freeze, payload do
82
93
  begin
83
94
  # the response stage must be the final stage in the list
@@ -95,18 +106,13 @@ module Praxis
95
106
  response_stage.run
96
107
 
97
108
  payload[:response] = controller.response
98
- controller.response.finish
109
+ controller.response.finish(application: application)
99
110
  rescue => e
100
- @application.error_handler.handle!(request, e)
111
+ @application.error_handler.handle!(request, e, app: application)
101
112
  end
102
113
  end
103
- ensure
104
- @controller = nil
105
- @action = nil
106
- @request = nil
107
114
  end
108
115
 
109
-
110
116
  # TODO: fix for multithreaded environments
111
117
  def reset_cache!
112
118
  return unless Praxis::Blueprint.caching_enabled?
@@ -5,7 +5,8 @@ module Praxis
5
5
  require 'active_support/core_ext/enumerable' # For index_by
6
6
 
7
7
  API_DOCS_DIRNAME = 'docs/api'
8
-
8
+
9
+ attr_reader :app_instance
9
10
  attr_reader :resources_by_version, :types_by_id, :infos_by_version
10
11
  attr_reader :doc_root_dir
11
12
 
@@ -20,16 +21,25 @@ module Praxis
20
21
  Attributor::Integer,
21
22
  Attributor::Object,
22
23
  Attributor::String,
23
- Attributor::Symbol
24
+ Attributor::Symbol,
25
+ Attributor::URI,
24
26
  ]).freeze
25
27
 
26
-
27
- def initialize(root)
28
+ def self.generate(root, name:, skip_sub_directory: false)
29
+ instance = Praxis::Application.registered_apps[name]
30
+ Thread.current[:praxis_instance] = instance
31
+ self.new(root, instance: instance, name: name, skip_sub_directory: skip_sub_directory).save!
32
+ Thread.current[:praxis_instance] = nil
33
+ end
34
+
35
+ def initialize(root, instance:, name:, skip_sub_directory:)
28
36
  require 'yaml'
29
37
  @resources_by_version = Hash.new do |h,k|
30
38
  h[k] = Set.new
31
39
  end
32
- initialize_directories(root)
40
+ @app_instance = instance
41
+ subdir = skip_sub_directory ? nil : name
42
+ initialize_directories(root, subdir: subdir )
33
43
 
34
44
  Attributor::AttributeResolver.current = Attributor::AttributeResolver.new
35
45
  collect_infos
@@ -47,9 +57,10 @@ module Praxis
47
57
 
48
58
  private
49
59
 
50
- def initialize_directories(root)
60
+ def initialize_directories(root, subdir: nil )
51
61
  @doc_root_dir = File.join(root, API_DOCS_DIRNAME)
52
-
62
+ @doc_root_dir = File.join(@doc_root_dir, subdir) if subdir
63
+
53
64
  # remove previous data (and reset the directory)
54
65
  FileUtils.rm_rf @doc_root_dir if File.exists?(@doc_root_dir)
55
66
  FileUtils.mkdir_p @doc_root_dir unless File.exists? @doc_root_dir
@@ -57,7 +68,7 @@ module Praxis
57
68
 
58
69
  def collect_resources
59
70
  # load all resource definitions registered with Praxis
60
- Praxis::Application.instance.resource_definitions.map do |resource|
71
+ app_instance.resource_definitions.map do |resource|
61
72
  # skip resources with doc_visibility of :none
62
73
  next if resource.metadata[:doc_visibility] == :none
63
74
  version = resource.version
@@ -75,7 +86,7 @@ module Praxis
75
86
 
76
87
  def collect_infos
77
88
  # All infos. Including keys for `:global`, "n/a", and any string version
78
- @infos_by_version = ApiDefinition.instance.describe
89
+ @infos_by_version = app_instance.api_definition.describe
79
90
  end
80
91
 
81
92
 
@@ -20,7 +20,7 @@ module Praxis
20
20
 
21
21
  def endpoint
22
22
  @endpoint ||= begin
23
- endpoint = ApiDefinition.instance.global_info.documentation_url
23
+ endpoint = Application.current_instance.api_definition.global_info.documentation_url
24
24
  endpoint.gsub(/\/index\.html$/i, '/') if endpoint
25
25
  end
26
26
  end
@@ -1,15 +1,15 @@
1
1
  module Praxis
2
2
  class ErrorHandler
3
-
4
- def handle!(request, error)
5
- Application.instance.logger.error error.inspect
3
+
4
+ def handle!(request, error, app:)
5
+ app.logger.error error.inspect
6
6
  error.backtrace.each do |line|
7
- Application.instance.logger.error line
7
+ app.logger.error line
8
8
  end
9
9
 
10
10
  response = Responses::InternalServerError.new(error: error)
11
11
  response.request = request
12
- response.finish
12
+ response.finish(application: app)
13
13
  end
14
14
 
15
15
  end
@@ -0,0 +1,28 @@
1
+ require 'praxis/extensions/attribute_filtering/filtering_params'
2
+ require 'praxis/extensions/attribute_filtering/query_builder'
3
+
4
+ # To include in a controller
5
+ module Praxis
6
+ module Extensions
7
+ module AttributeFiltering
8
+ extend ActiveSupport::Concern
9
+
10
+ def build_query(base_query) # rubocop:disable Metrics/AbcSize
11
+
12
+ domain_model = self.media_type&.domain_model
13
+ raise "No domain model defined for #{self.name}. Cannot use the attribute filtering helpers without it" unless domain_model
14
+
15
+ filters = request.params.filters if request.params&.respond_to?(:filters)
16
+ base_query = domain_model.craft_query( base_query , filters )
17
+
18
+ # TODO: add the field selector...and the pagination...and the ordering...
19
+ resolved = Praxis::MediaType::FieldResolver.resolve(self.media_type, self.expanded_fields)
20
+ base_query = FieldSelection::ActiveRecordQuerySelector.new(ds: base_query, model: domain_model.model,
21
+ selectors: identity_map.selectors, resolved: resolved).generate
22
+
23
+ # TODO: handle pagination and ordering
24
+ base_query
25
+ end
26
+ end
27
+ end
28
+ end
@@ -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
+ match[:value]
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