Skip to main content

Models

Rhino models are standard ActiveRecord models enhanced with declarative DSL methods and concerns that control how REST API endpoints are generated and behave. By configuring these methods directly on your model, Rhino automatically builds fully-featured API endpoints with filtering, sorting, searching, pagination, validation, and authorization — all without writing controllers or routes.

RhinoModel Base Class​

The recommended way to create Rhino models is to extend Rhino::RhinoModel — a convenience base class that pre-includes all the core concerns you need:

app/models/post.rb
class Post < Rhino::RhinoModel
rhino_filters :status, :user_id
rhino_sorts :created_at, :title
rhino_search :title, :content

validates :title, length: { maximum: 255 }, allow_nil: true
validates :status, inclusion: { in: %w[draft published] }, allow_nil: true

# Field permissions are controlled by the policy.
end

Rhino::RhinoModel extends ApplicationRecord and includes these concerns automatically:

ConcernPurpose
Rhino::HasRhinoQuery builder DSL (filters, sorts, includes, etc.)
Rhino::HasValidationRole-based field allowlisting and validation
Rhino::HidableColumnsDynamic column hiding from API responses
Rhino::HasAutoScopeAuto-discovery of Scopes::{Model}Scope classes (with Rhino::ResourceScope base)

You no longer need to manually include these concerns on every model.

Optional Concerns​

These concerns are not included in Rhino::RhinoModel because they require additional database columns, gems, or relationships. Add them manually when needed:

app/models/post.rb
class Post < Rhino::RhinoModel
include Rhino::HasAuditTrail
include Rhino::BelongsToOrganization
include Discard::Model # Soft deletes via discard gem
# ...
end
ConcernPurpose
Rhino::HasAuditTrailAutomatic change logging to audit_logs table
Rhino::HasUuidAuto-generated UUID on creation
Rhino::BelongsToOrganizationMulti-tenant organization scoping
Rhino::HasPermissionsPermission checking (User model only)
Discard::ModelSoft deletes via the Discard gem

Customizing RhinoModel​

You can publish and customize the base class for your application:

terminal
rails rhino:install --publish-model

This creates app/models/rhino_model.rb in your project, which extends the gem's base class. Add your own concerns or configuration that should apply to all Rhino models:

app/models/rhino_model.rb
class RhinoModel < Rhino::RhinoModel
include Rhino::HasAuditTrail # Now all models get audit trail
end
tip

You can still extend ApplicationRecord directly and include concerns manually if you prefer full control. Rhino::RhinoModel is a convenience, not a requirement.

Model Configuration DSL​

Below is a complete model example demonstrating all available DSL methods that Rhino recognizes:

app/models/post.rb
class Post < Rhino::RhinoModel
include Rhino::HasAuditTrail
include Rhino::BelongsToOrganization
include Discard::Model # Soft deletes via discard gem

# ── Query Builder ────────────────────────────────────────────
rhino_filters :status, :user_id, :category_id
rhino_sorts :created_at, :title, :updated_at
rhino_default_sort '-created_at'
rhino_fields :id, :title, :content, :status
rhino_includes :user, :comments, :tags
rhino_search :title, :content

# ── Named Scopes ─────────────────────────────────────────────
rhino_scopes :active, available_for_drivers: Scopes::AvailableForDriversScope
rhino_default_scope :active

# ── Pagination ───────────────────────────────────────────────
rhino_pagination_enabled true
rhino_per_page 25

# ── Middleware ────────────────────────────────────────────────
rhino_middleware 'throttle:60,1'
rhino_middleware_actions(
store: ['verified'],
destroy: ['admin']
)

# ── Route Exclusion ──────────────────────────────────────────
rhino_except_actions :destroy # skip DELETE endpoint

# ── Route Key ────────────────────────────────────────────────
rhino_route_key :hash_id # column matched by :id on member routes

# ── Relationships ────────────────────────────────────────────
belongs_to :user
belongs_to :blog
has_many :comments
has_many :tags
end

DSL Reference​

