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 +4 -4
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +137 -48
- data/lib/concerns_on_rails/controllers/cacheable.rb +318 -0
- data/lib/concerns_on_rails/encryption.rb +65 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +2 -0
- data/lib/concerns_on_rails/models/counter_cacheable.rb +279 -0
- data/lib/concerns_on_rails/models/encryptable.rb +279 -0
- data/lib/concerns_on_rails/support/encryptor.rb +103 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +22 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c419970fee56d2f4d1d02f534c07abd646219db975823b089f1801192eb15689
|
|
4
|
+
data.tar.gz: 266601e25a1dd81854dacc62f77c85d913e7dc633614a32001b43359abe33069
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 & controllers.**<br/>
|
|
6
|
+
One `include`, one declarative macro — done.
|
|
7
|
+
|
|
8
|
+
[](https://rubygems.org/gems/concerns_on_rails)
|
|
9
|
+
[](https://rubygems.org/gems/concerns_on_rails)
|
|
10
|
+
[](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml)
|
|
11
|
+
[](https://www.ruby-lang.org)
|
|
12
|
+
[](https://rubyonrails.org)
|
|
13
|
+
[](#-license)
|
|
14
|
+
|
|
15
|
+
🧩 **23 model concerns** · 🎮 **16 controller concerns** · 🪶 **lean deps** · ✅ **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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
39
|
+
[Why this gem?](#-why-this-gem) · [Installation](#-installation) · [Compatibility](#-compatibility) · [Quick Start](#-quick-start) · [Module paths](#-module-paths--namespacing) · [Development](#-development) · [Contributing](#-contributing) · [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 & 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-
|
|
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
|