Class: Rhino::ResourceRequest

Inherits:
Object
  • Object
show all
Includes:
ActiveModel::Attributes, ActiveModel::Model, ActiveModel::Validations
Defined in:
lib/rhino/resource_request.rb

Overview

Base class for per-model, per-action request classes.

A request class owns the entire shape/format contract for ONE action of ONE model. Unlike model-level validations it receives the full request context — the authenticated user, the resolved organization, the matched route group, the action, and (on update) the pre-update record — so a rule can branch on any of them.

Discovery is by convention: {Model}StoreRequest / {Model}UpdateRequest, autoloaded from app/requests/ (Zeitwerk autoloads every app/* directory, so no initializer or eager_load_paths entry is required). An explicit registration overrides the convention:

Rhino.configure do |config|
config.model :tasks, "Task", store_request: "CreateTask", update_request: "EditTask"
end

Usage:

# app/requests/task_store_request.rb
class TaskStoreRequest < Rhino::ResourceRequest
attribute :title, :string
attribute :status, :string
attribute :project_id, :integer

validates :title, presence: true, length: { maximum: 255 }
validates :status, inclusion: { in: %w[todo doing] }, unless: -> { user&.admin? }

def authorize?
  route_group != "public"
end

def prepare(input)
  input.merge("title" => input["title"].to_s.strip)
end
end

Rules are ordinary ActiveModel declarations. There is no rules method and no messages method: dynamic rules use validate :method_name or validates ..., if: -> { ... } with the context readers in scope, and messages use the standard message: option / i18n.

THE WRITE PAYLOAD IS validated. Only DECLARED attributes that are PRESENT in the prepared input are persisted, with their cast values. A field with no attribute declaration is silently dropped — including a field added by prepare that no attribute covers. This fails closed: when a policy permits ['*'], the request class is the only field filter left.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(input:, user: nil, organization: nil, route_group: nil, action: "store", record: nil) ⇒ ResourceRequest

Returns a new instance of ResourceRequest.

Parameters:

  • input (Hash) —

    the raw request data, AFTER the policy forbidden-field gate has run (so prepare can never launder a field past the policy)

  • user (Object, nil) (defaults to: nil)
  • organization (Object, nil) (defaults to: nil)
  • route_group (String, nil) (defaults to: nil)
  • action (String) (defaults to: "store") —

    "store" or "update"

  • record (Object, nil) (defaults to: nil)


82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/rhino/resource_request.rb', line 82

def initialize(input:, user: nil, organization: nil, route_group: nil, action: "store", record: nil)
  @user = user
  @organization = organization
  @route_group = route_group.nil? ? nil : route_group.to_s
  @action = action.to_s
  @record = record

  # ActiveModel::Attributes defaults must exist before anything reads or
  # writes an attribute — including a `prepare` override that touches one.
  super()

  raw = self.class.normalize_input_keys(input)
  prepared = prepare(raw.deep_dup)
  # A `prepare` that returns a non-Hash (or nil) is treated as "no change".
  @input = prepared.is_a?(Hash) ? self.class.normalize_input_keys(prepared) : raw

  assign_declared_attributes
end

Instance Attribute Details

#action ⇒ String (readonly)

Returns "store" or "update".

Returns:

  • (String) —

    "store" or "update"



69
70
71
# File 'lib/rhino/resource_request.rb', line 69

def action
  @action
end

#input ⇒ Hash (readonly)

Returns the prepared input, string-keyed.

Returns:

  • (Hash) —

    the prepared input, string-keyed



73
74
75
# File 'lib/rhino/resource_request.rb', line 73

def input
  @input
end

#organization ⇒ Object? (readonly)

Returns the resolved organization, nil outside a tenant context.

Returns:

  • (Object, nil) —

    the resolved organization, nil outside a tenant context



65
66
67
# File 'lib/rhino/resource_request.rb', line 65

def organization
  @organization
end

#record ⇒ Object? (readonly)

Returns the PRE-UPDATE record on update, nil on store.

Returns:

  • (Object, nil) —

    the PRE-UPDATE record on update, nil on store



71
72
73
# File 'lib/rhino/resource_request.rb', line 71

def record
  @record
end

#route_group ⇒ String? (readonly)

Returns the matched route's route group ("tenant", "public", ...).

Returns:

  • (String, nil) —

    the matched route's route group ("tenant", "public", ...)



67
68
69
# File 'lib/rhino/resource_request.rb', line 67

def route_group
  @route_group
end

#user ⇒ Object? (readonly)

The request context. All six values are available to authorize?, prepare and every validation.

Returns:

  • (Object, nil) —

    the authenticated user, nil when unauthenticated



63
64
65
# File 'lib/rhino/resource_request.rb', line 63

def user
  @user
end

Class Method Details

.normalize_input_keys(hash) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Stringify top-level keys so input["title"] works regardless of whether the caller handed us a Hash, a HashWithIndifferentAccess or symbol keys.



165
166
167
168
169
# File 'lib/rhino/resource_request.rb', line 165

def self.normalize_input_keys(hash)
  return {} unless hash.is_a?(Hash)

  hash.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
end

Instance Method Details

#authorize? ⇒ Boolean

Override point: return false to refuse the request with a 403 whose body is byte-identical to a policy denial, so authorize? cannot be used to enumerate anything about the model.

Returns:

  • (Boolean)


106
107
108
# File 'lib/rhino/resource_request.rb', line 106

def authorize?
  true
end

#error_messages ⇒ Hash<String, Array<String>>

Errors in the shape Rhino renders at 422:

{ "title" => ["can't be blank"], ... }

Built exactly the way HasValidation#validate_for_action builds it, but WITHOUT its "only report errors on fields the client sent" guard — on a request class an error on an absent-but-required field is the point.

Returns:

  • (Hash<String, Array<String>>)


151
152
153
154
155
156
157
158
159
# File 'lib/rhino/resource_request.rb', line 151

def error_messages
  messages = {}
  errors.each do |error|
    field_name = error.attribute.to_s
    messages[field_name] ||= []
    messages[field_name] << error.message
  end
  messages
end

#prepare(input) ⇒ Hash

Override point: normalize the input before validation. Runs BEFORE authorize?, so authorize? sees normalized input.

Fields added here are SERVER-AUTHORED and are not re-checked against the policy's permitted attributes — the forbidden-field gate already ran on exactly what the client sent. Never copy a client value into a different key here; that writes a field the policy denied.

A return value that is not a Hash is ignored.

Parameters:

  • input (Hash) —

    string-keyed copy of the raw input

Returns:

  • (Hash)


122
123
124
# File 'lib/rhino/resource_request.rb', line 122

def prepare(input)
  input
end

#run ⇒ Hash

Run the validations.

Returns:

  • (Hash) —

    { valid: Boolean, errors: Hash<String, Array>, validated: Hash }



129
130
131
132
133
# File 'lib/rhino/resource_request.rb', line 129

def run
  ok = valid?

  { valid: ok, errors: error_messages, validated: validated }
end

#validated ⇒ Hash<String, Object>

The write payload: declared attribute names that are present in the prepared input, mapped to their CAST values.

Returns:

  • (Hash<String, Object>)


139
140
141
# File 'lib/rhino/resource_request.rb', line 139

def validated
  attributes.select { |name, _| @input.key?(name) }
end