rhino-rails 4.8.1 → 4.9.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/argument_binder.rb +125 -0
- data/lib/rhino/commands/export_postman_command.rb +47 -15
- data/lib/rhino/computed_attribute_spec.rb +106 -0
- data/lib/rhino/concerns/hidable_columns.rb +70 -15
- data/lib/rhino/controllers/resources_controller.rb +122 -56
- data/lib/rhino/invalid_computed_attribute_arguments_error.rb +10 -0
- data/lib/rhino/scope_spec.rb +27 -66
- data/lib/rhino/version.rb +1 -1
- data/lib/rhino.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f33582b9aac75614096047f46227c443f63a31634c40cf8825a92d5fb064f583
|
|
4
|
+
data.tar.gz: 94f626caf29a1e66132524f649b8b4dd6f2aeb52c88b48eca5f20479af653316
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 27865b6bb3173908f8990a908f87aae8222f7237d691b83dd1fa2607fc1bb86eb88791a37a1b1de44db0d0f4e1d3b4a731ac4d89441775f2b77ce92f9d0ec2b8
|
|
7
|
+
data.tar.gz: 51bd6475c50215ff50bfede53d09bc627e86d19033cc45a5e4e7a3035dd6050430efc61a4b13b77c4d3461185edb519f093f9855f8cb50b98fdb35621a682aee
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Binds the arguments a client sent in the bracket query form
|
|
5
|
+
# (<tt>?scope[name][param]=value</tt>, <tt>?attributes[name][param]=value</tt>)
|
|
6
|
+
# to the parameters a model declared, in declared order.
|
|
7
|
+
#
|
|
8
|
+
# The algorithm is shared by named scopes and computed attributes so the two
|
|
9
|
+
# features cannot drift. Everything that differs between them is passed in:
|
|
10
|
+
#
|
|
11
|
+
# * +subject+ is the noun used in every error message ("Scope",
|
|
12
|
+
# "Computed attribute"), so each feature keeps its own wording;
|
|
13
|
+
# * +error_class+ is the exception raised, so each feature keeps its own
|
|
14
|
+
# controller +rescue_from+;
|
|
15
|
+
# * +underscore_keys+ reproduces the scope binder's wire-name translation.
|
|
16
|
+
# Computed attributes match parameter names verbatim, exactly as Laravel and
|
|
17
|
+
# NestJS do, so they leave it off.
|
|
18
|
+
#
|
|
19
|
+
# Nothing here decides whether a name may be used: callers MUST run the
|
|
20
|
+
# declared-check and the policy-check BEFORE binding, so an argument error can
|
|
21
|
+
# only ever be seen for a name the caller was already allowed to use.
|
|
22
|
+
module ArgumentBinder
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Clean a declared parameter list: stringify the names, and drop any
|
|
26
|
+
# +optional+ entry that is not actually a declared parameter.
|
|
27
|
+
def normalize_params(params, optional = [])
|
|
28
|
+
params = Array(params).map(&:to_s)
|
|
29
|
+
|
|
30
|
+
{ params: params, optional: Array(optional).map(&:to_s) & params }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Bind the raw value a client sent for one name to positional arguments,
|
|
34
|
+
# in the order the model declared them.
|
|
35
|
+
#
|
|
36
|
+
# +raw+ is whatever the query string produced for the bracket key: nil or ""
|
|
37
|
+
# (no arguments), a scalar (the single parameter), or a hash of parameter
|
|
38
|
+
# name => value.
|
|
39
|
+
def bind(subject:, name:, spec:, raw:, error_class:, underscore_keys: false)
|
|
40
|
+
params = Array(spec[:params]).map(&:to_s)
|
|
41
|
+
optional = Array(spec[:optional]).map(&:to_s)
|
|
42
|
+
|
|
43
|
+
given = normalize_raw_arguments(
|
|
44
|
+
subject: subject, name: name, params: params, raw: raw,
|
|
45
|
+
error_class: error_class, underscore_keys: underscore_keys
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
given.each_key do |key|
|
|
49
|
+
unless params.include?(key)
|
|
50
|
+
raise error_class, "#{subject} '#{name}' does not accept parameter '#{key}'"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
args = params.map do |param|
|
|
55
|
+
if given.key?(param)
|
|
56
|
+
coerce(given[param])
|
|
57
|
+
elsif optional.include?(param)
|
|
58
|
+
nil
|
|
59
|
+
else
|
|
60
|
+
raise error_class, "#{subject} '#{name}' requires parameter '#{param}'"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Drop trailing nils so an omitted optional parameter falls back to the
|
|
65
|
+
# default in the callable's own signature.
|
|
66
|
+
#
|
|
67
|
+
# NOTE: `args.empty?` rather than `args.any?` — Array#any? without a block
|
|
68
|
+
# is false for [nil], so the old form skipped the drop entirely when EVERY
|
|
69
|
+
# argument was nil (an all-optional declaration with nothing sent), passing
|
|
70
|
+
# [nil] where Laravel and NestJS pass []. Every other case is unchanged.
|
|
71
|
+
args.pop until args.empty? || !args.last.nil?
|
|
72
|
+
args
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Turn the raw query-string value into a parameter name => value hash.
|
|
76
|
+
def normalize_raw_arguments(subject:, name:, params:, raw:, error_class:, underscore_keys: false)
|
|
77
|
+
# ?scope[archived]= (or a bare ?scope[archived]): no arguments. A name
|
|
78
|
+
# with required parameters still fails, in bind, naming them.
|
|
79
|
+
return {} if raw.nil? || raw == ""
|
|
80
|
+
|
|
81
|
+
raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
|
|
82
|
+
|
|
83
|
+
if raw.is_a?(Array)
|
|
84
|
+
# A positional list (scope[between][]=a) names nothing.
|
|
85
|
+
raise error_class, "#{subject} '#{name}' requires named parameters"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
unless raw.is_a?(Hash)
|
|
89
|
+
raise error_class, "#{subject} '#{name}' does not accept arguments" if params.empty?
|
|
90
|
+
|
|
91
|
+
# A bare value binds to the single declared parameter. Two parameters can
|
|
92
|
+
# never be guessed at from one value.
|
|
93
|
+
if params.length > 1
|
|
94
|
+
raise error_class, "#{subject} '#{name}' requires named parameters"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return { params.first => raw }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
raise error_class, "#{subject} '#{name}' does not accept arguments" if params.empty?
|
|
101
|
+
|
|
102
|
+
raw.each_with_object({}) do |(key, value), out|
|
|
103
|
+
unless value.is_a?(String) || value.is_a?(Numeric) || value.is_a?(TrueClass) ||
|
|
104
|
+
value.is_a?(FalseClass) || value.nil?
|
|
105
|
+
raise error_class, "#{subject} '#{name}' requires named parameters"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
key = key.to_s
|
|
109
|
+
out[underscore_keys ? key.underscore : key] = value
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Query-string values always arrive as strings; hand callables real booleans
|
|
114
|
+
# so a check cannot be fooled by the string "false".
|
|
115
|
+
def coerce(value)
|
|
116
|
+
return value unless value.is_a?(String)
|
|
117
|
+
|
|
118
|
+
case value.downcase
|
|
119
|
+
when "true" then true
|
|
120
|
+
when "false" then false
|
|
121
|
+
else value
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -175,15 +175,42 @@ 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
|
|
179
|
-
|
|
180
|
-
|
|
178
|
+
# Computed attributes: names and parameter specs only — the callables
|
|
179
|
+
# never leave the server. The spec is what lets the collection show a
|
|
180
|
+
# parameterised attribute the way a client must actually send it.
|
|
181
|
+
collection_computed_attributes: computed_specs(model_class.try(:rhino_collection_computed_attributes)),
|
|
182
|
+
record_computed_attributes: computed_specs(record_computed_declaration(model_class)),
|
|
181
183
|
default_sort: model_class.try(:default_sort_field)
|
|
182
184
|
}
|
|
183
185
|
end
|
|
184
186
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
+
# name => { params:, optional: } — the same shape as a scope's spec, for
|
|
188
|
+
# the same reason.
|
|
189
|
+
def computed_specs(declared)
|
|
190
|
+
Rhino::ComputedAttributeSpec.normalize(declared).transform_values do |spec|
|
|
191
|
+
{ params: spec[:params], optional: spec[:optional] }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The query parameters that select one computed attribute, in whichever
|
|
196
|
+
# form its declaration requires: the plain list when it takes no
|
|
197
|
+
# parameters, the bracket form when it does.
|
|
198
|
+
def computed_attribute_query(key, attribute, spec)
|
|
199
|
+
params = Array(spec[:params])
|
|
200
|
+
|
|
201
|
+
return { key.to_sym => attribute } if params.empty?
|
|
202
|
+
return { "#{key}[#{attribute}]" => "example" } if params.size == 1
|
|
203
|
+
|
|
204
|
+
params.each_with_object({}) do |param, out|
|
|
205
|
+
out["#{key}[#{attribute}][#{param}]"] = "example"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The attribute names that can be requested without arguments — the only
|
|
210
|
+
# ones a combined "give me everything" request may name, since a required
|
|
211
|
+
# parameter left out is a guaranteed 403.
|
|
212
|
+
def argument_free_computed_attributes(specs)
|
|
213
|
+
specs.reject { |_, spec| Rhino::ComputedAttributeSpec.requires_arguments?(spec) }.keys
|
|
187
214
|
end
|
|
188
215
|
|
|
189
216
|
# Instantiating is safe (no DB round trip) and is the only way to read an
|
|
@@ -206,7 +233,7 @@ module Rhino
|
|
|
206
233
|
folders << { name: "Update", item: build_update_requests(base) } unless except.include?("update")
|
|
207
234
|
folders << { name: "Destroy", item: build_destroy_requests(base) } unless except.include?("destroy")
|
|
208
235
|
|
|
209
|
-
if
|
|
236
|
+
if (meta[:collection_computed_attributes] || {}).any? && !except.include?("computed")
|
|
210
237
|
folders << { name: "Computed Attributes", item: build_computed_requests(base, meta) }
|
|
211
238
|
end
|
|
212
239
|
|
|
@@ -251,9 +278,9 @@ module Rhino
|
|
|
251
278
|
{ "fields[#{slug}]" => meta[:allowed_fields].first(5).join(",") }, headers)
|
|
252
279
|
end
|
|
253
280
|
|
|
254
|
-
|
|
281
|
+
(meta[:record_computed_attributes] || {}).each do |attribute, spec|
|
|
255
282
|
requests << request_item("With computed attribute #{attribute}", "GET", base,
|
|
256
|
-
|
|
283
|
+
computed_attribute_query("computed_attributes", attribute, spec), headers)
|
|
257
284
|
end
|
|
258
285
|
|
|
259
286
|
unless meta[:allowed_search].empty?
|
|
@@ -274,9 +301,9 @@ module Rhino
|
|
|
274
301
|
requests << request_item("Show with include", "GET", path, { include: meta[:allowed_includes].first.to_s }, headers)
|
|
275
302
|
end
|
|
276
303
|
|
|
277
|
-
|
|
304
|
+
(meta[:record_computed_attributes] || {}).each do |attribute, spec|
|
|
278
305
|
requests << request_item("Show with computed attribute #{attribute}", "GET", path,
|
|
279
|
-
|
|
306
|
+
computed_attribute_query("computed_attributes", attribute, spec), headers)
|
|
280
307
|
end
|
|
281
308
|
|
|
282
309
|
requests
|
|
@@ -302,17 +329,22 @@ module Rhino
|
|
|
302
329
|
def build_computed_requests(base, meta)
|
|
303
330
|
headers = default_headers
|
|
304
331
|
path = "#{base}/computed"
|
|
305
|
-
attributes =
|
|
332
|
+
attributes = meta[:collection_computed_attributes] || {}
|
|
306
333
|
|
|
334
|
+
# A bare /computed skips required-parameter attributes server-side, so
|
|
335
|
+
# this stays a valid request.
|
|
307
336
|
requests = [request_item("All computed attributes", "GET", path, {}, headers)]
|
|
308
337
|
|
|
309
|
-
attributes.each do |attribute|
|
|
310
|
-
requests << request_item("Computed: #{attribute}", "GET", path,
|
|
338
|
+
attributes.each do |attribute, spec|
|
|
339
|
+
requests << request_item("Computed: #{attribute}", "GET", path,
|
|
340
|
+
computed_attribute_query("attributes", attribute, spec), headers)
|
|
311
341
|
end
|
|
312
342
|
|
|
313
|
-
|
|
343
|
+
combinable = argument_free_computed_attributes(attributes)
|
|
344
|
+
|
|
345
|
+
if combinable.size > 1
|
|
314
346
|
requests << request_item("Computed: multiple attributes", "GET", path,
|
|
315
|
-
{ attributes:
|
|
347
|
+
{ attributes: combinable.join(",") }, headers)
|
|
316
348
|
end
|
|
317
349
|
|
|
318
350
|
requests
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Parses a model's computed-attribute declarations
|
|
5
|
+
# (+rhino_record_computed_attributes+ / +rhino_collection_computed_attributes+)
|
|
6
|
+
# and binds the arguments a client sent for <tt>?attributes[name][param]=value</tt>
|
|
7
|
+
# (and <tt>?computed_attributes[name][param]=value</tt>) to the declared
|
|
8
|
+
# parameters.
|
|
9
|
+
#
|
|
10
|
+
# Declaration forms (both may be mixed in one hash):
|
|
11
|
+
#
|
|
12
|
+
# {
|
|
13
|
+
# # Legacy: anything that is not an extended spec is used as-is — a
|
|
14
|
+
# # callable is called, any other value is serialized literally.
|
|
15
|
+
# 'active_users_count' => ->(scope, _user) { scope.count },
|
|
16
|
+
# 'schema_version' => 3,
|
|
17
|
+
#
|
|
18
|
+
# # Extended: a hash carrying at least one of params/optional/with.
|
|
19
|
+
# 'revenue' => {
|
|
20
|
+
# params: %i[from to], optional: [:to],
|
|
21
|
+
# with: ->(scope, _user, from, to = nil) { ... }
|
|
22
|
+
# }
|
|
23
|
+
# }
|
|
24
|
+
#
|
|
25
|
+
# Unlike named scopes, there is deliberately NO symbol or bare-list shorthand:
|
|
26
|
+
# <tt>'version' => 'v3'</tt> and <tt>'tags' => %w[a b]</tt> are valid *literal*
|
|
27
|
+
# value declarations today, and reinterpreting them as parameter lists would
|
|
28
|
+
# silently change what a shipped model returns. A declaration is an extended
|
|
29
|
+
# spec if and only if it is a hash carrying +params+, +optional+ or +with+ —
|
|
30
|
+
# those three keys are reserved inside a computed-attribute declaration.
|
|
31
|
+
module ComputedAttributeSpec
|
|
32
|
+
# The noun every computed-attribute argument error message starts with.
|
|
33
|
+
SUBJECT = "Computed attribute"
|
|
34
|
+
|
|
35
|
+
# The keys whose presence marks a declaration hash as an extended spec.
|
|
36
|
+
SPEC_KEYS = %i[params optional with].freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# Normalize a raw declaration hash into
|
|
41
|
+
# <tt>name => { params:, optional:, target: }</tt>.
|
|
42
|
+
#
|
|
43
|
+
# +target+ is the callable (or the literal value) that produces the
|
|
44
|
+
# attribute; for a legacy declaration it is the declared value itself.
|
|
45
|
+
def normalize(declared)
|
|
46
|
+
return {} unless declared.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
declared.each_with_object({}) do |(name, value), out|
|
|
49
|
+
out[name.to_s] = normalize_entry(value)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def normalize_entry(value)
|
|
54
|
+
return { params: [], optional: [], target: value } unless spec?(value)
|
|
55
|
+
|
|
56
|
+
spec = value.symbolize_keys
|
|
57
|
+
|
|
58
|
+
Rhino::ArgumentBinder
|
|
59
|
+
.normalize_params(spec[:params], spec[:optional])
|
|
60
|
+
.merge(target: spec[:with])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Whether a declared value is an extended spec rather than a legacy
|
|
64
|
+
# callable/literal declaration.
|
|
65
|
+
def spec?(value)
|
|
66
|
+
return false unless value.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
SPEC_KEYS.any? { |key| value.key?(key) || value.key?(key.to_s) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The declared attribute names only.
|
|
72
|
+
def names(declared)
|
|
73
|
+
normalize(declared).keys
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Whether the attribute declares at least one parameter that the client MUST
|
|
77
|
+
# supply. Such attributes are skipped — never 403'd — when no selection was
|
|
78
|
+
# made (a bare <tt>GET /computed</tt>) and when a direct serialization call
|
|
79
|
+
# passes no arguments for them.
|
|
80
|
+
def requires_arguments?(spec)
|
|
81
|
+
(Array(spec[:params]).map(&:to_s) - Array(spec[:optional]).map(&:to_s)).any?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Whether an entry is parameterised, and therefore must be called strictly
|
|
85
|
+
# with the bound arguments rather than through the tolerant arity branch a
|
|
86
|
+
# parameterless declaration keeps.
|
|
87
|
+
def parameterised?(spec)
|
|
88
|
+
Array(spec[:params]).any?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Bind the raw value a client sent for one attribute to positional
|
|
92
|
+
# arguments, in the order the model declared them.
|
|
93
|
+
#
|
|
94
|
+
# Callers MUST have already checked that the attribute is declared and
|
|
95
|
+
# policy-visible: the messages raised here name the attribute.
|
|
96
|
+
def bind(name, spec, raw)
|
|
97
|
+
Rhino::ArgumentBinder.bind(
|
|
98
|
+
subject: SUBJECT,
|
|
99
|
+
name: name,
|
|
100
|
+
spec: spec,
|
|
101
|
+
raw: raw,
|
|
102
|
+
error_class: Rhino::InvalidComputedAttributeArgumentsError
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -72,15 +72,27 @@ module Rhino
|
|
|
72
72
|
# Declaring at least one attribute here is what registers the
|
|
73
73
|
# <tt>/computed</tt> route for the model.
|
|
74
74
|
#
|
|
75
|
+
# An attribute may also declare PARAMETERS the client supplies as
|
|
76
|
+
# <tt>?attributes[name][param]=value</tt>. Use the extended form — a hash
|
|
77
|
+
# carrying +params+ (and optionally +optional+ and +with+) — and the bound
|
|
78
|
+
# arguments are appended after +user+, in declared order. An attribute
|
|
79
|
+
# with a REQUIRED parameter is skipped by a bare <tt>GET /computed</tt>
|
|
80
|
+
# rather than 403'd, so adding one never breaks a client that asks for
|
|
81
|
+
# everything.
|
|
82
|
+
#
|
|
75
83
|
# @example
|
|
76
84
|
# def self.rhino_collection_computed_attributes
|
|
77
85
|
# {
|
|
78
86
|
# 'active_users_count' => ->(scope, _user) { scope.where(status: 'active').count },
|
|
79
|
-
# 'blocked_users_count' => ->(scope, _user) { scope.where(status: 'blocked').count }
|
|
87
|
+
# 'blocked_users_count' => ->(scope, _user) { scope.where(status: 'blocked').count },
|
|
88
|
+
# 'revenue' => {
|
|
89
|
+
# params: %i[from to],
|
|
90
|
+
# with: ->(scope, _user, from, to) { scope.where(created_at: from..to).sum(:total) }
|
|
91
|
+
# }
|
|
80
92
|
# }
|
|
81
93
|
# end
|
|
82
94
|
#
|
|
83
|
-
# @return [Hash{String =>
|
|
95
|
+
# @return [Hash{String => Object}]
|
|
84
96
|
def rhino_collection_computed_attributes
|
|
85
97
|
{}
|
|
86
98
|
end
|
|
@@ -108,8 +120,14 @@ module Rhino
|
|
|
108
120
|
# Do NOT override this method. Override +rhino_computed_attributes+ instead
|
|
109
121
|
# to add computed/virtual attributes to the JSON response.
|
|
110
122
|
#
|
|
123
|
+
# @param computed_attributes [Array<String>] opt-in record-level computed
|
|
124
|
+
# attributes to evaluate, selected via <tt>?computed_attributes=</tt>.
|
|
125
|
+
# @param computed_arguments [Hash{String => Array}] positional arguments per
|
|
126
|
+
# attribute name. An attribute with required parameters and no entry here
|
|
127
|
+
# is skipped rather than called with too few arguments, so an existing
|
|
128
|
+
# direct caller that passes only names keeps working.
|
|
111
129
|
# @return [Hash]
|
|
112
|
-
def as_rhino_json(computed_attributes: [])
|
|
130
|
+
def as_rhino_json(computed_attributes: [], computed_arguments: {})
|
|
113
131
|
user = rhino_current_user
|
|
114
132
|
hidden = hidden_columns_for(user)
|
|
115
133
|
result = as_json(except: hidden)
|
|
@@ -123,7 +141,9 @@ module Rhino
|
|
|
123
141
|
# by name, so declaring an expensive attribute costs nothing on requests
|
|
124
142
|
# that don't want it. Merged before policy filtering, so the blacklist and
|
|
125
143
|
# whitelist below still govern them.
|
|
126
|
-
result.merge!(
|
|
144
|
+
result.merge!(
|
|
145
|
+
rhino_resolve_record_computed_attributes(computed_attributes, user, computed_arguments)
|
|
146
|
+
)
|
|
127
147
|
|
|
128
148
|
# Apply blacklist to the final hash (covers DB columns from as_json
|
|
129
149
|
# overrides AND computed attributes from rhino_computed_attributes)
|
|
@@ -173,15 +193,27 @@ module Rhino
|
|
|
173
193
|
# Return a hash of attribute name => callable. The callable may accept
|
|
174
194
|
# zero, one (record) or two (record, user) arguments.
|
|
175
195
|
#
|
|
196
|
+
# An attribute may also declare PARAMETERS the client supplies as
|
|
197
|
+
# <tt>?computed_attributes[name][param]=value</tt>. Use the extended form —
|
|
198
|
+
# a hash carrying +params+ (and optionally +optional+ and +with+) — and the
|
|
199
|
+
# bound arguments are appended after +user+, in declared order. A
|
|
200
|
+
# parameterised entry is always called as <tt>call(record, user, *args)</tt>.
|
|
201
|
+
# Any other declared value (a callable, a scalar, a plain array) keeps its
|
|
202
|
+
# current meaning.
|
|
203
|
+
#
|
|
176
204
|
# @example
|
|
177
205
|
# def rhino_record_computed_attributes
|
|
178
206
|
# {
|
|
179
207
|
# 'open_tickets_count' => ->(record, _user) { record.tickets.where(closed_at: nil).count },
|
|
180
|
-
# 'full_name' => ->(record, _user) { "#{record.first_name} #{record.last_name}" }
|
|
208
|
+
# 'full_name' => ->(record, _user) { "#{record.first_name} #{record.last_name}" },
|
|
209
|
+
# 'tickets_since' => {
|
|
210
|
+
# params: [:since],
|
|
211
|
+
# with: ->(record, _user, since) { record.tickets.where("created_at >= ?", since).count }
|
|
212
|
+
# }
|
|
181
213
|
# }
|
|
182
214
|
# end
|
|
183
215
|
#
|
|
184
|
-
# @return [Hash{String =>
|
|
216
|
+
# @return [Hash{String => Object}]
|
|
185
217
|
def rhino_record_computed_attributes
|
|
186
218
|
{}
|
|
187
219
|
end
|
|
@@ -193,26 +225,49 @@ module Rhino
|
|
|
193
225
|
# Names that are not declared are silently skipped — the controller has
|
|
194
226
|
# already rejected unknown/forbidden names with a 403, and a direct
|
|
195
227
|
# +as_rhino_json+ caller must not be able to force an arbitrary call.
|
|
196
|
-
|
|
228
|
+
#
|
|
229
|
+
# An attribute that declares a required parameter is likewise skipped when
|
|
230
|
+
# +arguments+ carries no entry for it, so a custom controller calling
|
|
231
|
+
# <tt>as_rhino_json(computed_attributes: ['tickets_since'])</tt> gets a
|
|
232
|
+
# missing key rather than an ArgumentError.
|
|
233
|
+
def rhino_resolve_record_computed_attributes(names, user, arguments = {})
|
|
197
234
|
return {} if names.blank?
|
|
198
235
|
|
|
199
|
-
|
|
200
|
-
return {}
|
|
236
|
+
specs = Rhino::ComputedAttributeSpec.normalize(rhino_record_computed_attributes)
|
|
237
|
+
return {} if specs.empty?
|
|
238
|
+
|
|
239
|
+
arguments = (arguments || {}).transform_keys(&:to_s)
|
|
201
240
|
|
|
202
241
|
Array(names).each_with_object({}) do |name, memo|
|
|
203
242
|
key = name.to_s
|
|
204
|
-
|
|
243
|
+
spec = specs[key]
|
|
244
|
+
next if spec.nil?
|
|
205
245
|
|
|
206
|
-
|
|
246
|
+
if arguments.key?(key)
|
|
247
|
+
args = Array(arguments[key])
|
|
248
|
+
elsif Rhino::ComputedAttributeSpec.requires_arguments?(spec)
|
|
249
|
+
next
|
|
250
|
+
else
|
|
251
|
+
args = []
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
memo[key] = rhino_call_computed(spec, self, user, args)
|
|
207
255
|
end
|
|
208
256
|
end
|
|
209
257
|
|
|
210
|
-
# Invoke a declared
|
|
211
|
-
#
|
|
212
|
-
#
|
|
213
|
-
|
|
258
|
+
# Invoke a declared entry.
|
|
259
|
+
#
|
|
260
|
+
# A PARAMETERISED entry is always called as `call(record, user, *args)` —
|
|
261
|
+
# the declaration is the contract. A parameterless entry keeps today's
|
|
262
|
+
# tolerant arity 0/1/2 branch: Ruby lambdas are strict about arity, so the
|
|
263
|
+
# arity is honoured rather than forcing every declaration to accept both
|
|
264
|
+
# arguments.
|
|
265
|
+
def rhino_call_computed(spec, record, user, args = [])
|
|
266
|
+
entry = spec[:target]
|
|
214
267
|
return entry unless entry.respond_to?(:call)
|
|
215
268
|
|
|
269
|
+
return entry.call(record, user, *args) if Rhino::ComputedAttributeSpec.parameterised?(spec)
|
|
270
|
+
|
|
216
271
|
case entry.try(:arity)
|
|
217
272
|
when 0 then entry.call
|
|
218
273
|
when 1 then entry.call(record)
|
|
@@ -27,6 +27,10 @@ module Rhino
|
|
|
27
27
|
render json: { message: e.message }, status: :forbidden
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
rescue_from Rhino::InvalidComputedAttributeArgumentsError do |e|
|
|
31
|
+
render json: { message: e.message }, status: :forbidden
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
rescue_from Rhino::QueryAttributeNotAllowedError do |e|
|
|
31
35
|
render json: { message: e.message }, status: :forbidden
|
|
32
36
|
end
|
|
@@ -53,7 +57,7 @@ module Rhino
|
|
|
53
57
|
def index
|
|
54
58
|
authorize model_class, :index?, policy_class: policy_for(model_class)
|
|
55
59
|
|
|
56
|
-
computed = resolve_requested_computed_attributes
|
|
60
|
+
computed, computed_args = resolve_requested_computed_attributes
|
|
57
61
|
return if performed?
|
|
58
62
|
|
|
59
63
|
builder = QueryBuilder.new(model_class, params: params, named_scopes: true)
|
|
@@ -66,9 +70,9 @@ module Rhino
|
|
|
66
70
|
if per_page.present? || pagination_enabled
|
|
67
71
|
result = builder.paginate
|
|
68
72
|
set_pagination_headers(result[:pagination])
|
|
69
|
-
render json: { data: serialize_collection(result[:items], computed) }
|
|
73
|
+
render json: { data: serialize_collection(result[:items], computed, computed_args) }
|
|
70
74
|
else
|
|
71
|
-
render json: { data: serialize_collection(builder.to_scope, computed) }
|
|
75
|
+
render json: { data: serialize_collection(builder.to_scope, computed, computed_args) }
|
|
72
76
|
end
|
|
73
77
|
end
|
|
74
78
|
|
|
@@ -112,7 +116,7 @@ module Rhino
|
|
|
112
116
|
record = find_record
|
|
113
117
|
authorize record, :show?, policy_class: policy_for(record)
|
|
114
118
|
|
|
115
|
-
computed = resolve_requested_computed_attributes
|
|
119
|
+
computed, computed_args = resolve_requested_computed_attributes
|
|
116
120
|
return if performed?
|
|
117
121
|
|
|
118
122
|
# Apply includes if requested
|
|
@@ -128,7 +132,7 @@ module Rhino
|
|
|
128
132
|
record = builder.to_scope.first!
|
|
129
133
|
end
|
|
130
134
|
|
|
131
|
-
render json: serialize_record(record, computed)
|
|
135
|
+
render json: serialize_record(record, computed, computed_args)
|
|
132
136
|
end
|
|
133
137
|
|
|
134
138
|
# PUT /api/{slug}/:id
|
|
@@ -192,7 +196,7 @@ module Rhino
|
|
|
192
196
|
def trashed
|
|
193
197
|
authorize model_class, :view_trashed?, policy_class: policy_for(model_class)
|
|
194
198
|
|
|
195
|
-
computed = resolve_requested_computed_attributes
|
|
199
|
+
computed, computed_args = resolve_requested_computed_attributes
|
|
196
200
|
return if performed?
|
|
197
201
|
|
|
198
202
|
builder = QueryBuilder.new(model_class.discarded, params: params, named_scopes: true)
|
|
@@ -205,9 +209,9 @@ module Rhino
|
|
|
205
209
|
if per_page.present? || pagination_enabled
|
|
206
210
|
result = builder.paginate
|
|
207
211
|
set_pagination_headers(result[:pagination])
|
|
208
|
-
render json: { data: serialize_collection(result[:items], computed) }
|
|
212
|
+
render json: { data: serialize_collection(result[:items], computed, computed_args) }
|
|
209
213
|
else
|
|
210
|
-
render json: { data: serialize_collection(builder.to_scope, computed) }
|
|
214
|
+
render json: { data: serialize_collection(builder.to_scope, computed, computed_args) }
|
|
211
215
|
end
|
|
212
216
|
end
|
|
213
217
|
|
|
@@ -223,12 +227,18 @@ module Rhino
|
|
|
223
227
|
# listed. Sorting, sparse fieldsets, includes and pagination are
|
|
224
228
|
# deliberately NOT applied.
|
|
225
229
|
#
|
|
226
|
-
# Omitting `?attributes=` returns every declared attribute the policy allows
|
|
230
|
+
# Omitting `?attributes=` returns every declared attribute the policy allows,
|
|
231
|
+
# minus any that declares a required parameter — those are skipped silently
|
|
232
|
+
# so adding a parameterised attribute never breaks a bare `/computed` call.
|
|
233
|
+
#
|
|
234
|
+
# Attributes that declare parameters take them in the bracket form:
|
|
235
|
+
#
|
|
236
|
+
# ?attributes[revenue][from]=2026-01-01&attributes[revenue][to]=2026-02-01
|
|
227
237
|
def computed
|
|
228
238
|
authorize model_class, :index?, policy_class: policy_for(model_class)
|
|
229
239
|
|
|
230
|
-
|
|
231
|
-
names = resolve_requested_collection_attributes(
|
|
240
|
+
specs = Rhino::ComputedAttributeSpec.normalize(collection_computed_attributes)
|
|
241
|
+
names, arguments = resolve_requested_collection_attributes(collection_computed_attributes)
|
|
232
242
|
return if performed?
|
|
233
243
|
|
|
234
244
|
builder = QueryBuilder.new(model_class, params: params, named_scopes: true)
|
|
@@ -241,8 +251,11 @@ module Rhino
|
|
|
241
251
|
data = names.each_with_object({}) do |name, memo|
|
|
242
252
|
# Each attribute gets the base relation; ActiveRecord relations are
|
|
243
253
|
# immutable under chaining, so one callable's constraints can never
|
|
244
|
-
# leak into the next one's result.
|
|
245
|
-
|
|
254
|
+
# leak into the next one's result. The relation is already
|
|
255
|
+
# organization-scoped, filtered and searched, and no argument can
|
|
256
|
+
# widen it.
|
|
257
|
+
spec = specs[name] || { params: [], optional: [], target: nil }
|
|
258
|
+
memo[name] = call_computed_attribute(spec, scope, user, arguments[name] || [])
|
|
246
259
|
end
|
|
247
260
|
|
|
248
261
|
render json: { data: data }
|
|
@@ -666,67 +679,108 @@ module Rhino
|
|
|
666
679
|
declared.is_a?(Hash) ? declared.transform_keys(&:to_s) : {}
|
|
667
680
|
end
|
|
668
681
|
|
|
669
|
-
# Parse and authorize `?attributes
|
|
682
|
+
# Parse and authorize `?attributes=` for the /computed endpoint, in every
|
|
683
|
+
# accepted form:
|
|
684
|
+
#
|
|
685
|
+
# ?attributes=a,b legacy comma list, no arguments
|
|
686
|
+
# ?attributes[revenue]= one name, no arguments
|
|
687
|
+
# ?attributes[since]=2026-01-01 binds to the single declared param
|
|
688
|
+
# ?attributes[revenue][from]=a&... named arguments
|
|
670
689
|
#
|
|
671
|
-
#
|
|
672
|
-
#
|
|
673
|
-
#
|
|
690
|
+
# Returns <tt>[names, arguments]</tt> — arguments keyed by attribute name —
|
|
691
|
+
# or renders a 403 and returns <tt>[[], {}]</tt>. An undeclared name and a
|
|
692
|
+
# policy-denied name produce the SAME error, so the endpoint never reveals
|
|
693
|
+
# which attributes a model declares; both checks run BEFORE any argument
|
|
694
|
+
# binding, so the more specific argument messages can only ever be seen for
|
|
695
|
+
# a name the caller was already allowed to use.
|
|
674
696
|
def resolve_requested_collection_attributes(declared)
|
|
697
|
+
specs = Rhino::ComputedAttributeSpec.normalize(declared)
|
|
675
698
|
raw = params[:attributes]
|
|
699
|
+
user = current_user
|
|
676
700
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
701
|
+
if raw.nil? || (raw.is_a?(String) && raw.strip.empty?)
|
|
702
|
+
# No selection: every declared attribute the policy allows, minus the
|
|
703
|
+
# ones that cannot run without client arguments.
|
|
704
|
+
names = specs.reject { |_, spec| Rhino::ComputedAttributeSpec.requires_arguments?(spec) }
|
|
705
|
+
.keys
|
|
706
|
+
.select { |name| computed_attribute_allowed?(name, user) }
|
|
707
|
+
|
|
708
|
+
return [names, {}]
|
|
681
709
|
end
|
|
682
710
|
|
|
683
|
-
|
|
711
|
+
requested = parse_attribute_selection(raw)
|
|
712
|
+
return [[], {}] if performed?
|
|
684
713
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
return declared.keys.select { |name| computed_attribute_allowed?(name, user) }
|
|
688
|
-
end
|
|
714
|
+
bind_attribute_selection(requested, specs, user)
|
|
715
|
+
end
|
|
689
716
|
|
|
690
|
-
|
|
717
|
+
# Parse and authorize `?computed_attributes=` for index/show/trashed — the
|
|
718
|
+
# OPT-IN record-level computed attributes. Accepts the same four forms as
|
|
719
|
+
# `?attributes=` (see resolve_requested_collection_attributes).
|
|
720
|
+
#
|
|
721
|
+
# Absent or blank means "none", which is byte-for-byte the pre-feature
|
|
722
|
+
# behavior.
|
|
723
|
+
def resolve_requested_computed_attributes
|
|
724
|
+
raw = params[:computed_attributes]
|
|
725
|
+
return [[], {}] if raw.nil? || raw == ""
|
|
691
726
|
|
|
692
|
-
|
|
693
|
-
|
|
727
|
+
requested = parse_attribute_selection(raw)
|
|
728
|
+
return [[], {}] if performed? || requested.empty?
|
|
694
729
|
|
|
695
|
-
|
|
696
|
-
return []
|
|
697
|
-
end
|
|
730
|
+
declared = model_class.new.try(:rhino_record_computed_attributes)
|
|
698
731
|
|
|
699
|
-
|
|
732
|
+
bind_attribute_selection(
|
|
733
|
+
requested,
|
|
734
|
+
Rhino::ComputedAttributeSpec.normalize(declared),
|
|
735
|
+
current_user
|
|
736
|
+
)
|
|
700
737
|
end
|
|
701
738
|
|
|
702
|
-
#
|
|
703
|
-
#
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
raw = params[:computed_attributes]
|
|
707
|
-
return [] if raw.nil? || raw == ""
|
|
739
|
+
# Turn the raw query value into an ordered list of
|
|
740
|
+
# <tt>[name, raw_arguments]</tt> pairs.
|
|
741
|
+
def parse_attribute_selection(raw)
|
|
742
|
+
return parse_attribute_list(raw).map { |name| [name, ""] } if raw.is_a?(String)
|
|
708
743
|
|
|
709
|
-
|
|
744
|
+
raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
|
|
745
|
+
|
|
746
|
+
unless raw.is_a?(Hash)
|
|
710
747
|
render json: { message: "Computed attributes are not allowed" }, status: :forbidden
|
|
711
748
|
return []
|
|
712
749
|
end
|
|
713
750
|
|
|
714
|
-
|
|
715
|
-
|
|
751
|
+
pairs = []
|
|
752
|
+
raw.each do |key, value|
|
|
753
|
+
# A positional list (?attributes[]=x) or a blank key names nothing:
|
|
754
|
+
# reject before any lookup.
|
|
755
|
+
if key.to_s.empty?
|
|
756
|
+
render json: { message: "Computed attributes are not allowed" }, status: :forbidden
|
|
757
|
+
return []
|
|
758
|
+
end
|
|
716
759
|
|
|
717
|
-
|
|
718
|
-
|
|
760
|
+
pairs << [key.to_s, value]
|
|
761
|
+
end
|
|
719
762
|
|
|
720
|
-
|
|
763
|
+
pairs
|
|
764
|
+
end
|
|
721
765
|
|
|
722
|
-
|
|
723
|
-
|
|
766
|
+
# Gate every requested attribute, then bind its arguments.
|
|
767
|
+
def bind_attribute_selection(requested, specs, user)
|
|
768
|
+
names = []
|
|
769
|
+
arguments = {}
|
|
770
|
+
|
|
771
|
+
requested.each do |(name, raw_arguments)|
|
|
772
|
+
# Gate first — declared AND policy-visible — so nothing below can
|
|
773
|
+
# distinguish an undeclared name from a forbidden one.
|
|
774
|
+
unless specs.key?(name) && computed_attribute_allowed?(name, user)
|
|
775
|
+
render json: { message: "Computed attribute '#{name}' is not allowed" }, status: :forbidden
|
|
776
|
+
return [[], {}]
|
|
777
|
+
end
|
|
724
778
|
|
|
725
|
-
|
|
726
|
-
|
|
779
|
+
arguments[name] = Rhino::ComputedAttributeSpec.bind(name, specs[name], raw_arguments)
|
|
780
|
+
names << name
|
|
727
781
|
end
|
|
728
782
|
|
|
729
|
-
names
|
|
783
|
+
[names.uniq, arguments]
|
|
730
784
|
end
|
|
731
785
|
|
|
732
786
|
# Split a comma-separated attribute list, dropping blanks and duplicates.
|
|
@@ -759,10 +813,19 @@ module Rhino
|
|
|
759
813
|
true
|
|
760
814
|
end
|
|
761
815
|
|
|
762
|
-
# Invoke a declared
|
|
763
|
-
|
|
816
|
+
# Invoke a declared entry.
|
|
817
|
+
#
|
|
818
|
+
# A PARAMETERISED entry is always called as `call(scope, user, *args)` — the
|
|
819
|
+
# declaration is the contract, and a mismatched lambda is a developer error,
|
|
820
|
+
# exactly as it is for a parameterised scope. A parameterless entry keeps
|
|
821
|
+
# today's tolerant arity 0/1/2 branch, so no existing lambda changes
|
|
822
|
+
# behavior.
|
|
823
|
+
def call_computed_attribute(spec, scope, user, args = [])
|
|
824
|
+
entry = spec[:target]
|
|
764
825
|
return entry unless entry.respond_to?(:call)
|
|
765
826
|
|
|
827
|
+
return entry.call(scope, user, *args) if Rhino::ComputedAttributeSpec.parameterised?(spec)
|
|
828
|
+
|
|
766
829
|
case entry.try(:arity)
|
|
767
830
|
when 0 then entry.call
|
|
768
831
|
when 1 then entry.call(scope)
|
|
@@ -770,16 +833,19 @@ module Rhino
|
|
|
770
833
|
end
|
|
771
834
|
end
|
|
772
835
|
|
|
773
|
-
def serialize_record(record, computed_attributes = [])
|
|
836
|
+
def serialize_record(record, computed_attributes = [], computed_arguments = {})
|
|
774
837
|
if record.respond_to?(:as_rhino_json)
|
|
775
|
-
record.as_rhino_json(
|
|
838
|
+
record.as_rhino_json(
|
|
839
|
+
computed_attributes: computed_attributes,
|
|
840
|
+
computed_arguments: computed_arguments
|
|
841
|
+
)
|
|
776
842
|
else
|
|
777
843
|
record.as_json
|
|
778
844
|
end
|
|
779
845
|
end
|
|
780
846
|
|
|
781
|
-
def serialize_collection(records, computed_attributes = [])
|
|
782
|
-
records.map { |r| serialize_record(r, computed_attributes) }
|
|
847
|
+
def serialize_collection(records, computed_attributes = [], computed_arguments = {})
|
|
848
|
+
records.map { |r| serialize_record(r, computed_attributes, computed_arguments) }
|
|
783
849
|
end
|
|
784
850
|
|
|
785
851
|
# ------------------------------------------------------------------
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Raised when a client-requested computed attribute is given arguments that do
|
|
5
|
+
# not match the model's declared parameter spec. Rendered as 403 by
|
|
6
|
+
# ResourcesController, message and all: it is only ever raised after the
|
|
7
|
+
# attribute name itself passed the declaration check and the policy, so naming
|
|
8
|
+
# the parameters reveals nothing about attributes the user may not see.
|
|
9
|
+
class InvalidComputedAttributeArgumentsError < StandardError; end
|
|
10
|
+
end
|
data/lib/rhino/scope_spec.rb
CHANGED
|
@@ -16,6 +16,11 @@ module Rhino
|
|
|
16
16
|
# A scope with no declared parameters never receives arguments: sending any
|
|
17
17
|
# is a 403, so a scope written without client input can never be handed some.
|
|
18
18
|
module ScopeSpec
|
|
19
|
+
# The noun every scope-argument error message starts with. The binding
|
|
20
|
+
# algorithm itself lives in Rhino::ArgumentBinder and is shared with
|
|
21
|
+
# computed attributes; this constant is what keeps the scope wording its own.
|
|
22
|
+
SUBJECT = "Scope"
|
|
23
|
+
|
|
19
24
|
module_function
|
|
20
25
|
|
|
21
26
|
# Normalize a raw +allowed_scopes+ hash into
|
|
@@ -30,10 +35,10 @@ module Rhino
|
|
|
30
35
|
def normalize_entry(name, value)
|
|
31
36
|
if value.is_a?(Hash) || value.is_a?(ActiveSupport::HashWithIndifferentAccess)
|
|
32
37
|
spec = value.symbolize_keys
|
|
33
|
-
params = Array(spec[:params]).map(&:to_s)
|
|
34
|
-
optional = Array(spec[:optional]).map(&:to_s) & params
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
Rhino::ArgumentBinder
|
|
40
|
+
.normalize_params(spec[:params], spec[:optional])
|
|
41
|
+
.merge(target: spec[:with] || name.to_sym)
|
|
37
42
|
else
|
|
38
43
|
{ target: value, params: [], optional: [] }
|
|
39
44
|
end
|
|
@@ -48,77 +53,33 @@ module Rhino
|
|
|
48
53
|
#
|
|
49
54
|
# Raises Rhino::InvalidScopeArgumentsError.
|
|
50
55
|
def bind(name, spec, raw)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if given.key?(param)
|
|
62
|
-
coerce(given[param])
|
|
63
|
-
elsif spec[:optional].include?(param)
|
|
64
|
-
nil
|
|
65
|
-
else
|
|
66
|
-
raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' requires parameter '#{param}'"
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Drop trailing nils so an omitted optional parameter falls back to the
|
|
71
|
-
# default in the scope's own signature.
|
|
72
|
-
args.pop while args.any? && args.last.nil?
|
|
73
|
-
args
|
|
56
|
+
Rhino::ArgumentBinder.bind(
|
|
57
|
+
subject: SUBJECT,
|
|
58
|
+
name: name,
|
|
59
|
+
spec: spec,
|
|
60
|
+
raw: raw,
|
|
61
|
+
error_class: Rhino::InvalidScopeArgumentsError,
|
|
62
|
+
# Scope wire names are underscored (?scope[availableForDrivers]), and so
|
|
63
|
+
# are their parameter names.
|
|
64
|
+
underscore_keys: true
|
|
65
|
+
)
|
|
74
66
|
end
|
|
75
67
|
|
|
76
68
|
def normalize_raw_arguments(name, params, raw)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' requires named parameters"
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
unless raw.is_a?(Hash)
|
|
89
|
-
raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' does not accept arguments" if params.empty?
|
|
90
|
-
|
|
91
|
-
# A bare value binds to the single declared parameter. Two parameters can
|
|
92
|
-
# never be guessed at from one value.
|
|
93
|
-
if params.length > 1
|
|
94
|
-
raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' requires named parameters"
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
return { params.first => raw }
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' does not accept arguments" if params.empty?
|
|
101
|
-
|
|
102
|
-
raw.each_with_object({}) do |(key, value), out|
|
|
103
|
-
unless value.is_a?(String) || value.is_a?(Numeric) || value.is_a?(TrueClass) ||
|
|
104
|
-
value.is_a?(FalseClass) || value.nil?
|
|
105
|
-
raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' requires named parameters"
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
out[key.to_s.underscore] = value
|
|
109
|
-
end
|
|
69
|
+
Rhino::ArgumentBinder.normalize_raw_arguments(
|
|
70
|
+
subject: SUBJECT,
|
|
71
|
+
name: name,
|
|
72
|
+
params: params,
|
|
73
|
+
raw: raw,
|
|
74
|
+
error_class: Rhino::InvalidScopeArgumentsError,
|
|
75
|
+
underscore_keys: true
|
|
76
|
+
)
|
|
110
77
|
end
|
|
111
78
|
|
|
112
79
|
# Query-string values always arrive as strings; hand scope bodies real
|
|
113
80
|
# booleans so a check cannot be fooled by the string "false".
|
|
114
81
|
def coerce(value)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
case value.downcase
|
|
118
|
-
when "true" then true
|
|
119
|
-
when "false" then false
|
|
120
|
-
else value
|
|
121
|
-
end
|
|
82
|
+
Rhino::ArgumentBinder.coerce(value)
|
|
122
83
|
end
|
|
123
84
|
end
|
|
124
85
|
end
|
data/lib/rhino/version.rb
CHANGED
data/lib/rhino.rb
CHANGED
|
@@ -5,7 +5,10 @@ require "rhino/configuration"
|
|
|
5
5
|
require "rhino/auth_rejected"
|
|
6
6
|
require "rhino/scope_not_allowed_error"
|
|
7
7
|
require "rhino/invalid_scope_arguments_error"
|
|
8
|
+
require "rhino/invalid_computed_attribute_arguments_error"
|
|
9
|
+
require "rhino/argument_binder"
|
|
8
10
|
require "rhino/scope_spec"
|
|
11
|
+
require "rhino/computed_attribute_spec"
|
|
9
12
|
require "rhino/missing_tenant_context"
|
|
10
13
|
require "rhino/auth_hooks"
|
|
11
14
|
require "rhino/group_membership"
|
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.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bruno Cipolla
|
|
@@ -190,6 +190,7 @@ files:
|
|
|
190
190
|
- README.md
|
|
191
191
|
- lib/rhino-rails.rb
|
|
192
192
|
- lib/rhino.rb
|
|
193
|
+
- lib/rhino/argument_binder.rb
|
|
193
194
|
- lib/rhino/auth_hooks.rb
|
|
194
195
|
- lib/rhino/auth_rejected.rb
|
|
195
196
|
- lib/rhino/blueprint/blueprint_parser.rb
|
|
@@ -207,6 +208,7 @@ files:
|
|
|
207
208
|
- lib/rhino/commands/generate_command.rb
|
|
208
209
|
- lib/rhino/commands/install_command.rb
|
|
209
210
|
- lib/rhino/commands/invitation_link_command.rb
|
|
211
|
+
- lib/rhino/computed_attribute_spec.rb
|
|
210
212
|
- lib/rhino/concerns/belongs_to_organization.rb
|
|
211
213
|
- lib/rhino/concerns/has_audit_trail.rb
|
|
212
214
|
- lib/rhino/concerns/has_auto_scope.rb
|
|
@@ -223,6 +225,7 @@ files:
|
|
|
223
225
|
- lib/rhino/controllers/resources_controller.rb
|
|
224
226
|
- lib/rhino/engine.rb
|
|
225
227
|
- lib/rhino/group_membership.rb
|
|
228
|
+
- lib/rhino/invalid_computed_attribute_arguments_error.rb
|
|
226
229
|
- lib/rhino/invalid_scope_arguments_error.rb
|
|
227
230
|
- lib/rhino/mailers/invitation_mailer.rb
|
|
228
231
|
- lib/rhino/middleware/resolve_organization_from_route.rb
|