Class: Rhino::ResourceRequest
- Inherits:
-
Object
- Object
- Rhino::ResourceRequest
- 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
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
-
#action ⇒ String
readonly
"store" or "update".
-
#input ⇒ Hash
readonly
The prepared input, string-keyed.
-
#organization ⇒ Object?
readonly
The resolved organization, nil outside a tenant context.
-
#record ⇒ Object?
readonly
The PRE-UPDATE record on update, nil on store.
-
#route_group ⇒ String?
readonly
The matched route's route group ("tenant", "public", ...).
-
#user ⇒ Object?
readonly
The request context.
Class Method Summary collapse
-
.normalize_input_keys(hash) ⇒ Object
private
Stringify top-level keys so
input["title"]works regardless of whether the caller handed us a Hash, a HashWithIndifferentAccess or symbol keys.
Instance Method Summary collapse
-
#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. -
#error_messages ⇒ Hash<String, Array<String>>
Errors in the shape Rhino renders at 422: { "title" => ["can't be blank"], ... }.
-
#initialize(input:, user: nil, organization: nil, route_group: nil, action: "store", record: nil) ⇒ ResourceRequest
constructor
A new instance of ResourceRequest.
-
#prepare(input) ⇒ Hash
Override point: normalize the input before validation.
-
#run ⇒ Hash
Run the validations.
-
#validated ⇒ Hash<String, Object>
The write payload: declared attribute names that are present in the prepared input, mapped to their CAST values.
Constructor Details
#initialize(input:, user: nil, organization: nil, route_group: nil, action: "store", record: nil) ⇒ ResourceRequest
Returns a new instance of ResourceRequest.
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".
69 70 71 |
# File 'lib/rhino/resource_request.rb', line 69 def action @action end |
#input ⇒ Hash (readonly)
Returns 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.
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.
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", ...).
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.
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.
106 107 108 |
# File 'lib/rhino/resource_request.rb', line 106 def 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.
151 152 153 154 155 156 157 158 159 |
# File 'lib/rhino/resource_request.rb', line 151 def = {} errors.each do |error| field_name = error.attribute.to_s [field_name] ||= [] [field_name] << error. end 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.
122 123 124 |
# File 'lib/rhino/resource_request.rb', line 122 def prepare(input) input end |
#run ⇒ Hash
Run the validations.
129 130 131 132 133 |
# File 'lib/rhino/resource_request.rb', line 129 def run ok = valid? { valid: ok, errors: , 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.
139 140 141 |
# File 'lib/rhino/resource_request.rb', line 139 def validated attributes.select { |name, _| @input.key?(name) } end |