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.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +43 -0
  3. data/lib/restme_rails/adapters/controller_adapter.rb +77 -0
  4. data/lib/restme_rails/configuration.rb +20 -0
  5. data/lib/restme_rails/context.rb +128 -0
  6. data/lib/restme_rails/core/authorize/rules.rb +93 -0
  7. data/lib/restme_rails/core/create/rules.rb +164 -0
  8. data/lib/restme_rails/core/scope/field/attachable.rb +140 -0
  9. data/lib/restme_rails/core/scope/field/rules.rb +273 -0
  10. data/lib/restme_rails/core/scope/filter/rules.rb +284 -0
  11. data/lib/restme_rails/core/scope/filter/types/bigger_than_filterable.rb +106 -0
  12. data/lib/restme_rails/core/scope/filter/types/bigger_than_or_equal_to_filterable.rb +102 -0
  13. data/lib/restme_rails/core/scope/filter/types/equal_filterable.rb +106 -0
  14. data/lib/restme_rails/core/scope/filter/types/in_filterable.rb +124 -0
  15. data/lib/restme_rails/core/scope/filter/types/less_than_filterable.rb +102 -0
  16. data/lib/restme_rails/core/scope/filter/types/less_than_or_equal_to_filterable.rb +108 -0
  17. data/lib/restme_rails/core/scope/filter/types/like_filterable.rb +104 -0
  18. data/lib/restme_rails/core/scope/paginate/rules.rb +122 -0
  19. data/lib/restme_rails/core/scope/pipeline.rb +87 -0
  20. data/lib/restme_rails/core/scope/rules.rb +303 -0
  21. data/lib/restme_rails/core/scope/sort/rules.rb +142 -0
  22. data/lib/restme_rails/core/update/rules.rb +225 -0
  23. data/lib/restme_rails/error.rb +65 -0
  24. data/lib/restme_rails/model_finder.rb +86 -0
  25. data/lib/restme_rails/params_serializer.rb +107 -0
  26. data/lib/restme_rails/rules_find.rb +63 -0
  27. data/lib/restme_rails/runner.rb +170 -0
  28. data/lib/restme_rails/scope_error.rb +30 -0
  29. data/lib/restme_rails/user_roles_resolver.rb +83 -0
  30. data/lib/restme_rails/version.rb +5 -0
  31. data/lib/restme_rails.rb +160 -0
  32. 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