DSL MethodTypeDescription
rhino_filters*symbolsFields available for query-string filtering via ?filter[field]=value. Only the fields listed here can be filtered on.
rhino_sorts*symbolsFields available for sorting via ?sort=field. Prefix with - for descending order (e.g., ?sort=-created_at).
rhino_default_sortstringThe sort applied when no ?sort parameter is provided. Use the - prefix for descending (e.g., '-created_at').
rhino_fields*symbolsFields that can be selected via sparse fieldsets (?fields[model]=field1,field2). Limits which columns are returned.
rhino_includes*symbolsRelationships that can be eager-loaded via ?include=relation. Must correspond to defined ActiveRecord associations on the model.
rhino_search*symbols/stringsFields searched when ?search=term is used. Rhino performs a case-insensitive LIKE search across all listed fields. Supports dot-notation for relationships (e.g., 'user.name').
rhino_scopes*symbols, **hashNamed scopes selectable via ?scope=name. A bare symbol references a plain AR scope; a name: ScopeClass pair points at a Rhino::ResourceScope subclass. Wire names are camelCase (?scope=availableForDrivers). Non-whitelisted names return 403. See Querying — Named Scopes.
rhino_default_scopesymbolNamed scope applied automatically when no ?scope= is provided (e.g., :active). A listing convenience — not a security boundary; mandatory restrictions belong in a global scope. Requesting it by name is always allowed.
rhino_pagination_enabledboolEnables or disables pagination for the index endpoint. Defaults to false.
rhino_per_pageintegerNumber of records per page when pagination is enabled. Defaults to 25.
rhino_middleware*stringsMiddleware applied to all routes for this model.
rhino_middleware_actionshashMiddleware applied to specific actions only. Keys are action names (:index, :show, :store, :update, :destroy).
rhino_except_actions*symbolsList of CRUD actions to exclude from route generation. Valid values: :index, :show, :store, :update, :destroy.
rhino_route_keysymbolColumn matched against the :id URL segment on member routes (show, update, destroy, restore, force-delete). Falls back to the global config.route_key, then the primary key. See Route Key.
tip

You only need to declare DSL methods that differ from their defaults. For example, if you do not need filtering, simply omit rhino_filters entirely.

Route Key​

By default, the :id segment on member routes (show, update, destroy, restore, force-delete) is matched against the model's primary key. Set rhino_route_key to look the record up by a different column instead — for example, to serve records at hash URLs and keep incremental IDs out of your API:

app/models/job.rb
class Job < Rhino::RhinoModel
rhino_route_key :hash_id
end
GET /api/jobs/f3a9c1   ->  WHERE hash_id = 'f3a9c1'

The route parameter itself is still literally :id — only the lookup column changes.

Resolution order: rhino_route_key_column || Rhino.config.route_key || primary_key

  1. rhino_route_key on the model
  2. The global config.route_key in config/initializers/rhino.rb
  3. The model's primary key

A few behaviors to be aware of:

  • A configured column that does not exist on the table raises a clear ArgumentError on first use.
  • The route-key column and id are always kept in serialized output, regardless of policy whitelists — and sparse fieldsets (?fields[]) force-include the route key so responses stay routable.
  • In Blueprint specs, set options: { route_key: hash_id } to emit rhino_route_key in the generated model and use hash URLs in generated specs. The validator errors on unknown columns and warns when the column is not declared unique.
warning

The route-key column must be unique and should be indexed — every member request queries it. If values are not unique, an arbitrary matching record is returned.

db/migrate/create_jobs.rb
t.string :hash_id
add_index :jobs, :hash_id, unique: true
Scope of the route key

The route key only changes the URL lookup on the five member endpoints. Foreign keys in request payloads, nested-operation ids and $N.id references, FK validation, audit auditable_id, and multi-tenant organization resolution (organization_identifier_column) all remain primary-key based.

Known limitations
  • A record whose route-key value is literally trashed is unreachable — literal routes are registered before :id.
  • Hiding the real id column (via rhino_additional_hidden) is possible, but nested-operation results still emit the raw primary key.
  • Filtering by the route-key column requires declaring it in rhino_filters, as usual.

Available Concerns​

Rhino provides a collection of concerns that add specific behaviors to your models. When using Rhino::RhinoModel, the core concerns (HasRhino, HasValidation, HidableColumns, HasAutoScope) are already included. The concerns documented below can be added individually when needed.

