restme_rails 0.1.0
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 +7 -0
- data/README.md +43 -0
- data/lib/restme_rails/adapters/controller_adapter.rb +77 -0
- data/lib/restme_rails/configuration.rb +20 -0
- data/lib/restme_rails/context.rb +128 -0
- data/lib/restme_rails/core/authorize/rules.rb +93 -0
- data/lib/restme_rails/core/create/rules.rb +164 -0
- data/lib/restme_rails/core/scope/field/attachable.rb +140 -0
- data/lib/restme_rails/core/scope/field/rules.rb +273 -0
- data/lib/restme_rails/core/scope/filter/rules.rb +284 -0
- data/lib/restme_rails/core/scope/filter/types/bigger_than_filterable.rb +106 -0
- data/lib/restme_rails/core/scope/filter/types/bigger_than_or_equal_to_filterable.rb +102 -0
- data/lib/restme_rails/core/scope/filter/types/equal_filterable.rb +106 -0
- data/lib/restme_rails/core/scope/filter/types/in_filterable.rb +124 -0
- data/lib/restme_rails/core/scope/filter/types/less_than_filterable.rb +102 -0
- data/lib/restme_rails/core/scope/filter/types/less_than_or_equal_to_filterable.rb +108 -0
- data/lib/restme_rails/core/scope/filter/types/like_filterable.rb +104 -0
- data/lib/restme_rails/core/scope/paginate/rules.rb +122 -0
- data/lib/restme_rails/core/scope/pipeline.rb +87 -0
- data/lib/restme_rails/core/scope/rules.rb +303 -0
- data/lib/restme_rails/core/scope/sort/rules.rb +142 -0
- data/lib/restme_rails/core/update/rules.rb +225 -0
- data/lib/restme_rails/error.rb +65 -0
- data/lib/restme_rails/model_finder.rb +86 -0
- data/lib/restme_rails/params_serializer.rb +107 -0
- data/lib/restme_rails/rules_find.rb +63 -0
- data/lib/restme_rails/runner.rb +170 -0
- data/lib/restme_rails/scope_error.rb +30 -0
- data/lib/restme_rails/user_roles_resolver.rb +83 -0
- data/lib/restme_rails/version.rb +5 -0
- data/lib/restme_rails.rb +160 -0
- metadata +75 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RestmeRails
|
|
4
|
+
module Core
|
|
5
|
+
module Scope
|
|
6
|
+
module Paginate
|
|
7
|
+
# Provides pagination capabilities for scoped queries.
|
|
8
|
+
#
|
|
9
|
+
# Supported query parameters:
|
|
10
|
+
#
|
|
11
|
+
# ?page=2
|
|
12
|
+
# ?per_page=20
|
|
13
|
+
#
|
|
14
|
+
# Configuration defaults:
|
|
15
|
+
#
|
|
16
|
+
# - RestmeRails::Configuration.pagination_default_page
|
|
17
|
+
# - RestmeRails::Configuration.pagination_default_per_page
|
|
18
|
+
# - RestmeRails::Configuration.pagination_max_per_page
|
|
19
|
+
#
|
|
20
|
+
# Pagination is applied using:
|
|
21
|
+
# - limit
|
|
22
|
+
# - offset
|
|
23
|
+
#
|
|
24
|
+
class Rules
|
|
25
|
+
attr_reader :context, :scope_error_instance
|
|
26
|
+
|
|
27
|
+
def initialize(context:, scope_error_instance:)
|
|
28
|
+
@context = context
|
|
29
|
+
@scope_error_instance = scope_error_instance
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Applies limit and offset to the given scope.
|
|
33
|
+
#
|
|
34
|
+
# @param user_scope [ActiveRecord::Relation]
|
|
35
|
+
# @return [ActiveRecord::Relation]
|
|
36
|
+
def process(user_scope)
|
|
37
|
+
user_scope.limit(per_page).offset(paginate_offset)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns current page number.
|
|
41
|
+
#
|
|
42
|
+
# Defaults to configured default page if not provided.
|
|
43
|
+
#
|
|
44
|
+
# @return [Integer]
|
|
45
|
+
def page_no
|
|
46
|
+
context.params[:page]&.to_i || ::RestmeRails::Configuration.pagination_default_page
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Calculates total number of pages.
|
|
50
|
+
#
|
|
51
|
+
# @param user_scope [ActiveRecord::Relation]
|
|
52
|
+
# @return [Integer]
|
|
53
|
+
def pages(user_scope)
|
|
54
|
+
(total_items(user_scope) / per_page.to_f).ceil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns total number of items in the unpaginated scope.
|
|
58
|
+
#
|
|
59
|
+
# Memoized per request.
|
|
60
|
+
#
|
|
61
|
+
# @param user_scope [ActiveRecord::Relation]
|
|
62
|
+
# @return [Integer]
|
|
63
|
+
def total_items(user_scope)
|
|
64
|
+
@total_items ||= user_scope.size
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Validates per_page against maximum allowed value.
|
|
68
|
+
#
|
|
69
|
+
# If per_page exceeds:
|
|
70
|
+
# pagination_max_per_page
|
|
71
|
+
#
|
|
72
|
+
# Registers:
|
|
73
|
+
# - Error message
|
|
74
|
+
# - HTTP status :bad_request
|
|
75
|
+
#
|
|
76
|
+
# @return [Boolean, nil]
|
|
77
|
+
def errors
|
|
78
|
+
return if per_page <= ::RestmeRails::Configuration.pagination_max_per_page
|
|
79
|
+
|
|
80
|
+
add_per_page_errors
|
|
81
|
+
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Returns number of items per page.
|
|
88
|
+
#
|
|
89
|
+
# Defaults to configured default if not provided.
|
|
90
|
+
#
|
|
91
|
+
# @return [Integer]
|
|
92
|
+
def per_page
|
|
93
|
+
context.params[:per_page]&.to_i || ::RestmeRails::Configuration.pagination_default_per_page
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Calculates offset based on page and per_page.
|
|
97
|
+
#
|
|
98
|
+
# Formula:
|
|
99
|
+
# (page - 1) * per_page
|
|
100
|
+
#
|
|
101
|
+
# @return [Integer]
|
|
102
|
+
def paginate_offset
|
|
103
|
+
(page_no - 1) * per_page
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def add_per_page_errors
|
|
107
|
+
scope_error_instance.add_error(
|
|
108
|
+
{
|
|
109
|
+
message: "Invalid per page value",
|
|
110
|
+
body: {
|
|
111
|
+
per_page_max_value: ::RestmeRails::Configuration.pagination_max_per_page
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
scope_error_instance.add_status(:bad_request)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RestmeRails
|
|
4
|
+
module Core
|
|
5
|
+
module Scope
|
|
6
|
+
# Executes a sequential pipeline of scope processing steps.
|
|
7
|
+
#
|
|
8
|
+
# Each step in the pipeline must respond to:
|
|
9
|
+
#
|
|
10
|
+
# process(scope)
|
|
11
|
+
#
|
|
12
|
+
# and return a new ActiveRecord::Relation that will be passed
|
|
13
|
+
# to the next step in the pipeline.
|
|
14
|
+
#
|
|
15
|
+
# Typical pipeline steps include:
|
|
16
|
+
#
|
|
17
|
+
# - filtering
|
|
18
|
+
# - sorting
|
|
19
|
+
# - pagination
|
|
20
|
+
# - field selection
|
|
21
|
+
#
|
|
22
|
+
# The output of one step becomes the input of the next.
|
|
23
|
+
#
|
|
24
|
+
# Example:
|
|
25
|
+
#
|
|
26
|
+
# pipeline = Pipeline.new([
|
|
27
|
+
# FilterRules.new(...),
|
|
28
|
+
# SortRules.new(...),
|
|
29
|
+
# PaginateRules.new(...)
|
|
30
|
+
# ])
|
|
31
|
+
#
|
|
32
|
+
# pipeline.call(Product.all)
|
|
33
|
+
#
|
|
34
|
+
# @example Pipeline flow
|
|
35
|
+
#
|
|
36
|
+
# initial_scope
|
|
37
|
+
# -> filter
|
|
38
|
+
# -> sort
|
|
39
|
+
# -> paginate
|
|
40
|
+
# -> fields
|
|
41
|
+
#
|
|
42
|
+
class Pipeline
|
|
43
|
+
# Initializes the pipeline with a list of processing steps.
|
|
44
|
+
#
|
|
45
|
+
# Each step must implement:
|
|
46
|
+
#
|
|
47
|
+
# process(scope)
|
|
48
|
+
#
|
|
49
|
+
# @param steps [Array<Object>]
|
|
50
|
+
# List of rule instances that will be executed sequentially.
|
|
51
|
+
def initialize(steps)
|
|
52
|
+
@steps = steps
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Executes the pipeline.
|
|
56
|
+
#
|
|
57
|
+
# Each step receives the result of the previous step.
|
|
58
|
+
#
|
|
59
|
+
# @param initial_scope [ActiveRecord::Relation]
|
|
60
|
+
# The starting relation that will be processed.
|
|
61
|
+
#
|
|
62
|
+
# @return [ActiveRecord::Relation]
|
|
63
|
+
# The final scope after all steps have been applied.
|
|
64
|
+
def call(initial_scope)
|
|
65
|
+
@steps.reduce(initial_scope) do |scope, step|
|
|
66
|
+
step.process(scope)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Executes error checks for every pipeline step.
|
|
71
|
+
#
|
|
72
|
+
# Each step is expected to expose an `errors` method
|
|
73
|
+
# that validates parameters or scope rules and registers
|
|
74
|
+
# errors internally if necessary.
|
|
75
|
+
#
|
|
76
|
+
# This method ensures that all validation logic is triggered
|
|
77
|
+
# before executing the pipeline.
|
|
78
|
+
#
|
|
79
|
+
# @return [Array<Object>]
|
|
80
|
+
# The list of steps after their error checks have been executed.
|
|
81
|
+
def check_scope_errors
|
|
82
|
+
@check_scope_errors ||= @steps.each(&:errors)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "filter/rules"
|
|
4
|
+
require_relative "sort/rules"
|
|
5
|
+
require_relative "paginate/rules"
|
|
6
|
+
require_relative "field/rules"
|
|
7
|
+
require_relative "pipeline"
|
|
8
|
+
require_relative "../../rules_find"
|
|
9
|
+
require_relative "../../scope_error"
|
|
10
|
+
|
|
11
|
+
module RestmeRails
|
|
12
|
+
module Core
|
|
13
|
+
module Scope
|
|
14
|
+
# Provides a complete query scoping pipeline for index/show actions.
|
|
15
|
+
#
|
|
16
|
+
# Responsibilities:
|
|
17
|
+
#
|
|
18
|
+
# - Role-based user scope resolution
|
|
19
|
+
# - Filtering
|
|
20
|
+
# - Sorting
|
|
21
|
+
# - Pagination
|
|
22
|
+
# - Field selection
|
|
23
|
+
# - Error aggregation
|
|
24
|
+
#
|
|
25
|
+
# Expected convention:
|
|
26
|
+
#
|
|
27
|
+
# A Rules class may exist following the pattern:
|
|
28
|
+
# "#{ControllerName}Restme::Scope::Rules"
|
|
29
|
+
#
|
|
30
|
+
# Scope methods inside that class must follow:
|
|
31
|
+
# "#{role}_scope"
|
|
32
|
+
#
|
|
33
|
+
# Example:
|
|
34
|
+
# admin_scope
|
|
35
|
+
# manager_scope
|
|
36
|
+
#
|
|
37
|
+
# Each method must return an ActiveRecord::Relation.
|
|
38
|
+
#
|
|
39
|
+
class Rules
|
|
40
|
+
attr_reader :context, :scope_error_instance
|
|
41
|
+
|
|
42
|
+
# Ordered list of rule processors used to build the final scope pipeline.
|
|
43
|
+
#
|
|
44
|
+
# The order of these processors is critical because each step
|
|
45
|
+
# receives the result of the previous one.
|
|
46
|
+
#
|
|
47
|
+
# Pipeline order:
|
|
48
|
+
#
|
|
49
|
+
# 1. Filter
|
|
50
|
+
# 2. Sort
|
|
51
|
+
# 3. Paginate
|
|
52
|
+
# 4. Field selection
|
|
53
|
+
#
|
|
54
|
+
# Changing this order may break expected query behavior.
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<Symbol>]
|
|
57
|
+
PIPELINE_STEPS = [
|
|
58
|
+
{
|
|
59
|
+
identifier: :filter_rules,
|
|
60
|
+
klass: ::RestmeRails::Core::Scope::Filter::Rules
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
identifier: :sorte_rules,
|
|
64
|
+
klass: ::RestmeRails::Core::Scope::Sort::Rules
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
identifier: :paginate_rules,
|
|
68
|
+
klass: ::RestmeRails::Core::Scope::Paginate::Rules
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
identifier: :field_rules,
|
|
72
|
+
klass: ::RestmeRails::Core::Scope::Field::Rules
|
|
73
|
+
}
|
|
74
|
+
].freeze
|
|
75
|
+
|
|
76
|
+
def initialize(context:)
|
|
77
|
+
@context = context
|
|
78
|
+
@scope_error_instance = RestmeRails::ScopeError.new
|
|
79
|
+
|
|
80
|
+
check_scope_errors
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns paginated response structure.
|
|
84
|
+
#
|
|
85
|
+
# Output:
|
|
86
|
+
# {
|
|
87
|
+
# objects: [...],
|
|
88
|
+
# pagination: { page:, pages:, total_items: }
|
|
89
|
+
# }
|
|
90
|
+
#
|
|
91
|
+
# If any scope error occurs, returns the error payload instead.
|
|
92
|
+
#
|
|
93
|
+
# @return [Hash]
|
|
94
|
+
def pagination_response
|
|
95
|
+
@pagination_response ||= (pagination_response_object if scope_errors.blank?)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns a single scoped object (first record).
|
|
99
|
+
#
|
|
100
|
+
# Used for show-like behavior.
|
|
101
|
+
#
|
|
102
|
+
# If any scope error occurs, returns the error payload instead.
|
|
103
|
+
#
|
|
104
|
+
# @return [ActiveRecord::Base, Hash, nil]
|
|
105
|
+
def model_scope_object
|
|
106
|
+
@model_scope_object ||= (model_scope&.first if scope_errors.blank?)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Returns the HTTP-like status derived from scope errors.
|
|
110
|
+
#
|
|
111
|
+
# Delegates to ScopeError instance.
|
|
112
|
+
#
|
|
113
|
+
# Example:
|
|
114
|
+
# 200 -> success
|
|
115
|
+
# 400 -> invalid query parameters
|
|
116
|
+
# 403 -> forbidden access
|
|
117
|
+
#
|
|
118
|
+
# @return [Integer]
|
|
119
|
+
def scope_status
|
|
120
|
+
scope_error_instance.scope_status
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns the aggregated scope errors collected during rule execution.
|
|
124
|
+
#
|
|
125
|
+
# Errors may originate from:
|
|
126
|
+
#
|
|
127
|
+
# - filtering
|
|
128
|
+
# - sorting
|
|
129
|
+
# - pagination
|
|
130
|
+
# - field selection
|
|
131
|
+
#
|
|
132
|
+
# @return [Array<Hash>]
|
|
133
|
+
def scope_errors
|
|
134
|
+
scope_error_instance.scope_errors
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Builds paginated response structure.
|
|
140
|
+
def pagination_response_object
|
|
141
|
+
{
|
|
142
|
+
objects: model_scope,
|
|
143
|
+
pagination: pagination
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Executes all error-checking methods and aggregates errors.
|
|
148
|
+
#
|
|
149
|
+
# @return [Array, nil]
|
|
150
|
+
def check_scope_errors
|
|
151
|
+
@check_scope_errors ||= pipeline.check_scope_errors
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Final composed ActiveRecord::Relation.
|
|
155
|
+
#
|
|
156
|
+
# @return [ActiveRecord::Relation]
|
|
157
|
+
def model_scope
|
|
158
|
+
@model_scope ||= final_scope
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Pagination metadata.
|
|
162
|
+
#
|
|
163
|
+
# @return [Hash]
|
|
164
|
+
def pagination
|
|
165
|
+
{
|
|
166
|
+
page: @paginate_rules.page_no,
|
|
167
|
+
pages: @paginate_rules.pages(@filter_rules.scope),
|
|
168
|
+
total_items: @paginate_rules.total_items(@filter_rules.scope)
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Complete query pipeline:
|
|
173
|
+
#
|
|
174
|
+
# 1. Resolve user scope
|
|
175
|
+
# 2. Apply filtering
|
|
176
|
+
# 3. Apply sorting
|
|
177
|
+
# 4. Apply pagination
|
|
178
|
+
# 5. Apply field selection
|
|
179
|
+
#
|
|
180
|
+
# @return [ActiveRecord::Relation]
|
|
181
|
+
def final_scope
|
|
182
|
+
@final_scope ||= pipeline.call(user_scope)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def pipeline
|
|
186
|
+
@pipeline ||= begin
|
|
187
|
+
steps = PIPELINE_STEPS.map do |pipeline|
|
|
188
|
+
instance = pipeline[:klass].new(context: context, scope_error_instance: scope_error_instance)
|
|
189
|
+
|
|
190
|
+
instance_variable_name = pipeline[:identifier]
|
|
191
|
+
|
|
192
|
+
instance_variable_set(:"@#{instance_variable_name}", instance)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
Pipeline.new(steps)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Resolves base scope based on user roles.
|
|
200
|
+
#
|
|
201
|
+
# Strategy:
|
|
202
|
+
#
|
|
203
|
+
# - If no user: returns all records
|
|
204
|
+
# - If role scope methods exist: combine them using OR
|
|
205
|
+
# - If multiple scopes: ensures distinct results
|
|
206
|
+
# - If no matching scope: returns none
|
|
207
|
+
#
|
|
208
|
+
# @return [ActiveRecord::Relation]
|
|
209
|
+
def user_scope
|
|
210
|
+
@user_scope ||= none_user_scope || process_user_scope || none_scope
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Executes all matching role scope methods
|
|
214
|
+
# and combines them using `or`.
|
|
215
|
+
#
|
|
216
|
+
# @return [ActiveRecord::Relation, nil]
|
|
217
|
+
def process_user_scope
|
|
218
|
+
scopes = user_scope_methods.map { |m| scope_rules_class_instance.try(m) }
|
|
219
|
+
|
|
220
|
+
processed_scope = scopes.reduce { |combined, s| combined.or(s) }
|
|
221
|
+
|
|
222
|
+
user_scope_methods.many? ? processed_scope&.distinct : processed_scope
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Returns valid scope methods based on user roles.
|
|
226
|
+
#
|
|
227
|
+
# Example:
|
|
228
|
+
# admin_scope
|
|
229
|
+
# manager_scope
|
|
230
|
+
#
|
|
231
|
+
# @return [Array<Symbol>]
|
|
232
|
+
def user_scope_methods
|
|
233
|
+
@user_scope_methods ||=
|
|
234
|
+
methods_scopes.select do |method_scope|
|
|
235
|
+
scope_rules_class_instance.respond_to?(method_scope)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# If no user is present, returns full dataset.
|
|
240
|
+
#
|
|
241
|
+
# @return [ActiveRecord::Relation, nil]
|
|
242
|
+
def none_user_scope
|
|
243
|
+
context.model_class.all if context.current_user.blank?
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Fallback when no role scope matches.
|
|
247
|
+
#
|
|
248
|
+
# @return [ActiveRecord::Relation]
|
|
249
|
+
def none_scope
|
|
250
|
+
context.model_class.none
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Builds scope method names from roles.
|
|
254
|
+
#
|
|
255
|
+
# Example:
|
|
256
|
+
# :admin -> "admin_scope"
|
|
257
|
+
#
|
|
258
|
+
# @return [Array<String>]
|
|
259
|
+
def methods_scopes
|
|
260
|
+
@methods_scopes ||= context.current_user_roles.map do |role|
|
|
261
|
+
"#{role}_scope"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Lazily instantiates the dynamic Scope Rules class.
|
|
266
|
+
#
|
|
267
|
+
# The class is resolved using the Restme convention system
|
|
268
|
+
# and initialized with:
|
|
269
|
+
#
|
|
270
|
+
# (model_class, current_user, params)
|
|
271
|
+
#
|
|
272
|
+
# This class is responsible for defining role-based scopes,
|
|
273
|
+
# such as:
|
|
274
|
+
#
|
|
275
|
+
# admin_scope
|
|
276
|
+
# manager_scope
|
|
277
|
+
#
|
|
278
|
+
# @return [Object, nil]
|
|
279
|
+
def scope_rules_class_instance
|
|
280
|
+
@scope_rules_class_instance ||= scope_rules_class&.new(
|
|
281
|
+
context.model_class,
|
|
282
|
+
context.current_user,
|
|
283
|
+
context.params
|
|
284
|
+
)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Instantiates the Scope Rules class dynamically.
|
|
288
|
+
#
|
|
289
|
+
# Naming convention:
|
|
290
|
+
# "#{ControllerName}Restme::Scope::Rules"
|
|
291
|
+
#
|
|
292
|
+
# Initialized with:
|
|
293
|
+
# (model_class, current_user, params)
|
|
294
|
+
#
|
|
295
|
+
# @return [Object]
|
|
296
|
+
def scope_rules_class
|
|
297
|
+
@scope_rules_class ||=
|
|
298
|
+
RestmeRails::RulesFind.new(klass: context.model_class, rule_context: "Scope").rule_class
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RestmeRails
|
|
4
|
+
module Core
|
|
5
|
+
module Scope
|
|
6
|
+
module Sort
|
|
7
|
+
# Provides sorting capabilities based on query string parameters.
|
|
8
|
+
#
|
|
9
|
+
# Expected query format:
|
|
10
|
+
#
|
|
11
|
+
# GET /products?name_sort=asc&price_sort=desc
|
|
12
|
+
#
|
|
13
|
+
# Pattern:
|
|
14
|
+
# "#{field}_sort" => "asc" | "desc"
|
|
15
|
+
#
|
|
16
|
+
# Rules:
|
|
17
|
+
#
|
|
18
|
+
# - Sorting is applied only for GET requests.
|
|
19
|
+
# - Direction defaults to "asc" if invalid.
|
|
20
|
+
# - Only fields declared in `klass::SORTABLE_FIELDS` are allowed.
|
|
21
|
+
# - :id is always allowed.
|
|
22
|
+
#
|
|
23
|
+
class Rules
|
|
24
|
+
ID = :id
|
|
25
|
+
SORT_KEY = "sort"
|
|
26
|
+
SORTABLE_TYPES = %w[asc desc].freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :context, :scope_error_instance
|
|
29
|
+
|
|
30
|
+
def initialize(context:, scope_error_instance:)
|
|
31
|
+
@context = context
|
|
32
|
+
@scope_error_instance = scope_error_instance
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Applies ordering to the given scope if sorting is valid.
|
|
36
|
+
#
|
|
37
|
+
# @param user_scope [ActiveRecord::Relation]
|
|
38
|
+
# @return [ActiveRecord::Relation]
|
|
39
|
+
def process(user_scope)
|
|
40
|
+
return user_scope unless sortable_scope?
|
|
41
|
+
|
|
42
|
+
user_scope.order(serialize_sort_params)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Registers error if unknown sortable fields are detected.
|
|
46
|
+
#
|
|
47
|
+
# Sets:
|
|
48
|
+
# - Error message
|
|
49
|
+
# - HTTP status :bad_request
|
|
50
|
+
#
|
|
51
|
+
# @return [Boolean, nil]
|
|
52
|
+
def errors
|
|
53
|
+
return unless unknown_sortable_fields.present?
|
|
54
|
+
|
|
55
|
+
scope_error_instance.add_error(
|
|
56
|
+
{
|
|
57
|
+
message: "Unknown Sort",
|
|
58
|
+
body: unknown_sortable_fields
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
scope_error_instance.add_status(:bad_request)
|
|
63
|
+
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Determines whether sorting should be applied.
|
|
70
|
+
#
|
|
71
|
+
# Sorting is applied only if:
|
|
72
|
+
# - HTTP method is GET
|
|
73
|
+
# - At least one sortable field is present
|
|
74
|
+
#
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def sortable_scope?
|
|
77
|
+
context.request.get? && controller_params_sortable_fields.present?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Converts query parameters into an ActiveRecord-compatible
|
|
81
|
+
# order structure.
|
|
82
|
+
#
|
|
83
|
+
# Example:
|
|
84
|
+
# { "name_sort" => "asc" }
|
|
85
|
+
# becomes:
|
|
86
|
+
# { name: "asc" }
|
|
87
|
+
#
|
|
88
|
+
# Invalid directions default to "asc".
|
|
89
|
+
#
|
|
90
|
+
# @return [Array<Hash>]
|
|
91
|
+
def serialize_sort_params
|
|
92
|
+
@serialize_sort_params ||= controller_params_sortable_fields.map do |key, value|
|
|
93
|
+
key = key.to_s.gsub("_#{SORT_KEY}", "")
|
|
94
|
+
|
|
95
|
+
value = "asc" unless SORTABLE_TYPES.include?(value&.downcase)
|
|
96
|
+
|
|
97
|
+
{ key.to_sym => value&.downcase }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Extracts sortable parameters from query string.
|
|
102
|
+
#
|
|
103
|
+
# Only keys ending with "_sort" are considered.
|
|
104
|
+
#
|
|
105
|
+
# @return [Hash]
|
|
106
|
+
def controller_params_sortable_fields
|
|
107
|
+
@controller_params_sortable_fields ||= context.query_params.select do |key, _|
|
|
108
|
+
key.to_s.end_with?(SORT_KEY)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns fields requested for sorting that are not allowed.
|
|
113
|
+
#
|
|
114
|
+
# @return [Array<Symbol>]
|
|
115
|
+
def unknown_sortable_fields
|
|
116
|
+
@unknown_sortable_fields ||=
|
|
117
|
+
serialize_sort_params.map { |sort_param| sort_param.first.first } - sortable_fields
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Returns allowed sortable fields.
|
|
121
|
+
#
|
|
122
|
+
# Reads from:
|
|
123
|
+
# klass::SORTABLE_FIELDS
|
|
124
|
+
#
|
|
125
|
+
# :id is always allowed.
|
|
126
|
+
#
|
|
127
|
+
# If constant is not defined, defaults to [:id].
|
|
128
|
+
#
|
|
129
|
+
# @return [Array<Symbol>]
|
|
130
|
+
def sortable_fields
|
|
131
|
+
@sortable_fields ||=
|
|
132
|
+
if context.model_class.const_defined?(:SORTABLE_FIELDS)
|
|
133
|
+
Array.new(context.model_class::SORTABLE_FIELDS).push(ID)
|
|
134
|
+
else
|
|
135
|
+
[ID]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|