rhino-rails 4.8.1 → 4.10.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/commands/generate_command.rb +64 -0
- data/lib/rhino/computed_attribute_spec.rb +106 -0
- data/lib/rhino/concerns/has_validation.rb +24 -0
- data/lib/rhino/concerns/hidable_columns.rb +70 -15
- data/lib/rhino/configuration.rb +56 -2
- data/lib/rhino/configuration_error.rb +14 -0
- data/lib/rhino/controllers/resources_controller.rb +413 -77
- data/lib/rhino/invalid_computed_attribute_arguments_error.rb +10 -0
- data/lib/rhino/resource_request.rb +184 -0
- data/lib/rhino/scope_spec.rb +27 -66
- data/lib/rhino/templates/generate/request.rb.erb +111 -0
- data/lib/rhino/version.rb +1 -1
- data/lib/rhino.rb +5 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7940c1894a48b568ac853c65c73597adc6e56128b3a4d5958c003c6bc0ab03e9
|
|
4
|
+
data.tar.gz: 4214c47df8a3caaa7bb7ef81957a77935b795d67c057b1797bf4945e66c677cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f8235a208d9e2b23be8e6a33f2ad0623d56f43fa2443ed0727c19aa8888e888ea88778ea4885bb8d38abc2d3d3de030e500767f9e372b0986d0ebfb6037bd76
|
|
7
|
+
data.tar.gz: d373f8eed1679caef1d7f2f6a9f516161eb8eb6bfae9e6e536add126cf8a3f923ed849fe3713865810cd2d9e943905bf3befbc0201d599bbb0ab8455808fb4f2
|
|
@@ -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
|
|
@@ -15,6 +15,7 @@ module Rhino
|
|
|
15
15
|
menu.choice "Model (with migration and factory)", "model"
|
|
16
16
|
menu.choice "Policy (extends ResourcePolicy)", "policy"
|
|
17
17
|
menu.choice "Scope (for ScopedDB)", "scope"
|
|
18
|
+
menu.choice "Request (validation for store/update)", "request"
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
name = ask("What is the resource name? (PascalCase singular, e.g., Post):")
|
|
@@ -32,6 +33,8 @@ module Rhino
|
|
|
32
33
|
generate_policy(name)
|
|
33
34
|
when "scope"
|
|
34
35
|
generate_scope(name)
|
|
36
|
+
when "request"
|
|
37
|
+
generate_request(name)
|
|
35
38
|
end
|
|
36
39
|
end
|
|
37
40
|
|
|
@@ -198,6 +201,50 @@ module Rhino
|
|
|
198
201
|
say ""
|
|
199
202
|
end
|
|
200
203
|
|
|
204
|
+
# ----------------------------------------------------------------
|
|
205
|
+
# Request generation
|
|
206
|
+
# ----------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
# Generates {Model}StoreRequest / {Model}UpdateRequest into app/requests/,
|
|
209
|
+
# which Zeitwerk autoloads like every other app/* directory — no
|
|
210
|
+
# initializer and no eager_load_paths entry is needed for the naming
|
|
211
|
+
# convention to find them.
|
|
212
|
+
def generate_request(name)
|
|
213
|
+
model_name = name.sub(/(Store|Update)?Request\z/, "")
|
|
214
|
+
model_name = name if model_name.blank?
|
|
215
|
+
|
|
216
|
+
which = select("Which request classes should be generated?") do |menu|
|
|
217
|
+
menu.choice "Store (POST /{resource})", "store"
|
|
218
|
+
menu.choice "Update (PUT /{resource}/:id)", "update"
|
|
219
|
+
menu.choice "Both", "both"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
actions = which == "both" ? %w[store update] : [which]
|
|
223
|
+
created = []
|
|
224
|
+
|
|
225
|
+
actions.each do |request_action|
|
|
226
|
+
class_name = request_class_name(model_name, request_action)
|
|
227
|
+
task("Generating #{class_name}") do
|
|
228
|
+
created << write_request_file(model_name, request_action)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
say ""
|
|
233
|
+
say "#{created.length == 1 ? 'Request' : 'Requests'} generated successfully!", :green
|
|
234
|
+
say ""
|
|
235
|
+
created.each { |path| say " Created: #{path}" }
|
|
236
|
+
say ""
|
|
237
|
+
say " Next steps:", :yellow
|
|
238
|
+
say " 1. Declare an `attribute` for EVERY field the action may write —"
|
|
239
|
+
say " an undeclared field is dropped, not persisted."
|
|
240
|
+
say " 2. Add validations, and override authorize?/prepare if needed."
|
|
241
|
+
say ""
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def request_class_name(model_name, request_action)
|
|
245
|
+
"#{model_name}#{request_action == 'update' ? 'Update' : 'Store'}Request"
|
|
246
|
+
end
|
|
247
|
+
|
|
201
248
|
# ----------------------------------------------------------------
|
|
202
249
|
# Column collection
|
|
203
250
|
# ----------------------------------------------------------------
|
|
@@ -389,6 +436,23 @@ module Rhino
|
|
|
389
436
|
File.write(dest, content)
|
|
390
437
|
end
|
|
391
438
|
|
|
439
|
+
def write_request_file(name, request_action)
|
|
440
|
+
template = File.expand_path("../../templates/generate/request.rb.erb", __FILE__)
|
|
441
|
+
class_name = request_class_name(name, request_action)
|
|
442
|
+
relative = "app/requests/#{class_name.underscore}.rb"
|
|
443
|
+
dest = Rails.root.join(relative)
|
|
444
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
445
|
+
|
|
446
|
+
content = ERB.new(File.read(template), trim_mode: "-").result_with_hash(
|
|
447
|
+
name: name,
|
|
448
|
+
class_name: class_name,
|
|
449
|
+
action: request_action
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
File.write(dest, content)
|
|
453
|
+
relative
|
|
454
|
+
end
|
|
455
|
+
|
|
392
456
|
def register_model_in_config(name)
|
|
393
457
|
config_path = Rails.root.join("config/initializers/rhino.rb")
|
|
394
458
|
return unless File.exist?(config_path)
|
|
@@ -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
|
|
@@ -33,6 +33,11 @@ module Rhino
|
|
|
33
33
|
# Filters to only permitted fields, then runs ActiveModel validations
|
|
34
34
|
# and cross-tenant FK validation.
|
|
35
35
|
#
|
|
36
|
+
# @deprecated Model-level validation is superseded by request classes
|
|
37
|
+
# ({Model}StoreRequest / {Model}UpdateRequest, see Rhino::ResourceRequest).
|
|
38
|
+
# It still works unchanged for every model that has no request class for
|
|
39
|
+
# the action and will be removed in 5.0.
|
|
40
|
+
#
|
|
36
41
|
# @param params [Hash] The request data
|
|
37
42
|
# @param permitted_fields [Array<String>] Fields the user is allowed to set (['*'] for all)
|
|
38
43
|
# @param organization [Object, nil] Current organization for FK scoping (optional)
|
|
@@ -80,6 +85,25 @@ module Rhino
|
|
|
80
85
|
end
|
|
81
86
|
end
|
|
82
87
|
|
|
88
|
+
# Public entry point for cross-tenant FK validation.
|
|
89
|
+
#
|
|
90
|
+
# The request-class path (Rhino::ResourceRequest) runs its own validations
|
|
91
|
+
# and therefore never calls validate_for_action, but it still needs the
|
|
92
|
+
# cross-tenant FK check — including the indirect case, where the referenced
|
|
93
|
+
# table reaches the organization through a FK chain rather than an
|
|
94
|
+
# organization_id column. This wraps the existing private implementation so
|
|
95
|
+
# the chain walk and its class-level caches stay in one place.
|
|
96
|
+
#
|
|
97
|
+
# @param data [Hash] the write payload (string-keyed)
|
|
98
|
+
# @param organization [Object, nil]
|
|
99
|
+
# @return [Hash<String, Array<String>>] {} when there is no organization
|
|
100
|
+
def rhino_validate_foreign_keys(data, organization)
|
|
101
|
+
return {} unless organization
|
|
102
|
+
return {} unless data.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
validate_foreign_keys_for_organization(data, organization)
|
|
105
|
+
end
|
|
106
|
+
|
|
83
107
|
private
|
|
84
108
|
|
|
85
109
|
# Cache for FK chain lookups (class-level)
|
|
@@ -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)
|