rest_framework 1.1.0 → 2.0.0.beta1

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +107 -41
  3. data/VERSION +1 -1
  4. data/app/views/rest_framework/routes_and_forms/_html_form.html.erb +1 -1
  5. data/lib/rest_framework/controller/actions.rb +256 -0
  6. data/lib/rest_framework/controller/bulk.rb +247 -32
  7. data/lib/rest_framework/controller/crud.rb +13 -9
  8. data/lib/rest_framework/controller/openapi.rb +12 -8
  9. data/lib/rest_framework/controller.rb +275 -164
  10. data/lib/rest_framework/errors.rb +70 -3
  11. data/lib/rest_framework/filters/base_filter.rb +10 -0
  12. data/lib/rest_framework/filters/ordering_filter.rb +41 -23
  13. data/lib/rest_framework/filters/query_filter.rb +24 -5
  14. data/lib/rest_framework/filters/search_filter.rb +8 -4
  15. data/lib/rest_framework/paginators/page_number_paginator.rb +34 -19
  16. data/lib/rest_framework/routers.rb +52 -182
  17. data/lib/rest_framework/serializers/active_model_serializer_adapter_factory.rb +2 -2
  18. data/lib/rest_framework/serializers/base_serializer.rb +2 -2
  19. data/lib/rest_framework/serializers/native_serializer.rb +78 -24
  20. data/lib/rest_framework/utils.rb +39 -70
  21. data/lib/rest_framework/version.rb +8 -5
  22. data/lib/rest_framework.rb +7 -41
  23. metadata +5 -12
  24. data/lib/rest_framework/errors/base_error.rb +0 -5
  25. data/lib/rest_framework/errors/nil_passed_to_render_api_error.rb +0 -14
  26. data/lib/rest_framework/generators/controller_generator.rb +0 -64
  27. data/lib/rest_framework/generators.rb +0 -4
  28. data/lib/rest_framework/mixins/base_controller_mixin.rb +0 -12
  29. data/lib/rest_framework/mixins/bulk_model_controller_mixin.rb +0 -55
  30. data/lib/rest_framework/mixins/model_controller_mixin.rb +0 -110
  31. data/lib/rest_framework/mixins.rb +0 -7
@@ -1,6 +1,73 @@
1
1
  module RESTFramework::Errors
2
- end
2
+ class BaseError < StandardError
3
+ end
4
+
5
+ class NilPassedToRenderAPIError < BaseError
6
+ def message
7
+ <<~MSG.squish
8
+ Payload of `nil` was passed to `render_api`; this is unsupported. If you want a blank
9
+ response, pass `''` (an empty string) as the payload. If this was the result of a `find_by`
10
+ (or similar Active Record method) not finding a record, you should use the bang version
11
+ (e.g., `find_by!`) to raise `ActiveRecord::RecordNotFound`, which the REST controller will
12
+ catch and return an appropriate error response.
13
+ MSG
14
+ end
15
+ end
16
+
17
+ class InvalidBulkParametersError < BaseError
18
+ def initialize(detail = nil)
19
+ @detail = detail
20
+ end
21
+
22
+ def message
23
+ msg = "Invalid request parameters for bulk action."
24
+ msg += " #{@detail}" if @detail
25
+ msg
26
+ end
27
+ end
28
+
29
+ class DelegatedMethodError < BaseError
30
+ def initialize(receiver, target)
31
+ @receiver = receiver.is_a?(Class) ? receiver : receiver.class
32
+ @target = target
33
+ end
3
34
 
4
- require_relative "errors/base_error"
35
+ def message
36
+ <<~MSG.squish
37
+ Delegated action `#{@target}` does not resolve to a public method on `#{@receiver}`. This is
38
+ almost certainly a typo, a missing method, or a method that should be public. Define a
39
+ public class method (for a collection action) or instance method (for a member action), or
40
+ remove the action.
41
+ MSG
42
+ end
43
+ end
44
+
45
+ class BulkRecordErrorsError < BaseError
46
+ attr_reader :errors
47
+
48
+ def initialize(records)
49
+ @errors = records.each_with_index.filter_map { |record, i|
50
+ next unless record.errors.any?
51
+ { index: i, errors: record.errors.messages }
52
+ }
53
+ end
54
+
55
+ # Allow `e.try(:record).try(:errors)` to chain through the standard error handler.
56
+ def record
57
+ self
58
+ end
59
+
60
+ def message
61
+ "Bulk operation failed due to validation errors on #{@errors.length} #{
62
+ 'record'.pluralize(@errors.length)
63
+ }."
64
+ end
65
+ end
66
+ end
5
67
 
