rhino-rails 4.6.1 → 4.7.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: ec046eac08bcbf895ec331091f9204e4abba36bf712dc2d283b2c91a02f36671
4
- data.tar.gz: 9b72423cd739ed788cf2554b08aa7ba5f5d1dcbc4739689058bf6566b940f0f4
3
+ metadata.gz: 3da378b6f647733a88cb598ff3b23e06462f6c61494ebbc322fed024e9ba33a9
4
+ data.tar.gz: e3dec4fcb82201e652e76e01ff7b5d8fa25c1ec61dde9d3b58f688f9a0d4ceb2
5
5
  SHA512:
6
- metadata.gz: adc4639f14af9208841959882a089595573f910a67168551eca6c1cd6eb9d73e3f29523ad8ec49b0ce91e95692ca43b08c29a7d2b121605193c2e51d13b9a686
7
- data.tar.gz: 7e6eb9a292a199dbf2f1c42b1e6970c3f99b7bc0df49d1b5318f5987ef1a5b5b070dc4cf01b613bbf035d40d001c8bfd8bf0c3dd1e27141f008da37b48689807
6
+ metadata.gz: a37303c3543f0e140bc5f00371c1d8d8d310a30645a0ef2c3b9bd218d5cebe74e83c3f500f413792ce7496b764832219044739422f4a13f7be1b3dd1fe63bdec
7
+ data.tar.gz: fc60726be3e23b1da9ddcccc4fc884857608fb412125211b48cad5bfb51596d4986f6db13ed2728b70644f1d2c3a4be2e2e9a5fac5093ba6746783923fc01681
@@ -175,10 +175,26 @@ module Rhino
175
175
  allowed_fields: model_class.try(:allowed_fields) || [],
176
176
  allowed_includes: model_class.try(:allowed_includes) || [],
177
177
  allowed_search: model_class.try(:allowed_search) || [],
178
+ # Computed attributes: names only (the callables never leave the server).
179
+ collection_computed_attributes: computed_names(model_class.try(:rhino_collection_computed_attributes)),
180
+ record_computed_attributes: computed_names(record_computed_declaration(model_class)),
178
181
  default_sort: model_class.try(:default_sort_field)
179
182
  }
180
183
  end
181
184
 
185
+ def computed_names(declared)
186
+ declared.is_a?(Hash) ? declared.keys.map(&:to_s) : []
187
+ end
188
+
189
+ # Instantiating is safe (no DB round trip) and is the only way to read an
190
+ # instance-level declaration; a model that cannot be instantiated simply
191
+ # exports no record-level examples.
192
+ def record_computed_declaration(model_class)
193
+ model_class.new.try(:rhino_record_computed_attributes)
194
+ rescue StandardError
195
+ {}
196
+ end
197
+
182
198
  def build_action_folders(slug, meta, group_prefix)
183
199
  folders = []
184
200
  base = base_path(slug, group_prefix)
@@ -190,6 +206,10 @@ module Rhino
190
206
  folders << { name: "Update", item: build_update_requests(base) } unless except.include?("update")
191
207
  folders << { name: "Destroy", item: build_destroy_requests(base) } unless except.include?("destroy")
192
208
 
209
+ if Array(meta[:collection_computed_attributes]).any? && !except.include?("computed")
210
+ folders << { name: "Computed Attributes", item: build_computed_requests(base, meta) }
211
+ end
212
+
193
213
  if meta[:uses_soft_deletes]
194
214
  folders << { name: "Trashed", item: build_trashed_requests(base) } unless except.include?("trashed")
195
215
  folders << { name: "Restore", item: build_restore_requests(base) } unless except.include?("restore")
@@ -231,6 +251,11 @@ module Rhino
231
251
  { "fields[#{slug}]" => meta[:allowed_fields].first(5).join(",") }, headers)
232
252
  end
233
253
 
