Class: Rhino::ResourcesController

Inherits:
ActionController::API
  • Object
show all
Includes:
Pundit::Authorization
Defined in:
lib/rhino/controllers/resources_controller.rb

Overview

Global CRUD controller that handles all registered models. Mirrors the Laravel GlobalController exactly.

Routes pass the model slug via route defaults, and this controller resolves the appropriate ActiveRecord class to operate on.

Constant Summary collapse

@@organization_path_cache =

Cache for auto-detected organization paths (class-level, survives across requests)

{}

Instance Method Summary collapse

Instance Method Details

#computed ⇒ Object

GET /api/slug/computed?attributes=a,b

Collection-level computed attributes: each declared callable is evaluated ONCE over the whole (scoped + filtered) relation instead of once per row, which is what makes aggregates such as active_users_count cheap.

The relation handed to each callable has the organization scope, the model's default scopes, ?scope=, ?filter[]= and ?search= already applied — so the numbers describe exactly the set index would have listed. Sorting, sparse fieldsets, includes and pagination are deliberately NOT applied.

Omitting ?attributes= returns every declared attribute the policy allows, minus any that declares a required parameter — those are skipped silently so adding a parameterised attribute never breaks a bare /computed call.

Attributes that declare parameters take them in the bracket form:

?attributes[revenue][from]=2026-01-01&attributes[revenue][to]=2026-02-01


288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/rhino/controllers/resources_controller.rb', line 288

def computed
  authorize model_class, :index?, policy_class: policy_for(model_class)

  specs = Rhino::ComputedAttributeSpec.normalize(collection_computed_attributes)
  names, arguments = resolve_requested_collection_attributes(collection_computed_attributes)
  return if performed?

  builder = QueryBuilder.new(model_class, params: params, named_scopes: true)
  apply_organization_scope(builder)
  builder.build_for_computed

  scope = builder.to_scope
  user = current_user

  data = names.each_with_object({}) do |name, memo|
    # Each attribute gets the base relation; ActiveRecord relations are
    # immutable under chaining, so one callable's constraints can never
    # leak into the next one's result. The relation is already
    # organization-scoped, filtered and searched, and no argument can
    # widen it.
    spec = specs[name] || { params: [], optional: [], target: nil }
    memo[name] = call_computed_attribute(spec, scope, user, arguments[name] || [])
  end

  render json: { data: data }
end

#destroy ⇒ Object

DELETE /api/slug/:id



229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/rhino/controllers/resources_controller.rb', line 229

def destroy
  record = find_record
  authorize record, :destroy?, policy_class: policy_for(record)

  if record.respond_to?(:discard!)
    record.discard!
  else
    record.destroy!
  end

  head :no_content
end

#force_delete ⇒ Object

DELETE /api/slug/:id/force-delete



327
328
329
330
331
332
333
334
# File 'lib/rhino/controllers/resources_controller.rb', line 327

def force_delete
  record = find_by_route_key(organization_scoped(model_class.discarded))
  authorize record, :force_delete?, policy_class: policy_for(record)

  record.destroy!

  head :no_content
end

#index ⇒ Object

GET /api/slug



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/rhino/controllers/resources_controller.rb', line 57

def index
  authorize model_class, :index?, policy_class: policy_for(model_class)

  computed, computed_args = resolve_requested_computed_attributes
  return if performed?

  builder = QueryBuilder.new(model_class, params: params, named_scopes: true)
  apply_organization_scope(builder)
  builder.build

  per_page = params[:per_page]
  pagination_enabled = model_class.try(:pagination_enabled) || false

  if per_page.present? || pagination_enabled
    result = builder.paginate
    set_pagination_headers(result[:pagination])
    render json: { data: serialize_collection(result[:items], computed, computed_args) }
  else
    render json: { data: serialize_collection(builder.to_scope, computed, computed_args) }
  end
end

#nested ⇒ Object

POST /api/nested



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
# File 'lib/rhino/controllers/resources_controller.rb', line 341

def nested
  operations = validate_nested_structure
  return if performed?

  nested_config = Rhino.config.nested
  max_ops = nested_config[:max_operations]

  if max_ops && operations.length > max_ops
    return render json: {
      message: "Too many operations.",
      errors: { operations: ["Maximum #{max_ops} operations allowed."] }
    }, status: :unprocessable_entity
  end

  allowed_models = nested_config[:allowed_models]
  if allowed_models.is_a?(Array)
    operations.each_with_index do |op, index|
      unless allowed_models.include?(op["model"])
        return render json: {
          message: "Operation not allowed.",
          errors: { "operations.#{index}.model" => ["Model \"#{op['model']}\" is not allowed for nested operations."] }
        }, status: :unprocessable_entity
      end
    end
  end

  # Validate and authorize each operation
  validated_per_op = []
  auth_results = []

  operations.each_with_index do |operation, index|
    validated = validate_nested_operation(operation, index)
    return if performed?
    validated_per_op << validated

    auth_result = authorize_nested_operation(operation, validated, index)
    return if performed?
    auth_results << auth_result
  end

  # Execute all operations in a transaction
  results = execute_nested_operations(operations, validated_per_op, auth_results)
  # execute_nested_operations renders (and rolls back) when a model-level
  # rule fails at save time on the request-class path.
  return if performed?

  render json: { results: results }
end

#restore ⇒ Object

POST /api/slug/:id/restore



316
317
318
319
320
321
322
323
324
# File 'lib/rhino/controllers/resources_controller.rb', line 316

def restore
  record = find_by_route_key(organization_scoped(model_class.discarded))
  authorize record, :restore?, policy_class: policy_for(record)

  record.undiscard!
  record.reload

  render json: serialize_record(record)
end

#show ⇒ Object

