rhino-rails 4.6.0 → 4.7.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/rhino/commands/export_postman_command.rb +50 -0
- data/lib/rhino/concerns/has_rhino.rb +3 -0
- data/lib/rhino/concerns/hidable_columns.rb +87 -1
- data/lib/rhino/controllers/resources_controller.rb +185 -17
- data/lib/rhino/query_builder.rb +11 -0
- data/lib/rhino/routes.rb +13 -0
- data/lib/rhino/scopes_to_organization.rb +11 -4
- data/lib/rhino/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3da378b6f647733a88cb598ff3b23e06462f6c61494ebbc322fed024e9ba33a9
|
|
4
|
+
data.tar.gz: e3dec4fcb82201e652e76e01ff7b5d8fa25c1ec61dde9d3b58f688f9a0d4ceb2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a37303c3543f0e140bc5f00371c1d8d8d310a30645a0ef2c3b9bd218d5cebe74e83c3f500f413792ce7496b764832219044739422f4a13f7be1b3dd1fe63bdec
|
|
7
|
+
data.tar.gz: fc60726be3e23b1da9ddcccc4fc884857608fb412125211b48cad5bfb51596d4986f6db13ed2728b70644f1d2c3a4be2e2e9a5fac5093ba6746783923fc01681
|
|
@@ -175,10 +175,26 @@ module Rhino
|
|
|
175
175
|
allowed_fields: model_class.try(:allowed_fields) || [],
|
|
176
176
|
allowed_includes: model_class.try(:allowed_includes) || [],
|
|
177
177
|
allowed_search: model_class.try(:allowed_search) || [],
|
|
178
|
+
# Computed attributes: names only (the callables never leave the server).
|
|
179
|
+
collection_computed_attributes: computed_names(model_class.try(:rhino_collection_computed_attributes)),
|
|
180
|
+
record_computed_attributes: computed_names(record_computed_declaration(model_class)),
|
|
178
181
|
default_sort: model_class.try(:default_sort_field)
|
|
179
182
|
}
|
|
180
183
|
end
|
|
181
184
|
|
|
185
|
+
def computed_names(declared)
|
|
186
|
+
declared.is_a?(Hash) ? declared.keys.map(&:to_s) : []
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Instantiating is safe (no DB round trip) and is the only way to read an
|
|
190
|
+
# instance-level declaration; a model that cannot be instantiated simply
|
|
191
|
+
# exports no record-level examples.
|
|
192
|
+
def record_computed_declaration(model_class)
|
|
193
|
+
model_class.new.try(:rhino_record_computed_attributes)
|
|
194
|
+
rescue StandardError
|
|
195
|
+
{}
|
|
196
|
+
end
|
|
197
|
+
|
|
182
198
|
def build_action_folders(slug, meta, group_prefix)
|
|
183
199
|
folders = []
|
|
184
200
|
base = base_path(slug, group_prefix)
|
|
@@ -190,6 +206,10 @@ module Rhino
|
|
|
190
206
|
folders << { name: "Update", item: build_update_requests(base) } unless except.include?("update")
|
|
191
207
|
folders << { name: "Destroy", item: build_destroy_requests(base) } unless except.include?("destroy")
|
|
192
208
|
|
|
209
|
+
if Array(meta[:collection_computed_attributes]).any? && !except.include?("computed")
|
|
210
|
+
folders << { name: "Computed Attributes", item: build_computed_requests(base, meta) }
|
|
211
|
+
end
|
|
212
|
+
|
|
193
213
|
if meta[:uses_soft_deletes]
|
|
194
214
|
folders << { name: "Trashed", item: build_trashed_requests(base) } unless except.include?("trashed")
|
|
195
215
|
folders << { name: "Restore", item: build_restore_requests(base) } unless except.include?("restore")
|
|
@@ -231,6 +251,11 @@ module Rhino
|
|
|
231
251
|
{ "fields[#{slug}]" => meta[:allowed_fields].first(5).join(",") }, headers)
|
|
232
252
|
end
|
|
233
253
|
|
|
254
|
+
Array(meta[:record_computed_attributes]).each do |attribute|
|
|
255
|
+
requests << request_item("With computed attribute #{attribute}", "GET", base,
|
|
256
|
+
{ computed_attributes: attribute }, headers)
|
|
257
|
+
end
|
|
258
|
+
|
|
234
259
|
unless meta[:allowed_search].empty?
|
|
235
260
|
requests << request_item("Search", "GET", base, { search: "example" }, headers)
|
|
236
261
|
end
|
|
@@ -249,6 +274,11 @@ module Rhino
|
|
|
249
274
|
requests << request_item("Show with include", "GET", path, { include: meta[:allowed_includes].first.to_s }, headers)
|
|
250
275
|
end
|
|
251
276
|
|
|
277
|
+
Array(meta[:record_computed_attributes]).each do |attribute|
|
|
278
|
+
requests << request_item("Show with computed attribute #{attribute}", "GET", path,
|
|
279
|
+
{ computed_attributes: attribute }, headers)
|
|
280
|
+
end
|
|
281
|
+
|
|
252
282
|
requests
|
|
253
283
|
end
|
|
254
284
|
|
|
@@ -268,6 +298,26 @@ module Rhino
|
|
|
268
298
|
[request_item("Delete by ID", "DELETE", path, {}, default_headers)]
|
|
269
299
|
end
|
|
270
300
|
|
|
301
|
+
# Requests for GET {resource}/computed — the collection-level aggregates.
|
|
302
|
+
def build_computed_requests(base, meta)
|
|
303
|
+
headers = default_headers
|
|
304
|
+
path = "#{base}/computed"
|
|
305
|
+
attributes = Array(meta[:collection_computed_attributes])
|
|
306
|
+
|
|
307
|
+
requests = [request_item("All computed attributes", "GET", path, {}, headers)]
|
|
308
|
+
|
|
309
|
+
attributes.each do |attribute|
|
|
310
|
+
requests << request_item("Computed: #{attribute}", "GET", path, { attributes: attribute }, headers)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
if attributes.size > 1
|
|
314
|
+
requests << request_item("Computed: multiple attributes", "GET", path,
|
|
315
|
+
{ attributes: attributes.join(",") }, headers)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
requests
|
|
319
|
+
end
|
|
320
|
+
|
|
271
321
|
def build_trashed_requests(base)
|
|
272
322
|
[request_item("List trashed", "GET", "#{base}/trashed", {}, default_headers)]
|
|
273
323
|
end
|
|
@@ -101,6 +101,9 @@ module Rhino
|
|
|
101
101
|
self.rhino_middleware_actions_map = actions_hash.transform_keys(&:to_s)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
# CRUD actions to exclude from route registration.
|
|
105
|
+
# Valid values: :index, :show, :store, :update, :destroy, :trashed,
|
|
106
|
+
# :restore, :forceDelete, :computed.
|
|
104
107
|
def rhino_except_actions(*actions)
|
|
105
108
|
self.rhino_except_actions_list = actions.map(&:to_s)
|
|
106
109
|
end
|
|
@@ -59,6 +59,31 @@ module Rhino
|
|
|
59
59
|
def rhino_additional_hidden(*columns)
|
|
60
60
|
self.additional_hidden_columns = columns.map(&:to_s)
|
|
61
61
|
end
|
|
62
|
+
|
|
63
|
+
# Override this method to declare COLLECTION-level computed attributes,
|
|
64
|
+
# served by <tt>GET /api/{resource}/computed?attributes=a,b</tt>.
|
|
65
|
+
#
|
|
66
|
+
# Each callable receives the fully scoped relation (organization scope,
|
|
67
|
+
# default/global scopes, <tt>?scope=</tt>, <tt>?filter[]=</tt> and
|
|
68
|
+
# <tt>?search=</tt> already applied) plus the current user, and is
|
|
69
|
+
# evaluated ONCE for the whole collection — not once per row. This is the
|
|
70
|
+
# cheap way to expose aggregates such as counts.
|
|
71
|
+
#
|
|
72
|
+
# Declaring at least one attribute here is what registers the
|
|
73
|
+
# <tt>/computed</tt> route for the model.
|
|
74
|
+
#
|
|
75
|
+
# @example
|
|
76
|
+
# def self.rhino_collection_computed_attributes
|
|
77
|
+
# {
|
|
78
|
+
# 'active_users_count' => ->(scope, _user) { scope.where(status: 'active').count },
|
|
79
|
+
# 'blocked_users_count' => ->(scope, _user) { scope.where(status: 'blocked').count }
|
|
80
|
+
# }
|
|
81
|
+
# end
|
|
82
|
+
#
|
|
83
|
+
# @return [Hash{String => #call}]
|
|
84
|
+
def rhino_collection_computed_attributes
|
|
85
|
+
{}
|
|
86
|
+
end
|
|
62
87
|
end
|
|
63
88
|
|
|
64
89
|
# Get the list of columns to hide for the current user.
|
|
@@ -84,7 +109,7 @@ module Rhino
|
|
|
84
109
|
# to add computed/virtual attributes to the JSON response.
|
|
85
110
|
#
|
|
86
111
|
# @return [Hash]
|
|
87
|
-
def as_rhino_json
|
|
112
|
+
def as_rhino_json(computed_attributes: [])
|
|
88
113
|
user = rhino_current_user
|
|
89
114
|
hidden = hidden_columns_for(user)
|
|
90
115
|
result = as_json(except: hidden)
|
|
@@ -93,6 +118,13 @@ module Rhino
|
|
|
93
118
|
computed = rhino_computed_attributes
|
|
94
119
|
result.merge!(computed) if computed.is_a?(Hash) && computed.any?
|
|
95
120
|
|
|
121
|
+
# Merge the OPT-IN record-level computed attributes the client selected via
|
|
122
|
+
# ?computed_attributes=. Nothing here is evaluated unless it was asked for
|
|
123
|
+
# by name, so declaring an expensive attribute costs nothing on requests
|
|
124
|
+
# that don't want it. Merged before policy filtering, so the blacklist and
|
|
125
|
+
# whitelist below still govern them.
|
|
126
|
+
result.merge!(rhino_resolve_record_computed_attributes(computed_attributes, user))
|
|
127
|
+
|
|
96
128
|
# Apply blacklist to the final hash (covers DB columns from as_json
|
|
97
129
|
# overrides AND computed attributes from rhino_computed_attributes)
|
|
98
130
|
hidden_set = Set.new(hidden)
|
|
@@ -132,8 +164,62 @@ module Rhino
|
|
|
132
164
|
{}
|
|
133
165
|
end
|
|
134
166
|
|
|
167
|
+
# Override this method to declare OPT-IN record-level computed attributes.
|
|
168
|
+
#
|
|
169
|
+
# Unlike +rhino_computed_attributes+, nothing here is evaluated unless the
|
|
170
|
+
# client names it in <tt>?computed_attributes=a,b</tt> on index/show/trashed
|
|
171
|
+
# — so expensive per-row work is only paid for when it is actually wanted.
|
|
172
|
+
#
|
|
173
|
+
# Return a hash of attribute name => callable. The callable may accept
|
|
174
|
+
# zero, one (record) or two (record, user) arguments.
|
|
175
|
+
#
|
|
176
|
+
# @example
|
|
177
|
+
# def rhino_record_computed_attributes
|
|
178
|
+
# {
|
|
179
|
+
# 'open_tickets_count' => ->(record, _user) { record.tickets.where(closed_at: nil).count },
|
|
180
|
+
# 'full_name' => ->(record, _user) { "#{record.first_name} #{record.last_name}" }
|
|
181
|
+
# }
|
|
182
|
+
# end
|
|
183
|
+
#
|
|
184
|
+
# @return [Hash{String => #call}]
|
|
185
|
+
def rhino_record_computed_attributes
|
|
186
|
+
{}
|
|
187
|
+
end
|
|
188
|
+
|
|
135
189
|
private
|
|
136
190
|
|
|
191
|
+
# Evaluate the selected opt-in record-level computed attributes.
|
|
192
|
+
#
|
|
193
|
+
# Names that are not declared are silently skipped — the controller has
|
|
194
|
+
# already rejected unknown/forbidden names with a 403, and a direct
|
|
195
|
+
# +as_rhino_json+ caller must not be able to force an arbitrary call.
|
|
196
|
+
def rhino_resolve_record_computed_attributes(names, user)
|
|
197
|
+
return {} if names.blank?
|
|
198
|
+
|
|
199
|
+
declared = rhino_record_computed_attributes
|
|
200
|
+
return {} unless declared.is_a?(Hash) && declared.any?
|
|
201
|
+
|
|
202
|
+
Array(names).each_with_object({}) do |name, memo|
|
|
203
|
+
key = name.to_s
|
|
204
|
+
next unless declared.key?(key)
|
|
205
|
+
|
|
206
|
+
memo[key] = rhino_call_computed(declared[key], self, user)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Invoke a declared callable, tolerating lambdas of arity 0, 1 or 2.
|
|
211
|
+
# Ruby lambdas are strict about arity, so the arity is honoured rather than
|
|
212
|
+
# forcing every declaration to accept both arguments.
|
|
213
|
+
def rhino_call_computed(entry, record, user)
|
|
214
|
+
return entry unless entry.respond_to?(:call)
|
|
215
|
+
|
|
216
|
+
case entry.try(:arity)
|
|
217
|
+
when 0 then entry.call
|
|
218
|
+
when 1 then entry.call(record)
|
|
219
|
+
else entry.call(record, user)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
137
223
|
# Resolves the current user from RequestStore.
|
|
138
224
|
# @return [Object, nil]
|
|
139
225
|
def rhino_current_user
|
|
@@ -42,6 +42,9 @@ module Rhino
|
|
|
42
42
|
def index
|
|
43
43
|
authorize model_class, :index?, policy_class: policy_for(model_class)
|
|
44
44
|
|
|
45
|
+
computed = resolve_requested_computed_attributes
|
|
46
|
+
return if performed?
|
|
47
|
+
|
|
45
48
|
builder = QueryBuilder.new(model_class, params: params, named_scopes: true)
|
|
46
49
|
apply_organization_scope(builder)
|
|
47
50
|
builder.build
|
|
@@ -52,9 +55,9 @@ module Rhino
|
|
|
52
55
|
if per_page.present? || pagination_enabled
|
|
53
56
|
result = builder.paginate
|
|
54
57
|
set_pagination_headers(result[:pagination])
|
|
55
|
-
render json: { data: serialize_collection(result[:items]) }
|
|
58
|
+
render json: { data: serialize_collection(result[:items], computed) }
|
|
56
59
|
else
|
|
57
|
-
render json: { data: serialize_collection(builder.to_scope) }
|
|
60
|
+
render json: { data: serialize_collection(builder.to_scope, computed) }
|
|
58
61
|
end
|
|
59
62
|
end
|
|
60
63
|
|
|
@@ -98,6 +101,9 @@ module Rhino
|
|
|
98
101
|
record = find_record
|
|
99
102
|
authorize record, :show?, policy_class: policy_for(record)
|
|
100
103
|
|
|
104
|
+
computed = resolve_requested_computed_attributes
|
|
105
|
+
return if performed?
|
|
106
|
+
|
|
101
107
|
# Apply includes if requested
|
|
102
108
|
if params[:include].present?
|
|
103
109
|
auth_response = authorize_includes
|
|
@@ -111,7 +117,7 @@ module Rhino
|
|
|
111
117
|
record = builder.to_scope.first!
|
|
112
118
|
end
|
|
113
119
|
|
|
114
|
-
render json: serialize_record(record)
|
|
120
|
+
render json: serialize_record(record, computed)
|
|
115
121
|
end
|
|
116
122
|
|
|
117
123
|
# PUT /api/{slug}/:id
|
|
@@ -175,6 +181,9 @@ module Rhino
|
|
|
175
181
|
def trashed
|
|
176
182
|
authorize model_class, :view_trashed?, policy_class: policy_for(model_class)
|
|
177
183
|
|
|
184
|
+
computed = resolve_requested_computed_attributes
|
|
185
|
+
return if performed?
|
|
186
|
+
|
|
178
187
|
builder = QueryBuilder.new(model_class.discarded, params: params, named_scopes: true)
|
|
179
188
|
apply_organization_scope(builder)
|
|
180
189
|
builder.build
|
|
@@ -185,15 +194,52 @@ module Rhino
|
|
|
185
194
|
if per_page.present? || pagination_enabled
|
|
186
195
|
result = builder.paginate
|
|
187
196
|
set_pagination_headers(result[:pagination])
|
|
188
|
-
render json: { data: serialize_collection(result[:items]) }
|
|
197
|
+
render json: { data: serialize_collection(result[:items], computed) }
|
|
189
198
|
else
|
|
190
|
-
render json: { data: serialize_collection(builder.to_scope) }
|
|
199
|
+
render json: { data: serialize_collection(builder.to_scope, computed) }
|
|
191
200
|
end
|
|
192
201
|
end
|
|
193
202
|
|
|
203
|
+
# GET /api/{slug}/computed?attributes=a,b
|
|
204
|
+
#
|
|
205
|
+
# Collection-level computed attributes: each declared callable is evaluated
|
|
206
|
+
# ONCE over the whole (scoped + filtered) relation instead of once per row,
|
|
207
|
+
# which is what makes aggregates such as `active_users_count` cheap.
|
|
208
|
+
#
|
|
209
|
+
# The relation handed to each callable has the organization scope, the
|
|
210
|
+
# model's default scopes, `?scope=`, `?filter[]=` and `?search=` already
|
|
211
|
+
# applied — so the numbers describe exactly the set `index` would have
|
|
212
|
+
# listed. Sorting, sparse fieldsets, includes and pagination are
|
|
213
|
+
# deliberately NOT applied.
|
|
214
|
+
#
|
|
215
|
+
# Omitting `?attributes=` returns every declared attribute the policy allows.
|
|
216
|
+
def computed
|
|
217
|
+
authorize model_class, :index?, policy_class: policy_for(model_class)
|
|
218
|
+
|
|
219
|
+
declared = collection_computed_attributes
|
|
220
|
+
names = resolve_requested_collection_attributes(declared)
|
|
221
|
+
return if performed?
|
|
222
|
+
|
|
223
|
+
builder = QueryBuilder.new(model_class, params: params, named_scopes: true)
|
|
224
|
+
apply_organization_scope(builder)
|
|
225
|
+
builder.build_for_computed
|
|
226
|
+
|
|
227
|
+
scope = builder.to_scope
|
|
228
|
+
user = current_user
|
|
229
|
+
|
|
230
|
+
data = names.each_with_object({}) do |name, memo|
|
|
231
|
+
# Each attribute gets the base relation; ActiveRecord relations are
|
|
232
|
+
# immutable under chaining, so one callable's constraints can never
|
|
233
|
+
# leak into the next one's result.
|
|
234
|
+
memo[name] = call_computed_attribute(declared[name], scope, user)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
render json: { data: data }
|
|
238
|
+
end
|
|
239
|
+
|
|
194
240
|
# POST /api/{slug}/:id/restore
|
|
195
241
|
def restore
|
|
196
|
-
record = find_by_route_key(model_class.discarded)
|
|
242
|
+
record = find_by_route_key(organization_scoped(model_class.discarded))
|
|
197
243
|
authorize record, :restore?, policy_class: policy_for(record)
|
|
198
244
|
|
|
199
245
|
record.undiscard!
|
|
@@ -204,7 +250,7 @@ module Rhino
|
|
|
204
250
|
|
|
205
251
|
# DELETE /api/{slug}/:id/force-delete
|
|
206
252
|
def force_delete
|
|
207
|
-
record = find_by_route_key(model_class.discarded)
|
|
253
|
+
record = find_by_route_key(organization_scoped(model_class.discarded))
|
|
208
254
|
authorize record, :force_delete?, policy_class: policy_for(record)
|
|
209
255
|
|
|
210
256
|
record.destroy!
|
|
@@ -487,14 +533,22 @@ module Rhino
|
|
|
487
533
|
# ------------------------------------------------------------------
|
|
488
534
|
|
|
489
535
|
def find_record
|
|
490
|
-
|
|
491
|
-
|
|
536
|
+
find_by_route_key(organization_scoped(model_class.all))
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Apply organization scoping to +relation+ for member-endpoint lookups
|
|
540
|
+
# (show/update/destroy/restore/force_delete). Delegates to the same lenient
|
|
541
|
+
# (non-strict) mechanisms index uses — organization-is-self,
|
|
542
|
+
# for_organization, organization_id column, auto-detected belongs_to path
|
|
543
|
+
# (incl. nested) — so records with an indirect tenant chain cannot be
|
|
544
|
+
# fetched cross-tenant by id/route key. Without an org context
|
|
545
|
+
# (single-tenant apps / non-tenant route groups) the relation is returned
|
|
546
|
+
# unchanged, and models with no scoping mechanism stay reachable as before.
|
|
547
|
+
def organization_scoped(relation)
|
|
492
548
|
org = current_organization
|
|
493
|
-
|
|
494
|
-
scope = scope.where(organization_id: org.id)
|
|
495
|
-
end
|
|
549
|
+
return relation unless org
|
|
496
550
|
|
|
497
|
-
|
|
551
|
+
Rhino::ScopesToOrganization.scope_to_organization(relation, model_class, org)
|
|
498
552
|
end
|
|
499
553
|
|
|
500
554
|
# Column matched against the :id URL segment. Resolution chain:
|
|
@@ -591,16 +645,130 @@ module Rhino
|
|
|
591
645
|
# Serialization
|
|
592
646
|
# ------------------------------------------------------------------
|
|
593
647
|
|
|
594
|
-
|
|
648
|
+
# ------------------------------------------------------------------
|
|
649
|
+
# Computed attributes
|
|
650
|
+
# ------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
# The model's declared collection-level computed attributes (name => callable).
|
|
653
|
+
def collection_computed_attributes
|
|
654
|
+
declared = model_class.try(:rhino_collection_computed_attributes)
|
|
655
|
+
declared.is_a?(Hash) ? declared.transform_keys(&:to_s) : {}
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Parse and authorize `?attributes=a,b` for the /computed endpoint.
|
|
659
|
+
#
|
|
660
|
+
# Renders a 403 and returns [] on a bad/denied name. An undeclared name and
|
|
661
|
+
# a policy-denied name produce the SAME error, so the endpoint never reveals
|
|
662
|
+
# which attributes a model declares.
|
|
663
|
+
def resolve_requested_collection_attributes(declared)
|
|
664
|
+
raw = params[:attributes]
|
|
665
|
+
|
|
666
|
+
# Reject non-scalar input (?attributes[]=x) before any lookup.
|
|
667
|
+
if raw.present? && !raw.is_a?(String)
|
|
668
|
+
render json: { message: "Computed attributes are not allowed" }, status: :forbidden
|
|
669
|
+
return []
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
user = current_user
|
|
673
|
+
|
|
674
|
+
if raw.blank?
|
|
675
|
+
# No selection: every declared attribute the policy allows.
|
|
676
|
+
return declared.keys.select { |name| computed_attribute_allowed?(name, user) }
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
names = parse_attribute_list(raw)
|
|
680
|
+
|
|
681
|
+
names.each do |name|
|
|
682
|
+
next if declared.key?(name) && computed_attribute_allowed?(name, user)
|
|
683
|
+
|
|
684
|
+
render json: { message: "Computed attribute '#{name}' is not allowed" }, status: :forbidden
|
|
685
|
+
return []
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
names
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Parse and authorize `?computed_attributes=a,b` for index/show/trashed —
|
|
692
|
+
# the OPT-IN record-level computed attributes. Absent or blank means "none",
|
|
693
|
+
# which is byte-for-byte the pre-feature behavior.
|
|
694
|
+
def resolve_requested_computed_attributes
|
|
695
|
+
raw = params[:computed_attributes]
|
|
696
|
+
return [] if raw.nil? || raw == ""
|
|
697
|
+
|
|
698
|
+
unless raw.is_a?(String)
|
|
699
|
+
render json: { message: "Computed attributes are not allowed" }, status: :forbidden
|
|
700
|
+
return []
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
names = parse_attribute_list(raw)
|
|
704
|
+
return [] if names.empty?
|
|
705
|
+
|
|
706
|
+
declared = model_class.new.try(:rhino_record_computed_attributes)
|
|
707
|
+
declared = declared.is_a?(Hash) ? declared.transform_keys(&:to_s) : {}
|
|
708
|
+
|
|
709
|
+
user = current_user
|
|
710
|
+
|
|
711
|
+
names.each do |name|
|
|
712
|
+
next if declared.key?(name) && computed_attribute_allowed?(name, user)
|
|
713
|
+
|
|
714
|
+
render json: { message: "Computed attribute '#{name}' is not allowed" }, status: :forbidden
|
|
715
|
+
return []
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
names
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Split a comma-separated attribute list, dropping blanks and duplicates.
|
|
722
|
+
def parse_attribute_list(raw)
|
|
723
|
+
raw.to_s.split(",").map(&:strip).reject(&:empty?).uniq
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Whether the policy lets this user see a computed attribute.
|
|
727
|
+
#
|
|
728
|
+
# Computed attributes go through the SAME gate as columns:
|
|
729
|
+
# +hidden_attributes_for_show+ blacklists, and +permitted_attributes_for_show+
|
|
730
|
+
# whitelists unless it returns the ['*'] default.
|
|
731
|
+
def computed_attribute_allowed?(name, user)
|
|
732
|
+
policy = policy_for(model_class).new(user, model_class)
|
|
733
|
+
|
|
734
|
+
if policy.respond_to?(:hidden_attributes_for_show)
|
|
735
|
+
hidden = Array(policy.hidden_attributes_for_show(user)).map(&:to_s)
|
|
736
|
+
return false if hidden.include?(name)
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
return true unless policy.respond_to?(:permitted_attributes_for_show)
|
|
740
|
+
|
|
741
|
+
permitted = policy.permitted_attributes_for_show(user)
|
|
742
|
+
return true if permitted.nil? || permitted == ["*"]
|
|
743
|
+
|
|
744
|
+
Array(permitted).map(&:to_s).include?(name)
|
|
745
|
+
rescue StandardError
|
|
746
|
+
# Policy resolution failed — serialization applies the same filters again
|
|
747
|
+
# downstream, so nothing can leak through this fallback.
|
|
748
|
+
true
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
# Invoke a declared callable, tolerating lambdas of arity 0, 1 or 2.
|
|
752
|
+
def call_computed_attribute(entry, scope, user)
|
|
753
|
+
return entry unless entry.respond_to?(:call)
|
|
754
|
+
|
|
755
|
+
case entry.try(:arity)
|
|
756
|
+
when 0 then entry.call
|
|
757
|
+
when 1 then entry.call(scope)
|
|
758
|
+
else entry.call(scope, user)
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def serialize_record(record, computed_attributes = [])
|
|
595
763
|
if record.respond_to?(:as_rhino_json)
|
|
596
|
-
record.as_rhino_json
|
|
764
|
+
record.as_rhino_json(computed_attributes: computed_attributes)
|
|
597
765
|
else
|
|
598
766
|
record.as_json
|
|
599
767
|
end
|
|
600
768
|
end
|
|
601
769
|
|
|
602
|
-
def serialize_collection(records)
|
|
603
|
-
records.map { |r| serialize_record(r) }
|
|
770
|
+
def serialize_collection(records, computed_attributes = [])
|
|
771
|
+
records.map { |r| serialize_record(r, computed_attributes) }
|
|
604
772
|
end
|
|
605
773
|
|
|
606
774
|
# ------------------------------------------------------------------
|
data/lib/rhino/query_builder.rb
CHANGED
|
@@ -33,6 +33,17 @@ module Rhino
|
|
|
33
33
|
self
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Apply only the modifications that define WHICH rows are in the set:
|
|
37
|
+
# named scope, filters and search. Sorting, sparse fieldsets, includes and
|
|
38
|
+
# pagination are irrelevant to an aggregate over the collection and are
|
|
39
|
+
# deliberately skipped — a `select` in particular would break `count`.
|
|
40
|
+
def build_for_computed
|
|
41
|
+
apply_named_scope if @named_scopes
|
|
42
|
+
apply_filters
|
|
43
|
+
apply_search
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
36
47
|
# Get the final ActiveRecord relation.
|
|
37
48
|
def to_scope
|
|
38
49
|
@scope
|
data/lib/rhino/routes.rb
CHANGED
|
@@ -187,6 +187,14 @@ module Rhino
|
|
|
187
187
|
|
|
188
188
|
except_actions = model_class.try(:rhino_except_actions_list) || []
|
|
189
189
|
|
|
190
|
+
# The /computed endpoint exists only for models that actually
|
|
191
|
+
# declare collection-level computed attributes. Models that
|
|
192
|
+
# don't are byte-for-byte unchanged — in particular
|
|
193
|
+
# `/{slug}/computed` keeps resolving to show() for a record
|
|
194
|
+
# whose route key is literally "computed".
|
|
195
|
+
declared_computed = model_class.try(:rhino_collection_computed_attributes)
|
|
196
|
+
has_computed = declared_computed.is_a?(Hash) && declared_computed.any?
|
|
197
|
+
|
|
190
198
|
route_prefix = [group_prefix, slug.to_s].reject(&:blank?).join("/")
|
|
191
199
|
|
|
192
200
|
# Each model's CRUD routes live inside a path scope. When the
|
|
@@ -202,6 +210,11 @@ module Rhino
|
|
|
202
210
|
post "/", to: "rhino/resources#store", as: "rhino_#{group_name}_#{slug}_store"
|
|
203
211
|
end
|
|
204
212
|
|
|
213
|
+
# Registered BEFORE the :id routes so the literal segment wins.
|
|
214
|
+
if has_computed && !except_actions.include?("computed")
|
|
215
|
+
get "computed", to: "rhino/resources#computed", as: "rhino_#{group_name}_#{slug}_computed"
|
|
216
|
+
end
|
|
217
|
+
|
|
205
218
|
if model_class.try(:uses_soft_deletes?)
|
|
206
219
|
unless except_actions.include?("trashed")
|
|
207
220
|
get "trashed", to: "rhino/resources#trashed", as: "rhino_#{group_name}_#{slug}_trashed"
|
|
@@ -30,9 +30,11 @@ module Rhino
|
|
|
30
30
|
)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# Check for scopeForOrganization
|
|
33
|
+
# Check for scopeForOrganization. Merge into the incoming relation instead
|
|
34
|
+
# of replacing it so upstream conditions (e.g. `.discarded` for
|
|
35
|
+
# restore/force-delete lookups) are preserved alongside the org filter.
|
|
34
36
|
if model_class.respond_to?(:for_organization)
|
|
35
|
-
return model_class.for_organization(organization)
|
|
37
|
+
return relation.merge(model_class.for_organization(organization))
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# Check for organization_id column
|
|
@@ -69,11 +71,16 @@ module Rhino
|
|
|
69
71
|
|
|
70
72
|
def scope_through_relationship(relation, model_class, organization, relationship_path, strict: false)
|
|
71
73
|
if relationship_path.include?(".")
|
|
72
|
-
# Nested path: 'post.blog' -> joins(post:
|
|
74
|
+
# Nested path: 'post.blog' -> joins(post: { blog: :organization })
|
|
75
|
+
# .where(organizations: { id: org.id })
|
|
76
|
+
# The inject already folds every path segment (outermost first) around
|
|
77
|
+
# the trailing :organization association, so the chain is passed to
|
|
78
|
+
# joins as-is — re-wrapping it in parts.first would double-nest the
|
|
79
|
+
# first segment and raise ActiveRecord::ConfigurationError.
|
|
73
80
|
parts = relationship_path.split(".")
|
|
74
81
|
join_chain = parts.reverse.inject(:organization) { |inner, outer| { outer.to_sym => inner } }
|
|
75
82
|
|
|
76
|
-
relation.joins(join_chain
|
|
83
|
+
relation.joins(join_chain)
|
|
77
84
|
.where(organizations: { id: organization.id })
|
|
78
85
|
else
|
|
79
86
|
# Single relationship
|
data/lib/rhino/version.rb
CHANGED