concerns_on_rails 1.15.0 → 1.17.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 +16 -0
- data/README.md +137 -2
- data/lib/concerns_on_rails/controllers/idempotentable.rb +300 -0
- data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +323 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +2 -0
- data/lib/concerns_on_rails/models/auditable.rb +224 -0
- data/lib/concerns_on_rails/models/lockable.rb +317 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +4 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5759f8cf9f86e11087369181f53f807875909047c17fe8abb19a5b6bafaae697
|
|
4
|
+
data.tar.gz: d6e34235431b2c54a36de7126b4829b9892b05f66f0fc6b476ff561731452100
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2b101b659f6d47f0adcface39ef7069a978d8342e6499661d358af12037c12fb1b7d317cdd075ca9b5593989df2941e121ecf46e02d51c11e4f445cdf91801dd
|
|
7
|
+
data.tar.gz: 984342a4be9bae7692c880bb2436de85cd86a3a0e8cd4ba1c167168db297bf4a0ce42a3a7025de910b7e33495d67e8352667ea302b8d47e12c1824cd7bd4ae31
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.17.0 (2026-06-10)
|
|
4
|
+
|
|
5
|
+
Two new concerns, hardened by two adversarial review rounds (in-memory state is restored when a raising lock/unlock hook rolls the write back, so retries can't silently no-op; a hook aborting via `ActiveRecord::Rollback` makes `lock_access!`/`unlock_access!` return false instead of a fake success; invalid-UTF-8 signature headers fail closed instead of raising). 679 examples, 0 failures.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Models::Lockable**: failed-attempt tracking + account lockout ("Devise lockable-lite") for apps on Rails 8 native auth / `has_secure_password`. `lockable_by attempts: :failed_attempts, locked_at: :locked_at, max_attempts: 5, unlock_in: 15.minutes, prefix:/suffix:` — `register_failed_attempt!` increments SQL-side (`update_counters`: atomic under concurrency, NULL-coalescing) and auto-locks at the threshold; `access_locked?` / `lock_expired?` / `lock_expires_at` / `attempts_remaining` readers are side-effect free with lazy expiry (the boundary instant counts as unlocked); `lock_access!` / `unlock_access!` persist via `update_columns` (validations/callbacks bypassed so an invalid record can still be locked) with `before/after_lock`, `before/after_unlock` hooks in a transaction; `reset_failed_attempts!` is the hook-free successful-login path; expiry-aware `.locked` / `.unlocked` scopes (affixable, Ruby-computed cutoff so the SQL stays portable). An expired lock is cleared quietly on the next failed attempt — no unlock hooks fire from an attacker's guess. The attempts column is validated to be an integer column. Zero new runtime dependencies.
|
|
9
|
+
- **Controllers::WebhookVerifiable**: HMAC signature verification for inbound webhooks (Stripe/GitHub/Shopify and generic `:hex`/`:base64`). `verify_webhook *actions, secret:, scheme:, header:, tolerance:, digest:` appends rules (first match wins; no actions = catch-all); the `verify_webhook_signature!` before_action (public, skip-able) renders 401 (`webhook_signature_missing`/`webhook_signature_invalid`/`webhook_timestamp_stale`) or 400 (`webhook_signature_malformed`) and halts the action. Comparison is constant-time (digest-collapsed `secure_compare`, portable to Rails 5.0) and the attacker-controlled header is never decoded — garbage including invalid UTF-8 is scrubbed and fails closed instead of raising. Stripe: signs `"#{t}.#{body}"`, tries every `v1` (≤16), ignores unknown keys, first `t` feeds both the symmetric tolerance check (default 300s) and the payload so a re-stamped stale header stays dead. Secrets: String / callable (`instance_exec`'d per request, multi-tenant) / Array (rotation, any match passes); a secret resolving blank at request time raises `ArgumentError` (misconfiguration should page, not 401 into the provider's retry loop). `:shopify`/`:base64` encode via `pack("m0")` (no base64-gem dependency on Ruby 3.4). Delegates error bodies to `render_error` when Respondable is present. Zero new runtime dependencies.
|
|
10
|
+
|
|
11
|
+
## 1.16.0 (2026-06-10)
|
|
12
|
+
|
|
13
|
+
Two new concerns, hardened by an adversarial edge-case review. 574 examples, 0 failures.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **Models::Auditable**: lightweight single-column change history ("paper_trail-lite"). `auditable_by :price, :status, into: :audit_log, actor: -> { Current.user&.email }, max_entries: 100` appends one JSON entry per changed field per save (creates record `from: nil`) into one text column — no extra tables, written in the same INSERT/UPDATE via `before_save`. Readers: `audit_trail`, `last_change_for(:field)`, `audited_changes_since(time)`, `clear_audit_trail!`. Tolerant JSON decode (corrupt column → `[]`), newest-N trimming (default 200), opt-in value truncation (`max_value_length:` stores the first N characters of long String values + `…`), values JSON-coerced (times → ISO8601 UTC, BigDecimal → precision-safe string, non-finite floats → `"NaN"`/`"Infinity"` strings), `"by"` omitted when no actor. Entries build on the persisted trail, so a save aborted by a later callback cannot duplicate entries on retry. Zero new runtime dependencies.
|
|
17
|
+
- **Controllers::Idempotentable**: Stripe-style `Idempotency-Key` support with an injectable store (`self.idempotency_store = Rails.cache`; contract: `#read`, `#write(expires_in:, unless_exist:)`, `#delete` — no in-process default on purpose). `idempotent_actions :create, ttl: 24.hours, lock_ttl: 1.minute, header: "Idempotency-Key", required: false` claims each key atomically, caches 2xx–4xx responses and replays them with `X-Idempotency-Replayed: true`; concurrent duplicates get 409 + `Retry-After`, payload mismatches get 422 (`idempotency_key_reuse`, fingerprint overridable), 5xx/exceptions release the claim so retries re-execute. Keys are validated (≤255 chars, control characters rejected to prevent response-header injection via the echoed `X-Idempotency-Key`), SHA256-hashed, and scoped per `controller#action`. Error bodies delegate to `render_error` when Respondable is present. Zero new runtime dependencies.
|
|
18
|
+
|
|
3
19
|
## 1.15.0 (2026-06-10)
|
|
4
20
|
|
|
5
21
|
A review-driven release: 23 correctness/safety fixes (each with a regression spec) and 7 backward-compatible enhancements. 510 examples, 0 failures.
|
data/README.md
CHANGED
|
@@ -43,6 +43,8 @@ Article.published.without_deleted.find("hello-world")
|
|
|
43
43
|
- [Sanitizable](#-sanitizable) — opt-in HTML sanitization (XSS defense-in-depth)
|
|
44
44
|
- [Maskable](#-maskable) — non-destructive display masking of sensitive fields
|
|
45
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
|
|
46
48
|
- **Controller concerns**
|
|
47
49
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
48
50
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
@@ -55,6 +57,8 @@ Article.published.without_deleted.find("hello-world")
|
|
|
55
57
|
- [Authorizable](#-authorizable) — per-action 403 authorization gate (block-based)
|
|
56
58
|
- [Throttleable](#-throttleable) — rate limiting with 429 + `X-RateLimit-*` headers
|
|
57
59
|
- [Timezoneable](#-timezoneable) — per-request `Time.zone` from params / header / cookie
|
|
60
|
+
- [Idempotentable](#-idempotentable) — `Idempotency-Key` request replay (409 on concurrent duplicates)
|
|
61
|
+
- [WebhookVerifiable](#-webhookverifiable) — HMAC verification for inbound webhooks (Stripe/GitHub/Shopify)
|
|
58
62
|
- [Module paths & namespacing](#-module-paths--namespacing)
|
|
59
63
|
- [Development](#-development)
|
|
60
64
|
- [Contributing](#-contributing)
|
|
@@ -64,7 +68,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
64
68
|
|
|
65
69
|
## ✨ Why this gem?
|
|
66
70
|
|
|
67
|
-
- **
|
|
71
|
+
- **Twenty model concerns + thirteen controller concerns**, all production-ready
|
|
68
72
|
- **One include, one macro** — no boilerplate, no glue code
|
|
69
73
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
70
74
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -935,6 +939,71 @@ product.formatted_price # => "$19.99"
|
|
|
935
939
|
|
|
936
940
|
---
|
|
937
941
|
|
|
942
|
+
## 📜 Auditable
|
|
943
|
+
|
|
944
|
+
Lightweight change history ("paper_trail-lite") stored as JSON entries in **one text column** on the same table — no extra tables, no versioning engine.
|
|
945
|
+
|
|
946
|
+
```ruby
|
|
947
|
+
class Product < ApplicationRecord
|
|
948
|
+
include ConcernsOnRails::Auditable
|
|
949
|
+
|
|
950
|
+
auditable_by :price, :status # default column :audit_log
|
|
951
|
+
# auditable_by :price, into: :history,
|
|
952
|
+
# actor: -> { Current.user&.email }, # stamps "by" on each entry
|
|
953
|
+
# max_entries: 50 # keep the newest 50
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
product.update!(price: 200)
|
|
957
|
+
product.audit_trail
|
|
958
|
+
# => [{"field"=>"price", "from"=>100, "to"=>200, "at"=>"2026-06-10T12:34:56Z", "by"=>"admin@shop.com"}]
|
|
959
|
+
product.last_change_for(:price) # newest entry for one field
|
|
960
|
+
product.audited_changes_since(1.day.ago) # recent entries, oldest first
|
|
961
|
+
product.clear_audit_trail! # wipe the column (skips callbacks)
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
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.
|
|
965
|
+
|
|
966
|
+
**Options**: `into:` (`:audit_log`), `actor:` (callable, `instance_exec`'d on the record; `"by"` omitted when absent), `max_entries:` (`200`; keeps the newest N, `nil` = unlimited), `max_value_length:` (`nil`; truncates long String `from`/`to` values to the first N characters + `…`).
|
|
967
|
+
|
|
968
|
+
**Notes**
|
|
969
|
+
- Writes that skip callbacks (`update_column(s)`, `touch`, `increment!`) are **not** audited; `save(validate: false)` is.
|
|
970
|
+
- Values are JSON-coerced (times → ISO8601 UTC strings, `BigDecimal` → precision-safe numeric string); a corrupt column decodes as `[]` and is replaced on the next tracked save.
|
|
971
|
+
- Per-record and bounded by design — reach for [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) when you need reify/undo or audit queries across models.
|
|
972
|
+
|
|
973
|
+
---
|
|
974
|
+
|
|
975
|
+
## 🔐 Lockable
|
|
976
|
+
|
|
977
|
+
Failed-attempt tracking + **account lockout** ("Devise lockable-lite") for apps rolling their own authentication (Rails 8 auth generator / `has_secure_password`) — which ships **no brute-force protection** out of the box. Two columns on the model's own table; no tokens, no mailers.
|
|
978
|
+
|
|
979
|
+
```ruby
|
|
980
|
+
class User < ApplicationRecord
|
|
981
|
+
include ConcernsOnRails::Lockable
|
|
982
|
+
|
|
983
|
+
lockable_by max_attempts: 5, unlock_in: 15.minutes
|
|
984
|
+
# lockable_by attempts: :failed_logins, locked_at: :locked_until_at,
|
|
985
|
+
# prefix: :account # => .account_locked / .account_unlocked
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
user.register_failed_attempt! # atomic SQL increment; locks at max_attempts
|
|
989
|
+
user.access_locked? # true while locked (lapses after unlock_in)
|
|
990
|
+
user.attempts_remaining # => 3 (for "3 attempts remaining" messaging)
|
|
991
|
+
user.reset_failed_attempts! # call on successful login
|
|
992
|
+
user.lock_access! # manual lock (hooks: before/after_lock)
|
|
993
|
+
user.unlock_access! # manual unlock (hooks: before/after_unlock)
|
|
994
|
+
User.locked / User.unlocked # expiry-aware scopes
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
**Options**: `attempts:` (`:failed_attempts`, must be an integer column), `locked_at:` (`:locked_at`, datetime column), `max_attempts:` (`5`; `nil` = count but never auto-lock), `unlock_in:` (`nil` = locked until manual unlock; a duration makes the lock lapse by itself), `prefix:` / `suffix:` (affix the scope names).
|
|
998
|
+
|
|
999
|
+
**Notes**
|
|
1000
|
+
- The increment is SQL-side (`COALESCE(attempts, 0) + 1` via `update_counters`), so concurrent failures never lose updates and a NULL counter needs no column default; a locked account stops counting.
|
|
1001
|
+
- Expiry is **lazy**: readers and scopes treat a stale lock as unlocked but never write. The column is cleared by the next `unlock_access!` or failed attempt (quietly there — no unlock hooks fire from a failed login).
|
|
1002
|
+
- `lock_access!` / `unlock_access!` persist via `update_columns` — validations and AR callbacks deliberately bypassed so an otherwise-invalid record can still be locked (this also skips `updated_at`/`Auditable`). The `before/after_lock`, `before/after_unlock` hooks run in a transaction; `after_lock` is the place for the "account locked" email.
|
|
1003
|
+
- Reach for Devise's `lockable` when you need unlock tokens, unlock emails, or per-strategy unlocks.
|
|
1004
|
+
|
|
1005
|
+
---
|
|
1006
|
+
|
|
938
1007
|
# 🎮 Controller Concerns
|
|
939
1008
|
|
|
940
1009
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -1299,6 +1368,72 @@ Resolution order: `params[param]` → `Time-Zone` header → cookie (if enabled)
|
|
|
1299
1368
|
|
|
1300
1369
|
---
|
|
1301
1370
|
|
|
1371
|
+
## 🔁 Idempotentable
|
|
1372
|
+
|
|
1373
|
+
Stripe-style **`Idempotency-Key`** support for mutating endpoints, with a **store-agnostic, injectable** backend. The first request with a key runs the action and caches the response; a retry **replays** the cached response; a concurrent duplicate gets **409**.
|
|
1374
|
+
|
|
1375
|
+
```ruby
|
|
1376
|
+
class PaymentsController < ApplicationController
|
|
1377
|
+
include ConcernsOnRails::Controllers::Idempotentable
|
|
1378
|
+
|
|
1379
|
+
self.idempotency_store = Rails.cache # must support #read / #write(expires_in:, unless_exist:) / #delete
|
|
1380
|
+
|
|
1381
|
+
idempotent_actions :create, ttl: 24.hours, required: true
|
|
1382
|
+
end
|
|
1383
|
+
```
|
|
1384
|
+
|
|
1385
|
+
Per-key lifecycle: claim atomically (`write unless_exist`, TTL `lock_ttl:`) → run action → cache 2xx–4xx responses for `ttl:`; 5xx and raised exceptions release the claim so the client can retry. Replays carry `X-Idempotency-Replayed: true`; duplicates in flight get 409 + `Retry-After`; reusing a key with a **different payload** gets 422 (`idempotency_key_reuse`, fingerprint overridable via `idempotency_fingerprint`).
|
|
1386
|
+
|
|
1387
|
+
**Options**: `*actions` (allow-list, required), `ttl:` (`24.hours`), `lock_ttl:` (`1.minute`), `header:` (`"Idempotency-Key"`), `required:` (`false`).
|
|
1388
|
+
|
|
1389
|
+
**Notes**
|
|
1390
|
+
- Cache keys are scoped per `controller#action` and the client key is SHA256-hashed, so the same key on different endpoints never collides.
|
|
1391
|
+
- There is **no in-process default store** on purpose: the first keyed request raises `ArgumentError` until you set `idempotency_store`.
|
|
1392
|
+
- When `Respondable` is included, the 400/409/422 bodies delegate to `render_error`.
|
|
1393
|
+
- 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.
|
|
1394
|
+
- Keys must be ≤255 chars with no control characters (the raw key is echoed in `X-Idempotency-Key`); set `lock_ttl:` above the slowest declared action's worst case.
|
|
1395
|
+
|
|
1396
|
+
---
|
|
1397
|
+
|
|
1398
|
+
## 🪝 WebhookVerifiable
|
|
1399
|
+
|
|
1400
|
+
HMAC **signature verification for inbound webhooks** — the receiving side of Stripe/GitHub/Shopify-style integrations. The action runs only when the signature over the **raw request body** verifies; otherwise a 401/400 is rendered and the action never executes.
|
|
1401
|
+
|
|
1402
|
+
```ruby
|
|
1403
|
+
class WebhooksController < ApplicationController
|
|
1404
|
+
include ConcernsOnRails::Controllers::WebhookVerifiable # declare BEFORE Idempotentable
|
|
1405
|
+
|
|
1406
|
+
verify_webhook :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }, scheme: :stripe
|
|
1407
|
+
verify_webhook :github, secret: -> { ENV["GITHUB_WEBHOOK_SECRET"] }, scheme: :github
|
|
1408
|
+
verify_webhook :shopify, secret: [ENV["NEW_SECRET"], ENV["OLD_SECRET"]], scheme: :shopify # rotation
|
|
1409
|
+
verify_webhook :custom, secret: "s3cr3t", scheme: :hex, header: "X-Acme-Signature"
|
|
1410
|
+
# verify_webhook secret: ... # no actions = catch-all (declare specific rules first)
|
|
1411
|
+
|
|
1412
|
+
def stripe
|
|
1413
|
+
event = JSON.parse(request.raw_post) # parse the raw body — it is what was signed
|
|
1414
|
+
# ...
|
|
1415
|
+
end
|
|
1416
|
+
end
|
|
1417
|
+
```
|
|
1418
|
+
|
|
1419
|
+
| Scheme | Header (default) | Format |
|
|
1420
|
+
|--------|------------------|--------|
|
|
1421
|
+
| `:github` | `X-Hub-Signature-256` | `sha256=<hex>` |
|
|
1422
|
+
| `:shopify` | `X-Shopify-Hmac-Sha256` | strict Base64 of the binary HMAC |
|
|
1423
|
+
| `:stripe` | `Stripe-Signature` | `t=<unix>,v1=<hex>[,v1=…]` — signs `"#{t}.#{body}"`, every `v1` tried, `tolerance:` rejects stale **and** future timestamps |
|
|
1424
|
+
| `:hex` / `:base64` | — (`header:` required) | plain hex / strict Base64 HMAC of the body |
|
|
1425
|
+
|
|
1426
|
+
**Options**: `*actions` (none = catch-all; the first matching rule wins), `secret:` (String, callable `instance_exec`'d per request, or Array for rotation — any match passes), `scheme:` (`:hex`), `header:` (overrides the preset), `tolerance:` (Stripe only, `300`s default), `digest:` (`:sha256`; `:sha1`/`:sha512` for `:hex`/`:base64` only).
|
|
1427
|
+
|
|
1428
|
+
**Notes**
|
|
1429
|
+
- Comparison is constant-time and the attacker-controlled header is **never decoded** — garbage (including invalid UTF-8 bytes) just fails with 401, it cannot raise.
|
|
1430
|
+
- A secret that resolves **blank at request time raises `ArgumentError`** — a misconfigured endpoint should page you, not 401 into the provider's silent retry loop.
|
|
1431
|
+
- Failure codes: `webhook_signature_missing` / `webhook_signature_invalid` / `webhook_timestamp_stale` → 401; `webhook_signature_malformed` (unparseable Stripe header) → 400. With `Respondable`, bodies delegate to `render_error`; override `webhook_verification_failed` to customize.
|
|
1432
|
+
- Declare **before** `Idempotentable` (a 401 cached by its around filter would be replayed) and before `Throttleable` (forged traffic shouldn't burn rate budget). Webhook endpoints also need `skip_before_action :verify_authenticity_token`.
|
|
1433
|
+
- In tests: `skip_before_action :verify_webhook_signature!`, or sign payloads for real with `OpenSSL::HMAC`. After a pass, `webhook_verified?` is true.
|
|
1434
|
+
|
|
1435
|
+
---
|
|
1436
|
+
|
|
1302
1437
|
## 🗂️ Module paths & namespacing
|
|
1303
1438
|
|
|
1304
1439
|
Every concern is available under two paths:
|
|
@@ -1334,7 +1469,7 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
1334
1469
|
| Association-cascade soft delete / sentinel-aware unique indexes | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
|
|
1335
1470
|
| Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
|
|
1336
1471
|
| Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
|
|
1337
|
-
|
|
|
1472
|
+
| Versioned audit trails with undo/reify, who-dunnit queries, or association tracking | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
|
|
1338
1473
|
|
|
1339
1474
|
`Sluggable` wraps [`friendly_id`](https://github.com/norman/friendly_id) and `Sortable` wraps [`acts_as_list`](https://github.com/brendon/acts_as_list), so you get those leaders' engines behind the declarative macro.
|
|
1340
1475
|
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ConcernsOnRails
|
|
6
|
+
module Controllers
|
|
7
|
+
# Stripe-style `Idempotency-Key` support for mutating endpoints, with a
|
|
8
|
+
# store-agnostic, injectable backend. The first request with a key executes
|
|
9
|
+
# the action and caches the rendered response; a retry with the same key
|
|
10
|
+
# replays the cached response instead of re-running the action; a concurrent
|
|
11
|
+
# duplicate while the first is still in flight is halted with 409.
|
|
12
|
+
#
|
|
13
|
+
# class PaymentsController < ApplicationController
|
|
14
|
+
# include ConcernsOnRails::Controllers::Idempotentable
|
|
15
|
+
#
|
|
16
|
+
# self.idempotency_store = Rails.cache # must support #read / #write(expires_in:, unless_exist:) / #delete
|
|
17
|
+
#
|
|
18
|
+
# idempotent_actions :create, ttl: 24.hours, required: true
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Lifecycle per key (scoped to controller#action, so the same client key on
|
|
22
|
+
# different endpoints never collides):
|
|
23
|
+
# * claim won -> action runs; 2xx-4xx responses are cached for `ttl:`;
|
|
24
|
+
# 5xx responses and raised exceptions release the claim so
|
|
25
|
+
# the client can retry.
|
|
26
|
+
# * done -> the cached status/body/content type is replayed with
|
|
27
|
+
# `X-Idempotency-Replayed: true`.
|
|
28
|
+
# * in flight -> 409 with code "idempotency_conflict" and `Retry-After`.
|
|
29
|
+
# * same key, different request payload -> 422 "idempotency_key_reuse"
|
|
30
|
+
# (override `idempotency_fingerprint` to customize payload matching).
|
|
31
|
+
#
|
|
32
|
+
# The claim is taken atomically via `write(..., unless_exist: true)`
|
|
33
|
+
# (memcached `add` / Redis `SET NX` through Rails.cache); a store without
|
|
34
|
+
# that atomicity is best-effort under concurrency. There is no in-process
|
|
35
|
+
# default store on purpose — configure one explicitly or the first keyed
|
|
36
|
+
# request raises ArgumentError. Note that responses rendered by
|
|
37
|
+
# `rescue_from` handlers bypass the around filter's success path and are
|
|
38
|
+
# never cached.
|
|
39
|
+
#
|
|
40
|
+
# IMPORTANT — callback ordering: declare halting filters (authentication,
|
|
41
|
+
# authorization, rate limiting) BEFORE including this module. A
|
|
42
|
+
# before_action that runs *inside* the around filter and halts (401/403)
|
|
43
|
+
# has its response cached and replayed for the full `ttl:` — the client
|
|
44
|
+
# cannot fix credentials and retry until it expires. Rails offers no
|
|
45
|
+
# reliable way for the around filter to detect a halted inner chain.
|
|
46
|
+
# Likewise set `lock_ttl:` above the worst-case duration of the slowest
|
|
47
|
+
# declared action — if the action outlasts the claim, a concurrent retry
|
|
48
|
+
# can win the expired key and execute the action a second time.
|
|
49
|
+
module Idempotentable
|
|
50
|
+
extend ActiveSupport::Concern
|
|
51
|
+
|
|
52
|
+
DEFAULT_HEADER = "Idempotency-Key".freeze
|
|
53
|
+
MAX_KEY_LENGTH = 255
|
|
54
|
+
IGNORED_FINGERPRINT_KEYS = %w[controller action format].freeze
|
|
55
|
+
|
|
56
|
+
included do
|
|
57
|
+
class_attribute :idempotency_rules, instance_accessor: false, default: []
|
|
58
|
+
class_attribute :idempotency_store, instance_accessor: false, default: nil
|
|
59
|
+
around_action :enforce_idempotency
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module ClassMethods
|
|
63
|
+
# Declare idempotent actions. `ttl:` is the cached-response lifetime,
|
|
64
|
+
# `lock_ttl:` the in-flight claim lifetime (kept short so a crashed
|
|
65
|
+
# worker cannot wedge a key), `header:` the request header to read, and
|
|
66
|
+
# `required:` whether a missing key is a 400. Each call appends a rule;
|
|
67
|
+
# the first rule listing the current action wins.
|
|
68
|
+
def idempotent_actions(*actions, ttl: 86_400, lock_ttl: 60, header: DEFAULT_HEADER, required: false)
|
|
69
|
+
actions = actions.flatten.map(&:to_s)
|
|
70
|
+
validate_idempotent!(actions, ttl: ttl, lock_ttl: lock_ttl, header: header, required: required)
|
|
71
|
+
|
|
72
|
+
rule = { actions: actions, ttl: ttl.to_i, lock_ttl: lock_ttl.to_i, header: header.to_s, required: required }
|
|
73
|
+
self.idempotency_rules = idempotency_rules + [rule]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def validate_idempotent!(actions, ttl:, lock_ttl:, header:, required:)
|
|
79
|
+
prefix = "ConcernsOnRails::Controllers::Idempotentable"
|
|
80
|
+
raise ArgumentError, "#{prefix}: pass at least one action" if actions.empty?
|
|
81
|
+
raise ArgumentError, "#{prefix}: :ttl must be a positive duration" unless ttl.to_i.positive?
|
|
82
|
+
raise ArgumentError, "#{prefix}: :lock_ttl must be a positive duration" unless lock_ttl.to_i.positive?
|
|
83
|
+
raise ArgumentError, "#{prefix}: :header must be a non-blank String" if header.to_s.strip.empty?
|
|
84
|
+
raise ArgumentError, "#{prefix}: :required must be true or false" unless [true, false].include?(required)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# around_action entry point. Public so subclasses can override and specs
|
|
89
|
+
# can drive it directly with a block standing in for the action.
|
|
90
|
+
def enforce_idempotency(&)
|
|
91
|
+
rule = idempotency_rule_for_action
|
|
92
|
+
return yield unless rule
|
|
93
|
+
|
|
94
|
+
raw = read_idempotency_header(rule)
|
|
95
|
+
return idempotency_handle_missing_key(rule, &) if raw.nil?
|
|
96
|
+
|
|
97
|
+
key = raw.to_s.strip
|
|
98
|
+
unless valid_idempotency_key?(key)
|
|
99
|
+
return idempotency_error_response(message: "#{rule[:header]} is invalid (expected 1-#{MAX_KEY_LENGTH} characters).",
|
|
100
|
+
status: :bad_request, code: "idempotency_key_invalid")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
@idempotency_key = key
|
|
104
|
+
run_with_idempotency(rule, key, &)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The raw key sent for the matched rule (nil when absent). Handy for logging.
|
|
108
|
+
def idempotency_key
|
|
109
|
+
@idempotency_key
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Digest of the request payload, used to reject reusing one key for a
|
|
113
|
+
# different request. Public override point — e.g. for raw-body APIs:
|
|
114
|
+
# def idempotency_fingerprint = Digest::SHA256.hexdigest(request.raw_post)
|
|
115
|
+
# Override it for multipart endpoints too: an uploaded file stringifies
|
|
116
|
+
# with its object id, so retried uploads never match and 422 — digest
|
|
117
|
+
# stable parts instead (e.g. params[:file]&.original_filename).
|
|
118
|
+
def idempotency_fingerprint
|
|
119
|
+
raw = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
|
|
120
|
+
filtered = raw.reject { |k, _| IGNORED_FINGERPRINT_KEYS.include?(k.to_s) }
|
|
121
|
+
Digest::SHA256.hexdigest(JSON.generate(idempotency_deep_sort(filtered)))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Public override point for how a cached response is replayed.
|
|
125
|
+
def replay_idempotent_response(record)
|
|
126
|
+
return unless respond_to?(:response) && response
|
|
127
|
+
|
|
128
|
+
emit_idempotency_replayed_header(true)
|
|
129
|
+
options = { body: record["body"], status: record["status"] }
|
|
130
|
+
options[:content_type] = record["content_type"] if record["content_type"]
|
|
131
|
+
render(options)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Single funnel for all error outcomes. Uses Respondable's render_error
|
|
135
|
+
# when available, otherwise the same inline envelope as Throttleable.
|
|
136
|
+
def idempotency_error_response(message:, status:, code:)
|
|
137
|
+
return unless respond_to?(:response) && response
|
|
138
|
+
|
|
139
|
+
return render_error(message: message, status: status, code: code) if respond_to?(:render_error)
|
|
140
|
+
|
|
141
|
+
render json: { success: false, error: { message: message, code: code } }, status: status
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def idempotency_rule_for_action
|
|
147
|
+
action = respond_to?(:action_name) ? action_name.to_s : nil
|
|
148
|
+
return nil unless action
|
|
149
|
+
|
|
150
|
+
self.class.idempotency_rules.find { |rule| rule[:actions].include?(action) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def idempotency_handle_missing_key(rule, &)
|
|
154
|
+
return yield unless rule[:required]
|
|
155
|
+
|
|
156
|
+
idempotency_error_response(message: "#{rule[:header]} header is required for this action.",
|
|
157
|
+
status: :bad_request, code: "idempotency_key_missing")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def valid_idempotency_key?(key)
|
|
161
|
+
# Control characters are rejected because the raw key is echoed in the
|
|
162
|
+
# X-Idempotency-Key response header — CR/LF would enable header
|
|
163
|
+
# injection on servers that don't validate header values (Rack 2).
|
|
164
|
+
!key.empty? && key.length <= MAX_KEY_LENGTH && !key.match?(/[[:cntrl:]]/)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def run_with_idempotency(rule, key, &)
|
|
168
|
+
store = idempotency_store!
|
|
169
|
+
cache_key = idempotency_cache_key(key)
|
|
170
|
+
fingerprint = idempotency_fingerprint
|
|
171
|
+
emit_idempotency_key_header(key)
|
|
172
|
+
|
|
173
|
+
claim = { "state" => "in_flight", "fingerprint" => fingerprint, "claimed_at" => Time.now.to_i }
|
|
174
|
+
if store.write(cache_key, claim, expires_in: rule[:lock_ttl], unless_exist: true)
|
|
175
|
+
idempotency_execute_and_store(store, cache_key, rule, fingerprint, &)
|
|
176
|
+
else
|
|
177
|
+
idempotency_resolve_existing(store, cache_key, rule, fingerprint)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def idempotency_execute_and_store(store, cache_key, rule, fingerprint)
|
|
182
|
+
emit_idempotency_replayed_header(false)
|
|
183
|
+
completed = false
|
|
184
|
+
begin
|
|
185
|
+
yield
|
|
186
|
+
completed = true
|
|
187
|
+
ensure
|
|
188
|
+
# Covers raise and throw alike, so a retry can re-execute.
|
|
189
|
+
store.delete(cache_key) unless completed
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
status = idempotency_response_status
|
|
193
|
+
return store.delete(cache_key) if status >= 500
|
|
194
|
+
|
|
195
|
+
record = { "state" => "done", "status" => status, "body" => idempotency_response_body,
|
|
196
|
+
"content_type" => idempotency_response_content_type, "fingerprint" => fingerprint }
|
|
197
|
+
store.write(cache_key, record, expires_in: rule[:ttl])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def idempotency_resolve_existing(store, cache_key, rule, fingerprint)
|
|
201
|
+
record = store.read(cache_key)
|
|
202
|
+
|
|
203
|
+
if record && record["fingerprint"] != fingerprint
|
|
204
|
+
return idempotency_error_response(message: "#{rule[:header]} was already used with a different request payload.",
|
|
205
|
+
status: :unprocessable_entity, code: "idempotency_key_reuse")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
return replay_idempotent_response(record) if record && record["state"] == "done"
|
|
209
|
+
|
|
210
|
+
# In flight — or the claim expired between our failed write and this
|
|
211
|
+
# read (rare); answering 409 is the conservative, retry-safe choice.
|
|
212
|
+
idempotency_conflict_response(rule)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def idempotency_conflict_response(rule)
|
|
216
|
+
response.set_header("Retry-After", rule[:lock_ttl].to_s) if respond_to?(:response) && response
|
|
217
|
+
idempotency_error_response(message: "A request with this #{rule[:header]} is already in progress.",
|
|
218
|
+
status: :conflict, code: "idempotency_conflict")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def read_idempotency_header(rule)
|
|
222
|
+
return nil unless respond_to?(:request) && request.respond_to?(:headers) && request.headers
|
|
223
|
+
|
|
224
|
+
request.headers[rule[:header]]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def idempotency_cache_key(key)
|
|
228
|
+
# The user key is hashed so any validated key is safe in any backend
|
|
229
|
+
# (memcached limits key length and bans whitespace/control characters).
|
|
230
|
+
"idempotentable:#{idempotency_scope}:#{Digest::SHA256.hexdigest(key)}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def idempotency_scope
|
|
234
|
+
controller = respond_to?(:controller_path) ? controller_path : self.class.name || "anonymous"
|
|
235
|
+
action = respond_to?(:action_name) ? action_name.to_s : ""
|
|
236
|
+
"#{controller}##{action}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def idempotency_store!
|
|
240
|
+
store = self.class.idempotency_store
|
|
241
|
+
return store if store
|
|
242
|
+
|
|
243
|
+
raise ArgumentError,
|
|
244
|
+
"ConcernsOnRails::Controllers::Idempotentable: no store configured. " \
|
|
245
|
+
"Set `self.idempotency_store = Rails.cache` " \
|
|
246
|
+
"(must support #read, #write(expires_in:, unless_exist:) and #delete)."
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def idempotency_response_status
|
|
250
|
+
return 200 unless respond_to?(:response) && response.respond_to?(:status)
|
|
251
|
+
|
|
252
|
+
response.status.to_i
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def idempotency_response_body
|
|
256
|
+
return nil unless respond_to?(:response) && response.respond_to?(:body)
|
|
257
|
+
|
|
258
|
+
response.body
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def idempotency_response_content_type
|
|
262
|
+
return nil unless respond_to?(:response) && response
|
|
263
|
+
|
|
264
|
+
# media_type (real Rails) excludes the charset; the harness only has content_type.
|
|
265
|
+
if response.respond_to?(:media_type) && response.media_type
|
|
266
|
+
response.media_type
|
|
267
|
+
elsif response.respond_to?(:content_type)
|
|
268
|
+
response.content_type
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def emit_idempotency_key_header(key)
|
|
273
|
+
response.set_header("X-Idempotency-Key", key) if respond_to?(:response) && response
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def emit_idempotency_replayed_header(replayed)
|
|
277
|
+
response.set_header("X-Idempotency-Replayed", replayed.to_s) if respond_to?(:response) && response
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Deterministic JSON regardless of param insertion order: hashes become
|
|
281
|
+
# sorted [key, value] pairs, arrays keep their (significant) order, and
|
|
282
|
+
# non-JSON-primitive leaves are stringified so nothing can raise.
|
|
283
|
+
def idempotency_deep_sort(value)
|
|
284
|
+
case value
|
|
285
|
+
when Hash then value.map { |k, v| [k.to_s, idempotency_deep_sort(v)] }.sort_by(&:first)
|
|
286
|
+
when Array then value.map { |v| idempotency_deep_sort(v) }
|
|
287
|
+
else idempotency_scalar(value)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def idempotency_scalar(value)
|
|
292
|
+
case value
|
|
293
|
+
when Float then value.finite? ? value : value.to_s # JSON.generate raises on NaN/Infinity
|
|
294
|
+
when nil, true, false, Integer, String then value
|
|
295
|
+
else value.to_s
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|