GET /api/slug/:id



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/rhino/controllers/resources_controller.rb', line 143

def show
  record = find_record
  authorize record, :show?, policy_class: policy_for(record)

  computed, computed_args = resolve_requested_computed_attributes
  return if performed?

  # Apply includes if requested
  if params[:include].present?
    auth_response = authorize_includes
    return auth_response if auth_response

    builder = QueryBuilder.new(model_class, params: params)
    # record is already resolved via the route key; re-query by primary key
    builder.instance_variable_set(:@scope, model_class.where(model_class.primary_key => record.id))
    apply_organization_scope(builder)
    builder.build
    record = builder.to_scope.first!
  end

  render json: serialize_record(record, computed, computed_args)
end

#store ⇒ Object

POST /api/slug



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/rhino/controllers/resources_controller.rb', line 80

def store
  authorize model_class, :create?, policy_class: policy_for(model_class)

  data = params_hash

  # Strip organization_id — it's auto-set by the framework
  data.delete("organization_id") if current_organization

  permitted_fields = resolve_permitted_fields(current_user, "create")

  # Check for forbidden fields → 403
  forbidden = find_forbidden_fields(data, permitted_fields)
  if forbidden.any?
    return render json: {
      message: "You are not allowed to set the following field(s): #{forbidden.join(', ')}"
    }, status: :forbidden
  end

  # Request class (Rhino::ResourceRequest) for this model + action, if any.
  # Resolved AFTER the forbidden-field gate so `prepare` can never launder a
  # field past the policy, and BEFORE the legacy model rules, which are not
  # consulted at all when a request class is present for this action.
  if (request_class = request_class_for("store"))
    status, payload = run_resource_request(request_class, data, "store", nil, model_class)
    return render_request_class_forbidden if status == :forbidden
    return render json: { errors: payload }, status: :unprocessable_entity if status == :invalid

    add_organization_to_data(payload)

    begin
      created = model_class.create!(payload)
    rescue ActiveRecord::RecordInvalid => e
      # A model-level `validates` rule the request class did not reproduce
      # still runs inside create!. Render it in the same envelope the
      # request class's own failures use instead of letting it escape as a
      # 500. The legacy path cannot reach here — it ran those very rules up
      # front — so this rescue is confined to the request-class branch.
      return render json: { errors: record_validation_errors(e.record) },
                    status: :unprocessable_entity
    end

    return render json: serialize_record(created), status: :created
  end

  # @deprecated Legacy model-level validation path. Byte-for-byte unchanged;
  #   reached only when no request class exists for this model + action.
  model_instance = model_class.new
  validation = model_instance.validate_for_action(
    data, permitted_fields: permitted_fields, organization: current_organization
  )

  unless validation[:valid]
    return render json: { errors: validation[:errors] }, status: :unprocessable_entity
  end

  validated = validation[:validated]
  add_organization_to_data(validated)

  record = model_class.create!(validated)
  render json: serialize_record(record), status: :created
end

#trashed ⇒ Object

GET /api/slug/trashed



247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/rhino/controllers/resources_controller.rb', line 247

def trashed
  authorize model_class, :view_trashed?, policy_class: policy_for(model_class)

  computed, computed_args = resolve_requested_computed_attributes
  return if performed?

  builder = QueryBuilder.new(model_class.discarded, params: params, named_scopes: true)
  apply_organization_scope(builder)
  builder.build

  per_page = params[:per_page]
  pagination_enabled = model_class.try(:pagination_enabled) || false

  if per_page.present? || pagination_enabled
    result = builder.paginate
    set_pagination_headers(result[:pagination])
    render json: { data: serialize_collection(result[:items], computed, computed_args) }
  else
    render json: { data: serialize_collection(builder.to_scope, computed, computed_args) }
  end
end

#update ⇒ Object

PUT /api/slug/:id



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/rhino/controllers/resources_controller.rb', line 167

def update
  record = find_record
  authorize record, :update?, policy_class: policy_for(record)

  data = params_hash

  # Reject organization_id changes — cross-tenant reassignment is not allowed
  if current_organization && data.key?("organization_id")
    return render json: {
      message: "You are not allowed to change the organization_id."
    }, status: :forbidden
  end

  permitted_fields = resolve_permitted_fields(current_user, "update")

  # Check for forbidden fields → 403
  forbidden = find_forbidden_fields(data, permitted_fields)
  if forbidden.any?
    return render json: {
      message: "You are not allowed to set the following field(s): #{forbidden.join(', ')}"
    }, status: :forbidden
  end

  # Request class for this model + action, if any. `record` is the already
  # loaded, ORGANIZATION-SCOPED row (never re-fetched by bare id), so a
  # record-dependent rule can never see another tenant's state.
  if (request_class = request_class_for("update"))
    status, payload = run_resource_request(request_class, data, "update", record, model_class)
    return render_request_class_forbidden if status == :forbidden
    return render json: { errors: payload }, status: :unprocessable_entity if status == :invalid

    begin
      record.update!(payload)
    rescue ActiveRecord::RecordInvalid => e
      # See the note in `store`: a model rule stricter than the request
      # class must surface as the standard 422, not a 500.
      return render json: { errors: record_validation_errors(e.record) },
                    status: :unprocessable_entity
    end

    record.reload
    return render json: serialize_record(record)
  end

  # @deprecated Legacy model-level validation path. Byte-for-byte unchanged;
  #   reached only when no request class exists for this model + action.
  model_instance = model_class.new
  validation = model_instance.validate_for_action(
    data, permitted_fields: permitted_fields, organization: current_organization
  )

  unless validation[:valid]
    return render json: { errors: validation[:errors] }, status: :unprocessable_entity
  end

  record.update!(validation[:validated])
  record.reload

  render json: serialize_record(record)
end