praxis 0.21 → 0.22.pre.1

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.
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