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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7e6838473f0e4d6088c4d2b7684d3dbff5c7dc83ecaed65c72698f92b29ad603
4
+ data.tar.gz: 4c235119e87160cccbef952b9245f4f3794f30aa60d70bc01e60bacf92506cb0
5
+ SHA512:
6
+ metadata.gz: 1958a3cfdc5a666eb565daac932e3dbaefea77446da53a49862c8a2bef071876dbc7195e286d69563911aaf789424b2c83a09f9aa12f20b93f840b0c0eccd684
7
+ data.tar.gz: 5e522994f16bf5f7f6211f368ed48d0a382c582ac3864772a403de6cd349210d398c521d844f43f712f27c00690d2127f525d15d632be1783b4653add91e82d1
data/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # RestmeRails
2
+
3
+ TODO: Delete this and the text below, and describe your gem
4
+
5
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/restme_rails`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ ```bash
14
+ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
15
+ ```
16
+
17
+ If bundler is not being used to manage dependencies, install the gem by executing:
18
+
19
+ ```bash
20
+ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/restme_rails. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/restme_rails/blob/master/CODE_OF_CONDUCT.md).
36
+
37
+ ## License
38
+
39
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
40
+
41
+ ## Code of Conduct
42
+
43
+ Everyone interacting in the RestmeRails project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/restme_rails/blob/master/CODE_OF_CONDUCT.md).
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RestmeRails
4
+ module Adapters
5
+ # Adapter responsible for abstracting a Rails controller.
6
+ #
7
+ # This class isolates direct dependencies on ActionController,
8
+ # allowing the rest of the gem to interact with a normalized
9
+ # controller interface.
10
+ #
11
+ # By using this adapter, the gem avoids tight coupling with
12
+ # Rails internals and makes testing significantly easier.
13
+ #
14
+ # Example usage:
15
+ #
16
+ # adapter = RestmeRails::Rails::ControllerAdapter.new(controller)
17
+ #
18
+ # adapter.params
19
+ # adapter.query_params
20
+ # adapter.action_name
21
+ #
22
+ # @note This adapter assumes an ActionController-compatible object.
23
+ #
24
+ class ControllerAdapter
25
+ # @return [ActionController::Base]
26
+ # The underlying Rails controller instance.
27
+ attr_reader :controller
28
+
29
+ # @param controller [ActionController::Base]
30
+ # The controller instance to be wrapped.
31
+ def initialize(controller)
32
+ @controller = controller
33
+ end
34
+
35
+ # Returns request parameters.
36
+ #
37
+ # @return [ActionController::Parameters]
38
+ def params
39
+ controller.params
40
+ end
41
+
42
+ # Returns URL query parameters as a symbolized Hash.
43
+ #
44
+ # Example:
45
+ # GET /products?name=foo
46
+ # => { name: "foo" }
47
+ #
48
+ # @return [Hash]
49
+ def query_params
50
+ return {} unless controller.respond_to?(:request)
51
+
52
+ controller.request.query_parameters.deep_symbolize_keys
53
+ end
54
+
55
+ # Returns the underlying request object.
56
+ #
57
+ # @return [ActionDispatch::Request]
58
+ def request
59
+ controller.request
60
+ end
61
+
62
+ # Returns the current action name.
63
+ #
64
+ # @return [String]
65
+ def action_name
66
+ controller.action_name
67
+ end
68
+
69
+ # Returns the controller class.
70
+ #
71
+ # @return [Class]
72
+ def controller_class
73
+ controller.class
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RestmeRails
4
+ # Defines the initialization configuration for RestmeRails gem
5
+ module Configuration
6
+ @current_user_variable = :current_user
7
+ @user_role_field = :role
8
+ @pagination_default_per_page = 12
9
+ @pagination_default_page = 1
10
+ @pagination_max_per_page = 100
11
+
12
+ class << self
13
+ attr_accessor :current_user_variable,
14
+ :user_role_field,
15
+ :pagination_default_per_page,
16
+ :pagination_default_page,
17
+ :pagination_max_per_page
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model_finder"
4
+ require_relative "params_serializer"
5
+ require_relative "user_roles_resolver"
6
+
7
+ module RestmeRails
8
+ # Context object responsible for encapsulating all request-level
9
+ # information required by Restme rule engines.
10
+ #
11
+ # This class acts as a boundary between Rails controllers and
12
+ # the internal rule system of the gem.
13
+ #
14
+ # It extracts and normalizes:
15
+ # - Request parameters
16
+ # - Query parameters
17
+ # - Current user
18
+ # - User roles
19
+ # - Model class
20
+ # - Action name
21
+ #
22
+ # The goal is to avoid tight coupling between rules and
23
+ # ActionController directly.
24
+ #
25
+ # @example
26
+ # context = RestmeRails::Context.new(
27
+ # user: current_user,
28
+ # controller: ControllerAdapter.new(self)
29
+ # )
30
+ #
31
+ # context.model_class
32
+ # # => Product
33
+ #
34
+ # context.action_name
35
+ # # => :create
36
+ #
37
+ class Context
38
+ # @return [Object]
39
+ # Adapter wrapping the Rails controller.
40
+ attr_reader :controller_adapter
41
+
42
+ # @param user [Object, nil]
43
+ # Current authenticated user.
44
+ #
45
+ # @param controller [ControllerAdapter]
46
+ # Adapter object that abstracts ActionController.
47
+ def initialize(user:, controller:)
48
+ @user = user
49
+ @controller_adapter = controller
50
+ end
51
+
52
+ # Returns normalized and serialized controller params.
53
+ #
54
+ # Behavior:
55
+ # - Removes :controller and :action keys
56
+ # - Permits all parameters (no strong params enforcement here)
57
+ # - Extracts nested params under model key
58
+ # Example:
59
+ # { product: { name: "Book" } }
60
+ # - Deep symbolized keys
61
+ #
62
+ # @return [Hash]
63
+ def controller_params_serialized
64
+ @controller_params_serialized ||= params_serializer_instance.params_serialized
65
+ end
66
+
67
+ # Raw params from controller
68
+ #
69
+ # @return [ActionController::Parameters, Hash]
70
+ def params
71
+ params_serializer_instance.params
72
+ end
73
+
74
+ # Query string parameters only
75
+ #
76
+ # @return [Hash]
77
+ def query_params
78
+ params_serializer_instance.query_params
79
+ end
80
+
81
+ # Request object
82
+ #
83
+ # @return [ActionDispatch::Request]
84
+ def request
85
+ controller_adapter.request
86
+ end
87
+
88
+ # Controller class
89
+ #
90
+ # @return [Class]
91
+ def controller_class
92
+ controller_adapter.controller_class
93
+ end
94
+
95
+ # Model class inferred from controller
96
+ #
97
+ # @return [Class]
98
+ def model_class
99
+ RestmeRails::ModelFinder
100
+ .new(controller_class: controller_class)
101
+ .model_class
102
+ end
103
+
104
+ # Action name symbol
105
+ #
106
+ # @return [Symbol]
107
+ def action_name
108
+ controller_adapter.action_name.to_sym
109
+ end
110
+
111
+ # Current authenticated user
112
+ #
113
+ # @return [Object, nil]
114
+ def current_user
115
+ @user
116
+ end
117
+
118
+ def current_user_roles
119
+ ::RestmeRails::UserRolesResolver.new(current_user: current_user).current_user_roles
120
+ end
121
+
122
+ private
123
+
124
+ def params_serializer_instance
125
+ @params_serializer_instance ||= ParamsSerializer.new(controller: controller_adapter, model_class: model_class)
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../rules_find"
4
+
5
+ module RestmeRails
6
+ module Core
7
+ module Authorize
8
+ # Core::Authorize::Rules
9
+ #
10
+ # Provides a lightweight role-based authorization layer.
11
+ #
12
+ # ------------------------------------------------------------
13
+ # Authorization Strategy
14
+ # ------------------------------------------------------------
15
+ #
16
+ # 1. If there is no current user → access is allowed.
17
+ # 2. If the current user's roles intersect with allowed roles
18
+ # for the action → access is allowed.
19
+ # 3. Otherwise → raises NotAuthorizedError.
20
+ #
21
+ # ------------------------------------------------------------
22
+ # Expected Convention
23
+ # ------------------------------------------------------------
24
+ #
25
+ # A rules class may exist following the naming convention:
26
+ #
27
+ # "#{ModelName}Rules::Authorize::Rules"
28
+ #
29
+ # Example:
30
+ #
31
+ # class ProductRules::Authorize::Rules
32
+ # ALLOWED_ROLES_ACTIONS = {
33
+ # index: [:admin, :manager],
34
+ # create: [:admin]
35
+ # }
36
+ # end
37
+ #
38
+ # Each controller action maps to an array of allowed roles.
39
+ #
40
+ class Rules
41
+ attr_reader :context
42
+
43
+ # @param context [RestmeRails::Context]
44
+ def initialize(context:)
45
+ @context = context
46
+ end
47
+
48
+ # Performs authorization check.
49
+ #
50
+ # @raise [NotAuthorizedError] if user is not authorized
51
+ # @return [true] if authorized
52
+ def authorize!
53
+ return true if context.current_user.blank?
54
+ return true if authorized?
55
+
56
+ raise RestmeRails::NotAuthorizedError, "You are not allowed to access this resource"
57
+ end
58
+
59
+ private
60
+
61
+ # Checks if user roles intersect allowed roles for action.
62
+ #
63
+ # @return [Boolean]
64
+ def authorized?
65
+ allowed_roles_for_action.intersect?(context.current_user_roles)
66
+ end
67
+
68
+ # Returns allowed roles for current action.
69
+ #
70
+ # If no rules class or constant exists, defaults to empty array.
71
+ #
72
+ # @return [Array<Symbol>]
73
+ def allowed_roles_for_action
74
+ return [] unless rules_class&.const_defined?(:ALLOWED_ROLES_ACTIONS)
75
+
76
+ rules_class::ALLOWED_ROLES_ACTIONS[context.action_name] || []
77
+ end
78
+
79
+ # Dynamically resolves authorization rules class.
80
+ #
81
+ # Uses RestmeRails::RulesFind to follow naming convention.
82
+ #
83
+ # @return [Class, nil]
84
+ def rules_class
85
+ @rules_class ||= RestmeRails::RulesFind.new(
86
+ klass: context.model_class,
87
+ rule_context: "Authorize"
88
+ ).rule_class
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../rules_find"
4
+
5
+ module RestmeRails
6
+ module Core
7
+ module Create
8
+ # Provides a standardized create flow with:
9
+ #
10
+ # - Automatic model instantiation
11
+ # - Role-based scope validation
12
+ # - Optional Rules class integration
13
+ # - Error normalization
14
+ #
15
+ # Expected conventions:
16
+ #
17
+ # 1. A Rules class may exist following the pattern:
18
+ # "#{ControllerName}Restme::Create::RestmeRules"
19
+ #
20
+ # 2. The Rules class may define:
21
+ # RESTME_CREATE_ACTIONS_RULES = [:create]
22
+ #
23
+ # 3. Scope methods must follow this format:
24
+ # "#{action}_#{role}_scope?"
25
+ #
26
+ # Example:
27
+ # create_admin_scope?
28
+ # create_manager_scope?
29
+ #
30
+ class Rules
31
+ attr_reader :context
32
+
33
+ def initialize(context:)
34
+ @context = context
35
+ end
36
+
37
+ # Executes create flow
38
+ #
39
+ # @param custom_params [Hash]
40
+ # @param auto_save [Boolean]
41
+ #
42
+ # @return [ActiveRecord::Base, Hash]
43
+ def create(custom_params: {})
44
+ build(custom_params:)
45
+
46
+ instance.save unless errors
47
+
48
+ errors || instance
49
+ end
50
+
51
+ def create_status
52
+ errors ? :unprocessable_content : :created
53
+ end
54
+
55
+ private
56
+
57
+ # Builds the instance without persisting
58
+ #
59
+ # @param custom_params [Hash]
60
+ # @return [ActiveRecord::Base]
61
+ def build(custom_params: {})
62
+ @custom_params = custom_params
63
+ set_current_user
64
+ instance
65
+ end
66
+
67
+ # -----------------------------
68
+ # Instance
69
+ # -----------------------------
70
+
71
+ def instance
72
+ @instance ||= begin
73
+ params = context.controller_params_serialized
74
+ params = params.merge(@custom_params) if @custom_params.present?
75
+
76
+ context.model_class.new(params)
77
+ end
78
+ end
79
+
80
+ # -----------------------------
81
+ # Current user injection
82
+ # -----------------------------
83
+
84
+ def set_current_user
85
+ return unless context.current_user
86
+ return unless instance.respond_to?(:current_user=)
87
+
88
+ instance.current_user = context.current_user
89
+ end
90
+
91
+ # -----------------------------
92
+ # Errors
93
+ # -----------------------------
94
+
95
+ def errors
96
+ return unless scoped_action?
97
+ return unscoped_errors unless scope_allowed?
98
+ return if instance.errors.blank?
99
+
100
+ active_record_errors
101
+ end
102
+
103
+ def unscoped_errors
104
+ { errors: ["Unscoped"] }
105
+ end
106
+
107
+ def active_record_errors
108
+ { errors: instance.errors.messages }
109
+ end
110
+
111
+ # -----------------------------
112
+ # Scope validation
113
+ # -----------------------------
114
+
115
+ def scope_allowed?
116
+ return true unless context.current_user
117
+
118
+ scope_methods.any? do |method|
119
+ rules_instance.respond_to?(method) &&
120
+ rules_instance.public_send(method)
121
+ end
122
+ end
123
+
124
+ def scope_methods
125
+ context.current_user_roles.map do |role|
126
+ "#{current_action}_#{role}_scope?"
127
+ end
128
+ end
129
+
130
+ # -----------------------------
131
+ # Action validation
132
+ # -----------------------------
133
+
134
+ def scoped_action?
135
+ return false unless rules_class&.const_defined?(:RESTME_CREATE_ACTIONS_RULES)
136
+
137
+ context.action_name.presence_in(
138
+ rules_class::RESTME_CREATE_ACTIONS_RULES
139
+ )
140
+ end
141
+
142
+ def current_action
143
+ context.action_name
144
+ end
145
+
146
+ # -----------------------------
147
+ # Rules resolution
148
+ # -----------------------------
149
+
150
+ def rules_instance
151
+ @rules_instance ||= rules_class&.new(instance, context.current_user)
152
+ end
153
+
154
+ def rules_class
155
+ @rules_class ||= RestmeRails::RulesFind
156
+ .new(
157
+ klass: context.model_class,
158
+ rule_context: "Create"
159
+ ).rule_class
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RestmeRails
4
+ module Core
5
+ module Scope
6
+ module Field
7
+ # Handles attachment field selection for scoped queries.
8
+ #
9
+ # Responsibilities:
10
+ #
11
+ # - Validates selected attachment fields
12
+ # - Prevents unallowed attachment access
13
+ # - Serializes attachment URLs without mutating the model class
14
+ #
15
+ # Important:
16
+ # This version does NOT dynamically define methods on the model.
17
+ # Attachment URLs are injected directly into the serialized JSON.
18
+ #
19
+ # Expected behavior:
20
+ #
21
+ # If the request includes:
22
+ # attachment_fields_select: [:avatar]
23
+ #
24
+ # The JSON response will include:
25
+ # {
26
+ # avatar_url: "https://..."
27
+ # }
28
+ #
29
+ class Attachable
30
+ attr_reader :context,
31
+ :attachment_fields_select,
32
+ :valid_nested_fields_select,
33
+ :scope_error_instance
34
+
35
+ def initialize(context:, attachment_fields_select:, valid_nested_fields_select:, scope_error_instance:)
36
+ @context = context
37
+ @attachment_fields_select = attachment_fields_select
38
+ @valid_nested_fields_select = valid_nested_fields_select
39
+ @scope_error_instance = scope_error_instance
40
+ end
41
+
42
+ # Applies attachment logic to a given ActiveRecord scope.
43
+ #
44
+ # Steps:
45
+ # 1. Validates unallowed attachment fields
46
+ # 2. If none selected, returns normal JSON
47
+ # 3. Preloads attachments
48
+ # 4. Injects *_url fields directly into serialized hash
49
+ #
50
+ # @param scope [ActiveRecord::Relation]
51
+ # @return [Array<Hash>]
52
+ def insert_attachments(scope)
53
+ unallowed_attachment_fields_errors
54
+
55
+ return scope.as_json(json_options) if attachment_fields_select.blank?
56
+
57
+ records = scope.includes(attachment_includes)
58
+
59
+ serialize_with_attachments(records)
60
+ end
61
+
62
+ # Registers bad_request error if client selected
63
+ # attachment fields that do not exist in the model.
64
+ #
65
+ # @return [void]
66
+ def unallowed_attachment_fields_errors
67
+ return if unallowed_attachment_fields.blank?
68
+
69
+ scope_error_instance.add_error(
70
+ body: unallowed_attachment_fields,
71
+ message: "Selected not allowed attachment fields"
72
+ )
73
+
74
+ scope_error_instance.add_status(:bad_request)
75
+ end
76
+
77
+ private
78
+
79
+ # Base JSON options (without attachment methods).
80
+ #
81
+ # @return [Hash]
82
+ def json_options
83
+ {
84
+ include: valid_nested_fields_select
85
+ }
86
+ end
87
+
88
+ # Serializes records and injects attachment URLs.
89
+ #
90
+ # @param records [ActiveRecord::Relation]
91
+ # @return [Array<Hash>]
92
+ def serialize_with_attachments(records)
93
+ records.map do |record|
94
+ base_hash = record.as_json(json_options)
95
+
96
+ attachment_fields_select.each do |field|
97
+ attachment = record.public_send(field)
98
+
99
+ base_hash["#{field}_url"] =
100
+ attachment&.attached? ? attachment.url : nil
101
+ end
102
+
103
+ base_hash
104
+ end
105
+ end
106
+
107
+ # Builds includes structure for ActiveStorage eager loading.
108
+ #
109
+ # Example:
110
+ # { avatar_attachment: :blob }
111
+ #
112
+ # @return [Array<Hash>]
113
+ def attachment_includes
114
+ attachment_fields_select.map do |field|
115
+ { "#{field}_attachment": :blob }
116
+ end
117
+ end
118
+
119
+ # Returns all attachment names declared in the model.
120
+ #
121
+ # @return [Array<Symbol>]
122
+ def model_attachment_fields
123
+ @model_attachment_fields ||= context.model_class
124
+ .attachment_reflections
125
+ .map { |_name, reflection| reflection.name }
126
+ end
127
+
128
+ # Returns fields requested but not present in the model.
129
+ #
130
+ # @return [Array<Symbol>]
131
+ def unallowed_attachment_fields
132
+ return [] if attachment_fields_select.blank?
133
+
134
+ attachment_fields_select - model_attachment_fields
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end