HasRhino​

The core concern that provides all query-related DSL methods. It sets up class attributes for filters, sorts, includes, fields, search, pagination, middleware, and more.

Included in RhinoModel — no need to add manually.

app/models/post.rb
class Post < Rhino::RhinoModel
# HasRhino is already included
end

Also provides:

  • uses_soft_deletes? — Detects if the model has a discarded_at or deleted_at column

HasValidation​

Adds format validation to your model. Rhino calls validate_for_action() automatically during store and update actions.

Validation belongs in a request class

store and update are validated by {Model}StoreRequest / {Model}UpdateRequest in app/requests/, which see the user, the organization, the route group and the record being updated. The model-level validators below are deprecated and will be removed in 5.0; they are used only for a model and action with no request class. See Validation.

Format constraints are defined using standard ActiveModel validates declarations. Field permissions (which fields each role can set) are defined on the policy.

app/models/post.rb
class Post < Rhino::RhinoModel
validates :title, length: { maximum: 255 }, allow_nil: true
validates :status, inclusion: { in: %w[draft published archived] }, allow_nil: true
end
info

For a complete breakdown of validation behavior, see the Validation page.


HasPermissions​

Adds role-based permission checking to the User model. Rhino uses this concern to authorize API actions automatically when policies are in place.

Methods:

MethodDescription
has_permission?(permission, organization = nil)Returns true if the user has the given permission within the specified organization.
role_slug_for_validation(organization = nil)Returns the user's role slug within an organization, used for role-based validation rules.

Permission format: {resource_slug}.{action}

Permissions follow the pattern of the resource slug (the key in your Rhino.configure models block) combined with the CRUD action:

  • posts.index — can list posts
  • posts.store — can create posts
  • blogs.update — can update blogs
  • posts.destroy — can delete posts

Wildcard support:

  • * — grants access to everything across all resources
  • posts.* — grants access to all actions on posts
app/models/user.rb
class User < Rhino::RhinoModel
include Rhino::HasPermissions

has_many :user_roles
end

# Check if a user can create posts within an organization
if user.has_permission?('posts.store', organization)
# User can create posts
end

# Check for full access
if user.has_permission?('*', organization)
# User has unrestricted access to everything
end

# Check for all post actions
if user.has_permission?('posts.*', organization)
# User can index, show, store, update, and destroy posts
end

HasAuditTrail​

Automatically records changes to your model in an audit log. Rhino tracks creation, updates, deletion, force-deletion, and restoration events and stores the old and new values for each change.

Tracked events: created, updated, deleted, force_deleted, restored

DSL Methods:

MethodTypeDefaultDescription
rhino_audit_exclude*symbols/strings['password', 'remember_token']Fields excluded from audit log entries. Use this to prevent sensitive data from being recorded.
app/models/user.rb
class User < Rhino::RhinoModel
include Rhino::HasAuditTrail

# Exclude sensitive fields from audit logs
rhino_audit_exclude :password, :remember_token, :api_token
end

# Query the audit trail for any model instance
logs = post.audit_logs.order(created_at: :desc)

# Each log entry contains:
# - action (created, updated, deleted, etc.)
# - old_values (previous state)
# - new_values (current state)
# - user_id (who made the change)
# - timestamps

The audit_logs method is a polymorphic association, so it works identically on any model that uses the concern.

info

For full details on querying and managing audit logs, see the Audit Trail page.


HasUuid​

Automatically generates a UUID for the model when it is created. The concern hooks into ActiveRecord's before_create callback and fills the uuid column if it is empty.

app/models/invoice.rb
class Invoice < Rhino::RhinoModel
include Rhino::HasUuid

# No additional configuration needed.
# A UUID is generated and assigned to the `uuid` column on creation.
end
warning

Your database table must have a uuid column. Add it in your migration:

db/migrate/create_invoices.rb
t.uuid :uuid, null: true
add_index :invoices, :uuid, unique: true
tip

HasUuid generates the external-facing identifier; a route key is what makes it routable. Combine the two (rhino_route_key :uuid) to serve records at /api/invoices/{uuid} instead of exposing incremental IDs.


