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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c77838e9b0818c083a66439097840287fdfadcd94fe17f7e403b9a1c00e08322
4
- data.tar.gz: 698536e45e86aed2abd48ba646c0ebc4e821511838c79db07e6525413ace80c5
3
+ metadata.gz: f33582b9aac75614096047f46227c443f63a31634c40cf8825a92d5fb064f583
4
+ data.tar.gz: 94f626caf29a1e66132524f649b8b4dd6f2aeb52c88b48eca5f20479af653316
5
5
  SHA512:
6
- metadata.gz: 52ec8339ccd944fcb4b3c071bc4829acaf37c1d401abfce18bec28648eac3005d7ca18c407ad3784bc541dcd16cf3238144c8ebd7bbc58ada65b8537f64e0515
7
- data.tar.gz: '096db647db3f380fe23621e6fc31496321e857a80ce22911bc2133a7877a7e607a49be3e60cb08f88594030c784b9b3a6d3e8f9415969ed0188bfb11569e3ffc'
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 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
@@ -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 => #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)
@@ -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
- declared = collection_computed_attributes
231
- names = resolve_requested_collection_attributes(declared)
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
- memo[name] = call_computed_attribute(declared[name], scope, user)
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=a,b` for the /computed endpoint.
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
- # Renders a 403 and returns [] on a bad/denied name. An undeclared name and
672
- # a policy-denied name produce the SAME error, so the endpoint never reveals
673
- # which attributes a model declares.
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
- # Reject non-scalar input (?attributes[]=x) before any lookup.
678
- if raw.present? && !raw.is_a?(String)
679
- render json: { message: "Computed attributes are not allowed" }, status: :forbidden
680
- return []
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
- user = current_user
711
+ requested = parse_attribute_selection(raw)
712
+ return [[], {}] if performed?
684
713
 
685
- if raw.blank?
686
- # No selection: every declared attribute the policy allows.
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
- names = parse_attribute_list(raw)
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
- names.each do |name|
693
- next if declared.key?(name) && computed_attribute_allowed?(name, user)
727
+ requested = parse_attribute_selection(raw)
728
+ return [[], {}] if performed? || requested.empty?
694
729
 
695
- render json: { message: "Computed attribute '#{name}' is not allowed" }, status: :forbidden
696
- return []
697
- end
730
+ declared = model_class.new.try(:rhino_record_computed_attributes)
698
731
 
699
- names
732
+ bind_attribute_selection(
733
+ requested,
734
+ Rhino::ComputedAttributeSpec.normalize(declared),
735
+ current_user
736
+ )
700
737
  end
701
738
 
702
- # Parse and authorize `?computed_attributes=a,b` for index/show/trashed —
703
- # the OPT-IN record-level computed attributes. Absent or blank means "none",
704
- # which is byte-for-byte the pre-feature behavior.
705
- def resolve_requested_computed_attributes
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
- unless raw.is_a?(String)
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
- names = parse_attribute_list(raw)
715
- return [] if names.empty?
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
- declared = model_class.new.try(:rhino_record_computed_attributes)
718
- declared = declared.is_a?(Hash) ? declared.transform_keys(&:to_s) : {}
760
+ pairs << [key.to_s, value]
761
+ end
719
762
 
720
- user = current_user
763
+ pairs
764
+ end
721
765
 
722
- names.each do |name|
723
- next if declared.key?(name) && computed_attribute_allowed?(name, user)
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
- render json: { message: "Computed attribute '#{name}' is not allowed" }, status: :forbidden
726
- return []
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 callable, tolerating lambdas of arity 0, 1 or 2.
763
- def call_computed_attribute(entry, scope, user)
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(computed_attributes: computed_attributes)
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
@@ -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
- { target: spec[:with] || name.to_sym, params: params, optional: optional }
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
- params = spec[:params]
52
- given = normalize_raw_arguments(name, params, raw)
53
-
54
- given.each_key do |key|
55
- unless params.include?(key)
56
- raise Rhino::InvalidScopeArgumentsError, "Scope '#{name}' does not accept parameter '#{key}'"
57
- end
58
- end
59
-
60
- args = params.map do |param|
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
- # ?scope[archived]= (or a bare ?scope[archived]): no arguments. A scope
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 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
- return value unless value.is_a?(String)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rhino
4
- VERSION = "4.8.1"
4
+ VERSION = "4.9.0"
5
5
  end
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.8.1
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