6
- require_relative "errors/nil_passed_to_render_api_error"
68
+ # Aliases for convenience.
69
+ RESTFramework::BaseError = RESTFramework::Errors::BaseError
70
+ RESTFramework::NilPassedToRenderAPIError = RESTFramework::Errors::NilPassedToRenderAPIError
71
+ RESTFramework::InvalidBulkParametersError = RESTFramework::Errors::InvalidBulkParametersError
72
+ RESTFramework::DelegatedMethodError = RESTFramework::Errors::DelegatedMethodError
73
+ RESTFramework::BulkRecordErrorsError = RESTFramework::Errors::BulkRecordErrorsError
@@ -6,6 +6,16 @@ class RESTFramework::Filters::BaseFilter
6
6
  def filter_data(data)
7
7
  raise NotImplementedError
8
8
  end
9
+
10
+ # True when `v` is a query-parameter value safe to feed into `where`, string
11
+ # operations, or `split` — i.e. a String or an Array of Strings. Guards against
12
+ # nested-hash inputs like `?field[evil]=x`, which Rack parses into a Hash and
13
+ # which AR cannot quote as a bind.
14
+ def self._safe_query_value?(v)
15
+ return true if v.is_a?(String)
16
+ return v.all? { |el| el.is_a?(String) } if v.is_a?(Array)
17
+ false
18
+ end
9
19
  end
10
20
 
11
21
  # Alias for convenience.
@@ -1,52 +1,70 @@
1
1
  # A filter backend which handles ordering of the recordset.
2
2
  class RESTFramework::Filters::OrderingFilter < RESTFramework::Filters::BaseFilter
3
- # Get a list of ordering fields for the current action.
4
3
  def _get_fields
5
4
  @controller.class.ordering_fields&.map(&:to_s) || @controller.get_fields
6
5
  end
7
6
 
8
- # Convert ordering string to an ordering configuration.
7
+ # Convert the ordering param into an `[ordering, references]` pair: the ordering config for
8
+ # `order`/`reorder`, and the association names that must be joined for it to resolve.
9
9
  def _get_ordering
10
10
  return nil unless param = @controller.class.ordering_query_param.presence
11
11
 
12
12
  # Ensure ordering_fields are strings since the split param will be strings.
13
13
  fields = self._get_fields
14
- order_string = @controller.params[param]
14
+ order_string = @controller.request.query_parameters[param]
15
15
 
16
- if order_string.present?
17
- ordering = {}.with_indifferent_access
16
+ # Reject nested-hash inputs like `?ordering[evil]=x` (Rack parses these into
17
+ # a Hash, which can't be split into ordering tokens).
18
+ return nil unless self.class._safe_query_value?(order_string)
19
+ return nil unless order_string.present?
18
20
 
19
- order_string = order_string.join(",") if order_string.is_a?(Array)
20
- order_string.split(",").map(&:strip).each do |field|
21
- if field[0] == "-"
22
- column = field[1..-1]
23
- direction = :desc
24
- else
25
- column = field
26
- direction = :asc
27
- end
21
+ ordering = {}.with_indifferent_access
22
+ references = []
28
23
 
29
- next if !column.in?(fields) && !column.split(".").first.in?(fields)
24
+ order_string = order_string.join(",") if order_string.is_a?(Array)
25
+ order_string.split(",").map(&:strip).each do |field|
26
+ if field[0] == "-"
27
+ column = field[1..-1]
28
+ direction = :desc
29
+ else
30
+ column = field
31
+ direction = :asc
32
+ end
30
33
 
34
+ # A plain, directly-allowlisted field.
35
+ if column.in?(fields)
31
36
  ordering[column] = direction
37
+ next
32
38
  end
33
39
 
