concerns_on_rails 1.14.1 → 1.16.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 +44 -0
- data/README.md +64 -2
- data/lib/concerns_on_rails/controllers/authorizable.rb +7 -3
- data/lib/concerns_on_rails/controllers/error_handleable.rb +5 -2
- data/lib/concerns_on_rails/controllers/filterable.rb +15 -1
- data/lib/concerns_on_rails/controllers/idempotentable.rb +300 -0
- data/lib/concerns_on_rails/controllers/localizable.rb +24 -2
- data/lib/concerns_on_rails/controllers/paginatable.rb +19 -1
- data/lib/concerns_on_rails/controllers/secure_headable.rb +7 -5
- data/lib/concerns_on_rails/controllers/sortable.rb +16 -10
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/activatable.rb +14 -4
- data/lib/concerns_on_rails/models/auditable.rb +224 -0
- data/lib/concerns_on_rails/models/expirable.rb +26 -18
- data/lib/concerns_on_rails/models/hashable.rb +30 -3
- data/lib/concerns_on_rails/models/monetizable.rb +4 -0
- data/lib/concerns_on_rails/models/normalizable.rb +4 -0
- data/lib/concerns_on_rails/models/publishable.rb +52 -10
- data/lib/concerns_on_rails/models/searchable.rb +3 -0
- data/lib/concerns_on_rails/models/sluggable.rb +15 -1
- data/lib/concerns_on_rails/models/soft_deletable.rb +10 -2
- data/lib/concerns_on_rails/models/stateable.rb +11 -4
- data/lib/concerns_on_rails/models/taggable.rb +7 -3
- data/lib/concerns_on_rails/models/tokenizable.rb +8 -1
- data/lib/concerns_on_rails/support/money.rb +4 -1
- data/lib/concerns_on_rails/support/sequence_calculator.rb +8 -1
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +2 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dbe5bebe66a84e336f6dbffff99b12d12ed65f6238ea000b9a149908b1a78e0c
|
|
4
|
+
data.tar.gz: 8478287a2750b501288e02e30394cfb79a45e2208190c14e8fce945457229e2b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 06bd9e4b6a3493047ea48b60f2ff75517bb3d117c8cbe4712071f7464db173c8cb73f7a3763d22a4376f458ba1d85deffca47648544ec4879642d14dc40e6a78
|
|
7
|
+
data.tar.gz: 042252611fb6ef6a41c671d3cf2a17e29ee7dcf82349f8f84b22d04274ebbcd2c48c23a2b6a03291325e0d072daedf29981b84bc2fb0a11a6cf832d5f79a572e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.16.0 (2026-06-10)
|
|
4
|
+
|
|
5
|
+
Two new concerns, hardened by an adversarial edge-case review. 574 examples, 0 failures.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
|
|
11
|
+
## 1.15.0 (2026-06-10)
|
|
12
|
+
|
|
13
|
+
A review-driven release: 23 correctness/safety fixes (each with a regression spec) and 7 backward-compatible enhancements. 510 examples, 0 failures.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Controllers::SecureHeadable**: `content_security_policy_for(report_only: true)` no longer silently drops the policy block — the policy is defined via `content_security_policy` and report-only mode is toggled separately (a report-only rollout previously registered no policy at all).
|
|
17
|
+
- **Controllers::Authorizable**: `require_role` / actor resolution now find a private or `helper_method` `current_user` (`respond_to?(.., true)`), so Devise-style private `current_user` is no longer denied.
|
|
18
|
+
- **Controllers::Sortable**: uses `reorder` so the requested sort replaces a model `default_scope` ORDER BY instead of becoming a silent secondary key.
|
|
19
|
+
- **Controllers::Paginatable**: the total count no longer breaks on grouped relations (it returned a Hash); it collapses to the group count.
|
|
20
|
+
- **Controllers::Filterable**: a nested-hash param in direct-where mode no longer raises a user-triggerable 500 — non-scalar values are ignored.
|
|
21
|
+
- **Controllers::Localizable**: the `Accept-Language` parser honors q-values (rejects `q=0`, orders by preference) per RFC 7231.
|
|
22
|
+
- **Controllers::ErrorHandleable**: the 404 handler renders a generic message instead of the raw `RecordNotFound` message, which leaked the model class name and queried attribute/value.
|
|
23
|
+
- **Models::SoftDeletable**: `deleted_within` uses an explicit `>=` predicate (the previous endless range was unsupported on Rails 5.x); `soft_delete_all` / `restore_all` roll the whole batch back when a record fails.
|
|
24
|
+
- **Models::Sluggable**: backfills a blank slug on save even when the source is unchanged, and no longer overwrites an explicitly-assigned slug.
|
|
25
|
+
- **Models::Publishable**: scopes branch on the column type so a boolean publishable column uses equality predicates instead of nonsensical timestamp comparisons.
|
|
26
|
+
- **Models::Hashable**: validates that `length` is positive; `:integer` drops dead padding code.
|
|
27
|
+
- **Models::Taggable**: `tagged_with` matches consistently (all branches use LIKE) and escapes the delimiter so a wildcard delimiter matches literally.
|
|
28
|
+
- **Models::Monetizable** / **Support::Money**: no spurious `-` for amounts that round to zero; `subunit_to_unit: 0` is rejected.
|
|
29
|
+
- **Models::Sequenceable**: the generation-time clock is memoized so a record's period anchor stays consistent (no boundary straddle).
|
|
30
|
+
- **gemspec**: `spec.metadata` is merged rather than reassigned, preserving the `license` key.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **Models::Activatable** / **Models::Expirable**: `prefix:` / `suffix:` options to affix scope names, so `.active` / `.expired` can coexist with the same-named scopes from sibling concerns on one model.
|
|
34
|
+
- **Models::Publishable**: `before/after_publish` and `before/after_unpublish` lifecycle hooks.
|
|
35
|
+
- **Models::Stateable**: `before/after_transition` hooks fired by guarded `<event>!` transitions.
|
|
36
|
+
- **Models::Hashable**: `unique: true` retries on an in-Ruby collision before insert (parity with Tokenizable).
|
|
37
|
+
- **Controllers::Sortable**: applies multiple whitelisted columns from a comma-separated `params[:sort]`.
|
|
38
|
+
- **Controllers::Paginatable**: `pagination_meta(relation)` for body-based pagination (composes with `Respondable`'s `meta:`).
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
- **Controllers::ErrorHandleable**: the default 404 message is now generic (`"Resource not found"`); override `handle_record_not_found` to surface detail in non-production environments.
|
|
42
|
+
|
|
43
|
+
### Docs
|
|
44
|
+
- Rewrote `CLAUDE.md` to cover all 29 concerns and 7 support modules, the model/controller layout, the macro conventions, the supported dependency ranges, and the release process.
|
|
45
|
+
- Noted native Rails 7.1+ alternatives (`normalizes`, `generates_token_for`) in `Normalizable` / `Tokenizable`.
|
|
46
|
+
|
|
3
47
|
## 1.14.1 (2026-06-07)
|
|
4
48
|
|
|
5
49
|
### Fixed
|
data/README.md
CHANGED
|
@@ -43,6 +43,7 @@ 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")
|
|
46
47
|
- **Controller concerns**
|
|
47
48
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
48
49
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
@@ -55,6 +56,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
55
56
|
- [Authorizable](#-authorizable) — per-action 403 authorization gate (block-based)
|
|
56
57
|
- [Throttleable](#-throttleable) — rate limiting with 429 + `X-RateLimit-*` headers
|
|
57
58
|
- [Timezoneable](#-timezoneable) — per-request `Time.zone` from params / header / cookie
|
|
59
|
+
- [Idempotentable](#-idempotentable) — `Idempotency-Key` request replay (409 on concurrent duplicates)
|
|
58
60
|
- [Module paths & namespacing](#-module-paths--namespacing)
|
|
59
61
|
- [Development](#-development)
|
|
60
62
|
- [Contributing](#-contributing)
|
|
@@ -64,7 +66,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
64
66
|
|
|
65
67
|
## ✨ Why this gem?
|
|
66
68
|
|
|
67
|
-
- **
|
|
69
|
+
- **Nineteen model concerns + twelve controller concerns**, all production-ready
|
|
68
70
|
- **One include, one macro** — no boilerplate, no glue code
|
|
69
71
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
70
72
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -935,6 +937,39 @@ product.formatted_price # => "$19.99"
|
|
|
935
937
|
|
|
936
938
|
---
|
|
937
939
|
|
|
940
|
+
## 📜 Auditable
|
|
941
|
+
|
|
942
|
+
Lightweight change history ("paper_trail-lite") stored as JSON entries in **one text column** on the same table — no extra tables, no versioning engine.
|
|
943
|
+
|
|
944
|
+
```ruby
|
|
945
|
+
class Product < ApplicationRecord
|
|
946
|
+
include ConcernsOnRails::Auditable
|
|
947
|
+
|
|
948
|
+
auditable_by :price, :status # default column :audit_log
|
|
949
|
+
# auditable_by :price, into: :history,
|
|
950
|
+
# actor: -> { Current.user&.email }, # stamps "by" on each entry
|
|
951
|
+
# max_entries: 50 # keep the newest 50
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
product.update!(price: 200)
|
|
955
|
+
product.audit_trail
|
|
956
|
+
# => [{"field"=>"price", "from"=>100, "to"=>200, "at"=>"2026-06-10T12:34:56Z", "by"=>"admin@shop.com"}]
|
|
957
|
+
product.last_change_for(:price) # newest entry for one field
|
|
958
|
+
product.audited_changes_since(1.day.ago) # recent entries, oldest first
|
|
959
|
+
product.clear_audit_trail! # wipe the column (skips callbacks)
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
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.
|
|
963
|
+
|
|
964
|
+
**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 + `…`).
|
|
965
|
+
|
|
966
|
+
**Notes**
|
|
967
|
+
- Writes that skip callbacks (`update_column(s)`, `touch`, `increment!`) are **not** audited; `save(validate: false)` is.
|
|
968
|
+
- 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.
|
|
969
|
+
- 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.
|
|
970
|
+
|
|
971
|
+
---
|
|
972
|
+
|
|
938
973
|
# 🎮 Controller Concerns
|
|
939
974
|
|
|
940
975
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -1299,6 +1334,33 @@ Resolution order: `params[param]` → `Time-Zone` header → cookie (if enabled)
|
|
|
1299
1334
|
|
|
1300
1335
|
---
|
|
1301
1336
|
|
|
1337
|
+
## 🔁 Idempotentable
|
|
1338
|
+
|
|
1339
|
+
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**.
|
|
1340
|
+
|
|
1341
|
+
```ruby
|
|
1342
|
+
class PaymentsController < ApplicationController
|
|
1343
|
+
include ConcernsOnRails::Controllers::Idempotentable
|
|
1344
|
+
|
|
1345
|
+
self.idempotency_store = Rails.cache # must support #read / #write(expires_in:, unless_exist:) / #delete
|
|
1346
|
+
|
|
1347
|
+
idempotent_actions :create, ttl: 24.hours, required: true
|
|
1348
|
+
end
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
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`).
|
|
1352
|
+
|
|
1353
|
+
**Options**: `*actions` (allow-list, required), `ttl:` (`24.hours`), `lock_ttl:` (`1.minute`), `header:` (`"Idempotency-Key"`), `required:` (`false`).
|
|
1354
|
+
|
|
1355
|
+
**Notes**
|
|
1356
|
+
- Cache keys are scoped per `controller#action` and the client key is SHA256-hashed, so the same key on different endpoints never collides.
|
|
1357
|
+
- There is **no in-process default store** on purpose: the first keyed request raises `ArgumentError` until you set `idempotency_store`.
|
|
1358
|
+
- When `Respondable` is included, the 400/409/422 bodies delegate to `render_error`.
|
|
1359
|
+
- 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.
|
|
1360
|
+
- 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.
|
|
1361
|
+
|
|
1362
|
+
---
|
|
1363
|
+
|
|
1302
1364
|
## 🗂️ Module paths & namespacing
|
|
1303
1365
|
|
|
1304
1366
|
Every concern is available under two paths:
|
|
@@ -1334,7 +1396,7 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
1334
1396
|
| Association-cascade soft delete / sentinel-aware unique indexes | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
|
|
1335
1397
|
| Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
|
|
1336
1398
|
| Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
|
|
1337
|
-
|
|
|
1399
|
+
| 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
1400
|
|
|
1339
1401
|
`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
1402
|
|
|
@@ -50,8 +50,11 @@ module ConcernsOnRails
|
|
|
50
50
|
|
|
51
51
|
wanted = roles.map(&:to_s)
|
|
52
52
|
check = proc do
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
# respond_to?(via, true): current_user is usually private (Devise) or
|
|
54
|
+
# a helper_method (which keeps it private on the instance), so the
|
|
55
|
+
# default public-only check would resolve nil and deny everyone.
|
|
56
|
+
actor = respond_to?(via, true) ? send(via) : nil
|
|
57
|
+
actor.respond_to?(role_method, true) && wanted.include?(actor.send(role_method).to_s)
|
|
55
58
|
end
|
|
56
59
|
add_authorization_rule(check: check, only: only, except: except, status: status, message: message)
|
|
57
60
|
end
|
|
@@ -113,7 +116,8 @@ module ConcernsOnRails
|
|
|
113
116
|
end
|
|
114
117
|
|
|
115
118
|
def authorization_actor
|
|
116
|
-
|
|
119
|
+
# include_private: true — current_user is typically private/helper_method.
|
|
120
|
+
respond_to?(:current_user, true) ? current_user : nil
|
|
117
121
|
end
|
|
118
122
|
|
|
119
123
|
def authorization_action_name
|
|
@@ -30,9 +30,12 @@ module ConcernsOnRails
|
|
|
30
30
|
rescue_from "ActiveRecord::RecordInvalid", with: :handle_record_invalid
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def handle_record_not_found(
|
|
33
|
+
def handle_record_not_found(_error)
|
|
34
|
+
# Use a generic message: the raw RecordNotFound message leaks the model
|
|
35
|
+
# class name and the queried attribute/value to API clients. Subclasses
|
|
36
|
+
# can override this method to surface detail in non-production envs.
|
|
34
37
|
render_error_envelope(
|
|
35
|
-
message:
|
|
38
|
+
message: "Resource not found",
|
|
36
39
|
code: "not_found",
|
|
37
40
|
status: :not_found
|
|
38
41
|
)
|
|
@@ -60,10 +60,24 @@ module ConcernsOnRails
|
|
|
60
60
|
options[:with].call(relation, value)
|
|
61
61
|
elsif options[:scope]
|
|
62
62
|
relation.public_send(options[:scope])
|
|
63
|
-
|
|
63
|
+
elsif filterable_scalar?(value)
|
|
64
64
|
relation.where(field => value)
|
|
65
|
+
else
|
|
66
|
+
# A nested/structured param (e.g. ?status[gt]=5) in direct-where mode
|
|
67
|
+
# would raise TypeError ("can't quote Hash") and surface as a 500.
|
|
68
|
+
# Ignore it instead — hash/array shaping must go through a `with:` lambda.
|
|
69
|
+
relation
|
|
65
70
|
end
|
|
66
71
|
end
|
|
72
|
+
|
|
73
|
+
# Scalars (and arrays, which AR turns into `IN (...)`) are safe to pass to
|
|
74
|
+
# .where; a Hash / ActionController::Parameters is not.
|
|
75
|
+
def filterable_scalar?(value)
|
|
76
|
+
return false if value.is_a?(Hash)
|
|
77
|
+
return false if defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters)
|
|
78
|
+
|
|
79
|
+
true
|
|
80
|
+
end
|
|
67
81
|
end
|
|
68
82
|
end
|
|
69
83
|
end
|
|
@@ -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
|
|
@@ -77,14 +77,36 @@ module ConcernsOnRails
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def parse_accept_language(header, allowed)
|
|
80
|
-
header
|
|
81
|
-
lang = part.split(";").first.to_s.strip.split("-").first
|
|
80
|
+
ranked_accept_languages(header).each do |lang|
|
|
82
81
|
match = match_locale(lang, allowed)
|
|
83
82
|
return match if match
|
|
84
83
|
end
|
|
85
84
|
nil
|
|
86
85
|
end
|
|
87
86
|
|
|
87
|
+
# Languages from an Accept-Language header, q=0 dropped, highest-q first
|
|
88
|
+
# (RFC 7231 preference order).
|
|
89
|
+
def ranked_accept_languages(header)
|
|
90
|
+
pairs = header.split(",").filter_map do |part|
|
|
91
|
+
token, *params = part.split(";").map(&:strip)
|
|
92
|
+
quality = accept_language_quality(params)
|
|
93
|
+
next if quality <= 0.0
|
|
94
|
+
|
|
95
|
+
lang = token.to_s.split("-").first
|
|
96
|
+
[lang, quality] if lang.present?
|
|
97
|
+
end
|
|
98
|
+
pairs.sort_by { |(_lang, quality)| -quality }.map(&:first)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The q-value (relative quality) of an Accept-Language part: 1.0 when
|
|
102
|
+
# absent, 0.0 when malformed. q=0 means "not acceptable" and is dropped.
|
|
103
|
+
def accept_language_quality(params)
|
|
104
|
+
qparam = params.find { |p| p.start_with?("q=") }
|
|
105
|
+
return 1.0 unless qparam
|
|
106
|
+
|
|
107
|
+
Float(qparam[2..], exception: false) || 0.0
|
|
108
|
+
end
|
|
109
|
+
|
|
88
110
|
def match_locale(candidate, allowed)
|
|
89
111
|
return nil if candidate.blank?
|
|
90
112
|
|
|
@@ -41,7 +41,10 @@ module ConcernsOnRails
|
|
|
41
41
|
per_page = pagination_per_page
|
|
42
42
|
offset = (page - 1) * per_page
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
counted = relation.except(:order, :limit, :offset).count
|
|
45
|
+
# `.count` on a grouped relation returns a Hash (group => count); for
|
|
46
|
+
# offset pagination the meaningful total is the number of groups.
|
|
47
|
+
total = counted.is_a?(Hash) ? counted.length : counted
|
|
45
48
|
total_pages = per_page.positive? ? (total.to_f / per_page).ceil : 0
|
|
46
49
|
|
|
47
50
|
records = relation.limit(per_page).offset(offset)
|
|
@@ -50,6 +53,21 @@ module ConcernsOnRails
|
|
|
50
53
|
records
|
|
51
54
|
end
|
|
52
55
|
|
|
56
|
+
# Pagination metadata for a relation WITHOUT applying limit/offset — handy
|
|
57
|
+
# for body-based pagination (compose with Respondable's `meta:`).
|
|
58
|
+
def pagination_meta(relation)
|
|
59
|
+
page = pagination_page
|
|
60
|
+
per_page = pagination_per_page
|
|
61
|
+
counted = relation.except(:order, :limit, :offset).count
|
|
62
|
+
total = counted.is_a?(Hash) ? counted.length : counted
|
|
63
|
+
{
|
|
64
|
+
total: total,
|
|
65
|
+
page: page,
|
|
66
|
+
per_page: per_page,
|
|
67
|
+
total_pages: per_page.positive? ? (total.to_f / per_page).ceil : 0
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
53
71
|
private
|
|
54
72
|
|
|
55
73
|
def pagination_page
|
|
@@ -85,11 +85,13 @@ module ConcernsOnRails
|
|
|
85
85
|
"ActionController::ContentSecurityPolicy (Rails 5.2+)"
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
# The policy block is ONLY accepted by content_security_policy; the
|
|
89
|
+
# report-only variant is a flag toggle that takes no block. So always
|
|
90
|
+
# define the policy via content_security_policy, then additionally mark
|
|
91
|
+
# it report-only when requested — otherwise a report-only rollout would
|
|
92
|
+
# silently register no policy at all (the block would be dropped).
|
|
93
|
+
content_security_policy(**action_opts, &block)
|
|
94
|
+
content_security_policy_report_only(true, **action_opts) if report_only
|
|
93
95
|
end
|
|
94
96
|
end
|
|
95
97
|
|