๐Ÿงฉ 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?

  • Fourteen model concerns + six controller concerns, all production-ready
  • One include, one macro โ€” no boilerplate, no glue code
  • Lean dependencies โ€” only acts_as_list (Sortable) and friendly_id (Sluggable); controller concerns have zero extra deps
  • Schema-validated configuration โ€” every macro checks that the configured column exists and raises ArgumentError early
  • 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

Notes

  • Schema must have a slug column (string).
  • history: true requires a friendly_id_slugs table โ€” generate with rails generate friendly_id or add a manual migration.
  • scope: :col requires col to exist in the same table.
  • Falls back to to_s if 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

Notes

  • The configured field must exist as a column.
  • Direction values other than :asc / :desc silently 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_at is set and in the past โ€” so future-dated posts stay unpublished until their time arrives.
  • No default_scope is added by default; chain .published explicitly (or opt in with default_scope: true).

โŒ SoftDeletable

Soft delete records using a timestamp field (default: deleted_at). Includes a default_scope that hides deleted records and overrides destroy_all to soft-delete in bulk.

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.destroy_all          # soft-deletes all matching records
User.really_destroy_all   # hard-deletes all matching records
User.restore_all          # restores all soft-deleted records

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_create only 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 to before_validation in your model โ€” it uses before_create by 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 exactly ends_at.
  • A nil end means "never expires"; a nil start means "not yet started".
  • No default_scope; chain .current explicitly.

โณ 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_at is nil or in the past โ†’ new value is now + by
  • If expires_at is still in the future โ†’ by is 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.
  • nil values are skipped โ€” no nil โ†’ "" 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 emits ILIKE on Postgres (case-insensitive) and LIKE elsewhere.
  • 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

  • NULL is treated as inactive (same convention as most apps' "unset = off").
  • The configured column must exist; activatable_by raises ArgumentError otherwise.
  • SoftDeletable also defines a .active scope (alias of .without_deleted). If both concerns are included on the same model, the later one wins โ€” include the one whose .active semantics 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 unique DB index for real safety, especially for short alphanumeric/numeric codes.
  • .authenticate_by_<field> uses ActiveSupport::SecurityUtils.secure_compare to 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>) + 1 within 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 its into: string is still formatted from 100).
  • Generation reads MAX then inserts, so two concurrent inserts can race. It's best-effort โ€” add a scoped unique index on <field> (and on into:) for a real guarantee, the same way you would for any MAX-based numbering.
  • reset: requires a created_at column; the period is taken from each row's creation time.
  • For fixed-width display (00042), make the into: 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 ArgumentError if the column, states, default, or transition config is invalid.
  • Raises Stateable::InvalidTransition at runtime for disallowed guarded transitions.

Notes

  • String-column backed (not integer-backed like Rails enum) โ€” values are stored as-is.
  • States like active / expired overlap with Activatable/Expirable scopes โ€” use prefix: or suffix: to disambiguate.
  • No persistence of transition history; combine with Publishable / Schedulable for 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
                 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" Country used to pick the postal-code format when the record has no recognized country.
validate_state: false When true, validates the state against US / CA code sets.
verify_with: nil A callable for real-world verification (see below).

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).

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.
  • state: only when validate_state: true and the country is US/CA.

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.

๐ŸŽฎ 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.


๐Ÿ”Ž 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 :scope and :with raises ArgumentError.
  • 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] accepts asc / 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 via acts_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.full_messages)
    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 Respondable is also included, the handlers delegate to render_error so the envelope shape stays in one place. Otherwise they render the same envelope inline.
  • RecordInvalid.details are populated from error.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_fields to your serializer (e.g. AMS / Blueprinter) โ€” Includable itself does not alter the JSON output, only the query.

๐Ÿ—‚๏ธ 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.


๐Ÿ› ๏ธ 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.0.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.