34
- return ordering
40
+ # A dotted `association.sub_field` token. The root must be an allowlisted association field,
41
+ # and the sub-field must be one of that association's allowlisted fields. Otherwise a client
42
+ # could order by (and infer, via an ordering oracle) a column that is never serialized.
43
+ root, sub = column.split(".", 2)
44
+ next unless sub && root.in?(fields)
45
+
46
+ cfg = @controller.class.field_configuration[root]
47
+ next unless cfg && sub.in?(cfg[:fields] || [])
48
+
49
+ ordering[column] = direction
50
+ references << root.to_sym
35
51
  end
36
52
 
37
- nil
53
+ return nil if ordering.empty?
54
+
55
+ [ ordering, references ]
38
56
  end
39
57
 
40
58
  # Order data according to the request query parameters.
41
59
  def filter_data(data)
42
- ordering = self._get_ordering
43
- reorder = !@controller.class.ordering_no_reorder
60
+ ordering, references = self._get_ordering
61
+ return data unless ordering
44
62
 
45
- if ordering && !ordering.empty?
46
- return data.send(reorder ? :reorder : :order, ordering)
47
- end
63
+ # Join any referenced associations so dotted ordering keys resolve instead of raising.
64
+ data = data.includes(*references).references(*references) if references.present?
48
65
 
49
- data
66
+ reorder = !@controller.class.ordering_no_reorder
67
+ data.send(reorder ? :reorder : :order, ordering)
50
68
  end
51
69
  end
52
70
 
@@ -20,7 +20,13 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
20
20
  lte: ->(f, v) { { f => ..v } },
21
21
  gte: ->(f, v) { { f => v.. } },
22
22
  not: ->(f, v) { Not.new({ f => v }) },
23
- cont: ->(f, v) { [ "#{f} LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(v)}%" ] },
23
+ cont: ->(f, v) {
24
+ [
25
+ "#{ActiveRecord::Base.connection.quote_column_name(f)} LIKE ?", "%#{
26
+ ActiveRecord::Base.sanitize_sql_like(v)
27
+ }%"
28
+ ]
29
+ },
24
30
  in: ->(f, v) {
25
31
  if v.is_a?(Array)
26
32
  { f => v.map { |el| el == "null" ? nil : el } }
@@ -31,7 +37,11 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
31
37
  }.freeze