254
+ Array(meta[:record_computed_attributes]).each do |attribute|
255
+ requests << request_item("With computed attribute #{attribute}", "GET", base,
256
+ { computed_attributes: attribute }, headers)
257
+ end
258
+
234
259
  unless meta[:allowed_search].empty?
235
260
  requests << request_item("Search", "GET", base, { search: "example" }, headers)
236
261
  end
@@ -249,6 +274,11 @@ module Rhino
249
274
  requests << request_item("Show with include", "GET", path, { include: meta[:allowed_includes].first.to_s }, headers)
250
275
  end
251
276
 
277
+ Array(meta[:record_computed_attributes]).each do |attribute|
278
+ requests << request_item("Show with computed attribute #{attribute}", "GET", path,
279
+ { computed_attributes: attribute }, headers)
280
+ end
281
+
252
282
  requests
253
283
  end
254
284
 
@@ -268,6 +298,26 @@ module Rhino
268
298
  [request_item("Delete by ID", "DELETE", path, {}, default_headers)]
269
299
  end
270
300
 
301
+ # Requests for GET {resource}/computed — the collection-level aggregates.
302
+ def build_computed_requests(base, meta)
303
+ headers = default_headers
304
+ path = "#{base}/computed"
305
+ attributes = Array(meta[:collection_computed_attributes])
306
+
307
+ requests = [request_item("All computed attributes", "GET", path, {}, headers)]
308
+
309
+ attributes.each do |attribute|
310
+ requests << request_item("Computed: #{attribute}", "GET", path, { attributes: attribute }, headers)
311
+ end
312
+
313
+ if attributes.size > 1
314
+ requests << request_item("Computed: multiple attributes", "GET", path,
315
+ { attributes: attributes.join(",") }, headers)
316
+ end
317
+
318
+ requests
319
+ end
320
+
271
321
  def build_trashed_requests(base)
272
322
  [request_item("List trashed", "GET", "#{base}/trashed", {}, default_headers)]
273
323
  end
@@ -101,6 +101,9 @@ module Rhino
101
101
  self.rhino_middleware_actions_map = actions_hash.transform_keys(&:to_s)
102
102
  end
103
103
 
104
+ # CRUD actions to exclude from route registration.
105
+ # Valid values: :index, :show, :store, :update, :destroy, :trashed,
106
+ # :restore, :forceDelete, :computed.
104
107
  def rhino_except_actions(*actions)
105
108
  self.rhino_except_actions_list = actions.map(&:to_s)
106
109
  end
@@ -59,6 +59,31 @@ module Rhino
59
59
  def rhino_additional_hidden(*columns)
60
60
  self.additional_hidden_columns = columns.map(&:to_s)
61
61
  end
62
+
63
+ # Override this method to declare COLLECTION-level computed attributes,
64
+ # served by <tt>GET /api/{resource}/computed?attributes=a,b</tt>.
65
+ #
66
+ # Each callable receives the fully scoped relation (organization scope,
67
+ # default/global scopes, <tt>?scope=</tt>, <tt>?filter[]=</tt> and
68
+ # <tt>?search=</tt> already applied) plus the current user, and is
69
+ # evaluated ONCE for the whole collection — not once per row. This is the
70
+ # cheap way to expose aggregates such as counts.
71
+ #
72
+ # Declaring at least one attribute here is what registers the
73
+ # <tt>/computed</tt> route for the model.
74
+ #
75
+ # @example
76
+ # def self.rhino_collection_computed_attributes
77
+ # {
78
+ # 'active_users_count' => ->(scope, _user) { scope.where(status: 'active').count },
79
+ # 'blocked_users_count' => ->(scope, _user) { scope.where(status: 'blocked').count }
80
+ # }
81
+ # end
82
+ #
83
+ # @return [Hash{String => #call}]
84
+ def rhino_collection_computed_attributes
85
+ {}
86
+ end
62
87
  end
63
88
 
64
89
  # Get the list of columns to hide for the current user.
@@ -84,7 +109,7 @@ module Rhino
84
109
  # to add computed/virtual attributes to the JSON response.
