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.
- checksums.yaml +4 -4
- data/README.md +107 -41
- data/VERSION +1 -1
- data/app/views/rest_framework/routes_and_forms/_html_form.html.erb +1 -1
- data/lib/rest_framework/controller/actions.rb +256 -0
- data/lib/rest_framework/controller/bulk.rb +247 -32
- data/lib/rest_framework/controller/crud.rb +13 -9
- data/lib/rest_framework/controller/openapi.rb +12 -8
- data/lib/rest_framework/controller.rb +275 -164
- data/lib/rest_framework/errors.rb +70 -3
- data/lib/rest_framework/filters/base_filter.rb +10 -0
- data/lib/rest_framework/filters/ordering_filter.rb +41 -23
- data/lib/rest_framework/filters/query_filter.rb +24 -5
- data/lib/rest_framework/filters/search_filter.rb +8 -4
- data/lib/rest_framework/paginators/page_number_paginator.rb +34 -19
- data/lib/rest_framework/routers.rb +52 -182
- data/lib/rest_framework/serializers/active_model_serializer_adapter_factory.rb +2 -2
- data/lib/rest_framework/serializers/base_serializer.rb +2 -2
- data/lib/rest_framework/serializers/native_serializer.rb +78 -24
- data/lib/rest_framework/utils.rb +39 -70
- data/lib/rest_framework/version.rb +8 -5
- data/lib/rest_framework.rb +7 -41
- metadata +5 -12
- data/lib/rest_framework/errors/base_error.rb +0 -5
- data/lib/rest_framework/errors/nil_passed_to_render_api_error.rb +0 -14
- data/lib/rest_framework/generators/controller_generator.rb +0 -64
- data/lib/rest_framework/generators.rb +0 -4
- data/lib/rest_framework/mixins/base_controller_mixin.rb +0 -12
- data/lib/rest_framework/mixins/bulk_model_controller_mixin.rb +0 -55
- data/lib/rest_framework/mixins/model_controller_mixin.rb +0 -110
- data/lib/rest_framework/mixins.rb +0 -7
|
@@ -1,6 +1,73 @@
|
|
|
1
1
|
module RESTFramework::Errors
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
14
|
+
order_string = @controller.request.query_parameters[param]
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
+
ordering, references = self._get_ordering
|
|
61
|
+
return data unless ordering
|
|
44
62
|
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
#
|
|
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
|
-
|
|
83
|
-
if sub_field.in?(
|
|
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?(
|
|
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
|
-
|
|
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}) #{
|
|
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
|
-
|
|
12
|
-
|
|
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 =
|
|
23
|
+
page_size = nil
|
|
17
24
|
|
|
18
|
-
# Get from
|
|
25
|
+
# Get from query param, if allowed.
|
|
19
26
|
if param = @controller.class.page_size_query_param
|
|
20
|
-
if
|
|
21
|
-
|
|
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
|
-
#
|
|
26
|
-
|
|
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
|
|
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&.
|
|
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
|
|
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
|
-
|
|
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.
|
|
65
|
-
|
|
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
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
name
|
|
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
|
-
#
|
|
50
|
-
def
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
#
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
156
|
-
def
|
|
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.
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
# :
|
|
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
|
-
# :
|
|
17
|
+
# simplecov:enable
|
|
18
18
|
|
|
19
19
|
# Alias for convenience.
|
|
20
20
|
# rubocop:disable Layout/LineLength
|