32
38
  PREDICATES_REGEX = /^(.*)_(#{PREDICATES.keys.join("|")})$/
33
39
 
34
- # Get a list of filter fields for the current action.
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
+
35
45
  def _get_fields
36
46
  # Always return a list of strings; `@controller.get_fields` already does this.
37
47
  @controller.class.filter_fields&.map(&:to_s) || @controller.get_fields
@@ -61,6 +71,11 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
61
71
  pred_queries = []
62
72
 
63
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
+
64
79
  # First, if field is a simple filterable field, return early.
65
80
  if field.in?(fields)
66
81
  next [ field, v ]
@@ -79,11 +94,11 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
79
94
  if sub_field
80
95
  next nil unless root_field.in?(fields)
81
96
 
82
- sub_fields = @controller.class.field_configuration[root_field][:sub_fields] || []
83
- if sub_field.in?(sub_fields)
97
+ association_fields = @controller.class.field_configuration[root_field][:fields] || []
98
+ if sub_field.in?(association_fields)
84
99
  includes << root_field.to_sym
85
100
  next [ field, v ]
86
- elsif pred_sub_field && pred_sub_field.in?(sub_fields)
101
+ elsif pred_sub_field && pred_sub_field.in?(association_fields)
87
102
  includes << root_field.to_sym
88
103
  field = pred_field
89
104
  else
@@ -98,6 +113,10 @@ class RESTFramework::Filters::QueryFilter < RESTFramework::Filters::BaseFilter
98
113
  # value into a query that can be used in the ActiveRecord `where` API.
99
114
  cfg = PREDICATES[predicate.to_sym]
100
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
+
101
120
  pred_queries << cfg.call(field, v)
102
121
  else
103
122
  pred_queries << { field => cfg }
@@ -1,5 +1,4 @@
1
1
  class RESTFramework::Filters::SearchFilter < RESTFramework::Filters::BaseFilter
2
- # Get a list of search fields for the current action.
3
2
  def _get_fields
4
3
  if search_fields = @controller.class.search_fields
5
4
  return search_fields&.map(&:to_s)
@@ -15,6 +14,10 @@ class RESTFramework::Filters::SearchFilter < RESTFramework::Filters::BaseFilter
15
14
  def filter_data(data)
16
15
  search = @controller.request.query_parameters[@controller.class.search_query_param]
17
16
 
17
+ # Reject nested-hash inputs like `?search[evil]=x` (Rack parses these into a
18
+ # Hash, which `sanitize_sql_like` can't accept).
19
+ return data unless search.is_a?(String)
20
+
18
21
  if search.present?
19
22
  if fields = self._get_fields.presence
20
23
  # MySQL doesn't support casting to VARCHAR, so we need to use CHAR instead.
@@ -25,12 +28,13 @@ class RESTFramework::Filters::SearchFilter < RESTFramework::Filters::BaseFilter
25
28
  "VARCHAR"
26
29
  end
27
30
 
28
- # Ensure we pass user input as arguments to prevent SQL injection.
31
+ conn = data.connection
32
+ like_op = @controller.class.search_ilike ? "ILIKE" : "LIKE"
29
33
  return data.where(
30
34
  fields.map { |f|
31
- "CAST(#{f} AS #{data_type}) #{@controller.class.search_ilike ? "ILIKE" : "LIKE"} ?"
35
+ "CAST(#{conn.quote_column_name(f)} AS #{data_type}) #{like_op} ?"
32
36
  }.join(" OR "),
33
- *([ "%#{search}%" ] * fields.length),
37
+ *([ "%#{ActiveRecord::Base.sanitize_sql_like(search)}%" ] * fields.length),
34
38
  )
35
39
  end
36
40
  end
@@ -4,31 +4,37 @@
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
16
- page_size = 1
23
+ page_size = nil
17
24
 
18
- # Get from context, if allowed.
25
+ # Get from query param, if allowed.
19
26
  if param = @controller.class.page_size_query_param
20
- if page_size = @controller.params[param].presence
21
- page_size = page_size.to_i
27
+ if raw = @controller.request.query_parameters[param].presence
28
+ parsed = raw.to_i
29
+ page_size = parsed if parsed > 0
22
30
  end
23
31
  end
24
32
 
25
- # Otherwise, get from config.
26
- if !page_size && @controller.class.page_size
27
- page_size = @controller.class.page_size.to_i
28
- end
33
+ # Fall back to the configured page size.
34
+ page_size ||= @controller.class.page_size&.to_i || 1
29
35
 
30
36
  # Ensure we don't exceed the max page size.
31
- max_page_size = @controller.class.max_page_size&.to_i
37
+ max_page_size = @controller.class.max_page_size
32
38
  if max_page_size && page_size > max_page_size
33
39
  page_size = max_page_size
34
40
  end
@@ -39,14 +45,14 @@ class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators
39
45
 
40
46
  # Get the page and return it so the caller can serialize it.
41
47
  def get_page(page_number = nil)
42
- # 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.
43
49
  unless page_number
44
- page_number = @controller&.params&.[](@controller.class.page_query_param&.to_sym)
50
+ page_number = @controller&.request&.query_parameters&.[](@controller.class.page_query_param)
45
51
  if page_number.blank?
46
52
  page_number = 1
47
53
  else
48
54
  page_number = page_number.to_i
49
- if page_number.zero?
55
+ if page_number < 1
50
56
  page_number = 1
51
57
  end
52
58
  end
@@ -55,14 +61,23 @@ class RESTFramework::Paginators::PageNumberPaginator < RESTFramework::Paginators
55
61
 
56
62
  # Get the data page and return it so the caller can serialize the data in the proper format.
57
63
  page_index = @page_number - 1
58
- @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)
59
73
  end
60
74
 
61
75
  # Wrap the serialized page with appropriate metadata.
62
76
  def get_paginated_response(serialized_page)
63
77
  page_query_param = @controller.class.page_query_param
64
- base_params = @controller.params.to_unsafe_h
65
- next_url = if @page_number < @total_pages
78
+ base_params = @controller.request.query_parameters.symbolize_keys
79
+ has_next = @total_count ? @page_number < @total_pages : @has_next
80
+ next_url = if has_next
66
81
  @controller.url_for({ **base_params, page_query_param => @page_number + 1 })
67
82
  end
68
83
  previous_url = if @page_number > 1