85
110
  #
86
111
  # @return [Hash]
87
- def as_rhino_json
112
+ def as_rhino_json(computed_attributes: [])
88
113
  user = rhino_current_user
89
114
  hidden = hidden_columns_for(user)
90
115
  result = as_json(except: hidden)
@@ -93,6 +118,13 @@ module Rhino
93
118
  computed = rhino_computed_attributes
94
119
  result.merge!(computed) if computed.is_a?(Hash) && computed.any?
95
120
 
121
+ # Merge the OPT-IN record-level computed attributes the client selected via
122
+ # ?computed_attributes=. Nothing here is evaluated unless it was asked for
123
+ # by name, so declaring an expensive attribute costs nothing on requests
124
+ # that don't want it. Merged before policy filtering, so the blacklist and
125
+ # whitelist below still govern them.
126
+ result.merge!(rhino_resolve_record_computed_attributes(computed_attributes, user))
127
+
96
128
  # Apply blacklist to the final hash (covers DB columns from as_json
97
129
  # overrides AND computed attributes from rhino_computed_attributes)
98
130
  hidden_set = Set.new(hidden)
@@ -132,8 +164,62 @@ module Rhino
132
164
  {}
133
165
  end
134
166
 
167
+ # Override this method to declare OPT-IN record-level computed attributes.
168
+ #
169
+ # Unlike +rhino_computed_attributes+, nothing here is evaluated unless the
170
+ # client names it in <tt>?computed_attributes=a,b</tt> on index/show/trashed
171
+ # — so expensive per-row work is only paid for when it is actually wanted.
172
+ #
173
+ # Return a hash of attribute name => callable. The callable may accept
174
+ # zero, one (record) or two (record, user) arguments.
175
+ #
176
+ # @example
177
+ # def rhino_record_computed_attributes
178
+ # {
179
+ # 'open_tickets_count' => ->(record, _user) { record.tickets.where(closed_at: nil).count },
180
+ # 'full_name' => ->(record, _user) { "#{record.first_name} #{record.last_name}" }
181
+ # }
182
+ # end
183
+ #
184
+ # @return [Hash{String => #call}]
185
+ def rhino_record_computed_attributes
186
+ {}
187
+ end
188
+
135
189
  private
136
190
 
191
+ # Evaluate the selected opt-in record-level computed attributes.
192
+ #
193
+ # Names that are not declared are silently skipped — the controller has
194
+ # already rejected unknown/forbidden names with a 403, and a direct
195
+ # +as_rhino_json+ caller must not be able to force an arbitrary call.
196
+ def rhino_resolve_record_computed_attributes(names, user)
197
+ return {} if names.blank?
198
+
199
+ declared = rhino_record_computed_attributes
200
+ return {} unless declared.is_a?(Hash) && declared.any?
201
+
202
+ Array(names).each_with_object({}) do |name, memo|
203
+ key = name.to_s
204
+ next unless declared.key?(key)
205
+
206
+ memo[key] = rhino_call_computed(declared[key], self, user)
207
+ end
208
+ end
209
+
210
+ # Invoke a declared callable, tolerating lambdas of arity 0, 1 or 2.
211
+ # Ruby lambdas are strict about arity, so the arity is honoured rather than
212
+ # forcing every declaration to accept both arguments.
213
+ def rhino_call_computed(entry, record, user)
214
+ return entry unless entry.respond_to?(:call)
215
+
216
+ case entry.try(:arity)
217
+ when 0 then entry.call
218
+ when 1 then entry.call(record)
219
+ else entry.call(record, user)
220
+ end
221
+ end
222
+
137
223
  # Resolves the current user from RequestStore.
138
224
  # @return [Object, nil]
139
225
  def rhino_current_user
@@ -42,6 +42,9 @@ module Rhino
42
42
  def index
43
43
  authorize model_class, :index?, policy_class: policy_for(model_class)
44
44
 
