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
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
|