BelongsToOrganization​

Provides multi-tenant organization scoping. This concern automatically filters all queries to the current organization and sets the organization_id when creating new records.

Adds:

MemberTypeDescription
organizationBelongsTo associationLinks the model to its owning organization.
for_organization(org)Class methodReturns an unscoped query filtered to a specific organization.
Default scopeAutomaticAll queries are automatically filtered by organization_id.
Auto-set on createAutomaticorganization_id is filled from the current request context on creation.
app/models/post.rb
class Post < Rhino::RhinoModel
include Rhino::BelongsToOrganization

# All queries are now scoped to the current organization automatically.
# GET /api/acme-corp/posts -> only returns posts where organization_id matches acme-corp
end

Nested ownership:

Not every model has a direct organization_id column. For nested models, Rhino auto-detects the path to the organization by walking belongs_to relationships.

app/models/comment.rb
class Comment < Rhino::RhinoModel
include Rhino::BelongsToOrganization

# Comment → post → blog → organization is auto-detected
belongs_to :post
end

In this example, Rhino traverses Comment -> post -> blog to find the organization. The chain can be as deep as needed.

info

For a full explanation of multi-tenancy and organization scoping, see the Multi-Tenancy page.


HidableColumns​

Controls which columns are hidden from API responses. This concern provides multiple layers of column visibility control: base defaults, model-level configuration, and policy-based per-user hiding.

Layers of hidden columns (applied in order):

  1. Base hidden columns (always hidden): password, password_digest, remember_token, created_at, updated_at, deleted_at, discarded_at, email_verified_at
  2. Model-level hidden columns via rhino_additional_hidden: additional fields to always hide for this model
  3. Policy-level visibility via hidden_attributes_for_show() / permitted_attributes_for_show() methods on the policy: per-user dynamic hiding
app/models/user.rb
class User < Rhino::RhinoModel
# Always hide these columns from API responses (in addition to base defaults)
rhino_additional_hidden :api_token, :stripe_id
end
tip

Hidden columns are resolved per request based on the current user. The policy's hidden_attributes_for_show and permitted_attributes_for_show methods let you return different lists for different users.

info

For policy-based column hiding (showing different fields to different users), see the Policies page.

Computed Attributes with rhino_computed_attributes​

Override rhino_computed_attributes in your model to add virtual (computed) attributes to API responses. These attributes are not database columns — they are calculated at runtime and merged into the serialized output.

app/models/contract.rb
class Contract < Rhino::RhinoModel
def days_until_expiry
return nil unless expiry_date
(expiry_date - Date.current).to_i
end

def risk_score
calculate_risk
end

def rhino_computed_attributes
{
'days_until_expiry' => days_until_expiry,
'risk_score' => risk_score
}
end
end

The returned hash is merged into the JSON response before policy filtering is applied. This means computed attributes are always subject to policy-level blacklist and whitelist — just like database columns. The controller calls as_rhino_json automatically when rendering responses.

warning

Do not override as_rhino_json directly. Use rhino_computed_attributes instead. Overriding as_rhino_json with super.merge(...) would add attributes after policy filtering, bypassing hidden_attributes_for_show and permitted_attributes_for_show — a security risk.

Computed attributes can be controlled per-role via policy:

app/policies/contract_policy.rb
class ContractPolicy < Rhino::ResourcePolicy
def hidden_attributes_for_show(user)
return [] if has_role?(user, 'admin')
['risk_score'] # Only admins see the risk score
end
end

You can also use permitted_attributes_for_show to whitelist which attributes (including computed ones) each role can see.

Expensive values and aggregates have their own hooks

rhino_computed_attributes runs on every row of every read. For per-row values that cost a query, and for collection-wide aggregates such as active_users_count, use the opt-in and collection-level hooks instead — see Computed Attributes. Never write a custom controller just to return a count.


HasAutoScope​

Automatically applies a global scope to the model based on a naming convention. When this concern is used, Rhino looks for a scope class at Scopes::{ModelName}Scope (or ModelScopes::{ModelName}Scope as fallback) and applies it if found. No manual registration is needed.

