restme_rails 0.2.0 → 0.3.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 +4 -4
- data/lib/restme_rails/core/authorize/rules.rb +9 -32
- data/lib/restme_rails/core/create/rules.rb +1 -3
- data/lib/restme_rails/core/scope/field/rules.rb +35 -209
- data/lib/restme_rails/core/scope/field/select_attachments.rb +154 -0
- data/lib/restme_rails/core/scope/field/select_fields.rb +123 -0
- data/lib/restme_rails/core/scope/field/select_nested_fields.rb +138 -0
- data/lib/restme_rails/core/scope/filter/filterable.rb +96 -0
- data/lib/restme_rails/core/scope/filter/nested_filterable.rb +150 -0
- data/lib/restme_rails/core/scope/filter/rules.rb +127 -40
- data/lib/restme_rails/core/scope/paginate/rules.rb +22 -5
- data/lib/restme_rails/version.rb +1 -1
- data/lib/restme_rails.rb +37 -0
- metadata +6 -10
- data/README.md +0 -43
- data/lib/restme_rails/core/scope/field/attachable.rb +0 -140
- data/lib/restme_rails/core/scope/filter/types/bigger_than_filterable.rb +0 -106
- data/lib/restme_rails/core/scope/filter/types/bigger_than_or_equal_to_filterable.rb +0 -102
- data/lib/restme_rails/core/scope/filter/types/equal_filterable.rb +0 -106
- data/lib/restme_rails/core/scope/filter/types/in_filterable.rb +0 -124
- data/lib/restme_rails/core/scope/filter/types/less_than_filterable.rb +0 -102
- data/lib/restme_rails/core/scope/filter/types/less_than_or_equal_to_filterable.rb +0 -108
- data/lib/restme_rails/core/scope/filter/types/like_filterable.rb +0 -104
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ca56c1c05d82ce70341d77bc32b9ababe1330b77d00b8dff144d9fc42e3b0f8
|
|
4
|
+
data.tar.gz: 6d78d32795f8f6325b1737d91dd921949f7d890d0d69379faa49a374ffbc2c56
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a3edf6726a7c7e86739b3747e439aacf22995b5405ce305b3fd9b5b716df88c5dd53367436a212ca3fec92986cec712a000947396804bb59861d6964d67f5b57
|
|
7
|
+
data.tar.gz: cfc97baaa14c175b425f72d07117d05d1e0e85aaa08c9b9d7fc0b9881527c1aa296165ec19b78c8ee3688f6bfafc88ac4b98a237e1e804477b7ae0b162939572
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../../rules_find"
|
|
4
|
-
|
|
5
3
|
module RestmeRails
|
|
6
4
|
module Core
|
|
7
5
|
module Authorize
|
|
@@ -15,28 +13,23 @@ module RestmeRails
|
|
|
15
13
|
#
|
|
16
14
|
# 1. If there is no current user → access is allowed.
|
|
17
15
|
# 2. If the current user's roles intersect with allowed roles
|
|
18
|
-
#
|
|
16
|
+
# declared via restme_authorize_action DSL → access is allowed.
|
|
19
17
|
# 3. Otherwise → raises NotAuthorizedError.
|
|
20
18
|
#
|
|
21
19
|
# ------------------------------------------------------------
|
|
22
20
|
# Expected Convention
|
|
23
21
|
# ------------------------------------------------------------
|
|
24
22
|
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
# "#{ModelName}Rules::Authorize::Rules"
|
|
23
|
+
# Roles are declared on the controller class using the DSL:
|
|
28
24
|
#
|
|
29
|
-
#
|
|
25
|
+
# class ProductsController < ApplicationController
|
|
26
|
+
# include RestmeRails
|
|
30
27
|
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
# create: [:admin]
|
|
35
|
-
# }
|
|
28
|
+
# restme_authorize_action :index, %i[admin manager]
|
|
29
|
+
# restme_authorize_action :create, %i[admin]
|
|
30
|
+
# restme_authorize_action %i[index show], %i[admin manager]
|
|
36
31
|
# end
|
|
37
32
|
#
|
|
38
|
-
# Each controller action maps to an array of allowed roles.
|
|
39
|
-
#
|
|
40
33
|
class Rules
|
|
41
34
|
attr_reader :context
|
|
42
35
|
|
|
@@ -65,27 +58,11 @@ module RestmeRails
|
|
|
65
58
|
allowed_roles_for_action.intersect?(context.current_user_roles)
|
|
66
59
|
end
|
|
67
60
|
|
|
68
|
-
# Returns allowed roles for current action.
|
|
69
|
-
#
|
|
70
|
-
# If no rules class or constant exists, defaults to empty array.
|
|
61
|
+
# Returns allowed roles for current action from the controller DSL.
|
|
71
62
|
#
|
|
72
63
|
# @return [Array<Symbol>]
|
|
73
64
|
def allowed_roles_for_action
|
|
74
|
-
|
|
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
|
|
65
|
+
context.controller_class.restme_authorize_actions[context.action_name] || []
|
|
89
66
|
end
|
|
90
67
|
end
|
|
91
68
|
end
|
|
@@ -1,101 +1,74 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
4
|
-
require_relative "
|
|
3
|
+
require_relative "select_fields"
|
|
4
|
+
require_relative "select_nested_fields"
|
|
5
|
+
require_relative "select_attachments"
|
|
5
6
|
|
|
6
7
|
module RestmeRails
|
|
7
8
|
module Core
|
|
8
9
|
module Scope
|
|
9
10
|
module Field
|
|
10
|
-
#
|
|
11
|
+
# Orchestrates field selection for scoped queries.
|
|
11
12
|
#
|
|
12
|
-
#
|
|
13
|
+
# Delegates each concern to a focused class:
|
|
13
14
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# - MODEL_FIELDS_SELECT (whitelist)
|
|
18
|
-
# - UNALLOWED_MODEL_FIELDS_SELECT (blacklist)
|
|
19
|
-
#
|
|
20
|
-
# - Validates unallowed field selections
|
|
21
|
-
# - Applies nested preloads
|
|
22
|
-
# - Delegates attachment handling to Attachable
|
|
15
|
+
# SelectFields → model attribute selection (fields_select)
|
|
16
|
+
# SelectNestedFields → association preloading (nested_fields_select)
|
|
17
|
+
# SelectAttachments → attachment URL injection (attachment_fields_select)
|
|
23
18
|
#
|
|
24
19
|
# Query params supported:
|
|
25
20
|
#
|
|
26
21
|
# ?fields_select=id,name,email
|
|
27
22
|
# ?nested_fields_select=profile,company
|
|
23
|
+
# ?nested_fields_select[profile]=id,name&nested_fields_select[company]=id
|
|
28
24
|
# ?attachment_fields_select=avatar
|
|
29
25
|
#
|
|
30
|
-
# Expected convention:
|
|
31
|
-
#
|
|
32
|
-
# A Field Rules class may exist following:
|
|
33
|
-
#
|
|
34
|
-
# "#{ModelName}Restme::Field::Rules"
|
|
35
|
-
#
|
|
36
|
-
# It may define:
|
|
37
|
-
#
|
|
38
|
-
# MODEL_FIELDS_SELECT = [:id, :name]
|
|
39
|
-
# UNALLOWED_MODEL_FIELDS_SELECT = [:internal_token]
|
|
40
|
-
# NESTED_SELECTABLE_FIELDS = { profile: {}, company: {} }
|
|
41
|
-
#
|
|
42
26
|
class Rules
|
|
43
|
-
attr_reader :context, :scope_error_instance
|
|
27
|
+
attr_reader :context, :scope_error_instance
|
|
44
28
|
|
|
45
29
|
# @param context [RestmeRails::Context]
|
|
46
30
|
# @param scope_error_instance [ScopeError]
|
|
47
31
|
def initialize(context:, scope_error_instance:)
|
|
48
32
|
@context = context
|
|
49
33
|
@scope_error_instance = scope_error_instance
|
|
50
|
-
|
|
51
|
-
@attachable_instance = RestmeRails::Core::Scope::Field::Attachable.new(
|
|
52
|
-
context: context,
|
|
53
|
-
attachment_fields_select: attachment_fields_select,
|
|
54
|
-
valid_nested_fields_select: valid_nested_fields_select,
|
|
55
|
-
scope_error_instance: scope_error_instance
|
|
56
|
-
)
|
|
57
34
|
end
|
|
58
35
|
|
|
59
|
-
# Applies field selection
|
|
36
|
+
# Applies field selection pipeline to the given scope.
|
|
60
37
|
#
|
|
61
38
|
# Flow:
|
|
62
|
-
# 1.
|
|
63
|
-
# 2.
|
|
64
|
-
# 3.
|
|
39
|
+
# 1. Applies SELECT clause for model attributes
|
|
40
|
+
# 2. Preloads nested associations
|
|
41
|
+
# 3. Serializes to JSON with attachment URLs injected
|
|
65
42
|
#
|
|
66
43
|
# @param user_scope [ActiveRecord::Relation]
|
|
67
|
-
# @return [
|
|
44
|
+
# @return [Array<Hash>]
|
|
68
45
|
def process(user_scope)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
scoped
|
|
72
|
-
|
|
73
|
-
scoped = user_scope.select(model_fields_select) if model_fields_select
|
|
74
|
-
scoped = select_nested_scope(scoped) if valid_nested_fields_select
|
|
75
|
-
|
|
76
|
-
attachable_instance.insert_attachments(scoped)
|
|
46
|
+
scoped = select_fields.process(user_scope)
|
|
47
|
+
scoped = select_nested_fields.process(scoped)
|
|
48
|
+
select_attachments.process(scoped)
|
|
77
49
|
rescue ActiveModel::MissingAttributeError => e
|
|
78
50
|
raise RestmeRails::MissingAttributeError, e.message
|
|
79
51
|
end
|
|
80
52
|
|
|
81
|
-
#
|
|
53
|
+
# Validates field selections and registers errors on scope_error_instance.
|
|
82
54
|
#
|
|
83
55
|
# @return [Boolean, nil]
|
|
84
56
|
def errors
|
|
85
|
-
unallowed_select_fields_errors
|
|
86
|
-
unallowed_attachment_fields_errors
|
|
57
|
+
unallowed_select_fields_errors || select_attachments.errors
|
|
87
58
|
end
|
|
88
59
|
|
|
89
60
|
private
|
|
90
61
|
|
|
91
|
-
#
|
|
62
|
+
# Combines unallowed model fields and nested associations into a
|
|
63
|
+
# single error entry to preserve the original error format.
|
|
92
64
|
#
|
|
93
65
|
# @return [Boolean, nil]
|
|
94
66
|
def unallowed_select_fields_errors
|
|
95
|
-
|
|
67
|
+
unallowed = select_nested_fields.unallowed + select_fields.unallowed
|
|
68
|
+
return if unallowed.blank?
|
|
96
69
|
|
|
97
70
|
scope_error_instance.add_error(
|
|
98
|
-
body:
|
|
71
|
+
body: unallowed,
|
|
99
72
|
message: "Selected not allowed fields"
|
|
100
73
|
)
|
|
101
74
|
|
|
@@ -104,167 +77,20 @@ module RestmeRails
|
|
|
104
77
|
true
|
|
105
78
|
end
|
|
106
79
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
# @return [void]
|
|
110
|
-
def unallowed_attachment_fields_errors
|
|
111
|
-
attachable_instance.unallowed_attachment_fields_errors
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# Registers ActiveModel::MissingAttributeError
|
|
115
|
-
#
|
|
116
|
-
# @return [void]
|
|
117
|
-
def add_scope_error(message)
|
|
118
|
-
scope_error_instance.add_error(
|
|
119
|
-
body: model_fields_select,
|
|
120
|
-
message: message
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
scope_error_instance.add_status(:bad_request)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# Applies nested association preloads.
|
|
127
|
-
#
|
|
128
|
-
# @param scoped [ActiveRecord::Relation]
|
|
129
|
-
# @return [ActiveRecord::Relation]
|
|
130
|
-
def select_nested_scope(scoped)
|
|
131
|
-
scoped.preload(valid_nested_fields_select)
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# Determines whether any field selection param exists.
|
|
135
|
-
#
|
|
136
|
-
# @return [Boolean]
|
|
137
|
-
def select_any_field?
|
|
138
|
-
defined_fields_select ||
|
|
139
|
-
fields_select ||
|
|
140
|
-
nested_fields_select ||
|
|
141
|
-
attachment_fields_select
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
# Final model fields to select.
|
|
145
|
-
#
|
|
146
|
-
# If client specifies fields_select, merge with defined whitelist.
|
|
147
|
-
# Otherwise fallback to all allowed attributes.
|
|
148
|
-
#
|
|
149
|
-
# @return [Array<String>]
|
|
150
|
-
def model_fields_select
|
|
151
|
-
@model_fields_select ||= select_selected_fields.presence || model_attributes
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Merges whitelist + client selection.
|
|
155
|
-
#
|
|
156
|
-
# @return [Array<String>]
|
|
157
|
-
def select_selected_fields
|
|
158
|
-
@select_selected_fields ||= defined_fields_select |
|
|
159
|
-
fields_select.split(",").map(&:to_s)
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# Returns allowed model attributes excluding blacklisted ones.
|
|
163
|
-
#
|
|
164
|
-
# @return [Array<String>]
|
|
165
|
-
def model_attributes
|
|
166
|
-
@model_attributes ||= context.model_class.attribute_names -
|
|
167
|
-
unallowed_model_fields_select
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
# Fields explicitly defined via MODEL_FIELDS_SELECT.
|
|
171
|
-
#
|
|
172
|
-
# @return [Array<String>]
|
|
173
|
-
def defined_fields_select
|
|
174
|
-
return [] unless field_class_rules&.const_defined?(:MODEL_FIELDS_SELECT)
|
|
175
|
-
|
|
176
|
-
(field_class_rules::MODEL_FIELDS_SELECT || []).map(&:to_s)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
# Fields blacklisted via UNALLOWED_MODEL_FIELDS_SELECT.
|
|
180
|
-
#
|
|
181
|
-
# @return [Array<String>]
|
|
182
|
-
def unallowed_model_fields_select
|
|
183
|
-
return [] unless field_class_rules&.const_defined?(:UNALLOWED_MODEL_FIELDS_SELECT)
|
|
184
|
-
|
|
185
|
-
(field_class_rules::UNALLOWED_MODEL_FIELDS_SELECT || []).map(&:to_s)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
# Valid nested associations based on whitelist.
|
|
189
|
-
#
|
|
190
|
-
# @return [Array<Symbol>, nil]
|
|
191
|
-
def valid_nested_fields_select
|
|
192
|
-
@valid_nested_fields_select ||= nested_fields_select
|
|
193
|
-
&.split(",")
|
|
194
|
-
&.select { |field| nested_selectable_fields_keys.key?(field.to_sym) }
|
|
195
|
-
&.map(&:to_sym)
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
# Aggregates all invalid selections.
|
|
199
|
-
#
|
|
200
|
-
# @return [Array<Symbol>]
|
|
201
|
-
def unallowed_fields_selected
|
|
202
|
-
unallowed_nested_fields_select + unallowed_fields_select
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
# Nested associations not allowed.
|
|
206
|
-
#
|
|
207
|
-
# @return [Array<Symbol>]
|
|
208
|
-
def unallowed_nested_fields_select
|
|
209
|
-
return [] if nested_fields_select.blank?
|
|
210
|
-
|
|
211
|
-
nested_fields_select.split(",").map(&:to_sym) -
|
|
212
|
-
valid_nested_fields_select
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
# Model attributes not allowed.
|
|
216
|
-
#
|
|
217
|
-
# @return [Array<Symbol>]
|
|
218
|
-
def unallowed_fields_select
|
|
219
|
-
return [] if fields_select.blank?
|
|
220
|
-
|
|
221
|
-
fields_select.split(",").map(&:to_sym) -
|
|
222
|
-
model_attributes.map(&:to_sym)
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
# Query param: fields_select
|
|
226
|
-
#
|
|
227
|
-
# @return [String]
|
|
228
|
-
def fields_select
|
|
229
|
-
@fields_select ||= context.query_params[:fields_select] || ""
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
# Query param: nested_fields_select
|
|
233
|
-
#
|
|
234
|
-
# @return [String, nil]
|
|
235
|
-
def nested_fields_select
|
|
236
|
-
@nested_fields_select ||= context.query_params[:nested_fields_select]
|
|
80
|
+
def select_fields
|
|
81
|
+
@select_fields ||= SelectFields.new(context: context)
|
|
237
82
|
end
|
|
238
83
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
# @return [Array<Symbol>, nil]
|
|
242
|
-
def attachment_fields_select
|
|
243
|
-
@attachment_fields_select ||= context.query_params[:attachment_fields_select]
|
|
244
|
-
&.split(",")
|
|
245
|
-
&.map(&:to_sym)
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
# Returns allowed nested associations defined in rules.
|
|
249
|
-
#
|
|
250
|
-
# @return [Hash, nil]
|
|
251
|
-
def nested_selectable_fields_keys
|
|
252
|
-
@nested_selectable_fields_keys ||= if field_class_rules&.const_defined?(:NESTED_SELECTABLE_FIELDS)
|
|
253
|
-
field_class_rules::NESTED_SELECTABLE_FIELDS
|
|
254
|
-
end
|
|
84
|
+
def select_nested_fields
|
|
85
|
+
@select_nested_fields ||= SelectNestedFields.new(context: context)
|
|
255
86
|
end
|
|
256
87
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def field_class_rules
|
|
264
|
-
@field_class_rules ||= RestmeRails::RulesFind.new(
|
|
265
|
-
klass: context.model_class,
|
|
266
|
-
rule_context: "Field"
|
|
267
|
-
).rule_class
|
|
88
|
+
def select_attachments
|
|
89
|
+
@select_attachments ||= SelectAttachments.new(
|
|
90
|
+
context: context,
|
|
91
|
+
scope_error_instance: scope_error_instance,
|
|
92
|
+
valid_nested_fields_select: select_nested_fields.valid_nested_fields_select
|
|
93
|
+
)
|
|
268
94
|
end
|
|
269
95
|
end
|
|
270
96
|
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RestmeRails
|
|
4
|
+
module Core
|
|
5
|
+
module Scope
|
|
6
|
+
module Field
|
|
7
|
+
# Handles ActiveStorage attachment serialization for scoped queries.
|
|
8
|
+
#
|
|
9
|
+
# Responsibilities:
|
|
10
|
+
#
|
|
11
|
+
# - Validates requested attachment fields against the model
|
|
12
|
+
# - Preloads attachment blobs to avoid N+1
|
|
13
|
+
# - Injects *_url fields into the serialized JSON output
|
|
14
|
+
# - Applies nested field restrictions via as_json options
|
|
15
|
+
#
|
|
16
|
+
# Important:
|
|
17
|
+
# This class does NOT dynamically define methods on the model.
|
|
18
|
+
# Attachment URLs are injected directly into the serialized hash.
|
|
19
|
+
#
|
|
20
|
+
# Query param:
|
|
21
|
+
#
|
|
22
|
+
# ?attachment_fields_select=avatar,cover
|
|
23
|
+
#
|
|
24
|
+
# Response:
|
|
25
|
+
#
|
|
26
|
+
# { avatar_url: "https://...", cover_url: "https://..." }
|
|
27
|
+
#
|
|
28
|
+
class SelectAttachments
|
|
29
|
+
# @param context [RestmeRails::Context]
|
|
30
|
+
# @param scope_error_instance [RestmeRails::ScopeError]
|
|
31
|
+
# @param valid_nested_fields_select [Hash, nil]
|
|
32
|
+
def initialize(context:, scope_error_instance:, valid_nested_fields_select:)
|
|
33
|
+
@context = context
|
|
34
|
+
@scope_error_instance = scope_error_instance
|
|
35
|
+
@valid_nested_fields_select = valid_nested_fields_select
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Serializes the scope to JSON, injecting attachment URLs when requested.
|
|
39
|
+
#
|
|
40
|
+
# @param scope [ActiveRecord::Relation]
|
|
41
|
+
# @return [Array<Hash>]
|
|
42
|
+
def process(scope)
|
|
43
|
+
return scope.as_json(json_options) if attachment_fields_select.blank?
|
|
44
|
+
|
|
45
|
+
records = scope.includes(attachment_includes)
|
|
46
|
+
|
|
47
|
+
serialize_with_attachments(records)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Registers a bad_request error for attachment fields that do not
|
|
51
|
+
# exist in the model's attachment_reflections.
|
|
52
|
+
#
|
|
53
|
+
# @return [void]
|
|
54
|
+
def errors
|
|
55
|
+
return if unallowed_attachment_fields.blank?
|
|
56
|
+
|
|
57
|
+
scope_error_instance.add_error(
|
|
58
|
+
body: unallowed_attachment_fields,
|
|
59
|
+
message: "Selected not allowed attachment fields"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
scope_error_instance.add_status(:bad_request)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
attr_reader :context, :scope_error_instance, :valid_nested_fields_select
|
|
68
|
+
|
|
69
|
+
# Base as_json options including nested field restrictions.
|
|
70
|
+
#
|
|
71
|
+
# @return [Hash]
|
|
72
|
+
def json_options
|
|
73
|
+
{ include: nested_include_options }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Builds the include: hash for as_json with optional field restriction.
|
|
77
|
+
#
|
|
78
|
+
# Examples:
|
|
79
|
+
# { profile: nil, company: [:id, :name] }
|
|
80
|
+
# → { profile: {}, company: { only: [:id, :name] } }
|
|
81
|
+
#
|
|
82
|
+
# @return [Hash]
|
|
83
|
+
def nested_include_options
|
|
84
|
+
return {} if valid_nested_fields_select.blank?
|
|
85
|
+
|
|
86
|
+
valid_nested_fields_select.transform_values do |fields|
|
|
87
|
+
fields ? { only: fields } : {}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Serializes records and injects attachment URLs into each hash.
|
|
92
|
+
#
|
|
93
|
+
# Only fields present in model_attachment_fields are dispatched via
|
|
94
|
+
# public_send, regardless of pipeline call order.
|
|
95
|
+
#
|
|
96
|
+
# @param records [ActiveRecord::Relation]
|
|
97
|
+
# @return [Array<Hash>]
|
|
98
|
+
def serialize_with_attachments(records)
|
|
99
|
+
allowed_fields = attachment_fields_select & model_attachment_fields
|
|
100
|
+
|
|
101
|
+
records.map do |record|
|
|
102
|
+
base_hash = record.as_json(json_options)
|
|
103
|
+
|
|
104
|
+
allowed_fields.each do |field|
|
|
105
|
+
attachment = record.public_send(field)
|
|
106
|
+
base_hash["#{field}_url"] = attachment&.attached? ? attachment.url : nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
base_hash
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Builds the includes structure for ActiveStorage eager loading.
|
|
114
|
+
#
|
|
115
|
+
# Example: [:avatar] → [{ avatar_attachment: :blob }]
|
|
116
|
+
#
|
|
117
|
+
# @return [Array<Hash>]
|
|
118
|
+
def attachment_includes
|
|
119
|
+
attachment_fields_select.map do |field|
|
|
120
|
+
{ "#{field}_attachment": :blob }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# All attachment names declared in the model via has_one_attached / has_many_attached.
|
|
125
|
+
#
|
|
126
|
+
# @return [Array<Symbol>]
|
|
127
|
+
def model_attachment_fields
|
|
128
|
+
@model_attachment_fields ||= context.model_class
|
|
129
|
+
.attachment_reflections
|
|
130
|
+
.map { |_name, reflection| reflection.name }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Attachment fields requested but not present in the model.
|
|
134
|
+
#
|
|
135
|
+
# @return [Array<Symbol>]
|
|
136
|
+
def unallowed_attachment_fields
|
|
137
|
+
return [] if attachment_fields_select.blank?
|
|
138
|
+
|
|
139
|
+
attachment_fields_select - model_attachment_fields
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Query param: attachment_fields_select
|
|
143
|
+
#
|
|
144
|
+
# @return [Array<Symbol>, nil]
|
|
145
|
+
def attachment_fields_select
|
|
146
|
+
@attachment_fields_select ||= context.query_params[:attachment_fields_select]
|
|
147
|
+
&.split(",")
|
|
148
|
+
&.map(&:to_sym)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../rules_find"
|
|
4
|
+
|
|
5
|
+
module RestmeRails
|
|
6
|
+
module Core
|
|
7
|
+
module Scope
|
|
8
|
+
module Field
|
|
9
|
+
# Handles model attribute selection for scoped queries.
|
|
10
|
+
#
|
|
11
|
+
# Responsibilities:
|
|
12
|
+
#
|
|
13
|
+
# - Applies SELECT clause based on requested and allowed fields
|
|
14
|
+
# - Merges whitelist (MODEL_FIELDS_SELECT) with client selection
|
|
15
|
+
# - Enforces blacklist (UNALLOWED_MODEL_FIELDS_SELECT)
|
|
16
|
+
# - Exposes unallowed selections for error aggregation in Rules
|
|
17
|
+
#
|
|
18
|
+
# Query param:
|
|
19
|
+
#
|
|
20
|
+
# ?fields_select=id,name,email
|
|
21
|
+
#
|
|
22
|
+
# Convention — optional class per model:
|
|
23
|
+
#
|
|
24
|
+
# "#{ModelName}Restme::Field::Rules"
|
|
25
|
+
#
|
|
26
|
+
# May define:
|
|
27
|
+
#
|
|
28
|
+
# MODEL_FIELDS_SELECT = [:id, :name]
|
|
29
|
+
# UNALLOWED_MODEL_FIELDS_SELECT = [:internal_token]
|
|
30
|
+
#
|
|
31
|
+
class SelectFields
|
|
32
|
+
# @param context [RestmeRails::Context]
|
|
33
|
+
def initialize(context:)
|
|
34
|
+
@context = context
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Applies SELECT clause to the scope.
|
|
38
|
+
#
|
|
39
|
+
# @param scope [ActiveRecord::Relation]
|
|
40
|
+
# @return [ActiveRecord::Relation]
|
|
41
|
+
def process(scope)
|
|
42
|
+
scope.select(model_fields_select)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Fields that were requested but are not allowed.
|
|
46
|
+
# Used by Rules to build a single combined error.
|
|
47
|
+
#
|
|
48
|
+
# @return [Array<Symbol>]
|
|
49
|
+
def unallowed
|
|
50
|
+
return [] if fields_select.blank?
|
|
51
|
+
|
|
52
|
+
fields_select.split(",").map(&:to_sym) - model_attributes.map(&:to_sym)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Final list of model fields to apply in SELECT.
|
|
56
|
+
#
|
|
57
|
+
# Merges whitelist with client selection, falling back to all
|
|
58
|
+
# allowed attributes when no explicit selection is made.
|
|
59
|
+
#
|
|
60
|
+
# @return [Array<String>]
|
|
61
|
+
def model_fields_select
|
|
62
|
+
@model_fields_select ||= select_selected_fields.presence || model_attributes
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
attr_reader :context
|
|
68
|
+
|
|
69
|
+
# Merges whitelist + client selection.
|
|
70
|
+
#
|
|
71
|
+
# @return [Array<String>]
|
|
72
|
+
def select_selected_fields
|
|
73
|
+
@select_selected_fields ||= defined_fields_select |
|
|
74
|
+
fields_select.split(",").map(&:to_s)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# All model column names excluding blacklisted fields.
|
|
78
|
+
#
|
|
79
|
+
# @return [Array<String>]
|
|
80
|
+
def model_attributes
|
|
81
|
+
@model_attributes ||= context.model_class.attribute_names -
|
|
82
|
+
unallowed_model_fields_select
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Fields whitelisted via MODEL_FIELDS_SELECT.
|
|
86
|
+
#
|
|
87
|
+
# @return [Array<String>]
|
|
88
|
+
def defined_fields_select
|
|
89
|
+
return [] unless field_class_rules&.const_defined?(:MODEL_FIELDS_SELECT)
|
|
90
|
+
|
|
91
|
+
(field_class_rules::MODEL_FIELDS_SELECT || []).map(&:to_s)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Fields blacklisted via UNALLOWED_MODEL_FIELDS_SELECT.
|
|
95
|
+
#
|
|
96
|
+
# @return [Array<String>]
|
|
97
|
+
def unallowed_model_fields_select
|
|
98
|
+
return [] unless field_class_rules&.const_defined?(:UNALLOWED_MODEL_FIELDS_SELECT)
|
|
99
|
+
|
|
100
|
+
(field_class_rules::UNALLOWED_MODEL_FIELDS_SELECT || []).map(&:to_s)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Query param: fields_select
|
|
104
|
+
#
|
|
105
|
+
# @return [String]
|
|
106
|
+
def fields_select
|
|
107
|
+
@fields_select ||= context.query_params[:fields_select] || ""
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Dynamically resolves the Field Rules class for the model.
|
|
111
|
+
#
|
|
112
|
+
# @return [Class, nil]
|
|
113
|
+
def field_class_rules
|
|
114
|
+
@field_class_rules ||= RestmeRails::RulesFind.new(
|
|
115
|
+
klass: context.model_class,
|
|
116
|
+
rule_context: "Field"
|
|
117
|
+
).rule_class
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|