45
+ computed = resolve_requested_computed_attributes
46
+ return if performed?
47
+
45
48
  builder = QueryBuilder.new(model_class, params: params, named_scopes: true)
46
49
  apply_organization_scope(builder)
47
50
  builder.build
@@ -52,9 +55,9 @@ module Rhino
52
55
  if per_page.present? || pagination_enabled
53
56
  result = builder.paginate
54
57
  set_pagination_headers(result[:pagination])
55
- render json: { data: serialize_collection(result[:items]) }
58
+ render json: { data: serialize_collection(result[:items], computed) }
56
59
  else
57
- render json: { data: serialize_collection(builder.to_scope) }
60
+ render json: { data: serialize_collection(builder.to_scope, computed) }
58
61
  end
59
62
  end
60
63
 
@@ -98,6 +101,9 @@ module Rhino
98
101
  record = find_record
99
102
  authorize record, :show?, policy_class: policy_for(record)
100
103
 
104
+ computed = resolve_requested_computed_attributes
105
+ return if performed?
106
+
101
107
  # Apply includes if requested
102
108
  if params[:include].present?
103
109
  auth_response = authorize_includes
@@ -111,7 +117,7 @@ module Rhino
111
117
  record = builder.to_scope.first!
112
118
  end
113
119
 
114
- render json: serialize_record(record)
120
+ render json: serialize_record(record, computed)
115
121
  end
116
122
 
117
123
  # PUT /api/{slug}/:id
@@ -175,6 +181,9 @@ module Rhino
175
181
  def trashed
176
182
  authorize model_class, :view_trashed?, policy_class: policy_for(model_class)
177
183
 
184
+ computed = resolve_requested_computed_attributes
185
+ return if performed?
186
+
178
187
  builder = QueryBuilder.new(model_class.discarded, params: params, named_scopes: true)
179
188
  apply_organization_scope(builder)
180
189
  builder.build
@@ -185,10 +194,47 @@ module Rhino
185
194
  if per_page.present? || pagination_enabled
186
195
  result = builder.paginate
187
196
  set_pagination_headers(result[:pagination])
188
- render json: { data: serialize_collection(result[:items]) }
197
+ render json: { data: serialize_collection(result[:items], computed) }
189
198
  else
190
- render json: { data: serialize_collection(builder.to_scope) }
199
+ render json: { data: serialize_collection(builder.to_scope, computed) }
200
+ end
201
+ end
202
+
203
+ # GET /api/{slug}/computed?attributes=a,b
204
+ #
205
+ # Collection-level computed attributes: each declared callable is evaluated
206
+ # ONCE over the whole (scoped + filtered) relation instead of once per row,
207
+ # which is what makes aggregates such as `active_users_count` cheap.
208
+ #
209
+ # The relation handed to each callable has the organization scope, the
210
+ # model's default scopes, `?scope=`, `?filter[]=` and `?search=` already
211
+ # applied — so the numbers describe exactly the set `index` would have
212
+ # listed. Sorting, sparse fieldsets, includes and pagination are
213
+ # deliberately NOT applied.
214
+ #
215
+ # Omitting `?attributes=` returns every declared attribute the policy allows.
216
+ def computed
217
+ authorize model_class, :index?, policy_class: policy_for(model_class)
218
+
219
+ declared = collection_computed_attributes
220
+ names = resolve_requested_collection_attributes(declared)
221
+ return if performed?
222
+
223
+ builder = QueryBuilder.new(model_class, params: params, named_scopes: true)
224
+ apply_organization_scope(builder)
225
+ builder.build_for_computed
226
+
227
+ scope = builder.to_scope
228
+ user = current_user
229
+
230
+ data = names.each_with_object({}) do |name, memo|
231
+ # Each attribute gets the base relation; ActiveRecord relations are
232
+ # immutable under chaining, so one callable's constraints can never
233
+ # leak into the next one's result.
234
+ memo[name] = call_computed_attribute(declared[name], scope, user)
191
235
  end
