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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/README.md +64 -2
  4. data/lib/concerns_on_rails/controllers/authorizable.rb +7 -3
  5. data/lib/concerns_on_rails/controllers/error_handleable.rb +5 -2
  6. data/lib/concerns_on_rails/controllers/filterable.rb +15 -1
  7. data/lib/concerns_on_rails/controllers/idempotentable.rb +300 -0
  8. data/lib/concerns_on_rails/controllers/localizable.rb +24 -2
  9. data/lib/concerns_on_rails/controllers/paginatable.rb +19 -1
  10. data/lib/concerns_on_rails/controllers/secure_headable.rb +7 -5
  11. data/lib/concerns_on_rails/controllers/sortable.rb +16 -10
  12. data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
  13. data/lib/concerns_on_rails/models/activatable.rb +14 -4
  14. data/lib/concerns_on_rails/models/auditable.rb +224 -0
  15. data/lib/concerns_on_rails/models/expirable.rb +26 -18
  16. data/lib/concerns_on_rails/models/hashable.rb +30 -3
  17. data/lib/concerns_on_rails/models/monetizable.rb +4 -0
  18. data/lib/concerns_on_rails/models/normalizable.rb +4 -0
  19. data/lib/concerns_on_rails/models/publishable.rb +52 -10
  20. data/lib/concerns_on_rails/models/searchable.rb +3 -0
  21. data/lib/concerns_on_rails/models/sluggable.rb +15 -1
  22. data/lib/concerns_on_rails/models/soft_deletable.rb +10 -2
  23. data/lib/concerns_on_rails/models/stateable.rb +11 -4
  24. data/lib/concerns_on_rails/models/taggable.rb +7 -3
  25. data/lib/concerns_on_rails/models/tokenizable.rb +8 -1
  26. data/lib/concerns_on_rails/support/money.rb +4 -1
  27. data/lib/concerns_on_rails/support/sequence_calculator.rb +8 -1
  28. data/lib/concerns_on_rails/version.rb +1 -1
  29. data/lib/concerns_on_rails.rb +2 -0
  30. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0cbda4a7f45769f7997d0f5367d04a594946bee53e45e759a461bb73a535522e
4
- data.tar.gz: 692b5092f4b784a113dd09935eda88fc16bd1f0a484f349ae53c77f8b945b14d
3
+ metadata.gz: dbe5bebe66a84e336f6dbffff99b12d12ed65f6238ea000b9a149908b1a78e0c
4
+ data.tar.gz: 8478287a2750b501288e02e30394cfb79a45e2208190c14e8fce945457229e2b
5
5
  SHA512:
6
- metadata.gz: 215b94501afdbb59bfed5633eaa649efb244e2c9a01e21ac30a63ea1bf4de0ffa9f5b2c71da92d5fd980f90cc7d8ef397f17fa0c708d16be31059f7d29facf91
7
- data.tar.gz: 3778aa7588aef6315a40707fdfd1d01b2092d65fb4b5717a16a4ac4f81300f23086aa4a3f0133d86a84d84acbb9b210a7ee45f23d2806e6c9d8ade8797238733
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
- - **Eighteen model concerns + eight controller concerns**, all production-ready
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
- | Audit trails / version history | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
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
- actor = respond_to?(via) ? send(via) : nil
54
- actor.respond_to?(role_method) && wanted.include?(actor.public_send(role_method).to_s)
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
- respond_to?(:current_user) ? current_user : nil
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(error)
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: error.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
- else
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.split(",").each do |part|
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
- total = relation.except(:order, :limit, :offset).count
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
- if report_only
89
- content_security_policy_report_only(true, **action_opts, &block)
90
- else
91
- content_security_policy(**action_opts, &block)
92
- end
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