@@ -2,215 +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
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
- public_send(m, "", action: action) if self.respond_to?(m)
113
- end
114
- end
115
-
116
- # Route bulk actions, if configured. These require a model and are gated by the `bulk`
117
- # attribute, and may be individually excluded via `excluded_actions`.
118
- if controller_class.model && controller_class.bulk
119
- bulk_exclude = controller_class.excluded_actions&.to_set || Set.new
120
- RESTFramework::RRF_BUILTIN_BULK_ACTIONS.each do |action, methods|
121
- next unless controller_class.method_defined?(action)
122
- 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
123
28
 
124
- [ methods ].flatten.each do |m|
125
- public_send(m, "", action: action) if self.respond_to?(m)
126
- end
127
- end
128
- end
129
- end
130
-
131
- if unscoped
132
- yield if block_given?
133
- else
134
- scope(module: name, as: name) do
135
- yield if block_given?
136
- end
137
- end
29
+ key = "#{@scope[:path]}/#{spec.path}"
30
+ RESTFramework::EXTRA_ACTION_ROUTES.add(key)
31
+ RESTFramework::ROUTE_METADATA[key] = spec.metadata if spec.metadata
138
32
  end
139
33
  end
140
34
 
141
- # Public interface for creating singular RESTful resource routes.
142
- def rest_resource(*names, **kwargs, &block)
143
- names.each do |n|
144
- 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"
145
42
  end
146
- end
147
43
 
148
- # Public interface for creating plural RESTful resource routes.
149
- def rest_resources(*names, **kwargs, &block)
150
- names.each do |n|
151
- self._rest_resources(false, n, **kwargs, &block)
152
- end
44
+ names.each { |name| _rrf_rest_route(name, **kwargs, &block) }
153
45
  end
154
46
 
155
- # Route a controller without the default resourceful paths.
156
- def rest_route(name = nil, **kwargs, &block)
47
+ # Route a single controller from its action store.
48
+ def _rrf_rest_route(name, **kwargs)
157
49
  controller = kwargs.delete(:controller) || name
158
- route_root_to = kwargs.delete(:route_root_to)
159
50
  if controller.is_a?(Class)
160
51
  controller_class = controller
161
52
  else
162
- controller_class = self._get_controller_class(controller, pluralize: false)
53
+ controller_class = self._rrf_controller_class(controller)
163
54
  end
164
55
 
165
56
  # Set controller if it's not explicitly set.
166
57
  kwargs[:controller] = name unless kwargs[:controller]
167
58
 
168
- # Passing `unscoped: true` will prevent a nested resource from being scoped.
169
- unscoped = kwargs.delete(:unscoped)
170
-
171
- # Route actions using the resourceful router, but skip all builtin actions.
172
- public_send(:resource, name, only: [], **kwargs) do
173
- # Route a root for this resource.
174
- if route_root_to
175
- get("", action: route_root_to, as: "")
176
- end
177
-
178
- collection do
179
- # Route extra controller-defined actions.
180
- self._route_extra_actions(controller_class.extra_actions)
181
-
182
- # Route extra RRF-defined actions.
183
- RESTFramework::RRF_BUILTIN_ACTIONS.each do |action, methods|
184
- next unless controller_class.method_defined?(action)
185
-
186
- [ methods ].flatten.each do |m|
187
- public_send(m, "", action: action) if self.respond_to?(m)
188
- 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) }
189
78
  end
190
- end
191
-
192
- if unscoped
193
- yield if block_given?
194
79
  else
195
- scope(module: name, as: name) do
196
- yield if block_given?
197
- end
80
+ # Non-model controller: only actions (there is no member `:id` scope).
81
+ self._rrf_route_actions(actions)
198
82
  end
199
- end
200
- end
201
-
202
- # Route a controller's `#root` to '/' in the current scope/namespace, along with other actions.
203
- def rest_root(name = nil, **kwargs, &block)
204
- # By default, use RootController#root.
205
- root_action = kwargs.delete(:action) || :root
206
- controller = kwargs.delete(:controller) || name || :root
207
-
208
- # Remove path if name is nil (routing to the root of current namespace).
209
- unless name
210
- kwargs[:path] = ""
211
- end
212
83
 
213
- rest_route(controller, route_root_to: root_action, **kwargs) do
214
84
  yield if block_given?
215
85
  end
216
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