236
+
237
+ render json: { data: data }
192
238
  end
193
239
 
194
240
  # POST /api/{slug}/:id/restore
@@ -599,16 +645,130 @@ module Rhino
599
645
  # Serialization
600
646
  # ------------------------------------------------------------------
601
647
 
602
- def serialize_record(record)
648
+ # ------------------------------------------------------------------
649
+ # Computed attributes
650
+ # ------------------------------------------------------------------
651
+
652
+ # The model's declared collection-level computed attributes (name => callable).
653
+ def collection_computed_attributes
654
+ declared = model_class.try(:rhino_collection_computed_attributes)
655
+ declared.is_a?(Hash) ? declared.transform_keys(&:to_s) : {}
656
+ end
657
+
658
+ # Parse and authorize `?attributes=a,b` for the /computed endpoint.
659
+ #
660
+ # Renders a 403 and returns [] on a bad/denied name. An undeclared name and
661
+ # a policy-denied name produce the SAME error, so the endpoint never reveals
662
+ # which attributes a model declares.
663
+ def resolve_requested_collection_attributes(declared)
664
+ raw = params[:attributes]
665
+
666
+ # Reject non-scalar input (?attributes[]=x) before any lookup.
667
+ if raw.present? && !raw.is_a?(String)
668
+ render json: { message: "Computed attributes are not allowed" }, status: :forbidden
669
+ return []
670
+ end
671
+
672
+ user = current_user
673
+
674
+ if raw.blank?
675
+ # No selection: every declared attribute the policy allows.
676
+ return declared.keys.select { |name| computed_attribute_allowed?(name, user) }
677
+ end
678
+
679
+ names = parse_attribute_list(raw)
680
+
681
+ names.each do |name|
682
+ next if declared.key?(name) && computed_attribute_allowed?(name, user)
683
+
684
+ render json: { message: "Computed attribute '#{name}' is not allowed" }, status: :forbidden
685
+ return []
686
+ end
687
+
688
+ names
689
+ end
690
+
691
+ # Parse and authorize `?computed_attributes=a,b` for index/show/trashed —
692
+ # the OPT-IN record-level computed attributes. Absent or blank means "none",
693
+ # which is byte-for-byte the pre-feature behavior.
694
+ def resolve_requested_computed_attributes
695
+ raw = params[:computed_attributes]
696
+ return [] if raw.nil? || raw == ""
697
+
698
+ unless raw.is_a?(String)
699
+ render json: { message: "Computed attributes are not allowed" }, status: :forbidden
700
+ return []
701
+ end
702
+
703
+ names = parse_attribute_list(raw)
704
+ return [] if names.empty?
705
+
706
+ declared = model_class.new.try(:rhino_record_computed_attributes)
707
+ declared = declared.is_a?(Hash) ? declared.transform_keys(&:to_s) : {}
708
+
709
+ user = current_user
710
+
711
+ names.each do |name|
712
+ next if declared.key?(name) && computed_attribute_allowed?(name, user)
713
+
714
+ render json: { message: "Computed attribute '#{name}' is not allowed" }, status: :forbidden
715
+ return []
716
+ end
717
+
718
+ names
719
+ end
720
+
721
+ # Split a comma-separated attribute list, dropping blanks and duplicates.
722
+ def parse_attribute_list(raw)
723
+ raw.to_s.split(",").map(&:strip).reject(&:empty?).uniq
724
+ end
725
+
726
+ # Whether the policy lets this user see a computed attribute.
727
+ #
728
+ # Computed attributes go through the SAME gate as columns:
729
+ # +hidden_attributes_for_show+ blacklists, and +permitted_attributes_for_show+
730
+ # whitelists unless it returns the ['*'] default.
731
+ def computed_attribute_allowed?(name, user)
732
+ policy = policy_for(model_class).new(user, model_class)
733
+
734
+ if policy.respond_to?(:hidden_attributes_for_show)
735
+ hidden = Array(policy.hidden_attributes_for_show(user)).map(&:to_s)
736
+ return false if hidden.include?(name)
737
+ end
738
+
739
+ return true unless policy.respond_to?(:permitted_attributes_for_show)
740
+
741
+ permitted = policy.permitted_attributes_for_show(user)
742
+ return true if permitted.nil? || permitted == ["*"]
743
+
744
+ Array(permitted).map(&:to_s).include?(name)
745
+ rescue StandardError
746
+ # Policy resolution failed — serialization applies the same filters again
747
+ # downstream, so nothing can leak through this fallback.
748
+ true
749
+ end
750
+
751
+ # Invoke a declared callable, tolerating lambdas of arity 0, 1 or 2.
752
+ def call_computed_attribute(entry, scope, user)
753
+ return entry unless entry.respond_to?(:call)
754
+
755
+ case entry.try(:arity)
756
+ when 0 then entry.call
757
+ when 1 then entry.call(scope)
758
+ else entry.call(scope, user)
759
+ end
760
+ end
761
+
762
+ def serialize_record(record, computed_attributes = [])
603
763
  if record.respond_to?(:as_rhino_json)
