rest_framework 1.2.0 → 2.0.0.beta2

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.
@@ -1,51 +1,71 @@
1
1
  # A filter backend which handles ordering of the recordset.
2
2
  class RESTFramework::Filters::OrderingFilter < RESTFramework::Filters::BaseFilter
3
3
  def _get_fields
4
- @controller.class.ordering_fields&.map(&:to_s) || @controller.get_fields
4
+ # Always return a list of strings; `@controller.readable_columns_or_associations` already does.
5
+ @controller.class.ordering_fields&.map(&:to_s) || @controller.readable_columns_or_associations
5
6
  end
6
7
 
7
- # Convert ordering string to an ordering configuration.
8
+ # Convert the ordering param into an `[ordering, references]` pair: the ordering config for
9
+ # `order`/`reorder`, and the association names that must be joined for it to resolve.
8
10
  def _get_ordering
9
11
  return nil unless param = @controller.class.ordering_query_param.presence
10
12
 
11
13
  # Ensure ordering_fields are strings since the split param will be strings.
12
14
  fields = self._get_fields
13
- order_string = @controller.params[param]
15
+ order_string = @controller.request.query_parameters[param]
14
16
 
15
- if order_string.present?
16
- ordering = {}.with_indifferent_access
17
+ # Reject nested-hash inputs like `?ordering[evil]=x` (Rack parses these into
18
+ # a Hash, which can't be split into ordering tokens).
19
+ return nil unless self.class._safe_query_value?(order_string)
20
+ return nil unless order_string.present?
17
21
 
18
- order_string = order_string.join(",") if order_string.is_a?(Array)
19
- order_string.split(",").map(&:strip).each do |field|
20
- if field[0] == "-"
21
- column = field[1..-1]
22
- direction = :desc
23
- else
24
- column = field
25
- direction = :asc
26
- end
22
+ ordering = {}.with_indifferent_access
23
+ references = []
27
24
 
28
- next if !column.in?(fields) && !column.split(".").first.in?(fields)
25
+ order_string = order_string.join(",") if order_string.is_a?(Array)
26
+ order_string.split(",").map(&:strip).each do |field|
27
+ if field[0] == "-"
28
+ column = field[1..-1]
29
+ direction = :desc
30
+ else
31
+ column = field
32
+ direction = :asc
33
+ end
29
34
 
35
+ # A plain, directly-allowlisted field.
36
+ if column.in?(fields)
30
37
  ordering[column] = direction
38
+ next
31
39
  end
32
40
 
33
- return ordering
41
+ # A dotted `association.sub_field` token. The root must be an allowlisted association field,
42
+ # and the sub-field must be one of that association's allowlisted fields. Otherwise a client
43
+ # could order by (and infer, via an ordering oracle) a column that is never serialized.
44
+ root, sub = column.split(".", 2)
45
+ next unless sub && root.in?(fields)
46
+
47
+ cfg = @controller.class.field_configuration[root]
48
+ next unless cfg && sub.in?(cfg[:fields] || [])
49
+
50
+ ordering[column] = direction
51
+ references << root.to_sym
34
52
  end
35
53
 
36
- nil
54
+ return nil if ordering.empty?
55
+
56
+ [ ordering, references ]
37
57
  end
38
58
 
39
59
  # Order data according to the request query parameters.
40
60
  def filter_data(data)
41
- ordering = self._get_ordering
42
- reorder = !@controller.class.ordering_no_reorder
61
+ ordering, references = self._get_ordering
62
+ return data unless ordering
43
63
 
44
- if ordering && !ordering.empty?
45
- return data.send(reorder ? :reorder : :order, ordering)
46
- end
64
+ # Join any referenced associations so dotted ordering keys resolve instead of raising.
65
+ data = data.includes(*references).references(*references) if references.present?
47
66
 
48
- data
67
+ reorder = !@controller.class.ordering_no_reorder
68
+ data.send(reorder ? :reorder : :order, ordering)
49
69
  end
50
70
  end
51
71
 
@@ -37,9 +37,14 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
37
37
  }.freeze
