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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c77838e9b0818c083a66439097840287fdfadcd94fe17f7e403b9a1c00e08322
4
- data.tar.gz: 698536e45e86aed2abd48ba646c0ebc4e821511838c79db07e6525413ace80c5
3
+ metadata.gz: 7940c1894a48b568ac853c65c73597adc6e56128b3a4d5958c003c6bc0ab03e9
4
+ data.tar.gz: 4214c47df8a3caaa7bb7ef81957a77935b795d67c057b1797bf4945e66c677cb
5
5
  SHA512:
6
- metadata.gz: 52ec8339ccd944fcb4b3c071bc4829acaf37c1d401abfce18bec28648eac3005d7ca18c407ad3784bc541dcd16cf3238144c8ebd7bbc58ada65b8537f64e0515
7
- data.tar.gz: '096db647db3f380fe23621e6fc31496321e857a80ce22911bc2133a7877a7e607a49be3e60cb08f88594030c784b9b3a6d3e8f9415969ed0188bfb11569e3ffc'
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 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
+ # 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
- def computed_names(declared)
186
- declared.is_a?(Hash) ? declared.keys.map(&:to_s) : []
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 Array(meta[:collection_computed_attributes]).any? && !except.include?("computed")
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
- Array(meta[:record_computed_attributes]).each do |attribute|
281
+ (meta[:record_computed_attributes] || {}).each do |attribute, spec|
255
282
  requests << request_item("With computed attribute #{attribute}", "GET", base,
256
- { computed_attributes: attribute }, headers)
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
- Array(meta[:record_computed_attributes]).each do |attribute|
304
+ (meta[:record_computed_attributes] || {}).each do |attribute, spec|
278
305
  requests << request_item("Show with computed attribute #{attribute}", "GET", path,
279
- { computed_attributes: attribute }, headers)
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 = Array(meta[:collection_computed_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, { attributes: attribute }, headers)
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
- if attributes.size > 1
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: attributes.join(",") }, headers)
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 => #call}]
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!(rhino_resolve_record_computed_attributes(computed_attributes, user))
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 => #call}]
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
- def rhino_resolve_record_computed_attributes(names, user)
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
- declared = rhino_record_computed_attributes
200
- return {} unless declared.is_a?(Hash) && declared.any?
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
- next unless declared.key?(key)
243
+ spec = specs[key]
244
+ next if spec.nil?
205
245
 
206
- memo[key] = rhino_call_computed(declared[key], self, user)
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 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)
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)