concerns_on_rails 1.19.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 191acd17664d56588e54832b2377ede09cf836fe481a0e4c27246adae9f2eb76
4
- data.tar.gz: 95dc3faff52552da6688961fd51e926d4eb2a041f9974f05ea77a491b698d434
3
+ metadata.gz: c419970fee56d2f4d1d02f534c07abd646219db975823b089f1801192eb15689
4
+ data.tar.gz: 266601e25a1dd81854dacc62f77c85d913e7dc633614a32001b43359abe33069
5
5
  SHA512:
6
- metadata.gz: f472e83548c6ccd7850c3d3c9994cbb1efac4637210f07f35f69e16ca700aa7f131bed7d24737e825896aa8301cd2786dfe70aa0f563002f1bc8d199056fae28
7
- data.tar.gz: 6c231481577b2db38df1730cb16ad72c2f06ea7bba9d9dc0730e0c8068dce94597b26b514241e1ebd6be51b91caf4ae75dbb8ea32aa11f1aef9109cd45f3d38b
6
+ metadata.gz: 5069eb540a84ecda03c062eeae3dea59ec47ecff05d6107d1c198b936a0c55c2a8364a558c4376d7ee38ad9c082e5ddf0aafd72042a2370415f5056714a71098
7
+ data.tar.gz: 65f7b71e32329fbb8575342f44349757f37a835b913334ebf6f362b015962c70c5f3e8fd45fac0afdace8da2914beb2c49a1d74b564c36e3795de4a2b7f1b9b6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.21.0 (2026-07-01)
4
+
5
+ A new model concern for transparent field-level encryption — the "encrypt SSN/DOB at rest" capability the sensitive-data toolkit (Maskable, Sanitizable, Tokenizable) was missing. Hand-rolled on stdlib OpenSSL so it behaves identically on Rails 5.0–8, rather than delegating to Rails 7.1+ native `encrypts`. 978 examples, 0 failures.
6
+
7
+ ### Added
8
+ - **Models::Encryptable**: transparent per-field encryption for sensitive columns (SSN, DOB, cards) — AES-256-GCM via stdlib OpenSSL, no new dependency (the crypto toolbox already proven in Controllers::WebhookVerifiable). `encryptable :ssn, :dob, type: :date, key: ...` (repeatable; per-field options) registers a custom `ActiveModel::Type` on the declared column, so reads/writes stay plaintext and the DB column holds a versioned, authenticated Base64 envelope (`ver|alg|key_id|iv|tag|ciphertext`, the 3-byte header fed to GCM as additional authenticated data). Because it is an immutable value type, dirty tracking compares the decrypted plaintext — a re-save of unchanged data is never spuriously dirtied by GCM's random IV, and an unchanged field is not re-encrypted. `type:` casts the decrypted value through the Storable caster set (`:string`/`:integer`/`:float`/`:decimal`/`:boolean`/`:date`/`:datetime`; `:decimal` precision-safe, `:datetime` UTC microseconds). Keys come from a gem-level config (`ConcernsOnRails.configure_encryption { |c| c.key = ... }`, memoized like `.deprecator`; a String / 64-hex / 32-byte-raw value or a lazy Proc, stretched via PBKDF2-HMAC-SHA256) or a per-field `key:` override; a missing key raises `MissingKeyError` at first use, never at class-load. Adds `<field>_ciphertext` (raw envelope) and `<field>_encrypted?` readers. Composes transparently: Normalizable normalizes plaintext before it is encrypted (order-independent), Maskable masks the decrypted value; declaring a field with BOTH `encryptable` and `auditable_by` RAISES (auditing would persist plaintext). Wrong key / tampered ciphertext / malformed envelope raise `DecryptionError` (never a raw OpenSSL error); `on_missing_key: :passthrough` and `raise_on_decrypt_error: false` are documented dev-only escape hatches. Non-deterministic by design, so encrypted columns are not queryable/searchable — deterministic equality lookups and multi-key rotation are planned follow-ups (the envelope already reserves the `alg`/`key_id` bytes). Zero new runtime dependencies.
9
+
10
+ ## 1.20.0 (2026-06-18)
11
+
12
+ Two new concerns — the conditional counter cache the prior design panel held back as "too risky for one release" (1.19.0's notes), now shipped with the full update matrix specified and tested, plus the HTTP conditional-GET layer the API-oriented controller suite was missing. 933 examples, 0 failures.
13
+
14
+ ### Added
15
+ - **Models::CounterCacheable**: conditional, denormalized association counters ("counter_culture-lite"). Rails' native `counter_cache: true` counts *every* `belongs_to` child into one column — it can't keep an `approved_comments_count` beside a `comments_count`, and offers no drift repair. Declared on the CHILD, `counter_cacheable_by :post` (repeatable; rules reassigned-not-mutated so subclasses inherit; `count:` defaults to `"<table_name>_count"`, `if:` an optional Ruby condition, `touch:` to bump the parent's `updated_at`) keeps one or many parent columns in sync. `after_create`/`after_update`/`after_destroy` adjust via `update_counters` — a single SQL `COALESCE(col,0) ± 1`, atomic under concurrency — inside the record's own save transaction (a rolled-back save rolls back the counter). The update path resolves the full matrix: a foreign-key reparent decrements the old parent and increments the new one; a condition flip (`if:` result changed) increments/decrements in place; a simultaneous reparent + flip composes both; a no-op save writes nothing. The previous condition state is reconstructed by transiently restoring the changed attributes to their pre-save values (`saved_changes`, `previous_changes` on 5.0). `recount_counter_caches!(association = nil)` repairs drift / backfills portably (no adapter-specific SQL: unconditional counters via `group(fk).count`, conditional via a Ruby tally), rewriting every parent. Macro-time `ArgumentError` validation: the `belongs_to` must be declared first, must not be polymorphic, the parent counter column must exist (deferred when the parent class isn't yet loadable — load-order tolerant), `:if` callable, `:touch` boolean, unknown options rejected. Zero new runtime dependencies.
16
+ - **Controllers::Cacheable**: HTTP conditional GET + declarative `Cache-Control` ("fresh_when/stale?-lite" for JSON APIs), with method names chosen NOT to shadow Rails' `ActionController::ConditionalGet`. `http_cache_actions :index, :show, max_age: 5.minutes, visibility: :public, must_revalidate:, no_store:, stale_while_revalidate:, vary:` declares a policy (repeatable; no actions = catch-all; LAST matching rule wins — the Deprecatable override convention) emitted via `after_action`; `no_store` overrides `max_age`, `Vary` is appended (deduped) to any existing header, and the policy rides a 304. `stale_resource?(resource, etag:, last_modified:)` sets the validators and, for a safe (GET/HEAD) request whose precondition matches, sends `304 Not Modified` and returns false (mirrors `stale?`); `set_cache_validators` / `request_matches_cache?` / overridable `cache_etag_for` / `cache_last_modified_for` round out the surface. Standards-correct: weak ETag `W/"<md5>"` from the resource's cache key (collections fold member keys + size), `Last-Modified` via `Time#httpdate` (not the hand-rolled-ISO8601 bug), `If-None-Match` weak comparison honouring `*` and lists, `If-Modified-Since` compared at whole-second granularity, and `If-None-Match` taking precedence over `If-Modified-Since` (RFC 7232 §3.3). Every `request`/`response` touch is guarded so it runs on bare objects. Macro-time `ArgumentError` validation for `:visibility`, durations, `:vary`, and the booleans. Zero new runtime dependencies.
17
+
3
18
  ## 1.19.0 (2026-06-13)
4
19
 
5
20
  Two new concerns, selected by a unanimous three-judge design panel over six independently-proposed candidates (typed-JSON-settings and RFC-deprecation-headers each beat conditional counter caches, deep cloning, anonymization, params contracts, feature gates, and maintenance mode on value × shippability), then hardened in review (an `ActiveSupport::TimeWithZone` passed as `deprecated_at:`/`sunset_at:` — i.e. `Time.current` — was spuriously rejected as unparseable because `Module#===` ignores TimeWithZone's `is_a?(Time)` lie; a bare `Date` written to a `:datetime` key now anchors to midnight UTC instead of the host zone via `Date#to_time`; an unknown `header_format:` now raises at declaration time instead of silently emitting the RFC form). 894 examples, 0 failures.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Ethan Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,5 +1,21 @@
1
+ <div align="center">
2
+
1
3
  # 🧩 ConcernsOnRails
2
4
 
5
+ **Plug-and-play ActiveSupport concerns for Rails models &amp; controllers.**<br/>
6
+ One `include`, one declarative macro — done.
7
+
8
+ [![Gem Version](https://img.shields.io/gem/v/concerns_on_rails?logo=rubygems&logoColor=white&color=CC342D)](https://rubygems.org/gems/concerns_on_rails)
9
+ [![Downloads](https://img.shields.io/gem/dt/concerns_on_rails?color=1f6feb)](https://rubygems.org/gems/concerns_on_rails)
10
+ [![CI](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml/badge.svg)](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml)
11
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
12
+ [![Rails](https://img.shields.io/badge/rails-5.0--8.x-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org)
13
+ [![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](#-license)
14
+
15
+ 🧩 **23 model concerns** &nbsp;·&nbsp; 🎮 **16 controller concerns** &nbsp;·&nbsp; 🪶 **lean deps** &nbsp;·&nbsp; ✅ **schema-validated**
16
+
17
+ </div>
18
+
3
19
  > 🇻🇳 **Hoàng Sa and Trường Sa belong to Việt Nam.**
4
20
 
5
21
  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.
@@ -20,59 +36,62 @@ Article.published.without_deleted.find("hello-world")
20
36
 
21
37
  ## 📚 Table of Contents
22
38
 
23
- - [Why this gem?](#-why-this-gem)
24
- - [Installation](#-installation)
25
- - [Compatibility](#-compatibility)
26
- - [Quick Start](#-quick-start)
27
- - **Model concerns**
28
- - [Sluggable](#-sluggable) — URL-friendly slugs
29
- - [Sortable](#-sortable) list ordering via `acts_as_list`
30
- - [Publishable](#-publishable) `published_at` timestamp publishing
31
- - [SoftDeletable](#-softdeletable) soft delete with scopes & hooks
32
- - [Hashable](#-hashable) auto-generate tokens / UUIDs / codes
33
- - [Schedulable](#-schedulable) `starts_at` / `ends_at` time windows
34
- - [Expirable](#-expirable) single-timestamp expiry
35
- - [Normalizable](#-normalizable) attribute normalization (`:email`, `:phone`, …)
36
- - [Searchable](#-searchable) LIKE/ILIKE search across configured columns
37
- - [Activatable](#-activatable) boolean active/inactive toggle
38
- - [Tokenizable](#-tokenizable) security tokens with timing-safe lookup
39
- - [Sequenceable](#-sequenceable) ordered, human-friendly reference numbers
40
- - [Stateable](#-stateable) lightweight string-backed state machine
41
- - [Addressable](#-addressable) postal address normalization + format validation
42
- - [Taggable](#-taggable) lightweight tagging over a single column
43
- - [Sanitizable](#-sanitizable) opt-in HTML sanitization (XSS defense-in-depth)
44
- - [Maskable](#-maskable) non-destructive display masking of sensitive fields
45
- - [Monetizable](#-monetizable) integer-cents money columns (BigDecimal)
46
- - [Auditable](#-auditable) single-column change history ("paper_trail-lite")
47
- - [Lockable](#-lockable) failed-attempt tracking + account lockout
48
- - [Aliasable](#-aliasable) full read/write/query aliases for associations
49
- - [Storable](#-storable) typed accessors over one JSON settings column ("store_attribute-lite")
50
- - **Controller concerns**
51
- - [Paginatable](#-paginatable) offset pagination with headers
52
- - [CursorPaginatable](#-cursorpaginatable) — cursor (keyset) pagination with headers
53
- - [Filterable](#-filterable) declarative URL-param filters
54
- - [Sortable (controller)](#-sortable-controller) — URL-param ordering with allow-list
55
- - [Respondable](#-respondable) standardized JSON envelopes
56
- - [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
57
- - [Includable](#-includable) whitelisted association sideloading + sparse fieldsets
58
- - [SecureHeadable](#-secureheadable) security response headers + native CSP DSL
59
- - [Localizable](#-localizable) per-request locale from params / Accept-Language
60
- - [Authorizable](#-authorizable) per-action 403 authorization gate (block-based)
61
- - [Throttleable](#-throttleable) rate limiting with 429 + `X-RateLimit-*` headers
62
- - [Timezoneable](#-timezoneable) per-request `Time.zone` from params / header / cookie
63
- - [Idempotentable](#-idempotentable) `Idempotency-Key` request replay (409 on concurrent duplicates)
64
- - [WebhookVerifiable](#-webhookverifiable) HMAC verification for inbound webhooks (Stripe/GitHub/Shopify)
65
- - [Deprecatable](#-deprecatable) RFC `Deprecation`/`Sunset` headers + 410 sunset enforcement
66
- - [Module paths & namespacing](#-module-paths--namespacing)
67
- - [Development](#-development)
68
- - [Contributing](#-contributing)
69
- - [License](#-license)
39
+ [Why this gem?](#-why-this-gem) &nbsp;·&nbsp; [Installation](#-installation) &nbsp;·&nbsp; [Compatibility](#-compatibility) &nbsp;·&nbsp; [Quick Start](#-quick-start) &nbsp;·&nbsp; [Module paths](#-module-paths--namespacing) &nbsp;·&nbsp; [Development](#-development) &nbsp;·&nbsp; [Contributing](#-contributing) &nbsp;·&nbsp; [License](#-license)
40
+
41
+ ### 🧱 Model concerns
42
+
43
+ | Concern | What it does |
44
+ |---------|--------------|
45
+ | [📝 Sluggable](#-sluggable) | URL-friendly slugs |
46
+ | [🔢 Sortable](#-sortable) | List ordering via `acts_as_list` |
47
+ | [📤 Publishable](#-publishable) | `published_at` timestamp publishing |
48
+ | [❌ SoftDeletable](#-softdeletable) | Soft delete with scopes &amp; hooks |
49
+ | [🔐 Hashable](#-hashable) | Auto-generate tokens / UUIDs / codes |
50
+ | [🗓️ Schedulable](#-schedulable) | `starts_at` / `ends_at` time windows |
51
+ | [⏳ Expirable](#-expirable) | Single-timestamp expiry |
52
+ | [✨ Normalizable](#-normalizable) | Attribute normalization (`:email`, `:phone`, …) |
53
+ | [🔍 Searchable](#-searchable) | LIKE / ILIKE search across columns |
54
+ | [✅ Activatable](#-activatable) | Boolean active / inactive toggle |
55
+ | [🔑 Tokenizable](#-tokenizable) | Security tokens with timing-safe lookup |
56
+ | [🧾 Sequenceable](#-sequenceable) | Ordered, human-friendly reference numbers |
57
+ | [🔄 Stateable](#-stateable) | Lightweight string-backed state machine |
58
+ | [🏠 Addressable](#-addressable) | Postal address normalization + validation |
59
+ | [🏷️ Taggable](#-taggable) | Lightweight tagging over a single column |
60
+ | [🧼 Sanitizable](#-sanitizable) | Opt-in HTML sanitization (XSS defense) |
61
+ | [🙈 Maskable](#-maskable) | Non-destructive display masking |
62
+ | [💰 Monetizable](#-monetizable) | Integer-cents money columns (BigDecimal) |
63
+ | [📜 Auditable](#-auditable) | Single-column change history ("paper_trail-lite") |
64
+ | [🔐 Lockable](#-lockable) | Failed-attempt tracking + account lockout |
65
+ | [🪞 Aliasable](#-aliasable) | Full read / write / query association aliases |
66
+ | [⚙️ Storable](#-storable) | Typed accessors over one JSON column ("store_attribute-lite") |
67
+ | [🧮 CounterCacheable](#-countercacheable) | Conditional denormalized counters ("counter_culture-lite") |
68
+
69
+ ### 🎮 Controller concerns
70
+
71
+ | Concern | What it does |
72
+ |---------|--------------|
73
+ | [📄 Paginatable](#-paginatable) | Offset pagination with headers |
74
+ | [🧭 CursorPaginatable](#-cursorpaginatable) | Cursor (keyset) pagination with headers |
75
+ | [🔎 Filterable](#-filterable) | Declarative URL-param filters |
76
+ | [↕️ Sortable (controller)](#-sortable-controller) | URL-param ordering with allow-list |
77
+ | [📦 Respondable](#-respondable) | Standardized JSON envelopes |
78
+ | [🛟 ErrorHandleable](#-errorhandleable) | JSON `rescue_from` handlers |
79
+ | [🔗 Includable](#-includable) | Association sideloading + sparse fieldsets |
80
+ | [🛡️ SecureHeadable](#-secureheadable) | Security response headers + native CSP DSL |
81
+ | [🌐 Localizable](#-localizable) | Per-request locale from params / `Accept-Language` |
82
+ | [🔒 Authorizable](#-authorizable) | Per-action 403 authorization gate |
83
+ | [🚦 Throttleable](#-throttleable) | Rate limiting (429 + `X-RateLimit-*`) |
84
+ | [🕒 Timezoneable](#-timezoneable) | Per-request `Time.zone` from params / header / cookie |
85
+ | [🔁 Idempotentable](#-idempotentable) | `Idempotency-Key` request replay |
86
+ | [🪝 WebhookVerifiable](#-webhookverifiable) | HMAC verification for inbound webhooks |
87
+ | [🌅 Deprecatable](#-deprecatable) | RFC `Deprecation` / `Sunset` headers + 410 |
88
+ | [🗄️ Cacheable](#-cacheable) | HTTP conditional GET (ETag / 304) + `Cache-Control` |
70
89
 
71
90
  ---
72
91
 
73
92
  ## ✨ Why this gem?
74
93
 
75
- - **Twenty-one model concerns + fourteen controller concerns**, all production-ready
94
+ - **Twenty-three model concerns + sixteen controller concerns**, all production-ready
76
95
  - **One include, one macro** — no boilerplate, no glue code
77
96
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
78
97
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -1077,6 +1096,39 @@ account.flag_beta # affixed accessor
1077
1096
 
1078
1097
  ---
1079
1098
 
1099
+ ## 🧮 CounterCacheable
1100
+
1101
+ Conditional, denormalized association counters ("counter_culture-lite"). Rails' built-in `belongs_to ..., counter_cache: true` maintains exactly one column counting *every* child — it can't keep an `approved_comments_count` next to a `comments_count`, and has no way to repair drift. Declared on the **child**, this keeps one or many parent columns in sync, each with an optional condition.
1102
+
1103
+ ```ruby
1104
+ class Comment < ApplicationRecord
1105
+ include ConcernsOnRails::CounterCacheable
1106
+
1107
+ belongs_to :post # declare the belongs_to FIRST
1108
+ belongs_to :author, class_name: "User"
1109
+
1110
+ counter_cacheable_by :post # posts.comments_count
1111
+ counter_cacheable_by :post, count: :approved_comments_count,
1112
+ if: -> { approved? } # conditional
1113
+ counter_cacheable_by :author, count: :posts_count, touch: true
1114
+ end
1115
+
1116
+ post.comments_count # maintained on create / destroy / update
1117
+ Comment.recount_counter_caches! # repair drift / backfill every counter
1118
+ ```
1119
+
1120
+ Counters are adjusted with `update_counters` (a single atomic SQL `COALESCE(col,0) ± 1`) inside the record's own save transaction. The update path handles the full matrix: a **foreign-key reparent** moves the count from the old parent to the new one, a **condition flip** increments/decrements in place, and the two compose.
1121
+
1122
+ **Options** (`counter_cacheable_by association, …`, repeatable): `count:` (the parent column; default `"<table_name>_count"`), `if:` (a callable evaluated against the record — counts only when truthy; the previous state is reconstructed for updates), `touch:` (`false`; also bump the parent's `updated_at`).
1123
+
1124
+ **Notes**
1125
+ - The `belongs_to` must be declared **before** the macro (the reflection is validated at declaration). Polymorphic associations are not supported.
1126
+ - Don't also set native `counter_cache: true` on the same column — both would fire and double-count.
1127
+ - Counters track the **persisted** record; writes that skip callbacks (`update_column(s)`, `update_all`, `delete`) are not tracked — run `recount_counter_caches!` to reconcile. It rewrites every parent (portable across adapters, but O(n) for conditional counters) — a maintenance operation, run it offline.
1128
+ - Reach for [`counter_culture`](https://github.com/magnusvk/counter_culture) when you need multi-level rollups, delta columns, or after-commit execution.
1129
+
1130
+ ---
1131
+
1080
1132
  # 🎮 Controller Concerns
1081
1133
 
1082
1134
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1575,6 +1627,43 @@ end
1575
1627
 
1576
1628
  ---
1577
1629
 
1630
+ ## 🗄️ Cacheable
1631
+
1632
+ HTTP conditional GET + declarative `Cache-Control` ("fresh_when/stale?-lite" for JSON APIs). The method names are deliberately distinct from Rails' `ActionController::ConditionalGet`, so including it never shadows `fresh_when` / `stale?` / `expires_in`.
1633
+
1634
+ ```ruby
1635
+ class Api::ArticlesController < ApplicationController
1636
+ include ConcernsOnRails::Controllers::Cacheable
1637
+
1638
+ http_cache_actions :index, :show, max_age: 5.minutes,
1639
+ visibility: :public, vary: "Accept"
1640
+
1641
+ def show
1642
+ @article = Article.find(params[:id])
1643
+ return unless stale_resource?(@article) # 304 + halt when the client copy is fresh
1644
+ render json: @article
1645
+ end
1646
+ end
1647
+
1648
+ # A matching response then carries:
1649
+ # Cache-Control: public, max-age=300
1650
+ # Vary: Accept
1651
+ # ETag: W/"…"
1652
+ # Last-Modified: Thu, 01 Jan 2026 12:00:00 GMT
1653
+ ```
1654
+
1655
+ `http_cache_actions` declares the `Cache-Control`/`Vary` policy (emitted via `after_action` — it rides a 304 too); `stale_resource?` sets the ETag/Last-Modified validators and, on a safe request whose precondition matches, sends `304 Not Modified` and returns `false`.
1656
+
1657
+ **Options** (`http_cache_actions *actions, …`, repeatable; no actions = catch-all; **last matching rule wins**): `visibility:` (`:private` default | `:public`), `max_age:` (Integer/Duration), `must_revalidate:`, `no_store:` (overrides everything → bare `no-store`), `stale_while_revalidate:`, `vary:` (String or Array, appended to any existing `Vary`).
1658
+
1659
+ **Conditional-GET correctness**
1660
+ - Weak ETag `W/"<md5>"` from the resource's cache key (collections fold their members' keys + size); `If-None-Match` is matched with **weak comparison**, honours `*`, and accepts a comma-separated list.
1661
+ - `Last-Modified` is an IMF-fixdate via `Time#httpdate` (not hand-rolled ISO 8601); `If-Modified-Since` is compared at whole-second granularity.
1662
+ - When both are sent, `If-None-Match` wins and the date is ignored (RFC 7232 §3.3); a 304 is only sent for safe (GET/HEAD) requests, and still carries the validators and the `Cache-Control` policy.
1663
+ - Override `cache_etag_for` / `cache_last_modified_for` to customise validator derivation. For write-side preconditions (`If-Match` → 412), reach for Rails' own helpers.
1664
+
1665
+ ---
1666
+
1578
1667
  ## 🗂️ Module paths & namespacing
1579
1668
 
1580
1669
  Every concern is available under two paths:
@@ -0,0 +1,318 @@
1
+ require "active_support/concern"
2
+ require "digest/md5"
3
+ require "time"
4
+
5
+ module ConcernsOnRails
6
+ module Controllers
7
+ # HTTP conditional GET + declarative Cache-Control ("fresh_when/stale?-lite"
8
+ # for JSON APIs). Two layers:
9
+ #
10
+ # 1. A declarative `Cache-Control`/`Vary` POLICY per action (the macro).
11
+ # 2. Per-action validators (ETag / Last-Modified) with an automatic
12
+ # `304 Not Modified` short-circuit (the `stale_resource?` helper).
13
+ #
14
+ # class Api::ArticlesController < ApplicationController
15
+ # include ConcernsOnRails::Controllers::Cacheable
16
+ #
17
+ # http_cache_actions :index, :show, max_age: 5.minutes,
18
+ # visibility: :public, vary: "Accept"
19
+ #
20
+ # def show
21
+ # @article = Article.find(params[:id])
22
+ # return unless stale_resource?(@article) # 304 + halt when client copy is fresh
23
+ # render json: @article
24
+ # end
25
+ # end
26
+ #
27
+ # The method names are deliberately distinct from Rails'
28
+ # `ActionController::ConditionalGet` (`fresh_when` / `stale?` / `expires_in`)
29
+ # so including this concern in a real controller never shadows them.
30
+ #
31
+ # Conditional-GET correctness (the value over a hand-rolled version):
32
+ # * ETag is a WEAK validator `W/"<md5>"` derived from the resource's cache
33
+ # key — appropriate for serialized representations (semantic, not
34
+ # byte-for-byte, equivalence). `If-None-Match` is matched with weak
35
+ # comparison, honours `*`, and accepts a comma-separated list.
36
+ # * `Last-Modified` is an IMF-fixdate via `Time#httpdate` (NOT ISO 8601 —
37
+ # the classic bug). `If-Modified-Since` is compared at whole-second
38
+ # granularity (HTTP dates carry no sub-second part).
39
+ # * When BOTH `If-None-Match` and `If-Modified-Since` are sent, the ETag
40
+ # wins and the date is ignored (RFC 7232 §3.3).
41
+ # * The 304 is only sent for safe requests (GET/HEAD) and still carries the
42
+ # validators AND the policy headers (Cache-Control rides the 304, like
43
+ # Deprecatable's headers ride the 410).
44
+ #
45
+ # Notes:
46
+ # * `no_store: true` overrides everything (emits the lone `no-store`).
47
+ # * `Vary` is appended to any existing `Vary` header, de-duplicated.
48
+ # * No positional actions = catch-all; the LAST matching rule wins (the
49
+ # Deprecatable convention — caching policy is an override).
50
+ # * Works on bare objects (every `request`/`response` touch is guarded), so
51
+ # it is testable without the full Rails stack.
52
+ # * For write-side preconditions (`If-Match` / `If-Unmodified-Since` → 412)
53
+ # reach for Rails' own conditional-GET helpers; this concern covers the
54
+ # read path.
55
+ module Cacheable
56
+ extend ActiveSupport::Concern
57
+
58
+ LABEL = "ConcernsOnRails::Controllers::Cacheable".freeze
59
+ VALID_VISIBILITY = %i[public private].freeze
60
+ SAFE_METHODS = %w[GET HEAD].freeze
61
+
62
+ included do
63
+ class_attribute :cacheable_rules, instance_accessor: false, default: []
64
+ after_action :apply_http_cache_headers
65
+ end
66
+
67
+ module ClassMethods
68
+ # Declare a Cache-Control/Vary policy for the given actions (none =
69
+ # catch-all). Repeatable; rules accumulate (reassigned, never mutated, so
70
+ # subclasses inherit) and the LAST one matching the action wins.
71
+ def http_cache_actions(*actions, visibility: :private, max_age: nil, must_revalidate: false,
72
+ no_store: false, stale_while_revalidate: nil, vary: nil)
73
+ actions = actions.flatten.map(&:to_s)
74
+ visibility = visibility.to_sym
75
+ validate_http_cache!(visibility: visibility, max_age: max_age, no_store: no_store,
76
+ must_revalidate: must_revalidate, stale_while_revalidate: stale_while_revalidate)
77
+
78
+ rule = {
79
+ actions: actions, visibility: visibility,
80
+ max_age: http_cache_seconds(max_age),
81
+ must_revalidate: must_revalidate ? true : false,
82
+ no_store: no_store ? true : false,
83
+ stale_while_revalidate: http_cache_seconds(stale_while_revalidate),
84
+ vary: http_cache_vary(vary)
85
+ }
86
+ self.cacheable_rules = cacheable_rules + [rule]
87
+ end
88
+
89
+ private
90
+
91
+ def validate_http_cache!(visibility:, max_age:, no_store:, must_revalidate:, stale_while_revalidate:)
92
+ unless VALID_VISIBILITY.include?(visibility)
93
+ raise ArgumentError, "#{LABEL}: :visibility must be one of #{VALID_VISIBILITY.join(', ')}"
94
+ end
95
+ raise ArgumentError, "#{LABEL}: :max_age must be a positive Integer/Duration or nil" unless http_cache_duration_ok?(max_age)
96
+ unless http_cache_duration_ok?(stale_while_revalidate)
97
+ raise ArgumentError, "#{LABEL}: :stale_while_revalidate must be a positive Integer/Duration or nil"
98
+ end
99
+ raise ArgumentError, "#{LABEL}: :no_store must be true or false" unless [true, false].include?(no_store)
100
+ return if [true, false].include?(must_revalidate)
101
+
102
+ raise ArgumentError, "#{LABEL}: :must_revalidate must be true or false"
103
+ end
104
+
105
+ def http_cache_duration_ok?(value)
106
+ value.nil? || ((value.is_a?(Integer) || value.is_a?(ActiveSupport::Duration)) && value.to_i.positive?)
107
+ end
108
+
109
+ def http_cache_seconds(value)
110
+ value&.to_i
111
+ end
112
+
113
+ # Normalize + validate :vary into an Array of header names (or nil).
114
+ def http_cache_vary(value)
115
+ case value
116
+ when nil then nil
117
+ when String then http_cache_vary_list([value])
118
+ when Array then http_cache_vary_list(value)
119
+ else raise ArgumentError, "#{LABEL}: :vary must be a String or Array of Strings"
120
+ end
121
+ end
122
+
123
+ def http_cache_vary_list(values)
124
+ list = values.map { |v| v.to_s.strip }
125
+ if list.empty? || list.any?(&:empty?)
126
+ raise ArgumentError, "#{LABEL}: :vary must be a non-blank String or Array of non-blank Strings"
127
+ end
128
+
129
+ list
130
+ end
131
+ end
132
+
133
+ # after_action entry point. Public so host apps can `skip_after_action` it
134
+ # or override it. Emits the matching action's Cache-Control + Vary.
135
+ def apply_http_cache_headers
136
+ rule = http_cache_rule_for_action
137
+ return unless rule
138
+ return unless respond_to?(:response) && response
139
+
140
+ value = http_cache_control_value(rule)
141
+ response.set_header("Cache-Control", value) if value
142
+ response.set_header("Vary", http_cache_merge_vary(rule[:vary])) if rule[:vary]
143
+ nil
144
+ end
145
+
146
+ # Set ETag/Last-Modified validators for the resource, then — for a safe
147
+ # request whose precondition matches — send 304 and return false. Returns
148
+ # true when the client must be sent a fresh body. Mirrors Rails `stale?`.
149
+ def stale_resource?(resource = nil, etag: nil, last_modified: nil)
150
+ validators = set_cache_validators(resource, etag: etag, last_modified: last_modified)
151
+ return true unless http_cache_safe_request?
152
+ return true unless request_matches_cache?(etag: validators[:etag], last_modified: validators[:last_modified])
153
+
154
+ http_cache_send_not_modified
155
+ false
156
+ end
157
+
158
+ # Set the ETag/Last-Modified response headers (no short-circuit). Returns
159
+ # the computed { etag:, last_modified: } pair.
160
+ def set_cache_validators(resource = nil, etag: nil, last_modified: nil)
161
+ etag ||= cache_etag_for(resource) unless resource.nil?
162
+ last_modified ||= cache_last_modified_for(resource) unless resource.nil?
163
+ http_cache_write_validators(etag, last_modified)
164
+ { etag: etag, last_modified: last_modified }
165
+ end
166
+
167
+ # Side-effect-free: does the request's precondition match these validators?
168
+ def request_matches_cache?(etag: nil, last_modified: nil)
169
+ if_none_match = http_cache_request_header("If-None-Match")
170
+ if_modified_since = http_cache_request_header("If-Modified-Since")
171
+
172
+ # If-None-Match takes precedence when present (RFC 7232 §3.3).
173
+ if if_none_match
174
+ etag && http_cache_etag_matches?(if_none_match, etag)
175
+ elsif if_modified_since
176
+ last_modified ? http_cache_not_modified_since?(if_modified_since, last_modified) : false
177
+ else
178
+ false
179
+ end
180
+ end
181
+
182
+ # Override points for deriving validators from a resource.
183
+ def cache_etag_for(resource)
184
+ http_cache_weak_etag(http_cache_key_for(resource))
185
+ end
186
+
187
+ def cache_last_modified_for(resource)
188
+ http_cache_timestamp_for(resource)
189
+ end
190
+
191
+ private
192
+
193
+ def http_cache_write_validators(etag, last_modified)
194
+ return unless respond_to?(:response) && response
195
+
196
+ response.set_header("ETag", etag) if etag
197
+ response.set_header("Last-Modified", last_modified.httpdate) if last_modified
198
+ end
199
+
200
+ def http_cache_rule_for_action
201
+ action = http_cache_action_name
202
+ return nil unless action
203
+
204
+ self.class.cacheable_rules.reverse_each.find do |rule|
205
+ rule[:actions].empty? || rule[:actions].include?(action)
206
+ end
207
+ end
208
+
209
+ def http_cache_control_value(rule)
210
+ return "no-store" if rule[:no_store]
211
+
212
+ parts = [rule[:visibility].to_s]
213
+ parts << "max-age=#{rule[:max_age]}" if rule[:max_age]
214
+ parts << "must-revalidate" if rule[:must_revalidate]
215
+ parts << "stale-while-revalidate=#{rule[:stale_while_revalidate]}" if rule[:stale_while_revalidate]
216
+ parts.join(", ")
217
+ end
218
+
219
+ def http_cache_merge_vary(vary_list)
220
+ values = []
221
+ existing = response.headers["Vary"]
222
+ values.concat(existing.split(",").map(&:strip)) unless existing.to_s.empty?
223
+ values.concat(vary_list)
224
+ values.uniq.join(", ")
225
+ end
226
+
227
+ def http_cache_send_not_modified
228
+ response.status = 304 if respond_to?(:response) && response
229
+
230
+ if respond_to?(:head)
231
+ head :not_modified
232
+ else
233
+ render(status: :not_modified)
234
+ end
235
+ end
236
+
237
+ def http_cache_safe_request?
238
+ return true unless respond_to?(:request) && request
239
+
240
+ if request.respond_to?(:get?) && request.respond_to?(:head?)
241
+ request.get? || request.head?
242
+ elsif request.respond_to?(:request_method)
243
+ SAFE_METHODS.include?(request.request_method.to_s.upcase)
244
+ else
245
+ true
246
+ end
247
+ end
248
+
249
+ def http_cache_request_header(name)
250
+ return nil unless respond_to?(:request) && request.respond_to?(:headers) && request.headers
251
+
252
+ request.headers[name]
253
+ end
254
+
255
+ def http_cache_etag_matches?(header, etag)
256
+ candidates = header.to_s.split(",").map(&:strip)
257
+ return true if candidates.include?("*")
258
+
259
+ target = http_cache_normalize_etag(etag)
260
+ candidates.any? { |candidate| http_cache_normalize_etag(candidate) == target }
261
+ end
262
+
263
+ # Weak comparison: ignore the "W/" prefix (RFC 7232 §2.3.2).
264
+ def http_cache_normalize_etag(value)
265
+ value.to_s.strip.sub(%r{\AW/}, "")
266
+ end
267
+
268
+ def http_cache_not_modified_since?(header, last_modified)
269
+ since = http_cache_parse_http_date(header)
270
+ return false unless since
271
+
272
+ last_modified.to_i <= since.to_i
273
+ end
274
+
275
+ def http_cache_parse_http_date(value)
276
+ Time.httpdate(value.to_s)
277
+ rescue ArgumentError
278
+ nil
279
+ end
280
+
281
+ def http_cache_weak_etag(key)
282
+ %(W/"#{Digest::MD5.hexdigest(key.to_s)}")
283
+ end
284
+
285
+ # cache_key_with_version (Rails 5.2+) → cache_key → a manual key; a
286
+ # relation/array folds its members' keys (+ size) so a changed collection
287
+ # changes the ETag.
288
+ def http_cache_key_for(resource)
289
+ if resource.respond_to?(:cache_key_with_version)
290
+ resource.cache_key_with_version
291
+ elsif resource.respond_to?(:cache_key)
292
+ resource.cache_key
293
+ elsif resource.is_a?(String)
294
+ resource
295
+ elsif resource.respond_to?(:to_a)
296
+ members = resource.to_a
297
+ "#{members.size}-#{members.map { |member| http_cache_key_for(member) }.join('/')}"
298
+ else
299
+ resource.to_s
300
+ end
301
+ end
302
+
303
+ def http_cache_timestamp_for(resource)
304
+ if resource.respond_to?(:updated_at)
305
+ resource.updated_at
306
+ elsif resource.respond_to?(:maximum)
307
+ resource.maximum(:updated_at)
308
+ elsif !resource.is_a?(String) && resource.respond_to?(:map)
309
+ resource.map { |member| member.respond_to?(:updated_at) ? member.updated_at : nil }.compact.max
310
+ end
311
+ end
312
+
313
+ def http_cache_action_name
314
+ respond_to?(:action_name) ? action_name.to_s : nil
315
+ end
316
+ end
317
+ end
318
+ end