rhino-rails 4.6.1 → 4.7.2
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/concerns/route_group_context.rb +59 -0
- data/lib/rhino/configuration.rb +26 -2
- data/lib/rhino/context.rb +10 -0
- data/lib/rhino/controllers/resources_controller.rb +169 -9
- data/lib/rhino/engine.rb +1 -0
- data/lib/rhino/missing_tenant_context.rb +10 -1
- data/lib/rhino/query.rb +9 -4
- data/lib/rhino/query_builder.rb +11 -0
- data/lib/rhino/routes.rb +13 -0
- data/lib/rhino/templates/rhino.rb +16 -0
- data/lib/rhino/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 632861555ee3e2508a90ec546b6cb0412368bc141a7d387b2f92698531605b86
|
|
4
|
+
data.tar.gz: 91b3ad79b1c0b0b492f1f3003e906ce11e9f3fa62e81b067ecfb9c0b7740a025
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c5fee208f22ae88b0531f4075e6183d6d0a17137c43423e7f491dbf341848764ce16418feca131b13c45c4295f6b4a3507eebb52e1e30b2f887dcbcfdd624c1
|
|
7
|
+
data.tar.gz: 83468b5afe671a2b0313778e57c2f053747129156d643bb85d5543513ea2d02aa6c2b63c4b70bb5f04613cb79bbb21544ba142bb4f127ebf56247581aab4c079
|
|
@@ -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
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Places a custom (non-Rhino) controller into a Rhino route group, so the
|
|
5
|
+
# resource-scope resolver knows whether the request has a tenant boundary.
|
|
6
|
+
#
|
|
7
|
+
# Rhino's own generated controllers already publish their group; a controller
|
|
8
|
+
# you write yourself is outside that chain, so include this concern in it:
|
|
9
|
+
#
|
|
10
|
+
# class Admin::DashboardController < ApplicationController
|
|
11
|
+
# include Rhino::RouteGroupContext
|
|
12
|
+
# rhino_route_group :admin
|
|
13
|
+
#
|
|
14
|
+
# def summary
|
|
15
|
+
# # :admin is declared `tenant: false`, so this spans every organization
|
|
16
|
+
# # instead of raising Rhino::MissingTenantContext.
|
|
17
|
+
# render json: { tasks: Rhino.query(Task).count }
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Without an explicit `rhino_route_group`, the group is read from the route's
|
|
22
|
+
# own `route_group` default:
|
|
23
|
+
#
|
|
24
|
+
# get "/api/admin/dashboard", to: "admin/dashboard#summary",
|
|
25
|
+
# defaults: { route_group: "admin" }
|
|
26
|
+
module RouteGroupContext
|
|
27
|
+
extend ActiveSupport::Concern
|
|
28
|
+
|
|
29
|
+
included do
|
|
30
|
+
# Inheritable, so a base controller can declare the group for a whole
|
|
31
|
+
# namespace and subclasses may override it.
|
|
32
|
+
class_attribute :rhino_route_group_name, instance_writer: false, default: nil
|
|
33
|
+
|
|
34
|
+
before_action :rhino_set_route_group
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class_methods do
|
|
38
|
+
# Declare the route group this controller's actions belong to.
|
|
39
|
+
def rhino_route_group(name)
|
|
40
|
+
self.rhino_route_group_name = name.to_s
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Publish the group for Rhino::Context (and therefore for Rhino.query, the
|
|
47
|
+
# policies and the permission resolver) for the rest of this request.
|
|
48
|
+
def rhino_set_route_group
|
|
49
|
+
return unless defined?(RequestStore)
|
|
50
|
+
|
|
51
|
+
# An explicit declaration wins; otherwise fall back to the route's own
|
|
52
|
+
# `route_group` default, which only exists once a request is bound.
|
|
53
|
+
resolved = rhino_route_group_name.presence
|
|
54
|
+
resolved ||= params[:route_group].presence if request
|
|
55
|
+
|
|
56
|
+
RequestStore.store[:rhino_route_group] = resolved
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/rhino/configuration.rb
CHANGED
|
@@ -62,7 +62,15 @@ module Rhino
|
|
|
62
62
|
# captures the subdomain and feeds organization resolution exactly like the
|
|
63
63
|
# path-prefix ":organization" does. Groups without a domain (nil/blank)
|
|
64
64
|
# match any host (default, fully backward compatible).
|
|
65
|
-
|
|
65
|
+
#
|
|
66
|
+
# The optional `tenant:` keyword declares whether the group has a tenant
|
|
67
|
+
# boundary. It defaults to true: Rhino.query inside the group fails closed,
|
|
68
|
+
# raising Rhino::MissingTenantContext when an organization-scopable model is
|
|
69
|
+
# queried with no organization resolved. Pass `tenant: false` for a group
|
|
70
|
+
# that legitimately spans every organization — a back office or admin group
|
|
71
|
+
# whose operators see all tenants' rows.
|
|
72
|
+
def route_group(name, prefix: "", domain: nil, middleware: [], models: :all, auth: false, hooks: nil,
|
|
73
|
+
tenant: true)
|
|
66
74
|
normalized_domain = domain.to_s.strip
|
|
67
75
|
normalized_domain = nil if normalized_domain.empty?
|
|
68
76
|
|
|
@@ -72,10 +80,26 @@ module Rhino
|
|
|
72
80
|
middleware: Array(middleware),
|
|
73
81
|
models: models,
|
|
74
82
|
auth: !!auth,
|
|
75
|
-
hooks: hooks
|
|
83
|
+
hooks: hooks,
|
|
84
|
+
tenant: tenant != false
|
|
76
85
|
}
|
|
77
86
|
end
|
|
78
87
|
|
|
88
|
+
# Whether the named route group has a tenant boundary, i.e. whether
|
|
89
|
+
# Rhino.query must fail closed inside it. Only a group that explicitly
|
|
90
|
+
# declares `tenant: false` does not; every other answer — an unknown group,
|
|
91
|
+
# an untagged route, or no group at all (jobs, rake tasks, console) — is
|
|
92
|
+
# true, so the resolver keeps failing closed wherever the group is not
|
|
93
|
+
# provably non-tenant.
|
|
94
|
+
def group_tenant?(name)
|
|
95
|
+
return true if name.nil? || name.to_s.empty?
|
|
96
|
+
|
|
97
|
+
group = @route_groups[name.to_sym]
|
|
98
|
+
return true unless group
|
|
99
|
+
|
|
100
|
+
group.fetch(:tenant, true) != false
|
|
101
|
+
end
|
|
102
|
+
|
|
79
103
|
# Resolve a model class from its slug
|
|
80
104
|
def resolve_model(slug)
|
|
81
105
|
klass_name = @models[slug.to_sym]
|
data/lib/rhino/context.rb
CHANGED
|
@@ -30,6 +30,16 @@ module Rhino
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# The route group serving the current request, as stashed into RequestStore
|
|
34
|
+
# by Rhino's own controllers and by Rhino::RouteGroupContext in custom ones.
|
|
35
|
+
# Nil outside a request or when the route carries no group.
|
|
36
|
+
def route_group
|
|
37
|
+
return nil unless defined?(RequestStore)
|
|
38
|
+
|
|
39
|
+
value = RequestStore.store[:rhino_route_group]
|
|
40
|
+
value.nil? || value.to_s.empty? ? nil : value
|
|
41
|
+
end
|
|
42
|
+
|
|
33
43
|
# Run +block+ with the given user/organization installed into RequestStore.
|
|
34
44
|
# Snapshots the prior RequestStore user+org, sets the new ones, yields, and
|
|
35
45
|
# restores the snapshot in an ensure. Returns the block's value.
|
|
@@ -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,10 +194,47 @@ 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) }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
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)
|
|
191
235
|
end
|
|
236
|
+
|
|
237
|
+
render json: { data: data }
|
|
192
238
|
end
|
|
193
239
|
|
|
194
240
|
# POST /api/{slug}/:id/restore
|
|
@@ -599,16 +645,130 @@ module Rhino
|
|
|
599
645
|
# Serialization
|
|
600
646
|
# ------------------------------------------------------------------
|
|
601
647
|
|
|
602
|
-
|
|
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 = [])
|
|
603
763
|
if record.respond_to?(:as_rhino_json)
|
|
604
|
-
record.as_rhino_json
|
|
764
|
+
record.as_rhino_json(computed_attributes: computed_attributes)
|
|
605
765
|
else
|
|
606
766
|
record.as_json
|
|
607
767
|
end
|
|
608
768
|
end
|
|
609
769
|
|
|
610
|
-
def serialize_collection(records)
|
|
611
|
-
records.map { |r| serialize_record(r) }
|
|
770
|
+
def serialize_collection(records, computed_attributes = [])
|
|
771
|
+
records.map { |r| serialize_record(r, computed_attributes) }
|
|
612
772
|
end
|
|
613
773
|
|
|
614
774
|
# ------------------------------------------------------------------
|
data/lib/rhino/engine.rb
CHANGED
|
@@ -4,5 +4,14 @@ module Rhino
|
|
|
4
4
|
# Raised by Rhino.query (and the explicit builder) when an organization-scopable
|
|
5
5
|
# model is queried with no organization context available. Fail-closed: the
|
|
6
6
|
# resolver never returns an unscoped relation for a tenant-scopable model.
|
|
7
|
-
class MissingTenantContext < StandardError
|
|
7
|
+
class MissingTenantContext < StandardError
|
|
8
|
+
def initialize(model_class = nil)
|
|
9
|
+
super(
|
|
10
|
+
"Rhino.query(#{model_class}) requires an organization context but none is set. " \
|
|
11
|
+
"Use Rhino.for_user(...).in_organization(...) outside a tenant request, or declare " \
|
|
12
|
+
"the route group serving this request non-tenant with `tenant: false` if it " \
|
|
13
|
+
"legitimately spans every organization."
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
8
17
|
end
|
data/lib/rhino/query.rb
CHANGED
|
@@ -20,7 +20,10 @@ module Rhino
|
|
|
20
20
|
# (BelongsToOrganization / HasAutoScope read RequestStore at BUILD time).
|
|
21
21
|
#
|
|
22
22
|
# Fail closed: an org-scopable model with no org context RAISES
|
|
23
|
-
# Rhino::MissingTenantContext rather than returning an unscoped relation
|
|
23
|
+
# Rhino::MissingTenantContext rather than returning an unscoped relation —
|
|
24
|
+
# unless the request is served by a route group declared non-tenant
|
|
25
|
+
# (`tenant: false`), where a query legitimately spans every organization. An
|
|
26
|
+
# explicit organization is always honored, in every group.
|
|
24
27
|
def query(model_class)
|
|
25
28
|
org = Rhino::Context.organization
|
|
26
29
|
|
|
@@ -28,9 +31,11 @@ module Rhino
|
|
|
28
31
|
relation = model_class.all
|
|
29
32
|
|
|
30
33
|
if Rhino::ScopesToOrganization.organization_scoped?(model_class)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
if org
|
|
35
|
+
relation = Rhino::ScopesToOrganization.scope_to_organization(relation, model_class, org, strict: true)
|
|
36
|
+
elsif Rhino.config.group_tenant?(Rhino::Context.route_group)
|
|
37
|
+
raise Rhino::MissingTenantContext, model_class.name
|
|
38
|
+
end
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
relation
|
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"
|
|
@@ -44,6 +44,22 @@ Rhino.configure do |config|
|
|
|
44
44
|
# the path prefix ':organization' does. Groups without a domain match any host.
|
|
45
45
|
# config.route_group :admin, prefix: '', domain: 'admin.example.com', models: :all
|
|
46
46
|
# config.route_group :tenant, prefix: '', domain: '{organization}.example.com', models: :all
|
|
47
|
+
#
|
|
48
|
+
# The optional `tenant:` keyword declares whether a group has a tenant
|
|
49
|
+
# boundary. It defaults to true: Rhino.query inside the group fails closed,
|
|
50
|
+
# raising Rhino::MissingTenantContext when an organization-scopable model is
|
|
51
|
+
# queried with no organization resolved. Pass `tenant: false` for a group that
|
|
52
|
+
# legitimately spans every organization — a back office whose operators see all
|
|
53
|
+
# tenants' rows. There, Rhino.query applies no organization filter and does not
|
|
54
|
+
# raise; the models' own scopes and the policies still apply, and an explicit
|
|
55
|
+
# Rhino.for_user(u).in_organization(org) still scopes.
|
|
56
|
+
# config.route_group :admin, prefix: 'admin', tenant: false, models: :all
|
|
57
|
+
#
|
|
58
|
+
# Custom (non-Rhino) controllers publish their group by including
|
|
59
|
+
# Rhino::RouteGroupContext and declaring `rhino_route_group :admin`, or by
|
|
60
|
+
# tagging the route with `defaults: { route_group: 'admin' }`. Outside a
|
|
61
|
+
# request (jobs, rake tasks) no group resolves and the resolver keeps failing
|
|
62
|
+
# closed.
|
|
47
63
|
|
|
48
64
|
# config.route_group :default, prefix: '', middleware: [], models: :all
|
|
49
65
|
|
data/lib/rhino/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rhino-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.
|
|
4
|
+
version: 4.7.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bruno Cipolla
|
|
@@ -215,6 +215,7 @@ files:
|
|
|
215
215
|
- lib/rhino/concerns/has_uuid.rb
|
|
216
216
|
- lib/rhino/concerns/has_validation.rb
|
|
217
217
|
- lib/rhino/concerns/hidable_columns.rb
|
|
218
|
+
- lib/rhino/concerns/route_group_context.rb
|
|
218
219
|
- lib/rhino/configuration.rb
|
|
219
220
|
- lib/rhino/context.rb
|
|
220
221
|
- lib/rhino/controllers/auth_controller.rb
|