604
- record.as_rhino_json
764
+ record.as_rhino_json(computed_attributes: computed_attributes)
605
765
  else
606
766
  record.as_json
607
767
  end
608
768
  end
609
769
 
610
- def serialize_collection(records)
611
- records.map { |r| serialize_record(r) }
770
+ def serialize_collection(records, computed_attributes = [])
771
+ records.map { |r| serialize_record(r, computed_attributes) }
612
772
  end
613
773
 
614
774
  # ------------------------------------------------------------------
@@ -33,6 +33,17 @@ module Rhino
33
33
  self
34
34
  end
35
35
 
36
+ # Apply only the modifications that define WHICH rows are in the set:
37
+ # named scope, filters and search. Sorting, sparse fieldsets, includes and
38
+ # pagination are irrelevant to an aggregate over the collection and are
39
+ # deliberately skipped — a `select` in particular would break `count`.
40
+ def build_for_computed
41
+ apply_named_scope if @named_scopes
42
+ apply_filters
43
+ apply_search
44
+ self
45
+ end
46
+
36
47
  # Get the final ActiveRecord relation.
37
48
  def to_scope
38
49
  @scope
data/lib/rhino/routes.rb CHANGED
@@ -187,6 +187,14 @@ module Rhino
187
187
 
188
188
  except_actions = model_class.try(:rhino_except_actions_list) || []
189
189
 
190
+ # The /computed endpoint exists only for models that actually
191
+ # declare collection-level computed attributes. Models that
192
+ # don't are byte-for-byte unchanged — in particular
193
+ # `/{slug}/computed` keeps resolving to show() for a record
194
+ # whose route key is literally "computed".
195
+ declared_computed = model_class.try(:rhino_collection_computed_attributes)
196
+ has_computed = declared_computed.is_a?(Hash) && declared_computed.any?
197
+
190
198
  route_prefix = [group_prefix, slug.to_s].reject(&:blank?).join("/")
191
199
 
192
200
  # Each model's CRUD routes live inside a path scope. When the
@@ -202,6 +210,11 @@ module Rhino
202
210
  post "/", to: "rhino/resources#store", as: "rhino_#{group_name}_#{slug}_store"
203
211
  end
204
212
 
213
+ # Registered BEFORE the :id routes so the literal segment wins.
214
+ if has_computed && !except_actions.include?("computed")
215
+ get "computed", to: "rhino/resources#computed", as: "rhino_#{group_name}_#{slug}_computed"
216
+ end
217
+
205
218
  if model_class.try(:uses_soft_deletes?)
206
219
  unless except_actions.include?("trashed")
207
220
  get "trashed", to: "rhino/resources#trashed", as: "rhino_#{group_name}_#{slug}_trashed"
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.6.1"
4
+ VERSION = "4.7.0"
5
5
  end
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.6.1
4
+ version: 4.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Cipolla