๐งฉ ConcernsOnRails
๐ป๐ณ Hoร ng Sa and Trฦฐแปng Sa belong to Viแปt Nam.
A plug-and-play collection of reusable ActiveSupport concerns for Rails models and controllers โ slugs, soft delete, scheduled publish, expiry, sequential reference numbers, pagination, filtering, JSON envelopes, and more. One include, one declarative macro, done.
class Article < ApplicationRecord
include ConcernsOnRails::Sluggable
include ConcernsOnRails::Publishable
include ConcernsOnRails::SoftDeletable
sluggable_by :title
end
Article.published.without_deleted.find("hello-world")
๐ Table of Contents
- Why this gem?
- Installation
- Compatibility
- Quick Start
- Model concerns
- Sluggable โ URL-friendly slugs
- Sortable โ list ordering via
acts_as_list - Publishable โ
published_attimestamp publishing - SoftDeletable โ soft delete with scopes & hooks
- Hashable โ auto-generate tokens / UUIDs / codes
- Schedulable โ
starts_at/ends_attime windows - Expirable โ single-timestamp expiry
- Normalizable โ attribute normalization (
:email,:phone, โฆ) - Searchable โ LIKE/ILIKE search across configured columns
- Activatable โ boolean active/inactive toggle
- Tokenizable โ security tokens with timing-safe lookup
- Sequenceable โ ordered, human-friendly reference numbers
- Stateable โ lightweight string-backed state machine
- Addressable โ postal address normalization + format validation
- Taggable โ lightweight tagging over a single column
- Sanitizable โ opt-in HTML sanitization (XSS defense-in-depth)
- Maskable โ non-destructive display masking of sensitive fields
- Monetizable โ integer-cents money columns (BigDecimal)
- Auditable โ single-column change history ("paper_trail-lite")
- Lockable โ failed-attempt tracking + account lockout
- Aliasable โ full read/write/query aliases for associations
- Storable โ typed accessors over one JSON settings column ("store_attribute-lite")
- Controller concerns
- Paginatable โ offset pagination with headers
- CursorPaginatable โ cursor (keyset) pagination with headers
- Filterable โ declarative URL-param filters
- Sortable (controller) โ URL-param ordering with allow-list
- Respondable โ standardized JSON envelopes
- ErrorHandleable โ JSON
rescue_fromhandlers for common controller errors - Includable โ whitelisted association sideloading + sparse fieldsets
- SecureHeadable โ security response headers + native CSP DSL
- Localizable โ per-request locale from params / Accept-Language
- Authorizable โ per-action 403 authorization gate (block-based)
- Throttleable โ rate limiting with 429 +
X-RateLimit-*headers - Timezoneable โ per-request
Time.zonefrom params / header / cookie - Idempotentable โ
Idempotency-Keyrequest replay (409 on concurrent duplicates) - WebhookVerifiable โ HMAC verification for inbound webhooks (Stripe/GitHub/Shopify)
- Deprecatable โ RFC
Deprecation/Sunsetheaders + 410 sunset enforcement
- Module paths & namespacing
- Development
- Contributing
- License
โจ Why this gem?
- Twenty-one model concerns + fourteen controller concerns, all production-ready
- One include, one macro โ no boilerplate, no glue code
- Lean dependencies โ only
acts_as_list(Sortable) andfriendly_id(Sluggable); controller concerns have zero extra deps - Schema-validated configuration โ every macro checks that the configured column exists and raises
ArgumentErrorearly - Composable โ concerns are independent; mix and match per model
๐ฆ Installation
Add to your application's Gemfile:
gem "concerns_on_rails", "~> 1.11"
Or pull the latest from GitHub:
gem "concerns_on_rails", github: "VSN2015/concerns_on_rails"
Then run:
bundle install
๐งช Compatibility
- Ruby: 3.2+
- Rails: 5.0 through 8.x
๐ Quick Start
# A model that's sluggable, publishable, and soft-deletable
class Article < ApplicationRecord
include ConcernsOnRails::Sluggable
include ConcernsOnRails::Publishable
include ConcernsOnRails::SoftDeletable
include ConcernsOnRails::Normalizable
sluggable_by :title
normalizable :title, with: :squish
end
# A controller that paginates, filters, sorts, and renders JSON envelopes
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Paginatable
include ConcernsOnRails::Controllers::Filterable
include ConcernsOnRails::Controllers::Sortable
include ConcernsOnRails::Controllers::Respondable
filter_by :published, scope: :published
sortable_by :created_at, :title, default: :created_at, direction: :desc
def index
render_success(data: paginated(sorted(filtered(Article.all))))
end
end
That's it. The sections below document each concern individually.
๐งฑ Model Concerns
All model concerns are independent โ include only what you need.
๐ Sluggable
URL-friendly slugs via friendly_id โ auto-updates when the source attribute changes.
class Post < ApplicationRecord
include ConcernsOnRails::Sluggable
sluggable_by :title
end
post = Post.create!(title: "Hello World")
post.slug # => "hello-world"
post.update!(title: "Hello, World!")
post.slug # => "hello-world" (regenerates on title change)
Post.friendly.find("hello-world")
Options
# Keep old slugs resolvable after a title change (needs a friendly_id_slugs migration)
sluggable_by :title, history: true
Post.friendly.find("old-slug") # still resolves to the renamed post
# Unique slug only within a scope column (same slug allowed in different accounts)
sluggable_by :title, scope: :account_id
# Reject reserved slugs โ saving a record whose slug would be reserved fails validation
sluggable_by :title, reserved_words: %w[new edit admin]
# Let Model.find accept a slug directly (not just the id)
sluggable_by :title, finders: true
Post.find("hello-world") # resolves by slug
Notes
- Schema must have a
slugcolumn (string). history: truerequires afriendly_id_slugstable โ generate withrails generate friendly_idor add a manual migration.scope: :colrequirescolto exist in the same table.- Falls back to
to_sif the configured source field doesn't respond. - Uses friendly_id's
:slugged(+ optionally:history,:scoped) strategies under the hood.
๐ข Sortable
List ordering via acts_as_list โ adds a default_scope ordering plus move_higher, move_lower, move_to_top, move_to_bottom, and automatic position reordering on destroy.
class Task < ApplicationRecord
include ConcernsOnRails::Sortable
sortable_by :position # default โ ascending
end
Task.create!(name: "A")
Task.create!(name: "B")
Task.last.move_higher
Configuration
sortable_by :priority # ascending priority
sortable_by priority: :desc # descending priority
sortable_by :position, use_acts_as_list: false # just the default_scope ordering, no acts_as_list
sortable_by :position, scope: :list_id # independent position sequence per list (acts_as_list scope:)
sortable_by :position, add_new_at: :top # new rows insert at the top (acts_as_list add_new_at:)
Notes
- The configured field must exist as a column.
- Direction values other than
:asc/:descsilently fall back to:asc.
๐ค Publishable
Manage published / unpublished records via a published_at timestamp.
class Article < ApplicationRecord
include ConcernsOnRails::Publishable
publishable_by :published_at # default โ call is optional
end
article = Article.create!(title: "Draft")
article.published? # => false
article.publish!
article.published? # => true
article.unpublish!
Article.published # WHERE published_at <= NOW()
Article.unpublished # WHERE published_at IS NULL OR published_at > NOW()
Article.scheduled # WHERE published_at > NOW() (future-dated โ not live yet)
Article.draft # WHERE published_at IS NULL (no date set at all)
Scheduling
article.publish_at!(1.week.from_now) # sets published_at to a future time
article.scheduled? # => true (not live yet)
article.draft? # => false
Opt-in default scope
publishable_by :published_at, default_scope: true
# Article.all now returns only published records automatically
# Article.unscoped reaches everything
Notes
- "Published" means
published_atis set and in the past โ so future-dated posts stay unpublished until their time arrives. - No
default_scopeis added by default; chain.publishedexplicitly (or opt in withdefault_scope: true).
โ SoftDeletable
Soft delete records using a timestamp field (default: deleted_at). By default a default_scope hides deleted records โ opt out with soft_deletable_by :deleted_at, default_scope: false and chain .without_deleted explicitly (the safer choice for new models, avoiding default_scope's join/uniqueness footguns). soft_delete! / restore! and the bulk helpers run inside a transaction, so a raising hook rolls the change back.
class User < ApplicationRecord
include ConcernsOnRails::SoftDeletable
soft_deletable_by :deleted_at, touch: true # both args optional
end
user = User.create!(email: "alice@example.com")
user.soft_delete!
user.deleted? # => true
user.restore!
user.deleted? # => false
user.really_delete! # bypasses callbacks, hard deletes from DB
Scopes
User.active # alias of .without_deleted โ non-deleted records
User.without_deleted # same
User.soft_deleted # only deleted records (timestamp set)
User.only_deleted # alias for .soft_deleted
User.with_deleted # all records โ deleted + non-deleted
User.deleted_within(7.days) # soft-deleted within the last 7 days
User.all # default scope: non-deleted only
User.unscoped # everything (deleted + non-deleted)
Bulk operations
User.soft_delete_all # soft-deletes all matching records (explicit โ preferred)
User.destroy_all # alias of soft_delete_all (kept for backwards compatibility)
User.really_destroy_all # hard-deletes all matching records
User.restore_all # restores all soft-deleted records
All of these run in a transaction, so a raising hook rolls the whole batch back.
Lifecycle hooks โ override these methods on the model:
class User < ApplicationRecord
include ConcernsOnRails::SoftDeletable
def before_soft_delete; end
def after_soft_delete; end
def before_restore; end
def after_restore; end
end
Aliases: soft_deleted? and is_soft_deleted? both delegate to deleted?.
๐ Hashable
Auto-generate random values on create โ tokens, codes, UUIDs, or anything from a custom alphabet.
class Order < ApplicationRecord
include ConcernsOnRails::Hashable
hashable_by :token # default: type: :hex, length: 16 โ 32-char hex string
end
order = Order.create!
order.token # => "a3f7c9b1e2d40859e2f1c9b73d40a857"
order.regenerate_token! # rolls a new value and persists it
Generators
| Type | length means |
Example |
|---|---|---|
:hex |
byte count (output is length * 2 chars) |
"a3f7c9b1e2d40859" |
:uuid |
ignored | "550e8400-e29b-41d4-a716-446655440000" |
:integer |
digit count | 483921 |
:custom |
output length, samples from alphabet: |
"K7M3PQ9A" |
hashable_by :token, type: :hex, length: 16
hashable_by :external_id, type: :uuid
hashable_by :code, type: :integer, length: 6
hashable_by :code, type: :custom, length: 8,
alphabet: "ABCDEFGHJKMNPQRSTUVWXYZ23456789" # Crockford-style, no ambiguous chars
Notes
- Auto-assigns in
before_createonly when the field is blank โ callers can pass an explicit value. - A
regenerate_<field>!instance method is defined dynamically. - For fixed-width numeric codes (e.g.
000042), use a string column โ integer columns drop leading zeros. - No uniqueness retry is built in. For collision-prone configs (short integer codes), add a unique index and rescue at the app level.
- If your model has
validates :<field>, presence: true, switch this concern's hook tobefore_validationin your model โ it usesbefore_createby default.
๐๏ธ Schedulable
Records with a starts_at / ends_at time window โ promotions, events, feature flags.
class Promotion < ApplicationRecord
include ConcernsOnRails::Schedulable
schedulable_by # defaults: starts_at: :starts_at, ends_at: :ends_at
end
promo = Promotion.create!(starts_at: 1.hour.ago, ends_at: 1.day.from_now)
promo.current? # => true
promo.upcoming? # => false
promo.expired? # => false
Promotion.current # WHERE starts_at <= NOW AND (ends_at IS NULL OR ends_at > NOW)
Promotion.upcoming # WHERE starts_at > NOW
Promotion.expired # WHERE ends_at <= NOW
Promotion.active_at(time) # active at an arbitrary time
Configuration
# Custom column names
schedulable_by starts_at: :starts_on, ends_at: :ends_on
# Open-ended start (only an end / expiry)
schedulable_by starts_at: nil, ends_at: :expires_at
Mutators
promo.start! # sets starts_at = now
promo.finish! # sets ends_at = now
promo.reschedule!(starts_at: 1.day.from_now,
ends_at: 2.days.from_now)
Notes
- Boundary semantics: inclusive start, exclusive end โ active at exactly
starts_at, not at exactlyends_at. - A
nilend means "never expires"; anilstart means "not yet started". - No
default_scope; chain.currentexplicitly.
โณ Expirable
Single-timestamp expiry โ tokens, sessions, password-reset links, invitations.
class ApiToken < ApplicationRecord
include ConcernsOnRails::Expirable
expirable_by # default field: :expires_at
end
token = ApiToken.create!(expires_at: 1.hour.from_now)
token.active? # => true
token.expired? # => false
token.time_until_expiry # => ActiveSupport::Duration (~1.hour)
ApiToken.active # nil expiry OR future expiry
ApiToken.expired # past expiry
ApiToken.expiring_within(1.day) # future expiry within the next 1 day
Mutators
token.expire! # expires_at = now
token.expire!(2.hours.from_now) # explicit time
token.extend_expiry!(by: 1.day) # pushes expiry forward
extend_expiry! is smart about the base:
- If
expires_atisnilor in the past โ new value isnow + by - If
expires_atis still in the future โbyis added to the existing value
Custom field name
expirable_by :valid_until
Expirable vs. Schedulable: Expirable is the ergonomic choice when you just care about expiry; Schedulable adds a start time. They overlap (schedulable_by starts_at: nil, ends_at: :expires_at is similar) โ pick whichever reads better in your domain.
โจ Normalizable
Auto-normalize attribute values in before_validation โ strip whitespace, downcase emails, dedupe spaces, run any custom transform.
class User < ApplicationRecord
include ConcernsOnRails::Normalizable
normalizable :email, with: :email # strip + downcase
normalizable :phone, with: :phone # digits only
normalizable :first_name, :last_name, with: :whitespace # strip โ same rule, multiple fields
normalizable :slug, with: ->(v) { v.to_s.parameterize } # custom lambda
end
User.create(email: " ALICE@Example.com ").email # => "alice@example.com"
User.create(phone: "+1 (415) 555-1234").phone # => "14155551234"
Built-in presets
| Preset | Transform |
|---|---|
:email |
strip + downcase |
:phone |
digits only (gsub(/\D/, "")) |
:whitespace |
strip |
:squish |
squish (collapse inner whitespace) |
:downcase |
downcase |
:upcase |
upcase |
Notes
- Runs in
before_validation, so DB constraints and AR validations see the normalized value. nilvalues are skipped โ nonil โ ""coercion.- Preset normalizers pass non-string values through unchanged.
- Works on Rails 5+ (no dependency on Rails 7.1's built-in
normalizes).
๐ Searchable
LIKE-based search across one or more columns โ no external search engine, no extra gems.
class Article < ApplicationRecord
include ConcernsOnRails::Searchable
searchable_by :title, :body
end
Article.search("hello") # WHERE title ILIKE '%hello%' OR body ILIKE '%hello%'
Article.search("") # no-op โ returns the full relation
Article.search("foo").where(state: 1) # chainable like any scope
Options
# mode: :any (default) โ any term in any field matches (OR)
# mode: :all โ every whitespace-separated term must match somewhere (AND per term, OR across fields)
searchable_by :title, :body, mode: :all
Article.search("ruby framework") # title OR body must contain "ruby" AND "framework"
# match: :contains (default) โ %term% (substring)
# match: :prefix โ term% (starts with)
# match: :exact โ term (full match)
searchable_by :sku, match: :prefix
Notes
- Uses Arel's
matches, which emitsILIKEon Postgres (case-insensitive) andLIKEelsewhere. - The query is escaped before interpolation โ
%,_, and\from user input are treated as literals, not wildcards. - Blank or nil queries return the relation unchanged, so it's safe to drop into a controller pipeline.
- Reach for
pg_search/ Elasticsearch when you need ranking, stemming, or full-text indexes.
โ Activatable
Boolean active/inactive toggle backed by a single column.
class Subscription < ApplicationRecord
include ConcernsOnRails::Activatable
activatable_by # defaults to :active
# activatable_by :enabled # custom column name
end
sub = Subscription.create!(active: true)
sub.active? # => true
sub.deactivate!
sub.inactive? # => true
sub.toggle_active! # flips back to true
Subscription.active # WHERE active = TRUE
Subscription.inactive # WHERE active = FALSE OR active IS NULL
Notes
NULLis treated as inactive (same convention as most apps' "unset = off").- The configured column must exist;
activatable_byraisesArgumentErrorotherwise. SoftDeletablealso defines a.activescope (alias of.without_deleted). If both concerns are included on the same model, the later one wins โ include the one whose.activesemantics you want last, or stick to one of them.
๐ Tokenizable
Generate and manage security tokens โ API keys, invite codes, share links, password-reset tokens. One model can declare any number of independently-configured token fields.
class User < ApplicationRecord
include ConcernsOnRails::Tokenizable
tokenizable_by :api_token # 32-char URL-safe
tokenizable_by :reset_password_token, length: 24
tokenizable_by :invite_code, type: :alphanumeric, length: 8
end
user = User.create! # all three tokens auto-generated
user.api_token # => "k3Jf...g2" (32 URL-safe chars)
user.api_token? # => true
user.regenerate_api_token! # rotates and persists
user.revoke_api_token! # nils the column
User.find_by_api_token(token) # Rails default
User.authenticate_by_api_token(token) # timing-safe; returns user or nil
Options
| Option | Default | Notes |
|---|---|---|
type: |
:urlsafe |
One of :urlsafe, :hex, :alphanumeric, :numeric |
length: |
32 |
Character length of the generated token |
Notes
- URL-safe by default (
AโZ,aโz,0โ9,-,_) โ drop straight into URLs and headers. - Caller-supplied values are respected:
User.create!(api_token: "preset")won't be overwritten. - Generation does a best-effort uniqueness check before insert and retries up to 10 times. Pair with a
uniqueDB index for real safety, especially for short alphanumeric/numeric codes. .authenticate_by_<field>usesActiveSupport::SecurityUtils.secure_compareto avoid leaking partial matches via response timing.- Distinct from
Hashable: Hashable handles a single random field; Tokenizable focuses on security tokens (multi-field, URL-safe default, timing-safe lookup, revocation).
๐งพ Sequenceable
Ordered, human-friendly reference numbers โ invoice numbers, order numbers, ticket IDs, support cases. Unlike the random identifiers from Hashable / Tokenizable, Sequenceable produces sequential ones backed by an integer column that is the source of truth.
class Invoice < ApplicationRecord
include ConcernsOnRails::Sequenceable
sequenceable_by :sequence, # integer column โ the source of truth
into: :number, # optional string column for the formatted value
prefix: "INV-",
padding: 5,
scope: :account_id, # one independent counter per account
reset: :year # restart numbering each calendar year
end
invoice = Invoice.create!(account_id: 1)
invoice.sequence # => 1, 2, 3 ... (per account, per year)
invoice.number # => "INV-2026-00001"
invoice.formatted_sequence # => "INV-2026-00001"
Invoice.next_sequence(account_id: 1) # => 4 (peek the next value, without creating)
Options
| Option | Default | Purpose |
|---|---|---|
field (positional) |
:sequence |
Integer column holding the sequence โ the source of truth. |
into: |
nil |
String column to persist the formatted reference into (immutable display value). |
prefix: |
"" |
Prepended to the formatted value. |
padding: |
0 |
Zero-pad width of the numeric portion (0 = no padding). |
separator: |
"-" |
Joins prefix / period token / number in the default format. |
start_at: |
1 |
First value when the scope/period has no rows yet. |
scope: |
nil |
Column (or array of columns) the counter is scoped to โ e.g. one sequence per account_id. |
reset: |
:never |
:never / :year / :month / :day โ restart numbering each period (needs created_at). |
template: |
nil |
->(seq, record) { ... } full custom formatter; overrides prefix / padding / period. |
Default format
reset: |
Example | Shape |
|---|---|---|
:never |
INV-00001 |
prefix + padded |
:year |
INV-2026-00001 |
prefix + YYYY + sep + padded |
:month |
INV-202606-00001 |
prefix + YYYYMM + sep + padded |
:day |
INV-20260604-00001 |
prefix + YYYYMMDD + sep + padded |
Generated API
| Method | What it does |
|---|---|
formatted_<field> |
The formatted string โ the persisted into: value when set, otherwise computed. |
Model.next_<field>(scope_attrs) |
Peek the next integer for a scope without creating a record. |
Notes
- The next value is
MAX(<field>) + 1within the scope (and period), so numbering is dense and ordered โ not random. - Caller-supplied values are respected:
Invoice.create!(sequence: 100)is not overwritten (and itsinto:string is still formatted from100). - Generation reads
MAXthen inserts, so two concurrent inserts can race. It's best-effort โ add a scoped unique index on<field>(and oninto:) for a real guarantee, the same way you would for anyMAX-based numbering. reset:requires acreated_atcolumn; the period is taken from each row's creation time.- For fixed-width display (
00042), make theinto:column a string โ integer columns drop leading zeros. - Distinct from
Hashable/Tokenizable, which generate random values; reach for those when the identifier must be unguessable.
๐ Stateable
Lightweight string-backed state machine โ the 80% of AASM without the dependency.
class Article < ApplicationRecord
include ConcernsOnRails::Stateable
stateable_by :status,
states: %i[draft pending published archived],
default: :draft,
transitions: {
publish: { from: %i[draft pending], to: :published },
archive: { to: :archived } # no :from โ allowed from any state
}
end
Generated API
article = Article.new # status defaults to "draft"
article.draft? # => true (predicate per state)
article.published? # => false
Article.draft # scope: WHERE status = 'draft'
Article.published # scope: WHERE status = 'published'
article.published! # direct setter โ updates regardless of current state
article.publish! # guarded transition โ raises InvalidTransition if not allowed
article.may_publish? # => true (guard check without raising)
article.transition_to!(:archived) # generic move to any declared state
Prefix / suffix โ avoid clashes when the state names overlap with other concerns or scopes:
stateable_by :state, states: %i[open closed], prefix: true
# generates: state_open?, state_closed?, state_open!, state_closed!, State.state_open, โฆ
Validation
- Raises
ArgumentErrorif the column, states, default, or transition config is invalid. - Raises
Stateable::InvalidTransitionat runtime for disallowed guarded transitions.
Notes
- String-column backed (not integer-backed like Rails enum) โ values are stored as-is.
- States like
active/expiredoverlap withActivatable/Expirablescopes โ useprefix:orsuffix:to disambiguate. - No persistence of transition history; combine with
Publishable/Schedulablefor time-based state tracking.
๐ Addressable
Normalize and format-validate a postal address spread across several columns โ one macro for whitespace cleanup, postal-code and ISO country-code checks, required-part presence, and a full_address helper. No external geocoding service required.
class Location < ApplicationRecord
include ConcernsOnRails::Addressable
addressable_by # standard columns: line1, line2, city, state, postal_code, country
end
loc = Location.create(line1: " 1 Infinite Loop ", city: "Cupertino",
state: "ca", postal_code: "95014", country: "us")
loc.line1 # => "1 Infinite Loop" (stripped + squished)
loc.state # => "CA" (2-letter code upcased)
loc.country # => "US"
loc.full_address # => "1 Infinite Loop, Cupertino, CA, 95014, US"
Map onto your own column names and tune behavior:
class Place < ApplicationRecord
include ConcernsOnRails::Addressable
addressable_by line1: :street, postal_code: :zip, country: :country_code,
required: %i[line1 city postal_code country],
default_country: "GB", # country used when the record has none
validate_state: true, # opt-in US/CA state-code check
lengths: { line1: 100, city: 50, postal_code: 5..10 }, # max (Int) or min..max (Range)
allow_blank: %i[state], # these parts skip the length check when blank
normalize_country: true, # "Canada"/"CAN" -> "CA" (off by default)
verify_with: ->(rec) { Usps.verify(rec) } # opt-in external verifier
end
Options
| Option | Default | Purpose |
|---|---|---|
line1: โฆ country: |
same-named columns | Map each canonical part to a real column. Missing columns are skipped. |
required: |
%i[line1 city postal_code country] |
Parts (by canonical name) that must be present. Each must map to an existing column. |
default_country: |
"US" |
Postal-code format used when the country column is absent or blank. A present-but-unrecognized country value falls back to the permissive pattern instead โ see the postal note below. |
validate_state: |
false |
When true, validates the state against US / CA code sets. |
lengths: |
{} |
Per-part length limits: { line1: 100, city: 50, postal_code: 5..10 }. An Integer is a (positive) maximum; a Range is min..max (non-negative, satisfiable; endless 3.. and beginless ..50 allowed). Only the parts you list are checked. Bad bounds raise an ArgumentError at load time. |
allow_blank: |
false |
Per-field opt-out for the length check: an Array of parts (e.g. %i[line2 state]), or true for all parts. A blank value for an allowed part skips its length check. Independent of required:. |
normalize_country: |
false |
When true, canonicalize the country to its ISO 3166-1 alpha-2 code: an English name ("Canada", "United States") or a 3-letter alpha-3 ("CAN", "USA") maps to the alpha-2 ("CA", "US"); unrecognized values are left untouched. This also lets postal/state validation recognize a named country. |
verify_with: |
nil |
A callable for real-world verification (see below). |
if: / unless: |
nil |
Standard Rails validation conditions (Symbol, Proc, or Array) gating the address validations โ e.g. if: :on_addresses?. Normalization still runs unconditionally. |
What it normalizes (in before_validation)
- Text parts:
strip+squish. postal_code: squish + upcase, with canonical spacing for CA (A1A1A1โA1A 1A1).country/state: upcased when they look like a 2-letter code (full names left alone). Withnormalize_country: true, a recognized ISO country name or alpha-3 code is canonicalized to its alpha-2 ("Canada"/"CAN"โ"CA"); unrecognized values are left as-is.
What it validates
- Presence of every
required:part. country: a 2-letter value must be a real ISO 3166-1 alpha-2 code.postal_code: matched against a per-country pattern (US, CA, GB, AU, DE, FR) with a permissive fallback for everything else. A strict per-country pattern is only applied when the country is a recognized ISO alpha-2 code (ordefault_countrywhen the country column is absent/blank); a present-but-unrecognized country (e.g. a full name like"Canada") uses the permissive pattern, so valid foreign codes aren't rejected against the wrong country.state: only whenvalidate_state: trueand the country is US/CA.lengths: each listed part's length (measured on the normalized value) must fit itsmin..max(Integer = max only). Errors mirror Rails, including singular/plural:"is too short (minimum is N characters)"/"is too long (maximum is 1 character)". A blank value satisfies a max-only rule and, by default, fails a minimum greater than 0 โ list the part inallow_blank:to skip the check when blank.allow_blankis independent ofrequired:: presence is still governed solely byrequired:, so a required part with a minimum that's left blank reports both"can't be blank"and"is too short โฆ". Note length is counted after normalization, so a CApostal_codeincludes the inserted space ("A1A 1A1"is 7) โ size limits accordingly.
External verification (verify_with:) โ runs only after structural validation passes, so you never spend an API call on an obviously-broken address. The callable receives the record and may either add to record.errors itself, or return:
| Return value | Effect |
|---|---|
true / nil |
success |
false |
adds a generic :base error |
String |
added as a :base error |
Array |
each element added as a :base error |
Notes
- Scope is format/structure only โ it checks shape, not real-world deliverability. Plug a USPS/Google/Smarty client into
verify_with:for that. - Error messages are plain English strings โ no host-app i18n setup required.
- Partial schemas just work: a model without a
line2(or any other) column simply omits that part. - Pairs with Normalizable when you also have non-address fields to clean up.
๐ท๏ธ Taggable
Lightweight, dependency-free tagging stored in a single string column โ no join tables, no tagging engine. Works on any database, including SQLite.
class Article < ApplicationRecord
include ConcernsOnRails::Taggable
taggable_by :tags # default column :tags
# taggable_by :skills, downcase: true # custom column, case-folded
end
article = Article.new
article.tag_list = "Ruby, Rails, Ruby" # accepts a String or an Array
article.tag_list # => ["Ruby", "Rails"] (stripped + de-duped)
article.("api")
article.("Rails")
article.tagged_with?("ruby") # => membership predicate
article.save!
Article.tagged_with("ruby", "rails") # records carrying BOTH tags
Article.tagged_with("ruby", "go", any: true) # records carrying ANY tag
Article. # => sorted unique tags in use
Options
| Option | Default | Purpose |
|---|---|---|
delimiter: |
"," |
Character joining the stored tags (a tag must not contain it). |
downcase: |
false |
Case-fold tags on write so matching is case-insensitive. |
Notes
- Matching is boundary-safe โ searching
raildoes not matchrails. An explicit SQLESCAPEclause makes tags containing_/%match literally on every adapter. - Tags are normalized in
before_validation, so a directrecord.tags = "a, b"assignment is cleaned too. An empty list storesNULL. - Reach for
acts-as-taggable-onwhen you need tag contexts, ownership, counts/clouds, or polymorphic tags shared across models.
๐งผ Sanitizable
Opt-in HTML sanitization for string attributes โ defense-in-depth, not a replacement for Rails' default output escaping (<%= %> already escapes). Reach for it on the rare column you render as trusted HTML (raw / html_safe) or that must stay plain text. Zero extra dependencies โ it uses the rails-html-sanitizer that already ships with Action View.
class Article < ApplicationRecord
include ConcernsOnRails::Sanitizable
# DEFAULT (on: :read) โ non-destructive. The column stays raw; a
# `sanitized_<field>` reader returns the cleaned value:
sanitizable :body, with: :safe_list # => article.sanitized_body
sanitizable :summary, with: :strip # => article.sanitized_summary
sanitizable :body, with: { tags: %w[b i a], attributes: %w[href] }
# EXPLICIT destructive opt-in โ for plain-text-only columns only:
sanitizable :title, with: :strip, on: :write # overwrites in before_validation
end
article = Article.new(body: "<b>Hi</b><script>alert(1)</script>")
article.body # => "<b>Hi</b><script>alert(1)</script>" (raw, intact)
article.sanitized_body # => "<b>Hi</b>alert(1)" (script tag dropped)
Presets (with:)
| Preset | Behavior |
|---|---|
:strip |
Remove all tags, keep inner text (the default). |
:safe_list |
Rails' allow-list: keep formatting tags, drop <script> / <iframe>. |
:no_links |
Strip only <a> tags, keep their text. |
:none |
No-op (declare the field / reader without transforming). |
Array |
Custom tag allow-list, e.g. with: %w[b i a]. |
Hash |
{ tags: [...], attributes: [...] } allow-list. |
Proc |
Used as-is (you own the non-String guard). |
Notes
on: :read(default) is non-destructive: it adds asanitized_<field>reader and leaves the stored column untouched.on: :writeoverwrites the column inbefore_validationโ lossy and irreversible (never use it on code, Markdown, math, or prices), and bypassed byupdate_column/update_all/ raw SQL.- For full user-authored rich text, prefer Action Text.
๐ Maskable
Non-destructive display masking for sensitive attributes. Each declaration adds a masked_<field> reader and never writes the column โ the raw value stays in the database (masking is a presentation concern). Dependency-free.
class User < ApplicationRecord
include ConcernsOnRails::Maskable
maskable :email, with: :email # => user.masked_email "j****@example.com"
maskable :card, with: :credit_card # => user.masked_card "**** **** **** 4242"
maskable :ssn, with: :last4, mask: "โข"
maskable :token, with: ->(v) { "#{v.to_s[0, 3]}โฆ" }
end
Presets (with:)
| Preset | Result |
|---|---|
:email |
j****@example.com (first char + domain) |
:phone |
***-2671 (last 4 digits) |
:credit_card |
**** **** **** 4242 (last 4 digits) |
:last4 |
keep the last 4 characters |
:all |
mask every character (the default) |
Proc |
used as-is (you own the non-String guard) |
mask: sets the mask character (default *). Nil and non-string values pass through untouched. To strip dangerous HTML instead, see Sanitizable.
๐ฐ Monetizable
Money handling for an integer "subunit" column (e.g. cents) โ exact and float-free via BigDecimal. monetizable :price_cents derives three methods (the _cents suffix is stripped):
class Product < ApplicationRecord
include ConcernsOnRails::Monetizable
monetizable :price_cents # => price / price= / formatted_price
monetizable :shipping_cents, as: :shipping
monetizable :total_cents, unit: "โฌ", delimiter: ".", separator: ","
end
product.price = 19.99 # stores price_cents = 1999 (rounded to whole cents)
product.price # => BigDecimal 19.99
product.formatted_price # => "$19.99"
| Method | Returns |
|---|---|
price |
the amount as a BigDecimal (cents รท 100) |
price= |
assign in major units; rounded to whole cents |
formatted_price |
a display string ("$1,234.56") |
Options: as: (explicit method name โ required when the column does not end in _cents), unit: ("$"), precision: (2), delimiter: (","), separator: ("."), subunit_to_unit: (100). nil stays nil across all three methods.
๐ Auditable
Lightweight change history ("paper_trail-lite") stored as JSON entries in one text column on the same table โ no extra tables, no versioning engine.
class Product < ApplicationRecord
include ConcernsOnRails::Auditable
auditable_by :price, :status # default column :audit_log
# auditable_by :price, into: :history,
# actor: -> { Current.user&.email }, # stamps "by" on each entry
# max_entries: 50 # keep the newest 50
end
product.update!(price: 200)
product.audit_trail
# => [{"field"=>"price", "from"=>100, "to"=>200, "at"=>"2026-06-10T12:34:56Z", "by"=>"admin@shop.com"}]
product.last_change_for(:price) # newest entry for one field
product.audited_changes_since(1.day.ago) # recent entries, oldest first
product.clear_audit_trail! # wipe the column (skips callbacks)
One entry is recorded per changed field per save (creates record "from" => nil), appended in the same INSERT/UPDATE via before_save โ zero extra queries.
Options: into: (:audit_log), actor: (callable, instance_exec'd on the record; "by" omitted when absent), max_entries: (200; keeps the newest N, nil = unlimited), max_value_length: (nil; truncates long String from/to values to the first N characters + โฆ).
Notes
- Writes that skip callbacks (
update_column(s),touch,increment!) are not audited;save(validate: false)is. - Values are JSON-coerced (times โ ISO8601 UTC strings,
BigDecimalโ precision-safe numeric string); a corrupt column decodes as[]and is replaced on the next tracked save. - Per-record and bounded by design โ reach for
paper_trail/auditedwhen you need reify/undo or audit queries across models.
๐ Lockable
Failed-attempt tracking + account lockout ("Devise lockable-lite") for apps rolling their own authentication (Rails 8 auth generator / has_secure_password) โ which ships no brute-force protection out of the box. Two columns on the model's own table; no tokens, no mailers.
class User < ApplicationRecord
include ConcernsOnRails::Lockable
lockable_by max_attempts: 5, unlock_in: 15.minutes
# lockable_by attempts: :failed_logins, locked_at: :locked_until_at,
# prefix: :account # => .account_locked / .account_unlocked
end
user.register_failed_attempt! # atomic SQL increment; locks at max_attempts
user.access_locked? # true while locked (lapses after unlock_in)
user.attempts_remaining # => 3 (for "3 attempts remaining" messaging)
user.reset_failed_attempts! # call on successful login
user.lock_access! # manual lock (hooks: before/after_lock)
user.unlock_access! # manual unlock (hooks: before/after_unlock)
User.locked / User.unlocked # expiry-aware scopes
Options: attempts: (:failed_attempts, must be an integer column), locked_at: (:locked_at, datetime column), max_attempts: (5; nil = count but never auto-lock), unlock_in: (nil = locked until manual unlock; a duration makes the lock lapse by itself), prefix: / suffix: (affix the scope names).
Notes
- The increment is SQL-side (
COALESCE(attempts, 0) + 1viaupdate_counters), so concurrent failures never lose updates and a NULL counter needs no column default; a locked account stops counting. - Expiry is lazy: readers and scopes treat a stale lock as unlocked but never write. The column is cleared by the next
unlock_access!or failed attempt (quietly there โ no unlock hooks fire from a failed login). lock_access!/unlock_access!persist viaupdate_columnsโ validations and AR callbacks deliberately bypassed so an otherwise-invalid record can still be locked (this also skipsupdated_at/Auditable). Thebefore/after_lock,before/after_unlockhooks run in a transaction;after_lockis the place for the "account locked" email.- Reach for Devise's
lockablewhen you need unlock tokens, unlock emails, or per-strategy unlocks.
๐ช Aliasable
Alias an existing association under a second name with full semantics โ read, write/assign, build/create, and the query side (joins / includes / where-hash) โ not just a delegated reader. (alias_attribute covers columns only; Rails has no built-in association aliasing.)
class Book < ApplicationRecord
include ConcernsOnRails::Aliasable
belongs_to :author
has_many :chapters
alias_association :writer, :author # alias_method order: new, old
alias_association :sections, :chapters
end
book.writer # same cached object as book.author
book.writer = user # assigns through the original association
book.build_writer(...) # build_ / create_ / create_! / reload_ (singular)
book.sections << chapter # the same CollectionProxy as book.chapters
book.section_ids # ids reader/writer (collection)
Book.joins(:sections).where(sections: { title: "Intro" })
Options: alias_association new_name, source_name โ repeatable; declare it after the source association; re-declaring an existing alias with the same source (e.g. in a subclass that redefined the source) is allowed and refreshes it, while repointing an alias at a different source raises. Keyword options: only:/except: narrow the generated methods by group (:reader, :writer, :build, :reload, :ids); deprecated: true (or a String hint) makes every delegator warn through ConcernsOnRails.deprecator โ the gradual-rename story; alias_foreign_key: true (belongs_to only) also aliases <alias>_id (and <alias>_type when polymorphic) via alias_attribute.
Notes
- One loaded cache under two names:
record.association(:alias)ISrecord.association(:source), and only the source macro installs callbacks โdependent:, counter caches, autosave and validations run exactly once. - The where-hash key must match the name you joined under (stock-Rails rule):
joins(:sections).where(sections: {...})works;joins(:chapters).where(sections: {...})does not. - The
belongs_toforeign-key attribute is not aliased โ pair withalias_attribute :writer_id, :author_idif you need it. has_and_belongs_to_manycannot be aliased (usehas_many :through).has_many/has_one :throughcan โ the copy pinssource:so it is not re-derived from the alias name; if your classes load lazily and the through model names the source differently (e.g.belongs_to :authorbehindhas_many :authors), declaresource:explicitly on the original association. Aliases are inherited by subclasses.
โ๏ธ Storable
Typed, defaulted, optionally-validated accessors over a single JSON (or text) column ("store_attribute-lite"). Rails' native store_accessor is untyped on every supported version โ a form-submitted "true" stays the String "true" โ with no defaults and no per-key dirty tracking; that gap is why the store_attribute / jsonb_accessor gems exist.
class Account < ApplicationRecord
include ConcernsOnRails::Storable
storable_by :settings,
theme: { type: :string, default: "light", in: %w[light dark] },
notifications: { type: :boolean, default: true },
items_per_page: { type: :integer, default: 25 },
trial_ends_at: { type: :datetime }
storable_by :flags, { beta: { type: :boolean, default: false } }, prefix: :flag
end
account.theme # => "light" (virtual default; nothing persisted)
account.notifications = "0" # params arrive as stringsโฆ
account.notifications # => false (โฆand read back cast)
account.notifications? # boolean keys get a predicate
account.items_per_page_changed? # per-key dirty (and items_per_page_was)
account.reset_theme # drop the key โ the default applies again
account.flag_beta # affixed accessor
Options (per key): type: (:string default, :integer, :float, :decimal, :boolean, :date, :datetime, :json), default: (a value, or a Proc instance_exec'd per read), in: (inclusion validation, errors on the accessor name). Macro options: prefix: / suffix: affix the generated method names (the collision escape hatch). The macro is repeatable โ repeat calls for the same column merge keys, different columns are independent, and subclasses can add keys without affecting the parent.
Notes
- Works on a plain
textcolumn (JSON encoded/decoded internally), a nativejson/jsonbcolumn, or a column the host app alreadyserialized โ detected automatically.serializeitself is never used, so the Rails 7.1 API drift is irrelevant. - nil vs unset: a written
nil(explicit JSON null) reads back asniland does not fall back to the default;reset_<key>removes the key so the default applies again.:decimalis stored as a precision-safe string,:date/:datetimeas ISO8601 (datetime in UTC at microsecond precision). - Writing one key dirties (and saves) the whole column โ concurrent writers to different keys are last-write-wins on the hash. Undeclared keys are preserved.
:jsonreaders return a dup: reassign, don't mutate in place. - Generated names are collision-checked against existing methods and columns at macro time (
ArgumentError; affix to escape). Read-side casting never raises โ corrupt column JSON decodes as{}, garbage values cast tonil. - Reach for
store_attribute/jsonb_accessorwhen you need to query into the store (jsonb operators, store-backed scopes).
๐ฎ Controller Concerns
Pure ActionController + ActiveRecord โ zero extra runtime dependencies (no Kaminari, Pundit, or Ransack).
๐ Paginatable
Offset-based pagination with standard response headers โ no Kaminari needed.
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Paginatable
paginate_by per_page: 25, max_per_page: 200 # optional โ these are the defaults
def index
render json: paginated(Article.all)
end
end
URL params
| Param | Default | Notes |
|---|---|---|
?page= |
1 |
Page numbers below 1 are clamped to 1 |
?per_page= |
25 |
Capped at max_per_page (default 200) |
Response headers: X-Total-Count, X-Page, X-Per-Page, X-Total-Pages.
๐งญ CursorPaginatable
Cursor (keyset) pagination โ the constant-time complement to Paginatable: no COUNT query, stable under concurrent inserts, ideal for infinite scroll and sync feeds.
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::CursorPaginatable
cursor_paginate_by order: { created_at: :desc }, per_page: 25, max_per_page: 200
def index
render json: cursor_paginated(Article.all) # bad cursors are rescued to a 400 automatically
end
end
URL params
| Param | Default | Notes |
|---|---|---|
?cursor= |
โ | The opaque token from X-Next-Cursor (omit for page 1) |
?per_page= |
25 |
Capped at max_per_page (default 200; 0 disables the cap) |
?order= |
first preset | With order_presets: only โ selects a named ordering from the allow-list (unknown names โ 400 invalid_order_preset) |
Response headers: X-Per-Page, X-Count (rows on this page โ totals are deliberately not computed), X-Has-More, X-Next-Cursor (only while more pages exist). With bidirectional: true: also X-Has-Prev, X-Prev-Cursor.
Notes
- The primary key is always appended as a tiebreaker, so duplicate values never skip or repeat rows; ordering columns are chosen in code (never from params) and should be
NOT NULL(a NULL boundary value raises rather than silently dropping rows). - Cursors are opaque, table/order-pinned tokens โ a malformed, cross-endpoint, or stale-config cursor renders a 400 (
invalid_cursor; overriderender_invalid_cursorto customize, delegates to Respondable'srender_errorwhen present). They are not signed: a client can mint different boundary values, but values are cast through the model's attribute types and bound by Arel (no injection) and the relation's own scoping still applies โ treat a cursor as a page position, never an authorization boundary. cursor_paginatedusesreorder(replaces anydefault_scopeORDER BY) and returns a loaded Array. Don't wrap it with the controller Sortable'ssortedโ passorder:per call instead.- Forward-only by default โ
bidirectional: true(macro or per call) adds prev cursors andX-Has-Prev/X-Prev-Cursor; direction is pinned in the token, so prev tokens replayed on forward-only endpoints 400 and old direction-less tokens stay valid.order_presets: { newest: {...}, top: {...} }(+default_preset:,order_param:) lets clients pick a named ordering from an allow-list.predicate: :autoupgrades the keyset WHERE to a row-value tuple(a, b, id) > (x, y, z)on PostgreSQL/MySQL/SQLite when directions are uniform โ composite-index friendly โ falling back to the portable OR-expansion (:row/:orforce a strategy). - Use Paginatable when you need page numbers and totals.
๐ Filterable
Declarative URL-param filtering with three modes per filter.
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Filterable
filter_by :status, :category # ?status=draft โ .where(status: 'draft')
filter_by :published, scope: :published # ?published=1 โ Article.published
filter_by :q, with: ->(rel, v) { rel.where("title ILIKE ?", "%#{v}%") }
def index
render json: filtered(Article.all)
end
end
Modes
| Mode | Declaration | What it does |
|---|---|---|
| Direct | filter_by :status |
relation.where(status: params[:status]) |
| Scope | filter_by :published, scope: :published |
relation.published (when param is present) |
| Custom | filter_by :q, with: ->(rel, v) { ... } |
Calls your lambda with (relation, value) |
Notes
- Blank params are skipped โ unset filters don't narrow the relation.
- Passing both
:scopeand:withraisesArgumentError. - Scope mode pairs naturally with
Publishable.published,SoftDeletable.active,Expirable.active, etc.
โ๏ธ Sortable (controller)
URL-param-driven ordering with a strict allow-list โ never orders by an arbitrary user-supplied column.
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Sortable
sortable_by :created_at, :title, :published_at,
default: :created_at, direction: :desc
def index
render json: sorted(Article.all)
end
end
URL params: ?sort=title&direction=asc
params[:sort]selects the column; non-whitelisted values fall back to the declared default.params[:direction]acceptsasc/desc(case-insensitive); invalid values fall back to the declared default direction.- If no
default:is given, the first declared field is used.
Distinct from
Models::Sortable(which manages list position viaacts_as_list). Both can coexist on a model + its controller.
๐ฆ Respondable
Standardized JSON envelopes for API controllers โ two methods, zero state.
class Api::ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Respondable
def show
article = Article.find_by(id: params[:id])
return render_error(message: "Not found", status: :not_found) unless article
render_success(data: article)
end
def create
article = Article.new(article_params)
if article.save
render_success(data: article, status: :created)
else
render_error(message: "Invalid", errors: article.errors.)
end
end
end
Response shapes
// Success
{ "success": true, "data": {...}, "meta": {...} /* meta omitted when empty */ }
// Error
{ "success": false, "error": { "message": "...", "code": "...", "details": [...] } }
API
| Method | Signature |
|---|---|
render_success |
render_success(data: nil, status: :ok, meta: {}) |
render_error |
render_error(message:, status: :unprocessable_entity, code: nil, errors: nil) |
data:is a keyword arg (not positional) on purpose โ it sidesteps Ruby 3's behavior of treating hash literals as kwargs when a method declares any keyword params.
๐ ErrorHandleable
Install rescue_from handlers for the three most common controller exceptions and render them as the same JSON envelope used by Respondable.
class Api::BaseController < ApplicationController
include ConcernsOnRails::Controllers::Respondable # recommended
include ConcernsOnRails::Controllers::ErrorHandleable
end
Handled exceptions
| Exception | Status | code |
|---|---|---|
ActiveRecord::RecordNotFound |
404 | "not_found" |
ActionController::ParameterMissing |
400 | "parameter_missing" |
ActiveRecord::RecordInvalid |
422 | "record_invalid" |
Response shape (matches Respondable#render_error):
{ "success": false, "error": { "message": "...", "code": "...", "details": [...] } }
Overriding a handler
Each handler is a public instance method, so subclasses can customize the message or response shape without re-declaring the rescue_from:
class Api::BaseController < ApplicationController
include ConcernsOnRails::Controllers::ErrorHandleable
def handle_record_not_found(error)
render json: { success: false, error: { message: "Not here, friend." } }, status: :not_found
end
end
Notes
- When
Respondableis also included, the handlers delegate torender_errorso the envelope shape stays in one place. Otherwise they render the same envelope inline. RecordInvalid.detailsare populated fromerror.record.errors.full_messages.
๐ Includable
Whitelisted association sideloading + sparse fieldsets for JSON APIs โ zero arbitrary .includes from user input.
class ArticlesController < ApplicationController
include ConcernsOnRails::Controllers::Includable
includable :author, :comments,
fields: { articles: %i[id title published_at], authors: %i[id name] }
def index
render json: with_includes(Article.all),
include: requested_includes,
fields: requested_fields
end
end
URL params
GET /articles?include=author,comments&fields[articles]=id,title&fields[authors]=id,name
API
| Method | What it does |
|---|---|
with_includes(rel) |
Parses params[:include], intersects with the allow-list, calls relation.includes(...) |
requested_includes |
Returns the sanitized [:author, :comments] array (pass to render json:, include:) |
requested_fields |
Returns { articles: [:id, :title] } sanitized map (pass to your serializer) |
Notes
- Non-whitelisted associations are silently dropped โ no error, no arbitrary eager-loading.
- Non-whitelisted tables in
params[:fields]are dropped; non-whitelisted columns within an allowed table are dropped. - Pass
requested_fieldsto your serializer (e.g. AMS / Blueprinter) โIncludableitself does not alter the JSON output, only the query.
๐ก๏ธ SecureHeadable
Modern security response headers + a thin wrapper over Rails' native Content-Security-Policy DSL. Defense-in-depth on top of output escaping โ it does not scrub request params and never re-enables the deprecated X-XSS-Protection auditor. Zero extra dependencies.
class ApplicationController < ActionController::Base
include ConcernsOnRails::Controllers::SecureHeadable
# Preset headers, plus any custom "Header-Name" => "value" pairs:
secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :disable_legacy_xss
secure_headers "Permissions-Policy" => "geolocation=()"
# Delegates to Rails' native CSP DSL โ roll out report-only FIRST:
content_security_policy_for(report_only: true) do |policy|
policy.default_src :self
policy.script_src :self
policy.object_src :none
end
end
Header presets (secure_headers)
| Preset | Header |
|---|---|
:nosniff |
X-Content-Type-Options: nosniff |
:sameorigin_frame |
X-Frame-Options: SAMEORIGIN |
:deny_frame |
X-Frame-Options: DENY |
:no_referrer_leak |
Referrer-Policy: strict-origin-when-cross-origin |
:no_cross_domain |
X-Permitted-Cross-Domain-Policies: none |
:disable_legacy_xss |
X-XSS-Protection: 0 (the only correct modern value) |
Notes
- Headers are applied in an
after_action, so they reinforce Rails' middleware defaults; latersecure_headersdeclarations win on a colliding name. content_security_policy_forforwardsreport_only:and per-actiononly:/except:/if:/unless:straight to Rails โ it never re-implements CSP. Per-controller CSP overrides the global initializer for that controller.- CSP nonce generation (
content_security_policy_nonce_generator) is app-wide initializer config and intentionally stays out of the concern. - These headers mitigate clickjacking / MIME-sniffing and (via CSP) XSS as defense-in-depth โ output escaping remains the primary defense.
๐ Localizable
Per-request locale selection from the request params and/or the Accept-Language header, wrapped in an around_action so I18n.locale is set for the action and restored afterwards. Dependency-free.
class ApplicationController < ActionController::Base
include ConcernsOnRails::Controllers::Localizable
localizable available: %i[en fr de], default: :en
# localizable param: :lang, header: false # params[:lang] only
end
Resolution order: params[param] โ first match in Accept-Language โ default โ I18n.default_locale. The chosen locale is always validated against I18n.available_locales, so a stray param or a mismatched available: list can never raise I18n::InvalidLocale.
Options: available: (allow-list for matching; defaults to I18n.available_locales), default:, param: (default :locale), header: (default true).
๐ Authorizable
A declarative, block-only per-action authorization gate. Each rule is a predicate; the first one that applies to the current action and returns falsey halts the request with 403. Deliberately small โ not a Pundit/CanCan replacement.
class Api::BaseController < ApplicationController
include ConcernsOnRails::Controllers::Authorizable
{ current_user.present? } # every action
(only: %i[update destroy]) { |_action, user| user.admin? }
require_role :admin, :editor, only: :publish # role sugar
end
The predicate runs via instance_exec, so current_user (and any helper) resolves on the controller. It is arity-safe โ write it with zero, one (|action|), or two (|action, user|) parameters.
API
| Method | Signature |
|---|---|
authorize_by |
authorize_by(only: nil, except: nil, status: :forbidden, message: "Forbidden", &block) |
require_role |
require_role(*roles, via: :current_user, role_method: :role, only:, except:, status:, message:) |
Notes
- Rules run in declaration order; the first failing rule renders and halts.
- When
Respondableis also included, denials delegate torender_error(envelope{ success: false, error: { message:, code: "forbidden" } }); otherwise the same envelope is rendered inline. only:/except:are mutually exclusive (passing both raisesArgumentError);authorize_byrequires a block andrequire_rolerequires at least one role.- Non-goals: no policy objects, no ability DSL, no resource inference โ reach for
pundit/cancancanwhen you outgrow a predicate per action.
๐ฆ Throttleable
Per-request rate limiting with a store-agnostic, injectable backend โ no rack-attack needed. When a rule's limit is exceeded the request is halted with 429 plus Retry-After and X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset.
class Api::BaseController < ApplicationController
include ConcernsOnRails::Controllers::Throttleable
self.throttleable_store = Rails.cache # must support atomic #increment
throttle_by limit: 100, period: 1.minute # by IP (default)
throttle_by limit: 5, period: 1.minute, only: :create,
by: -> { current_user&.id || request.remote_ip }
end
Fixed-window counter: the key embeds a floored time bucket (epoch / period) so each window starts clean and X-RateLimit-Reset is exact.
Options: limit: (positive integer), period: (a Duration or seconds), by: (discriminator lambda, default per-IP), only: / except: (mutually exclusive action scoping), name: (disambiguates the counter key).
Notes
- The store MUST support atomic increment-with-expiry (
Rails.cachewith#increment, or Redis) โ a non-atomic store under-counts under concurrency. - There is no in-process default store on purpose: the first throttled request raises
ArgumentErroruntil you setthrottleable_store, so you never silently rate-limit per-process. - When
Respondableis included, the 429 body delegates torender_error(code: "rate_limited"). - Backports the essentials of Rails 7.2's
rate_limit(with standardized headers) to Rails 5.0+. For richer rules (fail2ban, allow/deny lists, exponential backoff) reach forrack-attack.
๐ Timezoneable
Per-request Time.zone selection wrapped in an around_action (Time.use_zone) โ the time analogue of Localizable. Dependency-free.
class ApplicationController < ActionController::Base
include ConcernsOnRails::Controllers::Timezoneable
timezoneable available: ["UTC", "Eastern Time (US & Canada)"], default: "UTC"
# timezoneable param: :tz, header: false, cookie: :time_zone
end
Resolution order: params[param] โ Time-Zone header โ cookie (if enabled) โ default โ the current Time.zone. Every value โ the configured available: / default: and each request candidate โ is resolved through ActiveSupport::TimeZone[...], so a zone accepted at boot can never be rejected at request time.
Options: available: (allow-list applied to param/header/cookie matching; default: bypasses it, mirroring Localizable), default:, param: (default :time_zone), header: (default true, reads the Time-Zone header), cookie: (default false; true reads the :time_zone cookie, or pass a cookie name).
Notes
- An unknown
available:/default:zone raisesArgumentErrorat declaration time (fail-fast on misconfiguration). - Pairs naturally with the model concerns that read the clock (
Schedulable,Publishable,Expirable,SoftDeletable).
๐ Idempotentable
Stripe-style Idempotency-Key support for mutating endpoints, with a store-agnostic, injectable backend. The first request with a key runs the action and caches the response; a retry replays the cached response; a concurrent duplicate gets 409.
class PaymentsController < ApplicationController
include ConcernsOnRails::Controllers::Idempotentable
self.idempotency_store = Rails.cache # must support #read / #write(expires_in:, unless_exist:) / #delete
idempotent_actions :create, ttl: 24.hours, required: true
end
Per-key lifecycle: claim atomically (write unless_exist, TTL lock_ttl:) โ run action โ cache 2xxโ4xx responses for ttl:; 5xx and raised exceptions release the claim so the client can retry. Replays carry X-Idempotency-Replayed: true; duplicates in flight get 409 + Retry-After; reusing a key with a different payload gets 422 (idempotency_key_reuse, fingerprint overridable via idempotency_fingerprint).
Options: *actions (allow-list, required), ttl: (24.hours), lock_ttl: (1.minute), header: ("Idempotency-Key"), required: (false).
Notes
- Cache keys are scoped per
controller#actionand the client key is SHA256-hashed, so the same key on different endpoints never collides. - There is no in-process default store on purpose: the first keyed request raises
ArgumentErroruntil you setidempotency_store. - When
Respondableis included, the 400/409/422 bodies delegate torender_error. - Declare halting filters (authentication,
Throttleable) before including this concern โ a 401/403 rendered by an inner filter would be cached and replayed for the full TTL. Responses rendered byrescue_fromhandlers are never cached. - Keys must be โค255 chars with no control characters (the raw key is echoed in
X-Idempotency-Key); setlock_ttl:above the slowest declared action's worst case.
๐ช WebhookVerifiable
HMAC signature verification for inbound webhooks โ the receiving side of Stripe/GitHub/Shopify-style integrations. The action runs only when the signature over the raw request body verifies; otherwise a 401/400 is rendered and the action never executes.
class WebhooksController < ApplicationController
include ConcernsOnRails::Controllers::WebhookVerifiable # declare BEFORE Idempotentable
verify_webhook :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }, scheme: :stripe
verify_webhook :github, secret: -> { ENV["GITHUB_WEBHOOK_SECRET"] }, scheme: :github
verify_webhook :shopify, secret: [ENV["NEW_SECRET"], ENV["OLD_SECRET"]], scheme: :shopify # rotation
verify_webhook :custom, secret: "s3cr3t", scheme: :hex, header: "X-Acme-Signature"
# verify_webhook secret: ... # no actions = catch-all (declare specific rules first)
def stripe
event = JSON.parse(request.raw_post) # parse the raw body โ it is what was signed
# ...
end
end
| Scheme | Header (default) | Format |
|---|---|---|
:github |
X-Hub-Signature-256 |
sha256=<hex> |
:shopify |
X-Shopify-Hmac-Sha256 |
strict Base64 of the binary HMAC |
:stripe |
Stripe-Signature |
t=<unix>,v1=<hex>[,v1=โฆ] โ signs "#{t}.#{body}", every v1 tried, tolerance: rejects stale and future timestamps |
:hex / :base64 |
โ (header: required) |
plain hex / strict Base64 HMAC of the body |
Options: *actions (none = catch-all; the first matching rule wins), secret: (String, callable instance_exec'd per request, or Array for rotation โ any match passes), scheme: (:hex), header: (overrides the preset), tolerance: (Stripe only, 300s default), digest: (:sha256; :sha1/:sha512 for :hex/:base64 only).
Notes
- Comparison is constant-time and the attacker-controlled header is never decoded โ garbage (including invalid UTF-8 bytes) just fails with 401, it cannot raise.
- A secret that resolves blank at request time raises
ArgumentErrorโ a misconfigured endpoint should page you, not 401 into the provider's silent retry loop. - Failure codes:
webhook_signature_missing/webhook_signature_invalid/webhook_timestamp_staleโ 401;webhook_signature_malformed(unparseable Stripe header) โ 400. WithRespondable, bodies delegate torender_error; overridewebhook_verification_failedto customize. - Declare before
Idempotentable(a 401 cached by its around filter would be replayed) and beforeThrottleable(forged traffic shouldn't burn rate budget). Webhook endpoints also needskip_before_action :verify_authenticity_token. - In tests:
skip_before_action :verify_webhook_signature!, or sign payloads for real withOpenSSL::HMAC. After a pass,webhook_verified?is true.
๐ Deprecatable
Standards-based API endpoint deprecation: the RFC 9745 Deprecation and RFC 8594 Sunset headers, Link rels pointing at the migration docs and the successor endpoint, an instrumentation hook to measure who still calls the endpoint, and optional 410 Gone enforcement once the sunset instant passes. This is how Stripe/GitHub/Zalando retire API versions โ and nothing native exists on any Rails version.
class Api::V1::OrdersController < ApplicationController
include ConcernsOnRails::Controllers::Deprecatable
deprecate_actions :index, :show,
deprecated_at: "2026-06-01",
sunset_at: "2026-12-31T00:00:00Z",
link: "https://docs.example.com/v1-migration",
successor: "https://api.example.com/v2/orders",
after_sunset: :gone, # default :headers โ announce, never block
notify: -> { StatsD.increment("api.v1.orders.deprecated") }
end
# Every matching response then carries:
# Deprecation: @1780272000
# Sunset: Thu, 31 Dec 2026 00:00:00 GMT
# Link: <https://docs.example.com/v1-migration>; rel="deprecation", <https://api.example.com/v2/orders>; rel="successor-version"
Options: deprecated_at: (required; Time/Date/String โ parsed eagerly, normalized to UTC), sunset_at: (optional, must be โฅ deprecated_at; a bare date means 00:00 UTC that day โ sunset is an instant, not end-of-day), link: / successor: (URLs), after_sunset: (:headers default | :gone โ 410 with code endpoint_sunset at/after the sunset instant), header_format: (:rfc9745 default, @<unix> | :legacy, the widely-deployed draft literal true), notify: (callable, instance_exec'd per matching request โ a raising notify propagates on purpose). No positional actions = catch-all for the whole controller. The last matching rule wins, so an action-specific declaration naturally overrides a base controller's catch-all.
Notes
- Headers go out on every matching response โ including the 410 itself, so the cut-off self-documents.
Linkvalues are appended to any existingLinkheader (pagination, CDN), never clobbered. - Each hit instruments
deprecated_endpoint.concerns_on_rails(ActiveSupport::Notifications) with{controller:, action:, deprecated_at:, sunset_at:}โ subscribe to count stragglers before flippingafter_sunset: :gone. Overrideon_deprecated_access(rule)to replace the default instrumentation. - 410 bodies delegate to
Respondable'srender_errorwhen present (inline JSON envelope otherwise).skip_before_action :apply_api_deprecationsopts an action out;deprecation_active?/sunset_passed?are available for serializers/response bodies. - Flipping
:goneis a deliberate, customer-facing cut-off โ coordinate it withnotify:-driven outreach, and mind CDN-cached responses that may outlive the headers.
๐๏ธ Module paths & namespacing
Every concern is available under two paths:
# Short form (recommended for brevity):
include ConcernsOnRails::Sluggable
include ConcernsOnRails::Normalizable
# Fully-qualified form:
include ConcernsOnRails::Models::Sluggable
include ConcernsOnRails::Models::Normalizable
Controller concerns live under ConcernsOnRails::Controllers::* (no short form, to disambiguate from Models::Sortable):
include ConcernsOnRails::Controllers::Paginatable
include ConcernsOnRails::Controllers::Sortable
Both forms reference the same module, so you can freely mix them.
๐งญ Philosophy & when to reach for a dedicated gem
concerns_on_rails aims to cover the common 80% of each behavior with one include + one declarative macro, schema-validated configuration, no-surprise defaults, and lean dependencies (only acts_as_list and friendly_id; controller concerns have none). It is deliberately not a re-implementation of the category leaders โ reach for a dedicated gem when you outgrow the basics:
| Need | Use instead |
|---|---|
| Complex state machines (callbacks, transition logging) | aasm |
| Association-cascade soft delete / sentinel-aware unique indexes | paranoia or discard |
| Tagging with contexts, ownership, or tag clouds | acts-as-taggable-on |
| Full-text search with ranking / stemming | pg_search / Elasticsearch |
| Versioned audit trails with undo/reify, who-dunnit queries, or association tracking | paper_trail / audited |
Sluggable wraps friendly_id and Sortable wraps acts_as_list, so you get those leaders' engines behind the declarative macro.
๐ ๏ธ Development
bundle install # install dev dependencies
bundle exec rspec # run the test suite
gem build concerns_on_rails.gemspec # build the gem
gem install ./concerns_on_rails-1.11.2.gem # install locally
The test suite uses an in-memory SQLite database and a lightweight FakeController harness for controller-concern specs โ no Rails routes or boot required.
๐ค Contributing
Bug reports and pull requests are welcome at github.com/VSN2015/concerns_on_rails. โญ๏ธ stars and ๐ด forks appreciated.
๐ License
MIT โ see LICENSE.
๐ป๐ณ Hoร ng Sa and Trฦฐแปng Sa belong to Viแปt Nam.