app/models/post.rb
class Post < Rhino::RhinoModel
# HasAutoScope is already included — automatically loads Scopes::PostScope (if it exists)
end

Extend Rhino::ResourceScope to get access to the current user, organization, and role inside your scope. This enables role-based or user-specific query filtering:

app/models/scopes/post_scope.rb
module Scopes
class PostScope < Rhino::ResourceScope
def apply(relation)
if role == "viewer"
relation.where(published: true)
else
relation
end
end
end
end

Available methods inside apply:

  • user — the current authenticated user (or nil)
  • organization — the current organization (or nil)
  • role — shortcut for the user's role slug in the current org (or nil)

Legacy Class-Method Scopes​

You can also use a plain class with self.apply as a class method. This approach doesn't provide access to user/org context:

app/models/scopes/post_scope.rb
module Scopes
class PostScope
def self.apply(scope)
scope.where(is_visible: true)
end
end
end

With either approach, every query for Post automatically includes the scope filter. This is useful for soft-visibility flags, status filtering, role-based access, or any default constraint you want applied globally.

tip

The scope is only applied if the class exists. You can safely add the HasAutoScope concern to any model without creating the scope class until you need it.


Complete Model Example​

Below is a full real-world model that combines multiple Rhino concerns into a feature-rich API resource:

app/models/blog_post.rb
class BlogPost < Rhino::RhinoModel
include Rhino::HasAuditTrail
include Rhino::HasUuid
include Rhino::BelongsToOrganization
include Discard::Model # Soft deletes via discard gem

# ── Validation ───────────────────────────────────────────────
validates :title, length: { maximum: 255 }, allow_nil: true
validates :slug, length: { maximum: 255 }, allow_nil: true
validates :content, length: { maximum: 50_000 }, allow_nil: true
validates :excerpt, length: { maximum: 500 }, allow_nil: true
validates :status, inclusion: { in: %w[draft published archived] }, allow_nil: true

# Field permissions are controlled by the policy.

# ── Audit Trail ──────────────────────────────────────────────
# No extra exclusions beyond the defaults (password, remember_token)

# ── Query Configuration ──────────────────────────────────────
rhino_filters :status, :user_id, :category_id
rhino_sorts :created_at, :title, :published_at
rhino_default_sort '-published_at'
rhino_includes :user, :category, :comments, :tags
rhino_search :title, :content, :excerpt
rhino_fields :id, :title, :slug, :excerpt, :status, :published_at

# ── Pagination ───────────────────────────────────────────────
rhino_pagination_enabled true
rhino_per_page 20

# ── Relationships ────────────────────────────────────────────
belongs_to :user
belongs_to :category
has_many :comments
has_and_belongs_to_many :tags
end

This single model definition gives you:

  • REST endpoints for listing, showing, creating, updating, and soft-deleting blog posts
  • Filtering by status, user, and category (?filter[status]=published)
  • Sorting by creation date, title, or publish date (?sort=-published_at)
  • Full-text search across title, content, and excerpt (?search=rails)
  • Eager loading of user, category, comments, and tags (?include=user,comments)
  • Sparse fieldsets to reduce payload size (?fields[blog_posts]=id,title,excerpt)
  • Pagination at 20 records per page
  • Format validation with ActiveModel validators
  • Audit logging of every change with before/after values
  • UUID generation for external-facing identifiers
  • Organization scoping for multi-tenant data isolation
  • Column hiding to keep sensitive fields out of API responses

Registration​

Models are registered in config/initializers/rhino.rb. The key becomes the URL slug and the permission prefix:

config/initializers/rhino.rb
Rhino.configure do |c|
c.model :'blog-posts', 'BlogPost'
c.model :comments, 'Comment'
c.model :categories, 'Category'
c.model :tags, 'Tag'
end

With this configuration, Rhino generates routes such as:

GET    /api/{organization}/blog-posts
GET /api/{organization}/blog-posts/{id}
POST /api/{organization}/blog-posts
PUT /api/{organization}/blog-posts/{id}
DELETE /api/{organization}/blog-posts/{id}
warning

The model key (e.g., blog-posts) is used as the permission prefix. Make sure it matches what you use in your role permission definitions (e.g., blog-posts.store, blog-posts.index).