concerns_on_rails 1.28.5 → 1.28.6
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 +109 -0
- data/README.md +102 -35
- data/lib/concerns_on_rails/configuration.rb +35 -0
- data/lib/concerns_on_rails/controllers/idempotentable.rb +76 -15
- data/lib/concerns_on_rails/controllers/includable.rb +88 -18
- data/lib/concerns_on_rails/controllers/respondable.rb +125 -0
- data/lib/concerns_on_rails/controllers/throttleable.rb +106 -10
- data/lib/concerns_on_rails/models/activatable.rb +90 -13
- data/lib/concerns_on_rails/models/auditable.rb +49 -5
- data/lib/concerns_on_rails/models/counter_cacheable.rb +94 -17
- data/lib/concerns_on_rails/models/duplicable.rb +13 -1
- data/lib/concerns_on_rails/models/hashable.rb +66 -11
- data/lib/concerns_on_rails/models/maskable.rb +83 -1
- data/lib/concerns_on_rails/models/sluggable.rb +18 -0
- data/lib/concerns_on_rails/models/tokenizable.rb +121 -29
- data/lib/concerns_on_rails/support/include_tree.rb +74 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d01a41891ae267bab705b85ab63d6555e7057719a218738bd82c858c63febfa9
|
|
4
|
+
data.tar.gz: 3bcaebd299e65af41029baebb90ba1670a3502fde222071bffeeef86a4e1fb7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 602d50edd8fd84224c73a6ddc09e756bf322f4b3b4e526d34e55774caa88594c54c3ba05d1920005c2152986035b230e2f982e5b6916c2a3a2ec402e1ac2c3c6
|
|
7
|
+
data.tar.gz: 9ab3f9c635ab132b284d673861c3a3a8282f93aa370f6f75cb1890a1205b9e3c924705b52afd7d182a94fb5247fdc38f51e94bcc4d2a0b1c8de39ea53ee03b54
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,114 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.28.6 (2026-09-18)
|
|
4
|
+
|
|
5
|
+
Ten feature PRs deepening existing concerns, released as a patch by request: no new
|
|
6
|
+
concerns and no dependency changes, though several add an optional column or option.
|
|
7
|
+
Every PR was reviewed before merge and carries the review's fixes; the `### Changed`
|
|
8
|
+
and `### Fixed` sections below are those fixes, and they are the ones to read — a few
|
|
9
|
+
tighten validation or start raising where the code used to accept bad input silently.
|
|
10
|
+
Eight further PRs were reviewed in the same pass and held back: they carry defects
|
|
11
|
+
that need more than a patch-sized change.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **Models::Maskable**: `masked_attributes` (every declared field masked, keyed like
|
|
15
|
+
`attributes`) and a `masked:` serialization option — `as_json(masked: true)` /
|
|
16
|
+
`to_json(masked: true)` / `serializable_hash(masked: true)` swap the declared fields
|
|
17
|
+
for their masked forms (`masked: [:email]` for a subset; undeclared fields raise).
|
|
18
|
+
Composes with `only:`/`except:`/`methods:`/`include:`; plain serialization is
|
|
19
|
+
unchanged. (#65)
|
|
20
|
+
- **Models::Activatable**: `before_activate` / `after_activate` / `before_deactivate` /
|
|
21
|
+
`after_deactivate` hooks (one transaction with the write; a raising after-hook rolls
|
|
22
|
+
back, a failed update skips it; overriding one moves that direction's batch verb to
|
|
23
|
+
the per-record path) and `activatable_by timestamps:` (`true` → `activated_at` /
|
|
24
|
+
`deactivated_at` stamped on each transition, or a Hash to rename/drop a side). The
|
|
25
|
+
batch fast path stamps too. (#78)
|
|
26
|
+
- **Models::Hashable**: `prefix:` prepends a literal to generated values (Stripe-style
|
|
27
|
+
public IDs such as `ord_k7m3pq9a`; string types only), and `to_param: true` makes the
|
|
28
|
+
hashed field the URL parameter (falls back to the id while blank). Both validated at
|
|
29
|
+
class load. (#70)
|
|
30
|
+
- **Models::Tokenizable**: `tokenizable_by … expires_in:` gives a token a lifetime —
|
|
31
|
+
`<field>_expires_at` is stamped on every generation, `authenticate_by_<field>` refuses
|
|
32
|
+
an expired token, `<field>_expired?` and the `<field>_expired` scope report it. New
|
|
33
|
+
`consume_<field>(value)` (every field) authenticates and revokes in one race-safe step
|
|
34
|
+
for single-use tokens. (#51)
|
|
35
|
+
- **Models::Auditable**: `ConcernsOnRails.setup { |c| c.audit_actor = -> { Current.user&.id } }`
|
|
36
|
+
sets the `"by"` actor once for every audited model that passes no `actor:` (resolved
|
|
37
|
+
per save, `instance_exec`'d on the record); `actor: false` opts a model out, and
|
|
38
|
+
`actor: :method_name` stamps a record method's value. (#50)
|
|
39
|
+
- **Models::CounterCacheable**: `recount_counter_caches!(association, parents:)` repairs
|
|
40
|
+
only the given parents (ids, records or a relation) — locked, zeroed and re-tallied in
|
|
41
|
+
one transaction, other rows untouched — so a post-import fix is O(their children).
|
|
42
|
+
Empty `parents:` is a no-op; the association is required when more than one is
|
|
43
|
+
declared. (#75)
|
|
44
|
+
- **Controllers::Includable**: nested include allow-lists — `includable :author,
|
|
45
|
+
comments: :author` accepts `?include=comments.author` (a path must match every
|
|
46
|
+
segment). `requested_includes(as: :query | :paths | :json)` returns the sanitized
|
|
47
|
+
includes for `includes`/`preload`, JSON:API serializers, or `as_json(include:)`;
|
|
48
|
+
`requested_include_paths` exposes the dotted paths. `default:` loads named paths when
|
|
49
|
+
`?include` is absent (blank opts out); `strategy:` picks
|
|
50
|
+
`includes`/`preload`/`eager_load`. New `Support::IncludeTree`. (#69)
|
|
51
|
+
- **Controllers::Idempotentable**: replays now carry the original response's `Location`,
|
|
52
|
+
`Content-Location`, `ETag`, `Last-Modified` and `Link` headers, so a retried `create`
|
|
53
|
+
still says where the resource lives. `idempotent_actions … headers:` tunes the
|
|
54
|
+
allow-list (`[]` to capture none). Records written before this release replay
|
|
55
|
+
unchanged. (#47)
|
|
56
|
+
- **Controllers::Throttleable**: `throttle_by … if:/unless:` per-request skip conditions
|
|
57
|
+
(Symbol method or callable; both must pass), mirroring Rails 7.2's `rate_limit`. A
|
|
58
|
+
throttled request instruments `rate_limited.concerns_on_rails` (rule, discriminator,
|
|
59
|
+
count/limit/period, reset_at/retry_after, controller, action) through the new public
|
|
60
|
+
`on_rate_limited(rule, result)` hook. (#44)
|
|
61
|
+
- **Controllers::Respondable**: `respondable_by error_format: :problem_details,
|
|
62
|
+
problem_type_base:` makes `render_error` emit RFC 9457 `application/problem+json`
|
|
63
|
+
documents (`type`, `title`, `status`, `detail`, `instance`, plus `code`/`errors`
|
|
64
|
+
extensions). Every concern that renders errors through Respondable follows, so the
|
|
65
|
+
switch is app-wide. The default stays `:envelope`. (#57)
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
- **Controllers::Throttleable**: with several applicable rules the `X-RateLimit-*`
|
|
69
|
+
headers now describe the tightest passing rule (fewest remaining) instead of the last
|
|
70
|
+
one declared, and a tie on remaining is broken by the rule that resets *last* — so the
|
|
71
|
+
advertised `X-RateLimit-Reset` can no longer promise a 60-second wait when an hourly
|
|
72
|
+
rule is the real budget. `result[:discriminator]` is now available to
|
|
73
|
+
`throttled_response`. Note it is raw personal data (an IP or a user id) in the
|
|
74
|
+
instrumentation payload too; override `on_rate_limited` to hash or drop it. (#44)
|
|
75
|
+
- **Controllers::Throttleable**: `if:`/`unless:` conditions are now arity-aware — a
|
|
76
|
+
one-argument callable receives the controller, a zero-arity Proc is still
|
|
77
|
+
`instance_exec`'d. Previously a `->(c) { … }` condition raised `ArgumentError` on
|
|
78
|
+
every request. (#44)
|
|
79
|
+
- **Models::Auditable**: an explicit `actor: nil` now keeps meaning "never record who"
|
|
80
|
+
and no longer falls through to the gem-wide `config.audit_actor`; only omitting
|
|
81
|
+
`actor:` takes the fallback. `config.audit_actor` accepts a non-Proc callable and
|
|
82
|
+
`false` (a synonym for nil), and rejects a lambda that demands arguments at
|
|
83
|
+
configuration time rather than raising inside `before_save`. (#50)
|
|
84
|
+
- **Models::CounterCacheable**: `recount_counter_caches!` now raises `ArgumentError`
|
|
85
|
+
for an association with no declared counter, instead of returning `{}` as a silent
|
|
86
|
+
success; and a scoped repair locks the named parent rows before tallying, so a
|
|
87
|
+
concurrent child write can no longer be lost. (#75)
|
|
88
|
+
- **Controllers::Idempotentable**: response-header capture is case-insensitive, so an
|
|
89
|
+
app that sets `location` rather than `Location` (correct under Rack 3) is captured on
|
|
90
|
+
every supported Rails version, not only 7.1+. `idempotency_scope` is now a documented
|
|
91
|
+
public override point — the default namespace has no per-principal component, so two
|
|
92
|
+
users sharing a client-chosen key can be served each other's cached response. (#47)
|
|
93
|
+
- **Controllers::Respondable**: the problem-details media type is emitted as bare
|
|
94
|
+
`application/problem+json`, with no `charset` parameter — RFC 9457 registers none, and
|
|
95
|
+
strict clients reject the parameterised form. (#57)
|
|
96
|
+
|
|
97
|
+
### Fixed
|
|
98
|
+
- **Models::Activatable**: lifecycle hooks are invoked with `send`, so a `private` hook
|
|
99
|
+
override runs instead of raising `NoMethodError` — which in a batch verb aborted and
|
|
100
|
+
rolled back the whole run. (#78)
|
|
101
|
+
- **Models::Hashable**: the `to_param: true` / Sluggable conflict now raises in *either*
|
|
102
|
+
declaration order. With Hashable declared first, friendly_id's `to_param` used to land
|
|
103
|
+
above Hashable's and silently win, so `to_param: true` did nothing. (#70)
|
|
104
|
+
- **Models::Tokenizable**: `expires_in:` now requires an `ActiveSupport::Duration` or a
|
|
105
|
+
`Numeric`. `expires_in: 2.hours.from_now` was accepted as ~1.8 billion seconds (tokens
|
|
106
|
+
effectively never expired) and `expires_in: "2 hours"` as 2 seconds. A model missing
|
|
107
|
+
both token columns now reports them in one error. (#51)
|
|
108
|
+
- **Controllers::Includable**: `requested_includes(as: :paths)` returns a fresh array on
|
|
109
|
+
the `default:` path; it previously handed back the class attribute itself, so a caller
|
|
110
|
+
mutating the result corrupted every later request in the process. (#69)
|
|
111
|
+
|
|
3
112
|
## 1.28.5 (2026-09-17)
|
|
4
113
|
|
|
5
114
|
Eight feature PRs deepening existing concerns, released as a patch by request:
|
data/README.md
CHANGED
|
@@ -148,7 +148,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
|
|
|
148
148
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
|
|
149
149
|
- **Schema-validated configuration** — every macro checks that the configured columns exist and raises `ArgumentError` early — listing *every* missing column at once, with one ready-to-paste `rails generate migration` command that adds them all
|
|
150
150
|
- **Composable** — concerns are independent; mix and match per model
|
|
151
|
-
- **Tested like an app, not a snippet** — **1,
|
|
151
|
+
- **Tested like an app, not a snippet** — **1,624 RSpec examples** run against a real database on every CI build
|
|
152
152
|
- **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
|
|
153
153
|
|
|
154
154
|
---
|
|
@@ -183,6 +183,7 @@ instead of per controller class:
|
|
|
183
183
|
# config/initializers/concerns_on_rails.rb
|
|
184
184
|
ConcernsOnRails.setup do |config|
|
|
185
185
|
config.cache_store = -> { Rails.cache } # fallback for Throttleable / Idempotentable
|
|
186
|
+
config.audit_actor = -> { Current.user&.id } # fallback "by" for every Auditable model
|
|
186
187
|
end
|
|
187
188
|
|
|
188
189
|
# Encryptable's key lives in its own config (see the Encryptable section):
|
|
@@ -541,7 +542,7 @@ end
|
|
|
541
542
|
|
|
542
543
|
## 🔐 Hashable
|
|
543
544
|
|
|
544
|
-
Auto-generate random values on create — tokens, codes, UUIDs, or anything from a custom alphabet.
|
|
545
|
+
Auto-generate random values on create — tokens, codes, UUIDs, or anything from a custom alphabet — and, optionally, use them as the public ID in your URLs.
|
|
545
546
|
|
|
546
547
|
```ruby
|
|
547
548
|
class Order < ApplicationRecord
|
|
@@ -570,13 +571,24 @@ hashable_by :external_id, type: :uuid
|
|
|
570
571
|
hashable_by :code, type: :integer, length: 6
|
|
571
572
|
hashable_by :code, type: :custom, length: 8,
|
|
572
573
|
alphabet: "ABCDEFGHJKMNPQRSTUVWXYZ23456789" # Crockford-style, no ambiguous chars
|
|
574
|
+
|
|
575
|
+
# Public IDs: a Stripe-style prefix and the value as the URL parameter
|
|
576
|
+
hashable_by :public_id, type: :custom, length: 14, prefix: "ord_", unique: true, to_param: true,
|
|
577
|
+
alphabet: "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
578
|
+
order.public_id # => "ord_k7m3pq9a2x5n8v"
|
|
579
|
+
order_path(order) # => "/orders/ord_k7m3pq9a2x5n8v"
|
|
580
|
+
Order.find_by!(public_id: params[:id])
|
|
581
|
+
# while backfilling, accept both shapes — to_param falls back to the integer id when public_id is blank
|
|
582
|
+
Order.find_by(public_id: params[:id]) || Order.find(params[:id])
|
|
573
583
|
```
|
|
574
584
|
|
|
575
585
|
**Notes**
|
|
576
586
|
- Auto-assigns in `before_create` only when the field is blank — callers can pass an explicit value.
|
|
577
587
|
- A `regenerate_<field>!` instance method is defined dynamically.
|
|
588
|
+
- `prefix:` is prepended to every generated value (string types only — not `:integer`); the uniqueness check sees the full prefixed value.
|
|
589
|
+
- `to_param: true` overrides `to_param` to return the hashed field, falling back to the id while it is blank — pair it with `find_by!(field: params[:id])`, or with `find_by(field: params[:id]) || find(params[:id])` while a backfill is still in flight (a blank field puts an integer in the URL). Raises at class load alongside Sluggable, in either declaration order — friendly_id overrides `to_param` too.
|
|
590
|
+
- `unique: true` prechecks for collisions and retries a bounded number of times (still add a unique index — that is the real guarantee).
|
|
578
591
|
- For fixed-width numeric codes (e.g. `000042`), use a **string** column — integer columns drop leading zeros.
|
|
579
|
-
- No uniqueness retry is built in. For collision-prone configs (short integer codes), add a unique index and rescue at the app level.
|
|
580
592
|
- 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.
|
|
581
593
|
|
|
582
594
|
---
|
|
@@ -795,6 +807,10 @@ class Subscription < ApplicationRecord
|
|
|
795
807
|
|
|
796
808
|
activatable_by # defaults to :active
|
|
797
809
|
# activatable_by :enabled # custom column name
|
|
810
|
+
# activatable_by timestamps: true # stamps activated_at / deactivated_at
|
|
811
|
+
# activatable_by timestamps: { activated_at: :enabled_at, deactivated_at: nil }
|
|
812
|
+
|
|
813
|
+
def after_deactivate = Billing.pause!(self) # before/after_activate, before/after_deactivate hooks
|
|
798
814
|
end
|
|
799
815
|
|
|
800
816
|
sub = Subscription.create!(active: true)
|
|
@@ -814,18 +830,23 @@ Subscription.inactive.activate_all # => 12
|
|
|
814
830
|
Subscription.active.deactivate_all # => 3
|
|
815
831
|
```
|
|
816
832
|
|
|
817
|
-
Both target the relation, return an Integer count, and run in a transaction.
|
|
818
|
-
`activate
|
|
833
|
+
Both target the relation, return an Integer count, and run in a transaction. Gating is per
|
|
834
|
+
direction: with `activate!`, `before_activate` and `after_activate` unoverridden and no
|
|
835
|
+
validations on the model — neither
|
|
819
836
|
`validates`/`validates_with`, a custom `validate :method`, nor an association's autosave
|
|
820
837
|
validation (a bare `has_many` registers one, so most models with associations take the
|
|
821
|
-
streaming path) —
|
|
822
|
-
`UPDATE`, which bumps `updated_at` exactly as the per-record path does; otherwise
|
|
823
|
-
per record so validations still run, and a record that fails to save raises
|
|
824
|
-
`ActiveRecord::RecordNotSaved` and rolls the whole batch back. `
|
|
838
|
+
streaming path) — `activate_all` collapses to a single
|
|
839
|
+
`UPDATE`, which bumps `updated_at` exactly as the per-record path does; otherwise it streams
|
|
840
|
+
per record so the hooks and validations still run, and a record that fails to save raises
|
|
841
|
+
`ActiveRecord::RecordNotSaved` and rolls the whole batch back. `deactivate_all` is gated the
|
|
842
|
+
same way by `deactivate!`/`before_deactivate`/`after_deactivate`, so overriding only
|
|
843
|
+
`after_deactivate` leaves `activate_all` on the fast path. `toggle_active!`'s row lock has
|
|
825
844
|
no batch analogue.
|
|
826
845
|
|
|
827
846
|
**Notes**
|
|
828
847
|
- `NULL` is treated as inactive (same convention as most apps' "unset = off").
|
|
848
|
+
- Hooks (`before_activate` / `after_activate` / `before_deactivate` / `after_deactivate`) share one transaction with the write: a raising after-hook rolls the flip back, a failed `update` (validation) skips the after-hook and returns `false`. `toggle_active!` and the batch verbs go through the same path.
|
|
849
|
+
- `timestamps: true` stamps `activated_at` on activate and `deactivated_at` on deactivate (the other column keeps its last value, so you can see both the last activation and the last deactivation); a Hash renames either column or drops a side with `nil`. The stamp columns must already exist — `activatable_by` checks that at declaration and raises `ArgumentError` otherwise; the `datetime` type itself is not enforced, it only types the migration hint in that error.
|
|
829
850
|
- The configured column must exist; `activatable_by` raises `ArgumentError` otherwise.
|
|
830
851
|
- `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.
|
|
831
852
|
|
|
@@ -840,19 +861,22 @@ class User < ApplicationRecord
|
|
|
840
861
|
include ConcernsOnRails::Tokenizable
|
|
841
862
|
|
|
842
863
|
tokenizable_by :api_token # 32-char URL-safe
|
|
843
|
-
tokenizable_by :reset_password_token, length: 24
|
|
864
|
+
tokenizable_by :reset_password_token, length: 24, expires_in: 2.hours # needs reset_password_token_expires_at
|
|
844
865
|
tokenizable_by :invite_code, type: :alphanumeric, length: 8
|
|
845
866
|
end
|
|
846
867
|
|
|
847
|
-
user = User.create! # all three tokens auto-generated
|
|
868
|
+
user = User.create! # all three tokens auto-generated (+ the reset token's expiry stamped)
|
|
848
869
|
user.api_token # => "k3Jf...g2" (32 URL-safe chars)
|
|
849
870
|
user.api_token? # => true
|
|
871
|
+
user.reset_password_token_expired? # => false, until reset_password_token_expires_at passes
|
|
850
872
|
|
|
851
|
-
user.regenerate_api_token! # rotates and persists
|
|
852
|
-
user.revoke_api_token! # nils the column
|
|
873
|
+
user.regenerate_api_token! # rotates and persists (an expiring field gets a fresh expiry too)
|
|
874
|
+
user.revoke_api_token! # nils the column (and the expiry)
|
|
853
875
|
|
|
854
876
|
User.find_by_api_token(token) # Rails default
|
|
855
|
-
User.authenticate_by_api_token(token) # timing-safe; returns user or nil
|
|
877
|
+
User.authenticate_by_api_token(token) # timing-safe; returns user or nil — nil for an EXPIRED token
|
|
878
|
+
User.consume_reset_password_token(token) # single use: authenticate AND revoke atomically; nil the second time
|
|
879
|
+
User.reset_password_token_expired # scope: rows whose expiry has passed (cleanup jobs)
|
|
856
880
|
```
|
|
857
881
|
|
|
858
882
|
**Options**
|
|
@@ -861,12 +885,15 @@ User.authenticate_by_api_token(token) # timing-safe; returns user or nil
|
|
|
861
885
|
| -------- | ----------- | ------------------------------------------------------------- |
|
|
862
886
|
| `type:` | `:urlsafe` | One of `:urlsafe`, `:hex`, `:alphanumeric`, `:numeric` |
|
|
863
887
|
| `length:`| `32` | Character length of the generated token |
|
|
888
|
+
| `expires_in:` | `nil` | A `Duration`/seconds. Stamps `<field>_expires_at` (a `datetime` column you add) on every generation; `authenticate_by_`/`consume_` refuse a stale token; adds `<field>_expired?` and the `<field>_expired` scope |
|
|
864
889
|
|
|
865
890
|
**Notes**
|
|
866
891
|
- URL-safe by default (`A–Z`, `a–z`, `0–9`, `-`, `_`) — drop straight into URLs and headers.
|
|
867
892
|
- Caller-supplied values are respected: `User.create!(api_token: "preset")` won't be overwritten.
|
|
868
893
|
- 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.
|
|
869
|
-
- `.authenticate_by_<field>` uses `ActiveSupport::SecurityUtils.secure_compare` to avoid leaking partial matches via response timing.
|
|
894
|
+
- `.authenticate_by_<field>` uses `ActiveSupport::SecurityUtils.secure_compare` to avoid leaking partial matches via response timing, and returns `nil` once an `expires_in:` token has expired.
|
|
895
|
+
- `.consume_<field>(value)` (every field) is the single-use verb — password resets, invite codes, magic links: it authenticates, then revokes with a **conditional `UPDATE`** keyed on the token still being present, so two concurrent consumers cannot both succeed; the loser gets `nil`. An expired token is refused and left in place.
|
|
896
|
+
- A caller-supplied token on an `expires_in:` field gets the configured lifetime **on create** unless the caller also sets `<field>_expires_at`. Assigning one to an already-persisted row (`user.update!(reset_password_token: "preset")`) stamps nothing, and a row whose expiry is `nil` never expires — rotate with `regenerate_<field>!` (which stamps a fresh expiry) or set `<field>_expires_at` yourself.
|
|
870
897
|
- Distinct from `Hashable`: Hashable handles a single random field; Tokenizable focuses on security tokens (multi-field, URL-safe default, timing-safe lookup, revocation).
|
|
871
898
|
|
|
872
899
|
---
|
|
@@ -1208,6 +1235,18 @@ end
|
|
|
1208
1235
|
|
|
1209
1236
|
`mask:` sets the mask character (default `*`). Nil and non-string values pass through untouched. To strip dangerous HTML instead, see [Sanitizable](#-sanitizable).
|
|
1210
1237
|
|
|
1238
|
+
**Serialization** — mask in the response, not just in the view
|
|
1239
|
+
|
|
1240
|
+
```ruby
|
|
1241
|
+
user.masked_attributes # => { "email" => "j****@example.com", "card" => "**** **** **** 4242" }
|
|
1242
|
+
user.as_json(masked: true) # every declared field swapped for its masked form, the rest raw
|
|
1243
|
+
user.as_json(masked: [:email]) # just these fields (undeclared ones raise)
|
|
1244
|
+
user.to_json(masked: true, only: %i[id email]) # composes with only:/except:/methods:/include:
|
|
1245
|
+
render json: users.map { |u| u.as_json(masked: true) }
|
|
1246
|
+
```
|
|
1247
|
+
|
|
1248
|
+
Plain `as_json` / `to_json` are untouched, so nothing changes until you ask.
|
|
1249
|
+
|
|
1211
1250
|
---
|
|
1212
1251
|
|
|
1213
1252
|
## 💰 Monetizable
|
|
@@ -1256,10 +1295,13 @@ class Product < ApplicationRecord
|
|
|
1256
1295
|
|
|
1257
1296
|
auditable_by :price, :status # default column :audit_log
|
|
1258
1297
|
# auditable_by :price, into: :history,
|
|
1259
|
-
# actor: -> { Current.user&.email }, # stamps "by" on each entry
|
|
1298
|
+
# actor: -> { Current.user&.email }, # stamps "by" on each entry (or actor: :updated_by_id)
|
|
1260
1299
|
# max_entries: 50 # keep the newest 50
|
|
1261
1300
|
end
|
|
1262
1301
|
|
|
1302
|
+
# Or set the actor once for every audited model — models that omit actor: use it, actor: nil/false opts out:
|
|
1303
|
+
ConcernsOnRails.setup { |config| config.audit_actor = -> { Current.user&.id } }
|
|
1304
|
+
|
|
1263
1305
|
product.update!(price: 200)
|
|
1264
1306
|
product.audit_trail
|
|
1265
1307
|
# => [{"field"=>"price", "from"=>100, "to"=>200, "at"=>"2026-06-10T12:34:56Z", "by"=>"admin@shop.com"}]
|
|
@@ -1270,7 +1312,7 @@ product.clear_audit_trail! # wipe the column (skips callbacks)
|
|
|
1270
1312
|
|
|
1271
1313
|
One entry is recorded **per changed field per save** (creates record `"from" => nil`), appended in the same `INSERT`/`UPDATE` via `before_save` — zero extra queries.
|
|
1272
1314
|
|
|
1273
|
-
**Options**: `into:` (`:audit_log`), `actor:` (
|
|
1315
|
+
**Options**: `into:` (`:audit_log`), `actor:` (a Proc `instance_exec`'d on the record, any other callable `#call`ed, or a Symbol naming a record method such as `:updated_by_id`; omit it to take the gem-wide `config.audit_actor`, an explicit `nil`/`false` opts out of that; `"by"` omitted when it resolves to nil), `max_entries:` (`200`; keeps the newest N, `nil` = unlimited), `max_value_length:` (`nil`; truncates long String `from`/`to` values to the first N characters + `…`).
|
|
1274
1316
|
|
|
1275
1317
|
**Notes**
|
|
1276
1318
|
- Writes that skip callbacks (`update_column(s)`, `touch`, `increment!`) are **not** audited; `save(validate: false)` is.
|
|
@@ -1410,6 +1452,7 @@ end
|
|
|
1410
1452
|
|
|
1411
1453
|
post.comments_count # maintained on create / destroy / update
|
|
1412
1454
|
Comment.recount_counter_caches! # repair drift / backfill every counter
|
|
1455
|
+
Comment.recount_counter_caches!(:post, parents: imported_posts) # repair just these parents (ids, records or a relation)
|
|
1413
1456
|
```
|
|
1414
1457
|
|
|
1415
1458
|
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.
|
|
@@ -1419,7 +1462,7 @@ Counters are adjusted with `update_counters` (a single atomic SQL `COALESCE(col,
|
|
|
1419
1462
|
**Notes**
|
|
1420
1463
|
- The `belongs_to` must be declared **before** the macro (the reflection is validated at declaration). Polymorphic associations are not supported.
|
|
1421
1464
|
- Don't also set native `counter_cache: true` on the same column — both would fire and double-count.
|
|
1422
|
-
- Counters track the **persisted** record; writes that skip callbacks (`update_column(s)`, `update_all`, `delete`) are not tracked — run `recount_counter_caches!` to reconcile.
|
|
1465
|
+
- Counters track the **persisted** record; writes that skip callbacks (`update_column(s)`, `update_all`, `delete`) are not tracked — run `recount_counter_caches!` to reconcile. Bare, it rewrites every parent (portable across adapters, but O(n) for conditional counters) — a maintenance operation, run it offline. With `parents:` it zeroes and re-tallies only those parents (O(their children)) in one transaction that locks those rows before tallying, so repairing one imported post is safe on the request path.
|
|
1423
1466
|
- Reach for [`counter_culture`](https://github.com/magnusvk/counter_culture) when you need multi-level rollups, delta columns, or after-commit execution.
|
|
1424
1467
|
|
|
1425
1468
|
---
|
|
@@ -1720,7 +1763,7 @@ end
|
|
|
1720
1763
|
|
|
1721
1764
|
## 📦 Respondable
|
|
1722
1765
|
|
|
1723
|
-
Standardized JSON envelopes for API controllers — two methods,
|
|
1766
|
+
Standardized JSON envelopes for API controllers — two intent-revealing render methods, plus an opt-in app-wide switch to RFC 9457 problem details.
|
|
1724
1767
|
|
|
1725
1768
|
```ruby
|
|
1726
1769
|
class Api::ArticlesController < ApplicationController
|
|
@@ -1754,12 +1797,27 @@ end
|
|
|
1754
1797
|
{ "success": false, "error": { "message": "...", "code": "...", "details": [...] } }
|
|
1755
1798
|
```
|
|
1756
1799
|
|
|
1800
|
+
**RFC 9457 problem details** — switch the error format once and every 4xx the gem's concerns render
|
|
1801
|
+
through `render_error` (ErrorHandleable, Throttleable, Idempotentable, CursorPaginatable, Deprecatable,
|
|
1802
|
+
WebhookVerifiable, Authorizable) becomes an `application/problem+json` document; `render_success` is untouched:
|
|
1803
|
+
|
|
1804
|
+
```ruby
|
|
1805
|
+
respondable_by error_format: :problem_details, problem_type_base: "https://api.example.com/problems"
|
|
1806
|
+
# 422 application/problem+json
|
|
1807
|
+
# { "type": "https://api.example.com/problems/record_invalid", "title": "Unprocessable Content", "status": 422,
|
|
1808
|
+
# "detail": "Validation failed", "instance": "/api/articles", "code": "record_invalid", "errors": ["Name can't be blank"] }
|
|
1809
|
+
```
|
|
1810
|
+
|
|
1811
|
+
`type` is `problem_type_base/<code>` (or `about:blank` without a base or code), `title` the status reason phrase,
|
|
1812
|
+
`instance` the request path; `code` and `errors` ride along as extension members.
|
|
1813
|
+
|
|
1757
1814
|
**API**
|
|
1758
1815
|
|
|
1759
1816
|
| Method | Signature |
|
|
1760
1817
|
|-------------------|--------------------------------------------------------------------------------------------|
|
|
1761
1818
|
| `render_success` | `render_success(data: nil, status: :ok, meta: {})` |
|
|
1762
1819
|
| `render_error` | `render_error(message:, status: :unprocessable_entity, code: nil, errors: nil)` |
|
|
1820
|
+
| `respondable_by` | `respondable_by(error_format: :envelope, problem_type_base: nil)` — class-level; `error_format:` is `:envelope` (default) or `:problem_details` |
|
|
1763
1821
|
|
|
1764
1822
|
> `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.
|
|
1765
1823
|
|
|
@@ -1856,18 +1914,20 @@ end
|
|
|
1856
1914
|
|
|
1857
1915
|
## 🔗 Includable
|
|
1858
1916
|
|
|
1859
|
-
Whitelisted association sideloading + sparse fieldsets for JSON APIs — zero arbitrary `.includes` from user input.
|
|
1917
|
+
Whitelisted association sideloading + sparse fieldsets for JSON APIs — zero arbitrary `.includes` from user input, nested paths included.
|
|
1860
1918
|
|
|
1861
1919
|
```ruby
|
|
1862
1920
|
class ArticlesController < ApplicationController
|
|
1863
1921
|
include ConcernsOnRails::Controllers::Includable
|
|
1864
1922
|
|
|
1865
|
-
includable :author, :
|
|
1866
|
-
fields: { articles: %i[id title published_at], authors: %i[id name] }
|
|
1923
|
+
includable :author, comments: :author, # flat + nested, like `includes` arguments
|
|
1924
|
+
fields: { articles: %i[id title published_at], authors: %i[id name] },
|
|
1925
|
+
default: :author, # loaded when the client sends no ?include at all
|
|
1926
|
+
strategy: :preload # :includes (default) | :preload | :eager_load
|
|
1867
1927
|
|
|
1868
1928
|
def index
|
|
1869
1929
|
render json: with_includes(Article.all),
|
|
1870
|
-
include: requested_includes,
|
|
1930
|
+
include: requested_includes(as: :json),
|
|
1871
1931
|
fields: requested_fields
|
|
1872
1932
|
end
|
|
1873
1933
|
end
|
|
@@ -1876,19 +1936,22 @@ end
|
|
|
1876
1936
|
**URL params**
|
|
1877
1937
|
|
|
1878
1938
|
```
|
|
1879
|
-
GET /articles?include=author,comments&fields[articles]=id,title&fields[authors]=id,name
|
|
1939
|
+
GET /articles?include=author,comments.author&fields[articles]=id,title&fields[authors]=id,name
|
|
1880
1940
|
```
|
|
1881
1941
|
|
|
1882
1942
|
**API**
|
|
1883
1943
|
|
|
1884
|
-
| Method
|
|
1885
|
-
|
|
1886
|
-
| `with_includes(rel)`
|
|
1887
|
-
| `requested_includes`
|
|
1888
|
-
| `
|
|
1944
|
+
| Method | What it does |
|
|
1945
|
+
|-------------------------------|--------------------------------------------------------------------------------------------------|
|
|
1946
|
+
| `with_includes(rel)` | Parses `params[:include]`, keeps only allow-listed paths, applies them with the configured strategy |
|
|
1947
|
+
| `requested_includes(as:)` | `:query` (default) → `[:author, { comments: :author }]` for `includes`/`preload`; `:paths` → `["author", "comments.author"]` for JSON:API serializers; `:json` → `[:author, { comments: { include: :author } }]` for `as_json`/`render json:` |
|
|
1948
|
+
| `requested_include_paths` | The sanitized dotted paths in request order (what `as: :paths` returns) |
|
|
1949
|
+
| `requested_fields` | Returns `{ articles: [:id, :title] }` sanitized map (pass to your serializer) |
|
|
1889
1950
|
|
|
1890
1951
|
**Notes**
|
|
1891
|
-
-
|
|
1952
|
+
- A path is kept only if **every** segment follows the allow-list tree (`comments.author` needs `comments: :author`); anything else is **silently dropped** — no error, no arbitrary eager-loading.
|
|
1953
|
+
- `default:` applies only when `?include` is absent; `?include=` (blank) means "nothing" and is honoured. Defaults are validated against the allow-list at class load, stored frozen and `dup`ed per request.
|
|
1954
|
+
- **A typo in an option name becomes an association.** Inline nested Hashes arrive as `**nested`, so any keyword the macro does not name is registered as an allow-listed association instead of raising: `feilds:` silently leaves `includable_fields` empty — i.e. no sparse-fieldset allow-list at all.
|
|
1892
1955
|
- Non-whitelisted tables in `params[:fields]` are dropped; non-whitelisted columns within an allowed table are dropped.
|
|
1893
1956
|
- Pass `requested_fields` to your serializer (e.g. AMS / Blueprinter) — `Includable` itself does not alter the JSON output, only the query.
|
|
1894
1957
|
|
|
@@ -2010,12 +2073,15 @@ class Api::BaseController < ApplicationController
|
|
|
2010
2073
|
throttle_by limit: 100, period: 1.minute # by IP (default)
|
|
2011
2074
|
throttle_by limit: 5, period: 1.minute, only: :create,
|
|
2012
2075
|
by: -> { current_user&.id || request.remote_ip }
|
|
2076
|
+
throttle_by limit: 1000, period: 1.hour, unless: :staff? # skip conditions
|
|
2013
2077
|
end
|
|
2014
2078
|
```
|
|
2015
2079
|
|
|
2016
2080
|
Fixed-window counter: the key embeds a floored time bucket (`epoch / period`) so each window starts clean and `X-RateLimit-Reset` is exact.
|
|
2017
2081
|
|
|
2018
|
-
**Options**: `limit:` (positive integer), `period:` (a `Duration` or seconds), `by:` (discriminator lambda, default per-IP), `only:` / `except:` (mutually exclusive action scoping), `name:` (disambiguates the counter key).
|
|
2082
|
+
**Options**: `limit:` (positive integer), `period:` (a `Duration` or seconds), `by:` (discriminator lambda, default per-IP), `only:` / `except:` (mutually exclusive action scoping), `if:` / `unless:` (a Symbol naming a controller method or a callable, evaluated per request — staff accounts, internal IPs, feature flags; both must pass when both given), `name:` (disambiguates the counter key).
|
|
2083
|
+
|
|
2084
|
+
When several rules apply to one request the `X-RateLimit-*` headers describe the **tightest** one (fewest requests remaining, ties going to the rule that resets last), so a client sees the budget that runs out first. A throttled request instruments `rate_limited.concerns_on_rails` (payload: `rule`, `discriminator`, `count`, `limit`, `period`, `reset_at`, `retry_after`, `controller`, `action`) via the public `on_rate_limited(rule, result)` hook before the 429 is rendered — subscribe to alert on abusive clients, or override it (call `super` to keep the event). Note `discriminator` is the **raw** client IP (or user id) — personal data that `filter_parameters` does not reach, so hash, truncate or drop it in `on_rate_limited` if subscribers persist it.
|
|
2019
2085
|
|
|
2020
2086
|
**Notes**
|
|
2021
2087
|
- The store MUST support **atomic increment-with-expiry** (`Rails.cache` with `#increment`, or Redis) — a non-atomic store under-counts under concurrency.
|
|
@@ -2062,12 +2128,13 @@ class PaymentsController < ApplicationController
|
|
|
2062
2128
|
end
|
|
2063
2129
|
```
|
|
2064
2130
|
|
|
2065
|
-
Per-key lifecycle: claim atomically (`write unless_exist`, TTL `lock_ttl:`) → run action → cache 2xx–4xx responses for `ttl:`; 5xx and raised exceptions release the claim so the client can retry. Replays carry `X-Idempotency-Replayed: true
|
|
2131
|
+
Per-key lifecycle: claim atomically (`write unless_exist`, TTL `lock_ttl:`) → run action → cache 2xx–4xx responses for `ttl:`; 5xx and raised exceptions release the claim so the client can retry. Replays carry `X-Idempotency-Replayed: true` **and the original's `Location` / `Content-Location` / `ETag` / `Last-Modified` / `Link` headers** (captured with the cached response — a replayed 201 still says where the resource lives; tune the allow-list with `headers:`, `[]` to capture none); duplicates in flight get 409 + `Retry-After`; reusing a key with a **different payload** gets 422 (`idempotency_key_reuse`, fingerprint overridable via `idempotency_fingerprint`).
|
|
2066
2132
|
|
|
2067
|
-
**Options**: `*actions` (allow-list, required), `ttl:` (`24.hours`), `lock_ttl:` (`1.minute`), `header:` (`"Idempotency-Key"`), `required:` (`false`).
|
|
2133
|
+
**Options**: `*actions` (allow-list, required), `ttl:` (`24.hours`), `lock_ttl:` (`1.minute`), `header:` (`"Idempotency-Key"`), `required:` (`false`), `headers:` (response headers replayed with the cached response; default `%w[Location Content-Location ETag Last-Modified Link]` — an allow-list on purpose: `Set-Cookie`, `Date`, request ids and rate-limit headers describe the original exchange and are never replayed).
|
|
2068
2134
|
|
|
2069
2135
|
**Notes**
|
|
2070
2136
|
- Cache keys are scoped per `controller#action` and the client key is SHA256-hashed, so the same key on different endpoints never collides.
|
|
2137
|
+
- The scope carries **no principal**: with client-chosen keys, two users sending the same key and payload to one endpoint share a record (the second is served the first's response, `Location` included). Override `idempotency_scope` — `def idempotency_scope = "#{super}:#{current_user&.id}"`.
|
|
2071
2138
|
- There is **no in-process default store** on purpose: the first keyed request raises `ArgumentError` until you set `idempotency_store` (or the gem-wide fallback `ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } }`).
|
|
2072
2139
|
- When `Respondable` is included, the 400/409/422 bodies delegate to `render_error`.
|
|
2073
2140
|
- Declare halting filters (authentication, `Throttleable`) **before** including this concern — a 401/403 rendered by an inner filter would be cached and replayed for the full TTL. Responses rendered by `rescue_from` handlers are never cached.
|
|
@@ -2300,9 +2367,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
|
|
|
2300
2367
|
|
|
2301
2368
|
```sh
|
|
2302
2369
|
bundle install # install dev dependencies
|
|
2303
|
-
bundle exec rspec # run the test suite (1,
|
|
2370
|
+
bundle exec rspec # run the test suite (1,624 examples)
|
|
2304
2371
|
gem build concerns_on_rails.gemspec # build the gem
|
|
2305
|
-
gem install ./concerns_on_rails-1.28.
|
|
2372
|
+
gem install ./concerns_on_rails-1.28.6.gem # install locally
|
|
2306
2373
|
|
|
2307
2374
|
# Preview the docs site locally (GitHub Pages serves docs/ as-is):
|
|
2308
2375
|
cd docs && python3 -m http.server 8000 # → http://localhost:8000
|
|
@@ -14,8 +14,32 @@ module ConcernsOnRails
|
|
|
14
14
|
# #write(expires_in:, unless_exist:) / #delete for idempotency. There is
|
|
15
15
|
# still no in-process default on purpose — a non-atomic store silently
|
|
16
16
|
# under-counts, so the host must opt in explicitly (just once, here).
|
|
17
|
+
#
|
|
18
|
+
# `audit_actor` is the fallback actor for Models::Auditable: a zero-arg
|
|
19
|
+
# callable (a Proc is instance_exec'd on the record at save time, any other
|
|
20
|
+
# callable is #call'd), whose value is stamped as "by" on every audit entry
|
|
21
|
+
# of every model that passes no `actor:` of its own (`auditable_by ...,
|
|
22
|
+
# actor: -> { ... }` still wins; an explicit `actor: nil`/`actor: false`
|
|
23
|
+
# opts a model out). Typically `-> { Current.user&.id }`; nil or false
|
|
24
|
+
# means no gem-wide actor.
|
|
17
25
|
class Configuration
|
|
18
26
|
attr_accessor :cache_store
|
|
27
|
+
attr_reader :audit_actor
|
|
28
|
+
|
|
29
|
+
# false is accepted as a synonym for nil ("no gem-wide actor"). A lambda
|
|
30
|
+
# is arity-strict, so one that demands arguments is rejected here rather
|
|
31
|
+
# than raising from inside before_save on every audited save.
|
|
32
|
+
def audit_actor=(value)
|
|
33
|
+
unless value.nil? || value == false || value.respond_to?(:call)
|
|
34
|
+
raise ArgumentError, "ConcernsOnRails.config.audit_actor must be callable (respond to #call), nil or false"
|
|
35
|
+
end
|
|
36
|
+
unless audit_actor_callable_without_args?(value)
|
|
37
|
+
raise ArgumentError,
|
|
38
|
+
"ConcernsOnRails.config.audit_actor lambda must take no arguments (it is instance_exec'd on the record)"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@audit_actor = value
|
|
42
|
+
end
|
|
19
43
|
|
|
20
44
|
# The fallback store with any callable resolved (per lookup, so a Proc
|
|
21
45
|
# reading Rails.cache follows a swapped-out cache in tests). nil when the
|
|
@@ -23,5 +47,16 @@ module ConcernsOnRails
|
|
|
23
47
|
def resolved_cache_store
|
|
24
48
|
cache_store.respond_to?(:call) ? cache_store.call : cache_store
|
|
25
49
|
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Only lambdas enforce arity: 0 (no parameters) and -1 (*args or optional
|
|
54
|
+
# parameters only) can still be called with none. Non-lambda Procs and
|
|
55
|
+
# other callables are left alone.
|
|
56
|
+
def audit_actor_callable_without_args?(value)
|
|
57
|
+
return true unless value.is_a?(Proc) && value.lambda?
|
|
58
|
+
|
|
59
|
+
value.arity.zero? || value.arity == -1
|
|
60
|
+
end
|
|
26
61
|
end
|
|
27
62
|
end
|