concerns_on_rails 1.15.0 → 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 +8 -0
- data/README.md +64 -2
- data/lib/concerns_on_rails/controllers/idempotentable.rb +300 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/auditable.rb +224 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +2 -0
- metadata +3 -1
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,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 1.15.0 (2026-06-10)
|
|
4
12
|
|
|
5
13
|
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,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
|
|
|
@@ -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
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "bigdecimal"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ConcernsOnRails
|
|
6
|
+
module Models
|
|
7
|
+
# Lightweight change history ("paper_trail-lite") stored as JSON entries in
|
|
8
|
+
# a single text column on the same table — no extra tables, no versioning
|
|
9
|
+
# engine — so it works on any database, including SQLite.
|
|
10
|
+
#
|
|
11
|
+
# class Product < ApplicationRecord
|
|
12
|
+
# include ConcernsOnRails::Auditable
|
|
13
|
+
#
|
|
14
|
+
# auditable_by :price, :status # default column :audit_log
|
|
15
|
+
# # auditable_by :price, into: :history,
|
|
16
|
+
# # actor: -> { Current.user&.email }, # stamps "by"
|
|
17
|
+
# # max_entries: 50, # keep the newest 50
|
|
18
|
+
# # max_value_length: 120 # truncate long from/to strings
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# product.update!(price: 200)
|
|
22
|
+
# product.audit_trail
|
|
23
|
+
# # => [{"field"=>"price", "from"=>100, "to"=>200,
|
|
24
|
+
# # "at"=>"2026-06-10T12:34:56Z", "by"=>"admin@shop.com"}]
|
|
25
|
+
# product.last_change_for(:price) # newest entry for one field
|
|
26
|
+
# product.audited_changes_since(1.day.ago)
|
|
27
|
+
# product.clear_audit_trail! # wipe the column (skips callbacks)
|
|
28
|
+
#
|
|
29
|
+
# Notes:
|
|
30
|
+
# * One entry per changed field per save; creates record `"from" => nil`.
|
|
31
|
+
# "by" is omitted entirely when no actor is configured (or it returns nil).
|
|
32
|
+
# * Entries are appended in the same INSERT/UPDATE via before_save — no
|
|
33
|
+
# extra queries. Writes that skip callbacks (update_column(s), touch,
|
|
34
|
+
# increment!) are NOT audited.
|
|
35
|
+
# * Values are JSON-coerced: times → ISO8601 UTC strings, BigDecimal →
|
|
36
|
+
# plain numeric string, symbols → strings. With `max_value_length:`,
|
|
37
|
+
# String values longer than the limit are stored as the first N
|
|
38
|
+
# characters plus a trailing "…" (non-strings are never truncated).
|
|
39
|
+
# * A corrupt or non-array column decodes as [] and is overwritten on the
|
|
40
|
+
# next tracked save. Concurrent saves of one row are last-writer-wins.
|
|
41
|
+
# * New entries are built on the PERSISTED trail (so an aborted save
|
|
42
|
+
# can't duplicate entries on retry). Assigning the audit column by
|
|
43
|
+
# hand in the same save as a tracked change is therefore ignored —
|
|
44
|
+
# use clear_audit_trail! to reset it.
|
|
45
|
+
# * Reach for paper_trail/audited when you need reify/undo, who-dunnit
|
|
46
|
+
# queries across models, or association tracking.
|
|
47
|
+
module Auditable
|
|
48
|
+
extend ActiveSupport::Concern
|
|
49
|
+
|
|
50
|
+
LABEL = "ConcernsOnRails::Models::Auditable".freeze
|
|
51
|
+
DEFAULT_INTO = :audit_log
|
|
52
|
+
DEFAULT_MAX_ENTRIES = 200
|
|
53
|
+
|
|
54
|
+
included do
|
|
55
|
+
class_attribute :auditable_fields, instance_accessor: false, default: []
|
|
56
|
+
class_attribute :auditable_into, instance_accessor: false, default: DEFAULT_INTO
|
|
57
|
+
class_attribute :auditable_actor, instance_accessor: false, default: nil
|
|
58
|
+
class_attribute :auditable_max_entries, instance_accessor: false, default: DEFAULT_MAX_ENTRIES
|
|
59
|
+
class_attribute :auditable_max_value_length, instance_accessor: false, default: nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module ClassMethods
|
|
63
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
64
|
+
|
|
65
|
+
# Configure the tracked fields and the audit column. See the module docs.
|
|
66
|
+
def auditable_by(*fields, into: DEFAULT_INTO, actor: nil, max_entries: DEFAULT_MAX_ENTRIES, max_value_length: nil)
|
|
67
|
+
fields = fields.flatten.map(&:to_sym).uniq
|
|
68
|
+
into = into.to_sym
|
|
69
|
+
validate_auditable!(fields, into: into, actor: actor, max_entries: max_entries, max_value_length: max_value_length)
|
|
70
|
+
|
|
71
|
+
self.auditable_fields = fields
|
|
72
|
+
self.auditable_into = into
|
|
73
|
+
self.auditable_actor = actor
|
|
74
|
+
self.auditable_max_entries = max_entries
|
|
75
|
+
self.auditable_max_value_length = max_value_length
|
|
76
|
+
ensure_columns!(LABEL, into, *fields)
|
|
77
|
+
|
|
78
|
+
before_save :auditable_capture_changes
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def validate_auditable!(fields, into:, actor:, max_entries:, max_value_length:)
|
|
84
|
+
raise ArgumentError, "#{LABEL}: auditable_by requires at least one field" if fields.empty?
|
|
85
|
+
raise ArgumentError, "#{LABEL}: cannot track the audit column ':#{into}' itself" if fields.include?(into)
|
|
86
|
+
raise ArgumentError, "#{LABEL}: max_entries must be a positive Integer or nil" unless positive_integer_or_nil?(max_entries)
|
|
87
|
+
unless positive_integer_or_nil?(max_value_length)
|
|
88
|
+
raise ArgumentError, "#{LABEL}: max_value_length must be a positive Integer or nil"
|
|
89
|
+
end
|
|
90
|
+
raise ArgumentError, "#{LABEL}: actor must be callable (respond to #call)" unless actor.nil? || actor.respond_to?(:call)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def positive_integer_or_nil?(value)
|
|
94
|
+
value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ---- instance methods ----
|
|
99
|
+
|
|
100
|
+
# Decoded audit entries, oldest first. [] for blank/corrupt columns.
|
|
101
|
+
def audit_trail
|
|
102
|
+
auditable_decode(self[self.class.auditable_into])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The most recent entry recorded for `field`, or nil.
|
|
106
|
+
def last_change_for(field)
|
|
107
|
+
name = field.to_s
|
|
108
|
+
audit_trail.reverse_each.find { |entry| entry["field"] == name }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Entries recorded at or after `time`, oldest first. Entries whose "at"
|
|
112
|
+
# is missing or unparseable are excluded.
|
|
113
|
+
def audited_changes_since(time)
|
|
114
|
+
audit_trail.select do |entry|
|
|
115
|
+
at = auditable_parse_time(entry["at"])
|
|
116
|
+
at && at >= time
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Wipe the trail with a single UPDATE. Deliberately uses update_column so
|
|
121
|
+
# clearing can never itself be captured or run other callbacks/validations.
|
|
122
|
+
def clear_audit_trail!
|
|
123
|
+
raise ArgumentError, "#{LABEL}: clear_audit_trail! cannot be called on a new record" if new_record?
|
|
124
|
+
|
|
125
|
+
update_column(self.class.auditable_into, nil)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# before_save hook — appends one entry per changed tracked field so the
|
|
131
|
+
# audit column rides the same INSERT/UPDATE statement.
|
|
132
|
+
def auditable_capture_changes
|
|
133
|
+
fields = self.class.auditable_fields
|
|
134
|
+
return if fields.blank?
|
|
135
|
+
|
|
136
|
+
pending = respond_to?(:changes_to_save) ? changes_to_save : changes
|
|
137
|
+
tracked = pending.slice(*fields.map(&:to_s))
|
|
138
|
+
return if tracked.empty?
|
|
139
|
+
|
|
140
|
+
entries = auditable_persisted_trail + auditable_build_entries(tracked)
|
|
141
|
+
max = self.class.auditable_max_entries
|
|
142
|
+
entries = entries.last(max) if max
|
|
143
|
+
self[self.class.auditable_into] = JSON.generate(entries)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Base the new trail on the PERSISTED column value, not the in-memory
|
|
147
|
+
# attribute: an aborted save leaves the in-memory column holding the
|
|
148
|
+
# entry it appended, and reading it back on retry would duplicate the
|
|
149
|
+
# change. (attribute_in_database is Rails 5.1+; fall back for 5.0.)
|
|
150
|
+
def auditable_persisted_trail
|
|
151
|
+
into = self.class.auditable_into
|
|
152
|
+
raw = respond_to?(:attribute_in_database) ? attribute_in_database(into.to_s) : self[into]
|
|
153
|
+
auditable_decode(raw)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def auditable_build_entries(tracked)
|
|
157
|
+
at = Time.now.utc.iso8601
|
|
158
|
+
by = auditable_resolve_actor
|
|
159
|
+
tracked.map do |field, (from, to)|
|
|
160
|
+
entry = { "field" => field, "from" => auditable_entry_value(from), "to" => auditable_entry_value(to), "at" => at }
|
|
161
|
+
entry["by"] = by unless by.nil?
|
|
162
|
+
entry
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def auditable_resolve_actor
|
|
167
|
+
actor = self.class.auditable_actor
|
|
168
|
+
return nil unless actor
|
|
169
|
+
|
|
170
|
+
auditable_json_value(instance_exec(&actor))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# from/to pipeline: JSON coercion, then opt-in truncation.
|
|
174
|
+
def auditable_entry_value(value)
|
|
175
|
+
auditable_truncate(auditable_json_value(value))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Keep the first max_value_length characters and mark the cut with "…".
|
|
179
|
+
# Only String values are truncated — numbers, booleans, arrays pass through.
|
|
180
|
+
def auditable_truncate(value)
|
|
181
|
+
limit = self.class.auditable_max_value_length
|
|
182
|
+
return value unless limit && value.is_a?(String) && value.length > limit
|
|
183
|
+
|
|
184
|
+
"#{value[0, limit]}…"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Coerce a Ruby value to a JSON-safe primitive. Times → ISO8601 UTC,
|
|
188
|
+
# BigDecimal → plain numeric string (precision-safe), Symbol → String.
|
|
189
|
+
# TimeWithZone is caught by `when Time` (it masquerades via #is_a?).
|
|
190
|
+
def auditable_json_value(value)
|
|
191
|
+
case value
|
|
192
|
+
when nil, true, false, Integer, String then value
|
|
193
|
+
when Float then auditable_float_value(value)
|
|
194
|
+
when BigDecimal then value.to_s("F")
|
|
195
|
+
when Time, DateTime then value.to_time.utc.iso8601
|
|
196
|
+
when Date then value.iso8601
|
|
197
|
+
when Symbol then value.to_s
|
|
198
|
+
else value.as_json
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# JSON.generate raises on non-finite floats — store NaN/Infinity as strings.
|
|
203
|
+
def auditable_float_value(value)
|
|
204
|
+
value.finite? ? value : value.to_s
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def auditable_parse_time(raw)
|
|
208
|
+
Time.iso8601(raw.to_s)
|
|
209
|
+
rescue ArgumentError, TypeError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Tolerant decode: blank, invalid JSON or non-array payloads become [].
|
|
214
|
+
def auditable_decode(raw)
|
|
215
|
+
return [] if raw.nil? || raw.to_s.strip.empty?
|
|
216
|
+
|
|
217
|
+
parsed = JSON.parse(raw)
|
|
218
|
+
parsed.is_a?(Array) ? parsed.grep(Hash) : []
|
|
219
|
+
rescue JSON::ParserError
|
|
220
|
+
[]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -35,6 +35,7 @@ require "concerns_on_rails/models/taggable"
|
|
|
35
35
|
require "concerns_on_rails/models/sanitizable"
|
|
36
36
|
require "concerns_on_rails/models/maskable"
|
|
37
37
|
require "concerns_on_rails/models/monetizable"
|
|
38
|
+
require "concerns_on_rails/models/auditable"
|
|
38
39
|
|
|
39
40
|
# Controller concerns
|
|
40
41
|
require "concerns_on_rails/controllers/paginatable"
|
|
@@ -48,6 +49,7 @@ require "concerns_on_rails/controllers/localizable"
|
|
|
48
49
|
require "concerns_on_rails/controllers/authorizable"
|
|
49
50
|
require "concerns_on_rails/controllers/throttleable"
|
|
50
51
|
require "concerns_on_rails/controllers/timezoneable"
|
|
52
|
+
require "concerns_on_rails/controllers/idempotentable"
|
|
51
53
|
|
|
52
54
|
# Backwards compatibility (top-level aliases for pre-1.6 module paths)
|
|
53
55
|
require "concerns_on_rails/legacy_aliases"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: concerns_on_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
@@ -73,6 +73,7 @@ files:
|
|
|
73
73
|
- lib/concerns_on_rails/controllers/authorizable.rb
|
|
74
74
|
- lib/concerns_on_rails/controllers/error_handleable.rb
|
|
75
75
|
- lib/concerns_on_rails/controllers/filterable.rb
|
|
76
|
+
- lib/concerns_on_rails/controllers/idempotentable.rb
|
|
76
77
|
- lib/concerns_on_rails/controllers/includable.rb
|
|
77
78
|
- lib/concerns_on_rails/controllers/localizable.rb
|
|
78
79
|
- lib/concerns_on_rails/controllers/paginatable.rb
|
|
@@ -84,6 +85,7 @@ files:
|
|
|
84
85
|
- lib/concerns_on_rails/legacy_aliases.rb
|
|
85
86
|
- lib/concerns_on_rails/models/activatable.rb
|
|
86
87
|
- lib/concerns_on_rails/models/addressable.rb
|
|
88
|
+
- lib/concerns_on_rails/models/auditable.rb
|
|
87
89
|
- lib/concerns_on_rails/models/expirable.rb
|
|
88
90
|
- lib/concerns_on_rails/models/hashable.rb
|
|
89
91
|
- lib/concerns_on_rails/models/maskable.rb
|