38
38
  PREDICATES_REGEX = /^(.*)_(#{PREDICATES.keys.join("|")})$/
39
39
 
40
+ # Predicates whose value may be an array (e.g. `?id_in[]=1&id_in[]=2`). Every other predicate
41
+ # operates on a single scalar and skips array input, which would otherwise raise: `cont` in
42
+ # `sanitize_sql_like`, the range predicates while casting the endpoint.
43
+ ARRAY_PREDICATES = %i[in not].freeze
44
+
40
45
  def _get_fields
41
- # Always return a list of strings; `@controller.get_fields` already does this.
42
- @controller.class.filter_fields&.map(&:to_s) || @controller.get_fields
46
+ # Always return a list of strings; `@controller.readable_columns_or_associations` already does.
47
+ @controller.class.filter_fields&.map(&:to_s) || @controller.readable_columns_or_associations
43
48
  end
44
49
 
45
50
  # Helper to find a variation of a field using a predicate. For example, there could be a field
@@ -66,6 +71,11 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
66
71
  pred_queries = []
67
72
 
68
73
  base_query = @controller.request.query_parameters.map { |field, v|
74
+ # Skip params whose values aren't bind-safe (e.g. a user submitted
75
+ # `?field[evil]=x`, which Rack parses into a Hash). AR can't quote
76
+ # those, and the predicate lambdas below would also blow up on them.
77
+ next nil unless self.class._safe_query_value?(v)
78
+
69
79
  # First, if field is a simple filterable field, return early.
70
80
  if field.in?(fields)
71
81
  next [ field, v ]
@@ -84,11 +94,11 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
84
94
  if sub_field
85
95
  next nil unless root_field.in?(fields)
86
96
 
87
- sub_fields = @controller.class.field_configuration[root_field][:sub_fields] || []
88
- if sub_field.in?(sub_fields)
97
+ association_fields = @controller.class.field_configuration[root_field][:fields] || []
98
+ if sub_field.in?(association_fields)
89
99
  includes << root_field.to_sym
90
100
  next [ field, v ]
91
- elsif pred_sub_field && pred_sub_field.in?(sub_fields)
101
+ elsif pred_sub_field && pred_sub_field.in?(association_fields)
92
102
  includes << root_field.to_sym
93
103
  field = pred_field
94
104
  else
@@ -103,6 +113,10 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
103
113
  # value into a query that can be used in the ActiveRecord `where` API.
104
114
  cfg = PREDICATES[predicate.to_sym]
105
115
  if cfg.is_a?(Proc)
116
+ # Skip a scalar predicate given an array value (Rack parses `?field_cont[]=a&field_cont[]=b`
117
+ # into an array); only `in`/`not` accept arrays, the rest would raise.
118
+ next nil if v.is_a?(Array) && !predicate.to_sym.in?(ARRAY_PREDICATES)
119
+
106
120
  pred_queries << cfg.call(field, v)
107
121
  else
108
122
  pred_queries << { field => cfg }
@@ -1,19 +1,19 @@
1
1
  class RESTFramework::Filters::SearchFilter < RESTFramework::Filters::BaseFilter
2
2
  def _get_fields
3
- if search_fields = @controller.class.search_fields
4
- return search_fields&.map(&:to_s)
5
- end
6
-
7
- columns = @controller.class.model.column_names
8
- @controller.get_fields.select { |f|
9
- f.in?(RESTFramework.config.search_columns) && f.in?(columns)
10
- }
3
+ # Always return a list of strings; `@controller.readable_columns` already does.
4
+ @controller.class.search_fields&.map(&:to_s) || (
5
+ @controller.readable_columns & RESTFramework.config.search_columns
6
+ )
11
7
  end
12
8
 
13
9
  # Filter data according to the request query parameters.
14
10
  def filter_data(data)
15
11
  search = @controller.request.query_parameters[@controller.class.search_query_param]
16
12
 
13
+ # Reject nested-hash inputs like `?search[evil]=x` (Rack parses these into a
14
+ # Hash, which `sanitize_sql_like` can't accept).
15
+ return data unless search.is_a?(String)
16
+
17
17
  if search.present?
18
18
  if fields = self._get_fields.presence
19
19
  # MySQL doesn't support casting to VARCHAR, so we need to use CHAR instead.
@@ -4,12 +4,19 @@
4
4
  class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators::BasePaginator
5
5
  def initialize(**kwargs)
6
6
  super
7
- # Exclude any `select` clauses since that would cause `count` to fail with a SQL `SyntaxError`.
8
- @count = @data.except(:select).count
9
7
  @page_size = self._page_size
8
+ @total_count = @controller.class.page_total_count
10
9
 
11
- @total_pages = @count / @page_size
12
- @total_pages += 1 if @count % @page_size != 0
10
+ # Compute the total count (and total pages) unless disabled. On large tables `page_total_count`
11
+ # can be set to `false` to skip this `COUNT(*)` over the whole filtered set; `next` is then
12
+ # derived by fetching one extra record in `get_page`.
13
+ if @total_count
14
+ # Exclude any `select` clauses, since that would cause `count` to fail with a SQL
15
+ # `SyntaxError`.
16
+ @count = @data.except(:select).count
17
+ @total_pages = @count / @page_size
18
+ @total_pages += 1 if @count % @page_size != 0
19
+ end
13
20
  end
14
21
 
15
22
  def _page_size
@@ -17,7 +24,7 @@ class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators
17
24
 
18
25
  # Get from query param, if allowed.
19
26
  if param = @controller.class.page_size_query_param
20
- if raw = @controller.params[param].presence
27
+ if raw = @controller.request.query_parameters[param].presence
21
28
  parsed = raw.to_i
22
29
  page_size = parsed if parsed > 0
23
30
  end
@@ -38,9 +45,9 @@ class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators
38
45
 
39
46
  # Get the page and return it so the caller can serialize it.
40
47
  def get_page(page_number = nil)
41
- # If page number isn't provided, infer from the params or use 1 as a fallback value.
48
+ # If page number isn't provided, infer from the query params or use 1 as a fallback value.
42
49
  unless page_number
43
- page_number = @controller&.params&.[](@controller.class.page_query_param&.to_sym)
50
+ page_number = @controller&.request&.query_parameters&.[](@controller.class.page_query_param)
44
51
  if page_number.blank?
45
52
  page_number = 1
46
53
  else
@@ -54,14 +61,23 @@ class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators
54
61
 
55
62
  # Get the data page and return it so the caller can serialize the data in the proper format.
56
63
  page_index = @page_number - 1
57
- @data.limit(@page_size).offset(page_index * @page_size)
64
+ offset = page_index * @page_size
65
+
66
+ # Without a total count we can't derive `next` from `total_pages`, so detect whether a further
67
+ # page exists with a cheap existence check (a `LIMIT 1` past this page) instead of a full count.
68
+ unless @total_count
69
+ @has_next = @data.except(:select).offset(offset + @page_size).exists?
70
+ end
71
+
72
+ @data.limit(@page_size).offset(offset)
58
73
  end
59
74
 
60
75
  # Wrap the serialized page with appropriate metadata.
61
76
  def get_paginated_response(serialized_page)
62
77
  page_query_param = @controller.class.page_query_param
63
78
  base_params = @controller.request.query_parameters.symbolize_keys
64
- next_url = if @page_number < @total_pages
79
+ has_next = @total_count ? @page_number < @total_pages : @has_next
80
+ next_url = if has_next
65
81
  @controller.url_for({ **base_params, page_query_param => @page_number + 1 })
66
82
  end
67
83
  previous_url = if @page_number > 1
@@ -2,221 +2,85 @@ require "action_dispatch/routing/mapper"
2
2
 
3
3
  module ActionDispatch::Routing
4
4
  class Mapper
5
- # Internal interface to get the controller class from the name and current scope.
6
- def _get_controller_class(name, pluralize: true, fallback_reverse_pluralization: true)
7
- # Get class name.
8
- name = name.to_s.camelize # Camelize to leave plural names plural.
9
- name = name.pluralize if pluralize
10
- if name == name.pluralize
11
- name_reverse = name.singularize
12
- else
13
- name_reverse = name.pluralize
14
- end
15
- name += "Controller"
16
- name_reverse += "Controller"
17
-
18
- # Get scope for the class.
19
- if @scope[:module]
20
- mod = @scope[:module].to_s.camelize.constantize
21
- else
22
- mod = Object
23
- end
24
-
25
- # Convert class name to class.
26
- begin
27
- controller = mod.const_get(name)
28
- rescue NameError
29
- if fallback_reverse_pluralization
30
- reraise = false
31
-
32
- begin
33
- controller = mod.const_get(name_reverse)
34
- rescue NameError
35
- reraise = true
36
- end
37
-
38
- if reraise
39
- raise
40
- end
41
- else
42
- raise
43
- end
44
- end
45
-
46
- controller
5
+ # Resolve a controller class from a route name and the current scope. The name must match the
6
+ # controller exactly (camelized, plus `Controller`) there is no pluralization fallback.
7
+ def _rrf_controller_class(name)
8
+ mod = @scope[:module] ? @scope[:module].to_s.camelize.constantize : Object
9
+ mod.const_get("#{name.to_s.camelize}Controller")
47
10
  end
48
11
 
49
- # Internal interface for routing extra actions.
50
- def _route_extra_actions(actions, &block)
51
- parsed_actions = RESTFramework::Utils.parse_extra_actions(actions)
52
-
53
- parsed_actions.each do |action, config|
54
- config[:methods].each do |m|
55
- public_send(m, config[:path], action: action, **config[:kwargs])
12
+ # Route each action from a controller's action store.
13
+ def _rrf_route_actions(actions)
14
+ actions.each_value do |spec|
15
+ # Delegated actions keep their declared action name (so routing and OpenAPI show the real
16
+ # name); `method_for_action` redirects dispatch to `rrf_delegate`, which needs the scope.
17
+ kwargs = spec.kwargs
18
+ if !spec.builtin && spec.metadata&.[](:delegate)
19
+ kwargs = kwargs.merge(rrf_delegate_scope: spec.type)
56
20
  end
57
21
 
58
- # Record that this route is an extra action and any metadata associated with it.
59
- metadata = config[:metadata]
60
- key = "#{@scope[:path]}/#{config[:path]}"
61
- RESTFramework::EXTRA_ACTION_ROUTES.add(key)
62
- RESTFramework::ROUTE_METADATA[key] = metadata if metadata
63
-
64
- yield if block_given?
65
- end
66
- end
67
-
68
- # Internal core implementation of the `rest_resource(s)` router, both singular and plural.
69
- # @param default_singular [Boolean] the default plurality of the resource if the plurality is
70
- # not otherwise defined by the controller
71
- # @param name [Symbol] the resource name, from which path and controller are deduced by default
72
- def _rest_resources(default_singular, name, **kwargs, &block)
73
- controller = kwargs.delete(:controller) || name
74
- if controller.is_a?(Class)
75
- controller_class = controller
76
- else
77
- controller_class = self._get_controller_class(controller, pluralize: !default_singular)
78
- end
79
-
80
- # Set controller if it's not explicitly set.
81
- kwargs[:controller] = name unless kwargs[:controller]
82
-
83
- # Passing `unscoped: true` will prevent a nested resource from being scoped.
84
- unscoped = kwargs.delete(:unscoped)
85
-
86
- # Determine plural/singular resource.
87
- if !controller_class.singleton_controller.nil?
88
- singular = controller_class.singleton_controller
89
- else
90
- singular = default_singular
91
- end
92
- resource_method = singular ? :resource : :resources
93
-
94
- # Call either `resource` or `resources`, passing appropriate modifiers.
95
- skip = RESTFramework::Utils.get_skipped_builtin_actions(controller_class, singular)
96
- public_send(resource_method, name, except: skip, **kwargs) do
97
- if controller_class.respond_to?(:extra_member_actions)
98
- member do
99
- self._route_extra_actions(controller_class.extra_member_actions)
100
- end
22
+ spec.methods.each do |m|
23
+ public_send(m, spec.path, action: spec.name, **kwargs)
101
24
  end
102
25
 
103
- collection do
104
- # Route extra controller-defined actions.
105
- self._route_extra_actions(controller_class.extra_actions)
106
-
107
- # Route extra RRF-defined actions.
108
- RESTFramework::RRF_BUILTIN_ACTIONS.each do |action, methods|
109
- next unless controller_class.method_defined?(action)
110
-
111
- [ methods ].flatten.each do |m|
112
- # Anchor the route since Rails 8.1 OPTIONS routes are non-anchored by default, which
113
- # causes parent OPTIONS routes to greedily intercept sub-path requests.
114
- public_send(m, "", action: action, anchor: true) if self.respond_to?(m)
115
- end
116
- end
117
-
118
- # Route bulk actions, if configured. These require a model and are gated by the `bulk`
119
- # attribute, and may be individually excluded via `excluded_actions`.
120
- if controller_class.model && controller_class.bulk
121
- bulk_exclude = controller_class.excluded_actions&.to_set || Set.new
122
- RESTFramework::RRF_BUILTIN_BULK_ACTIONS.each do |action, methods|
123
- next unless controller_class.method_defined?(action)
124
- next if bulk_exclude.include?(action)
26
+ # Record non-builtin (extra) actions and their metadata for the browsable API / OpenAPI.
27
+ next if spec.builtin
125
28
 
126
- [ methods ].flatten.each do |m|
127
- # Anchor the route since Rails 8.1 OPTIONS routes are non-anchored by default, which
128
- # causes parent OPTIONS routes to greedily intercept sub-path requests.
129
- public_send(m, "", action: action, anchor: true) if self.respond_to?(m)
130
- end
131
- end
132
- end
133
- end
134
-
135
- if unscoped
136
- yield if block_given?
137
- else
138
- scope(module: name, as: name) do
139
- yield if block_given?
140
- end
141
- end
29
+ key = "#{@scope[:path]}/#{spec.path}"
30
+ RESTFramework::EXTRA_ACTION_ROUTES.add(key)
31
+ RESTFramework::ROUTE_METADATA[key] = spec.metadata if spec.metadata
142
32
  end
143
33
  end
144
34
 
145
- # Public interface for creating singular RESTful resource routes.
146
- def rest_resource(*names, **kwargs, &block)
147
- names.each do |n|
148
- self._rest_resources(true, n, **kwargs, &block)
35
+ # Route one or more controllers from their action stores. Plural model controllers get
36
+ # collection/member scopes; singular and non-model controllers route everything at the root.
37
+ # Passing several names condenses simple routes into one call; per-name options (`path:`, `as:`,
38
+ # `controller:`, and a block) only apply to a single name.
39
+ def rest_route(*names, **kwargs, &block)
40
+ if names.size > 1 && (block || (kwargs.keys & [ :path, :as, :controller ]).any?)
41
+ raise ArgumentError, "rest_route: options and a block require a single name"
149
42
  end
150
- end
151
43
 
152
- # Public interface for creating plural RESTful resource routes.
153
- def rest_resources(*names, **kwargs, &block)
154
- names.each do |n|
155
- self._rest_resources(false, n, **kwargs, &block)
156
- end
44
+ names.each { |name| _rrf_rest_route(name, **kwargs, &block) }
157
45
  end
158
46
 
159
- # Route a controller without the default resourceful paths.
160
- def rest_route(name = nil, **kwargs, &block)
47
+ # Route a single controller from its action store.
48
+ def _rrf_rest_route(name, **kwargs)
161
49
  controller = kwargs.delete(:controller) || name
162
- route_root_to = kwargs.delete(:route_root_to)
163
50
  if controller.is_a?(Class)
164
51
  controller_class = controller
165
52
  else
166
- controller_class = self._get_controller_class(controller, pluralize: false)
53
+ controller_class = self._rrf_controller_class(controller)
167
54
  end
168
55
 
169
56
  # Set controller if it's not explicitly set.
170
57
  kwargs[:controller] = name unless kwargs[:controller]
171
58
 
172
- # Passing `unscoped: true` will prevent a nested resource from being scoped.
173
- unscoped = kwargs.delete(:unscoped)
174
-
175
- # Route actions using the resourceful router, but skip all builtin actions.
176
- public_send(:resource, name, only: [], **kwargs) do
177
- # Route a root for this resource.
178
- if route_root_to
179
- get("", action: route_root_to, as: "")
180
- end
181
-
182
- collection do
183
- # Route extra controller-defined actions.
184
- self._route_extra_actions(controller_class.extra_actions)
185
-
186
- # Route extra RRF-defined actions.
187
- RESTFramework::RRF_BUILTIN_ACTIONS.each do |action, methods|
188
- next unless controller_class.method_defined?(action)
189
-
190
- [ methods ].flatten.each do |m|
191
- # Anchor the route since Rails 8.1 OPTIONS routes are non-anchored by default, which
192
- # causes parent OPTIONS routes to greedily intercept sub-path requests.
193
- public_send(m, "", action: action, anchor: true) if self.respond_to?(m)
194
- end
59
+ has_model = !!controller_class.model
60
+ singular = controller_class.singular
61
+ actions = controller_class.actions
62
+ member_actions = controller_class.member_actions
63
+
64
+ # Use `resources` (plural) for plural model controllers to get the member `:id` scope; use
65
+ # `resource` (singular) for everything else.
66
+ resource_method = (has_model && !singular) ? :resources : :resource
67
+
68
+ public_send(resource_method, name, only: [], **kwargs) do
69
+ if has_model
70
+ if singular
71
+ # Singular model controller: actions and member actions are the same.
72
+ self._rrf_route_actions(actions)
73
+ self._rrf_route_actions(member_actions)
74
+ else
75
+ # Plural model controller: route collection/member actions separately.
76
+ collection { self._rrf_route_actions(actions) }
77
+ member { self._rrf_route_actions(member_actions) }
195
78
  end
196
- end
197
-
198
- if unscoped
199
- yield if block_given?
200
79
  else
201
- scope(module: name, as: name) do
202
- yield if block_given?
203
- end
80
+ # Non-model controller: only actions (there is no member `:id` scope).
81
+ self._rrf_route_actions(actions)
204
82
  end
205
- end
206
- end
207
-
208
- # Route a controller's `#root` to '/' in the current scope/namespace, along with other actions.
209
- def rest_root(name = nil, **kwargs, &block)
210
- # By default, use RootController#root.
211
- root_action = kwargs.delete(:action) || :root
212
- controller = kwargs.delete(:controller) || name || :root
213
-
214
- # Remove path if name is nil (routing to the root of current namespace).
215
- unless name
216
- kwargs[:path] = ""
217
- end
218
83
 
219
- rest_route(controller, route_root_to: root_action, **kwargs) do
220
84
  yield if block_given?
221
85
  end
222
86
  end
@@ -1,6 +1,6 @@
1
1
  # This is a helper factory to wrap an ActiveModelSerializer to provide a `serialize` method which
2
2
  # accepts both collections and individual records. Use `.for` to build adapters.
3
- # :nocov:
3
+ # simplecov:disable
4
4
  class RESTFramework::Serializers::ActiveModelSerializerAdapterFactory
5
5
  def self.for(active_model_serializer)
6
6
  Class.new(active_model_serializer) do
@@ -14,7 +14,7 @@ class RESTFramework::Serializers::ActiveModelSerializerAdapterFactory
14
14
  end
15
15
  end
16
16
  end
17
- # :nocov:
17
+ # simplecov:enable
18
18
 
19
19
  # Alias for convenience.
20
20
  # rubocop:disable Layout/LineLength
@@ -16,7 +16,7 @@ class RESTFramework::Serializers::BaseSerializer
16
16
  end
17
17
 
18
18
  # Synonym for `serialize` for compatibility with `active_model_serializers`.
19
- # :nocov:
19
+ # simplecov:disable
20
20
  def serializable_hash(*args)
21
21
  self.serialize(*args)
22
22
  end
@@ -35,7 +35,7 @@ class RESTFramework::Serializers::BaseSerializer
35
35
  def associations(*args, **kwargs)
36
36
  []
37
37
  end
38
- # :nocov:
38
+ # simplecov:enable
39
39
  end
40
40
 
41
41
  # Alias for convenience.