concerns_on_rails 1.21.1 → 1.23.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 +58 -0
- data/README.md +111 -9
- data/lib/concerns_on_rails/controllers/authorizable.rb +14 -6
- data/lib/concerns_on_rails/controllers/cacheable.rb +25 -7
- data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +23 -8
- data/lib/concerns_on_rails/controllers/deprecatable.rb +18 -10
- data/lib/concerns_on_rails/controllers/error_handleable.rb +4 -5
- data/lib/concerns_on_rails/controllers/filterable.rb +6 -6
- data/lib/concerns_on_rails/controllers/idempotentable.rb +15 -6
- data/lib/concerns_on_rails/controllers/includable.rb +5 -1
- data/lib/concerns_on_rails/controllers/localizable.rb +17 -8
- data/lib/concerns_on_rails/controllers/paginatable.rb +37 -16
- data/lib/concerns_on_rails/controllers/secure_headable.rb +9 -2
- data/lib/concerns_on_rails/controllers/throttleable.rb +24 -6
- data/lib/concerns_on_rails/controllers/timezoneable.rb +20 -10
- data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +2 -3
- data/lib/concerns_on_rails/legacy_aliases.rb +2 -0
- data/lib/concerns_on_rails/models/activatable.rb +1 -0
- data/lib/concerns_on_rails/models/addressable.rb +23 -5
- data/lib/concerns_on_rails/models/aliasable.rb +7 -7
- data/lib/concerns_on_rails/models/anonymizable.rb +218 -0
- data/lib/concerns_on_rails/models/auditable.rb +37 -1
- data/lib/concerns_on_rails/models/counter_cacheable.rb +35 -18
- data/lib/concerns_on_rails/models/duplicable.rb +207 -0
- data/lib/concerns_on_rails/models/encryptable.rb +21 -18
- data/lib/concerns_on_rails/models/expirable.rb +5 -0
- data/lib/concerns_on_rails/models/hashable.rb +22 -2
- data/lib/concerns_on_rails/models/lockable.rb +1 -0
- data/lib/concerns_on_rails/models/maskable.rb +1 -0
- data/lib/concerns_on_rails/models/monetizable.rb +14 -1
- data/lib/concerns_on_rails/models/normalizable.rb +8 -1
- data/lib/concerns_on_rails/models/publishable.rb +32 -10
- data/lib/concerns_on_rails/models/sanitizable.rb +1 -0
- data/lib/concerns_on_rails/models/schedulable.rb +17 -6
- data/lib/concerns_on_rails/models/searchable.rb +1 -0
- data/lib/concerns_on_rails/models/sequenceable.rb +15 -0
- data/lib/concerns_on_rails/models/sluggable.rb +6 -1
- data/lib/concerns_on_rails/models/soft_deletable.rb +67 -11
- data/lib/concerns_on_rails/models/sortable.rb +32 -14
- data/lib/concerns_on_rails/models/stateable.rb +16 -6
- data/lib/concerns_on_rails/models/storable.rb +66 -10
- data/lib/concerns_on_rails/models/taggable.rb +1 -0
- data/lib/concerns_on_rails/models/tokenizable.rb +19 -7
- data/lib/concerns_on_rails/railtie.rb +18 -0
- data/lib/concerns_on_rails/support/column_guard.rb +30 -4
- data/lib/concerns_on_rails/support/encryptor.rb +40 -11
- data/lib/concerns_on_rails/support/error_envelope.rb +29 -0
- data/lib/concerns_on_rails/support/filter_parameter_registry.rb +67 -0
- data/lib/concerns_on_rails/support/money.rb +3 -1
- data/lib/concerns_on_rails/support/random_value.rb +18 -1
- data/lib/concerns_on_rails/support/scalar_param.rb +35 -0
- data/lib/concerns_on_rails/support/unique_retry.rb +38 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +31 -2
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba3e5dc1bc6af51ec2bd4e29946973c5fc68736d4c1994fdbd472874f965c4bc
|
|
4
|
+
data.tar.gz: 16b2372635abe33484efabcae5407d1debe5a0627df9df0496a04e930c46ec65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8a159b0026bca1ebc72dbc11c45e875caaa19ad2ec0615ee658abd5b3e8ab53e0bb309d816a5371ddba6bad5c609b82a76228e66d678d3346d624021634eddf5
|
|
7
|
+
data.tar.gz: da89fb098f9e17fd9ae49662efdb027efb64958007bf790ee895c0faf39a1c9f2cb76d2e95a4b0b3b075bcb404fe926c36d970f78ca152bdd287be81e7a3a991
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.23.0 (2026-07-26)
|
|
4
|
+
|
|
5
|
+
Two new model concerns — the roadmap picks after 1.22's fixes round: the right-to-erasure capability that completes the sensitive-data suite, and the concern-aware deep copy that makes "duplicate this invoice" safe next to unique slugs, tokens, and sequence numbers. 1075 examples, 0 failures.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Models::Anonymizable**: declarative right-to-erasure ("GDPR-lite") for personal data — the fourth member of the sensitive-data suite (Maskable masks display, Sanitizable strips HTML, Encryptable protects at rest; Anonymizable DESTROYS). `anonymizable *fields, with:` declares an erasure strategy per field group (repeatable; rules merge): presets `:nullify`, `:redact`, `:hash` (SHA-256 — deterministic pseudonymization, joins keep working), `:email` (random unique `anon-…@anonymized.invalid`, so NOT NULL + unique email columns survive erasure), `:random_hex`, or a callable (`->(value)` / `->(value, record)`); presets pass nil through untouched. `anonymize!` runs `before_anonymize` + ONE `update_columns` UPDATE + `after_anonymize` in a single transaction — deliberately skipping validations (erasure must not be blocked by a presence check) and callbacks (nothing may copy the old values elsewhere; Auditable's capture hook is the canonical example) — then reloads. Values serialize through the model's attribute types, so a field that is also `encryptable` stores a fresh ciphertext envelope of the anonymized value, never plaintext (spec-verified). When an erased field is also `auditable_by`, the audit column (which holds its plaintext history) is cleared in the same UPDATE (`clear_audit_trail: false` opts out). `stamp:` column (default `:anonymized_at`; `false` opts out) powers `anonymized?`, the `anonymized`/`not_anonymized` scopes (affixable via `prefix:`/`suffix:`), and the idempotent batch `anonymize_all!` (Integer count, skips stamped records — the 1.22 batch contract). Macro-time `ArgumentError` validation for fields, presets, callables, and columns. Zero new dependencies.
|
|
9
|
+
- **Models::Duplicable**: concern-aware deep copy ("clone this invoice/template"). A bare AR `dup` copies identity-bearing columns — slug, API token, invoice number, audit trail, even `created_at` (which AR preserves on save when present) — so naive copies collide with unique indexes or lie about their history. `duplicate` (unsaved) / `duplicate!(overrides)` (saved, one transaction, autosaved children) blank exactly those columns automatically: timestamps, Sluggable's slug, Tokenizable/Hashable columns, Sequenceable sequence + `into:` columns, Auditable's trail (the copy inherits no history; its own creation is then audited normally), SoftDeletable's timestamp (a copy of trash is live), and Lockable's attempts (0) / locked_at (nil) — each concern regenerates fresh values on the copy's save. Business state (Publishable/Stateable/…) is deliberately NOT auto-reset — list it in `reset:`. `duplicable_by associations:, reset:, suffix:` (optional — bare include works): the association allow-list is validated at macro time (declared-before, the CounterCacheable convention); `has_many`/`has_one` children deep-copy — a child whose class also includes Duplicable copies via ITS OWN rules, so nested graphs stay declarative; `has_and_belongs_to_many` re-links the same records; `belongs_to` and `has_many :through` are rejected with explanations. `suffix: { title: " (copy)" }` appends to present values; `on_duplicate(copy)` is the override hook. Zero new dependencies.
|
|
10
|
+
|
|
11
|
+
### Internal
|
|
12
|
+
- Top-level aliases `ConcernsOnRails::Anonymizable` / `ConcernsOnRails::Duplicable` registered alongside the existing set; docs site pages + registry entries added for both concerns.
|
|
13
|
+
|
|
14
|
+
## 1.22.0 (2026-07-26)
|
|
15
|
+
|
|
16
|
+
A fixes-and-optimizations release driven by a full-library review of all 40 concerns: one data-loss bug, three request-crashing 500s verified through real ActionController dispatch, security headers restored on rescued error responses, a fail-open authorization path closed, and the gem's largest performance defect (unmemoized PBKDF2 per encrypted-value access) eliminated. 1031 examples, 0 failures.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **Models::SoftDeletable**: `really_destroy_all` now hard-deletes only the records matching the calling relation (soft-deleted included) — previously it ran `unscoped.delete_all` and deleted the ENTIRE table regardless of scope (`User.where(account_id: 1).really_destroy_all` wiped every account). Note `unscope` also drops a caller's own predicate on the soft-delete column, so `only_deleted.really_destroy_all` widens to the whole relation — use `soft_deleted.delete_all` to purge trash.
|
|
20
|
+
- **Controllers::Includable**: sparse fieldsets (`?fields[articles]=id,title`) no longer 500 — in a real controller `params[:fields]` is an `ActionController::Parameters`, which has no `each_with_object`; every documented fieldsets request raised `NoMethodError`. Verified through a new real-ActionController integration harness.
|
|
21
|
+
- **Controllers::Paginatable / CursorPaginatable**: `?page[]=1`, `?per_page[x]=5` and friends no longer 500 — untrusted param reads route through the new `Support::ScalarParam` (Filterable's guard, now shared; Filterable also rejects arrays *containing* nested hashes, the `?status[][x]=1` shape).
|
|
22
|
+
- **Controllers::SecureHeadable**: security headers are now also applied via `prepend_before_action` — Rails skips `after_action` callbacks entirely when `rescue_from` handles an exception, so every handled 404/422/500 previously shipped with NO security headers. (Opting out now requires `skip_before_action :apply_secure_headers` alongside the existing `skip_after_action`.)
|
|
23
|
+
- **Controllers::Cacheable**: `no_store` policies are likewise applied early, so rescued error responses on `no_store` endpoints carry `Cache-Control: no-store`; positive freshness deliberately stays post-action (a rescued 500 must never become CDN-cacheable). `stale_resource?` no longer writes ETag/Last-Modified validators for unsafe (non-GET/HEAD) requests, and the 304 path is a single write.
|
|
24
|
+
- **Controllers::Authorizable**: fails CLOSED — a denial that cannot be rendered (no response object) now raises instead of silently letting the action run; `require_role` supports actors whose role method returns an Array (`roles: ["admin"]` previously stringified and was always denied).
|
|
25
|
+
- **Controllers::Localizable**: regional locales match — `Accept-Language: fr-CA` now tries the full tag before falling back to the primary subtag (previously the region was discarded, so `available: [:"fr-CA"]` could never match).
|
|
26
|
+
- **Controllers::Throttleable**: the first-hit seed uses `unless_exist:` so two concurrent first requests can't under-count the window; a nil/blank discriminator (e.g. `-> { current_user&.id }` on an anonymous request) raises instead of collapsing every client into one shared bucket.
|
|
27
|
+
- **Controllers::Idempotentable**: the 409 conflict's `Retry-After` reports the remaining lock time (from the claim's `claimed_at`) instead of the full `lock_ttl`.
|
|
28
|
+
- **Controllers::Deprecatable**: `link:`/`successor:` values are validated against control characters and `<`/`>` at macro time (Link-header injection).
|
|
29
|
+
- **Support::ColumnGuard** (all 24 model concerns): macros no longer raise when the schema is unreachable — models using these concerns previously could not even load without a migrated database, breaking `db:create`, fresh `db:migrate`, `assets:precompile`, and CI bootstrap. Validation is skipped (never silently for a *reachable* schema with a missing column) and the guard is shared with CounterCacheable/Aliasable/Storable, which had each hand-rolled their own.
|
|
30
|
+
- **Models::Sortable**: a bare `sortable_by` no longer crashes with `NoMethodError` (uses the documented `:position` default); the `default_scope` no longer re-validates the schema on every relation construction; new `default_scope: false` opt-out with a `sorted` scope for explicit ordering.
|
|
31
|
+
- **Models::Monetizable**: the money setter casts garbage input (`"abc"`, `""`) to nil — ActiveModel convention — instead of raising `ArgumentError` out of a form assignment.
|
|
32
|
+
- **Models::Encryptable**: `where_<field>(nil)` / `find_by_<field>(nil)` return `none`/nil instead of matching every row without a fingerprint; encrypted field names now register with Rails' parameter filtering through a live registry + railtie (`before: "active_record.set_filter_attributes"`), so redaction works with boot-time snapshots (ActiveRecord `filter_attributes`, lograge-style initializers) and lazily-loaded model classes.
|
|
33
|
+
- **Models::Sequenceable**: with `reset:` enabled, `created_at` is pinned to the period-anchor instant during `before_create`, so a row can no longer be numbered for one year/month/day but timestamped into the next.
|
|
34
|
+
- **Models::Sluggable**: the slug column itself is validated at macro time (a missing `slug` column previously failed at first save with an opaque friendly_id error).
|
|
35
|
+
- **Models::Schedulable**: `reschedule!` accepts either/both keywords (single-column windows no longer fabricate the missing kwarg) and raises when a value targets an unconfigured column instead of silently dropping it.
|
|
36
|
+
- **Models::CounterCacheable**: `touch: true` raises at macro time on Rails < 6.0 (`update_counters` lacks the option there and would read it as a column named `touch`).
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- **Models::SoftDeletable**: `soft_delete_all` / `restore_all` (and the `destroy_all` alias) now return the Integer count of records transitioned and raise `ActiveRecord::RecordNotSaved` (rolling the batch back) when a record fails — previously the rollback happened silently and the methods returned nil/records. With `touch: false` and no overridden hooks both collapse to a single `UPDATE`.
|
|
40
|
+
- **Models::Publishable / Stateable**: lifecycle hooks now share one transaction with the state write (SoftDeletable's pattern) — a raising `after_publish`/`after_transition` rolls the change back instead of leaving it committed with the side effect half-done. `publish_at!` now fires the publish hooks.
|
|
41
|
+
- **Models::Encryptable + Auditable**: the encrypted×audited overlap is rejected at macro time from BOTH declaration orders (Auditable gained the mirror guard); the per-save backstop callback is gone.
|
|
42
|
+
- **Models::Hashable / Tokenizable**: `regenerate_<field>!` goes through the same uniqueness path as create-time assignment (precheck + bounded retry on `ActiveRecord::RecordNotUnique` via the new `Support::UniqueRetry`) — previously one blind `update!` with no collision handling. Hashable's `type: :integer` now generates fixed-width codes (`length: 6` is always 6 digits).
|
|
43
|
+
- **Effective Rails support documented**: the gemspec keeps `rails >= 5.0, < 9`, but Ruby 3.2 (required) means Rails 7.0.4+ in practice; README updated accordingly.
|
|
44
|
+
|
|
45
|
+
### Performance
|
|
46
|
+
- **Support::Encryptor**: PBKDF2-derived keys are memoized (bounded, mutex-guarded, keyed by digest+salt+iterations) — previously EVERY encrypt/decrypt/blind-index call re-ran the 65,536-iteration KDF, turning a 200-row load of one encrypted column into seconds of key stretching. `configure_encryption` purges the cache.
|
|
47
|
+
- **Models::Storable**: per-record decode memoization — readers/writers/dirty-checks/validation shared one `JSON.parse` per raw column value instead of re-parsing per key access (a ten-key form save cost dozens of parses). Read-back semantics are byte-identical (pinned by spec); the native-hash column detection is now mutex-guarded and never caches a negative result taken without a reachable schema.
|
|
48
|
+
- **Models::Auditable**: saves that touch no tracked field skip building the changes hash (`will_save_change_to_attribute?` pre-check); `audit_trail` is memoized per raw column value (returns a fresh Array each call; entry hashes shared, treat as read-only).
|
|
49
|
+
- **Controllers::Paginatable**: `paginated` + `pagination_meta` (no argument) now share one memoized COUNT — the documented records+meta composition ran the identical COUNT twice. The COUNT also strips `:select` and uses `count(:all)`, so custom SELECT lists and DISTINCT relations no longer produce invalid SQL or skewed totals.
|
|
50
|
+
- **Models::Stateable**: the state default uses `attribute ... default:` instead of an `after_initialize` that ran for every row materialized from the database. (Edge: `Model.new(state_field => nil)` now keeps the explicit nil.)
|
|
51
|
+
- **Models::CounterCacheable**: `recount_counter_caches!` runs in a transaction (a crash mid-repair could previously leave every counter zeroed) and groups parents by tally value — O(distinct counts) UPDATEs instead of one per parent row.
|
|
52
|
+
- **Models::Normalizable / Addressable**: normalization skips fields that are not part of the current save on persisted records; `resolved_country` is memoized per raw value (was computed three times per validation cycle).
|
|
53
|
+
- **Controllers::Localizable / Timezoneable / Deprecatable / CursorPaginatable**: per-request memoization of the resolved locale/zone/deprecation rule; the timezone wrapper is skipped when it would be a no-op; the row-predicate adapter check is cached per model class.
|
|
54
|
+
- **Support::RandomValue / Money**: batched CSPRNG draws with rejection sampling (one `SecureRandom` call per token instead of per character); single-pass thousands delimiter.
|
|
55
|
+
|
|
56
|
+
### Internal
|
|
57
|
+
- New support modules: `ScalarParam` (shared untrusted-param coercion), `UniqueRetry` (bounded `RecordNotUnique` retry — also usable directly: `UniqueRetry.with_retries { Invoice.create!(attrs) }`), `ErrorEnvelope` (the `render_error`-or-inline dance, previously hand-copied across seven controller concerns — custom `render_error(message:, status:, code:)` overrides keep working), `FilterParameterRegistry` + `ConcernsOnRails::Railtie`.
|
|
58
|
+
- `ConcernsOnRails.deprecator` / `.encryption` / `.filter_parameter_registry` memoization is now thread-safe; every concern file explicitly requires the support modules it uses (files are self-contained under direct `require`).
|
|
59
|
+
- New real-ActionController integration harness (`spec/support/integration_harness.rb`) — the dependency-free FakeController cannot reproduce `ActionController::Parameters` or `rescue_from` semantics, which is how the fixed 500s previously stayed green; regression specs now assert mechanisms (statement counts, parse counts, KDF invocations) through real dispatch.
|
|
60
|
+
|
|
3
61
|
## 1.21.1 (2026-07-18)
|
|
4
62
|
|
|
5
63
|
Blind-index support for Models::Encryptable, making encrypted fields queryable by exact match — the follow-up promised in 1.21.0. 991 examples, 0 failures.
|
data/README.md
CHANGED
|
@@ -65,6 +65,9 @@ Article.published.without_deleted.find("hello-world")
|
|
|
65
65
|
| [🪞 Aliasable](#-aliasable) | Full read / write / query association aliases |
|
|
66
66
|
| [⚙️ Storable](#-storable) | Typed accessors over one JSON column ("store_attribute-lite") |
|
|
67
67
|
| [🧮 CounterCacheable](#-countercacheable) | Conditional denormalized counters ("counter_culture-lite") |
|
|
68
|
+
| [🔏 Encryptable](#-encryptable) | Transparent field encryption (AES-256-GCM) + blind-index lookups |
|
|
69
|
+
| [🕵️ Anonymizable](#-anonymizable) | GDPR right-to-erasure with per-field strategies |
|
|
70
|
+
| [🧬 Duplicable](#-duplicable) | Concern-aware deep copy ("clone this invoice") |
|
|
68
71
|
|
|
69
72
|
### 🎮 Controller concerns
|
|
70
73
|
|
|
@@ -91,7 +94,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
91
94
|
|
|
92
95
|
## ✨ Why this gem?
|
|
93
96
|
|
|
94
|
-
- **Twenty-
|
|
97
|
+
- **Twenty-six model concerns + sixteen controller concerns**, all production-ready
|
|
95
98
|
- **One include, one macro** — no boilerplate, no glue code
|
|
96
99
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
97
100
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -104,7 +107,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
104
107
|
Add to your application's `Gemfile`:
|
|
105
108
|
|
|
106
109
|
```ruby
|
|
107
|
-
gem "concerns_on_rails", "~> 1.
|
|
110
|
+
gem "concerns_on_rails", "~> 1.23"
|
|
108
111
|
```
|
|
109
112
|
|
|
110
113
|
Or pull the latest from GitHub:
|
|
@@ -124,7 +127,7 @@ bundle install
|
|
|
124
127
|
## 🧪 Compatibility
|
|
125
128
|
|
|
126
129
|
- **Ruby**: 3.2+
|
|
127
|
-
- **Rails**: 5.0 through 8.x
|
|
130
|
+
- **Rails**: declared 5.0 through 8.x — in practice the Ruby 3.2 requirement means Rails 7.0.4+ is what can actually install the gem; the test suite runs against Rails 7.1
|
|
128
131
|
|
|
129
132
|
---
|
|
130
133
|
|
|
@@ -325,13 +328,17 @@ User.unscoped # everything (deleted + non-deleted)
|
|
|
325
328
|
**Bulk operations**
|
|
326
329
|
|
|
327
330
|
```ruby
|
|
328
|
-
User.soft_delete_all # soft-deletes all matching records (explicit — preferred)
|
|
329
|
-
User.destroy_all # alias of soft_delete_all (kept for backwards compatibility)
|
|
330
|
-
User.really_destroy_all # hard-deletes
|
|
331
|
-
User.restore_all # restores
|
|
331
|
+
User.soft_delete_all # soft-deletes all matching records; returns the count (explicit — preferred)
|
|
332
|
+
User.destroy_all # alias of soft_delete_all (kept for backwards compatibility; returns a count, not records)
|
|
333
|
+
User.really_destroy_all # hard-deletes the records matching the CURRENT relation (soft-deleted included)
|
|
334
|
+
User.restore_all # restores the matching soft-deleted records; returns the count
|
|
332
335
|
```
|
|
333
336
|
|
|
334
|
-
|
|
337
|
+
A record that fails to transition raises `ActiveRecord::RecordNotSaved` and rolls the whole
|
|
338
|
+
batch back. With `touch: false` and no overridden hooks, `soft_delete_all` / `restore_all`
|
|
339
|
+
collapse to a single `UPDATE`. Note that `really_destroy_all` peels the soft-delete
|
|
340
|
+
predicate off the relation, so `only_deleted.really_destroy_all` widens to the whole
|
|
341
|
+
relation — purge trash with `User.soft_deleted.delete_all` instead.
|
|
335
342
|
|
|
336
343
|
**Lifecycle hooks** — override these methods on the model:
|
|
337
344
|
|
|
@@ -1129,6 +1136,99 @@ Counters are adjusted with `update_counters` (a single atomic SQL `COALESCE(col,
|
|
|
1129
1136
|
|
|
1130
1137
|
---
|
|
1131
1138
|
|
|
1139
|
+
## 🔏 Encryptable
|
|
1140
|
+
|
|
1141
|
+
Transparent per-field encryption for sensitive columns (SSN, DOB, cards) — AES-256-GCM on stdlib OpenSSL, zero new dependencies. Reads and writes stay plaintext in memory; the DB column holds a versioned, authenticated Base64 envelope.
|
|
1142
|
+
|
|
1143
|
+
```ruby
|
|
1144
|
+
ConcernsOnRails.configure_encryption { |c| c.key = ENV["ENCRYPTION_KEY"] }
|
|
1145
|
+
|
|
1146
|
+
class Patient < ApplicationRecord
|
|
1147
|
+
include ConcernsOnRails::Models::Encryptable
|
|
1148
|
+
|
|
1149
|
+
encryptable :ssn, :notes # transparent string encryption
|
|
1150
|
+
encryptable :dob, type: :date # decrypts back to a Date
|
|
1151
|
+
encryptable :email, blind_index: true # + a queryable fingerprint column
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
patient.ssn # => "123-45-6789" (plaintext in memory)
|
|
1155
|
+
patient.ssn_ciphertext # => "AQEA..." (what's actually at rest)
|
|
1156
|
+
patient.ssn_encrypted? # => true
|
|
1157
|
+
Patient.find_by_email("a@b.com") # exact-match lookup via the blind index
|
|
1158
|
+
Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
**Options** (`encryptable *fields, …`, repeatable): `type:` (cast the decrypted value — `:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`), `key:` (per-field override; a String or lazy Proc), `blind_index:` (`true`, or `{ column:, expression: }` — maintains a deterministic keyed-HMAC companion column, default `<field>_bidx`, for equality lookups; `expression:` normalizes symmetrically on write and query).
|
|
1162
|
+
|
|
1163
|
+
**Notes**
|
|
1164
|
+
- The declared column must be `text`/binary (it stores an opaque envelope, not the logical type); a blind-index column holds a 64-char hex digest — add an index on it.
|
|
1165
|
+
- Ciphertext is non-deterministic (random IV), so `where(ssn: ...)` matches nothing — query through a blind index. `nil` stays `nil`; presence checks work normally.
|
|
1166
|
+
- Never `update_column(s)` an encrypted field — that bypasses the type and writes raw plaintext. Declaring a field with both `encryptable` and `auditable_by` raises (either order).
|
|
1167
|
+
- Wrong key / tampered ciphertext / malformed envelope raise `Encryption::DecryptionError`. Encrypted field names are auto-registered with Rails' `filter_parameters` (via the gem's railtie), so they're redacted from request logs.
|
|
1168
|
+
- Reach for [`lockbox`](https://github.com/ankane/lockbox) or Rails 7+ native `encrypts` when you need key rotation today or Rails-managed key infrastructure (rotation is planned — the envelope already reserves the `key_id` byte).
|
|
1169
|
+
|
|
1170
|
+
---
|
|
1171
|
+
|
|
1172
|
+
## 🕵️ Anonymizable
|
|
1173
|
+
|
|
1174
|
+
Declarative right-to-erasure ("GDPR-lite"): each personal-data field gets an erasure strategy, and `anonymize!` rewrites them all in **one UPDATE** while stamping `anonymized_at`. Completes the sensitive-data suite — Maskable masks *display*, Sanitizable strips *HTML*, Encryptable protects *at rest*; Anonymizable **destroys**.
|
|
1175
|
+
|
|
1176
|
+
```ruby
|
|
1177
|
+
class User < ApplicationRecord
|
|
1178
|
+
include ConcernsOnRails::Models::Anonymizable
|
|
1179
|
+
|
|
1180
|
+
anonymizable :email, with: :email # unique fake address
|
|
1181
|
+
anonymizable :first_name, :last_name, with: :redact
|
|
1182
|
+
anonymizable :ssn, with: :nullify
|
|
1183
|
+
anonymizable :bio, with: ->(value) { value && "removed by user request" }
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1186
|
+
user.anonymize! # hooks + single UPDATE + stamp, in one transaction
|
|
1187
|
+
user.anonymized? # => true
|
|
1188
|
+
User.not_anonymized # scope (and .anonymized)
|
|
1189
|
+
User.where(...).anonymize_all! # batch; returns the count, skips stamped records
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
**Strategies** (`with:`): `:nullify`, `:redact` (`"[REDACTED]"`), `:hash` (SHA-256 — deterministic *pseudonymization*, joins keep working), `:email` (random unique `anon-…@anonymized.invalid`, so NOT NULL + unique email columns survive), `:random_hex`, or a callable (`->(value)` / `->(value, record)`). Presets pass `nil` through untouched.
|
|
1193
|
+
|
|
1194
|
+
**Options** (repeatable; field rules merge): `stamp:` (default `:anonymized_at`; `false` disables stamping + scopes), `clear_audit_trail:` (default `true` — when an erased field is also `auditable_by`, the trail holding its plaintext history is cleared in the same UPDATE), `prefix:`/`suffix:` (scope names).
|
|
1195
|
+
|
|
1196
|
+
**Notes**
|
|
1197
|
+
- Deliberately `update_columns`: erasure is never blocked by validations and never runs callbacks that could copy old values elsewhere. Values still serialize through the attribute types, so an `encryptable` field stores a fresh ciphertext envelope — never plaintext.
|
|
1198
|
+
- `before_anonymize`/`after_anonymize` hooks run inside the transaction; the record reloads afterwards (erasure is terminal for the instance).
|
|
1199
|
+
- `:hash` is pseudonymization — use `:nullify`/`:random_hex` for true erasure. Backups/replicas/logs are out of scope.
|
|
1200
|
+
|
|
1201
|
+
---
|
|
1202
|
+
|
|
1203
|
+
## 🧬 Duplicable
|
|
1204
|
+
|
|
1205
|
+
Concern-aware deep copy — the "clone this invoice / duplicate this template" feature. A bare `dup` copies identity-bearing columns (slug, token, invoice number, audit trail, even `created_at`, which AR preserves on save), so naive copies collide with unique indexes. Duplicable blanks exactly those columns and lets each sibling concern regenerate fresh values on save.
|
|
1206
|
+
|
|
1207
|
+
```ruby
|
|
1208
|
+
class Invoice < ApplicationRecord
|
|
1209
|
+
include ConcernsOnRails::Models::Duplicable
|
|
1210
|
+
|
|
1211
|
+
has_many :line_items
|
|
1212
|
+
duplicable_by associations: %i[line_items],
|
|
1213
|
+
reset: %i[issued_at],
|
|
1214
|
+
suffix: { title: " (copy)" }
|
|
1215
|
+
end
|
|
1216
|
+
|
|
1217
|
+
copy = invoice.duplicate # unsaved deep copy
|
|
1218
|
+
copy = invoice.duplicate!(title: "Q3") # saved (one transaction, autosaved children)
|
|
1219
|
+
```
|
|
1220
|
+
|
|
1221
|
+
**Auto-reset identity columns** (no configuration): `created_at`/`updated_at`, Sluggable slug, Tokenizable/Hashable tokens, Sequenceable sequence + `into:` columns, Auditable trail, SoftDeletable timestamp, Lockable attempts/locked_at. Business state (Publishable, Stateable, …) is a judgment call — list it in `reset:`.
|
|
1222
|
+
|
|
1223
|
+
**Associations** (`associations:` allow-list, declared before the macro, validated at macro time): `has_many`/`has_one` children are deep-copied — a child that also includes Duplicable copies via **its own** rules, so nested graphs stay declarative; `has_and_belongs_to_many` re-links the *same* records; `belongs_to` and `has_many :through` are rejected with an explanation.
|
|
1224
|
+
|
|
1225
|
+
**Notes**
|
|
1226
|
+
- The macro is optional — bare `include` gives `duplicate`/`duplicate!` with the auto resets.
|
|
1227
|
+
- Override `on_duplicate(copy)` for custom tweaks; it receives the unsaved copy last.
|
|
1228
|
+
- Reach for [`amoeba`](https://github.com/amoeba-rb/amoeba) when you need per-attribute regex/prepend rules or belongs_to graph copying.
|
|
1229
|
+
|
|
1230
|
+
---
|
|
1231
|
+
|
|
1132
1232
|
# 🎮 Controller Concerns
|
|
1133
1233
|
|
|
1134
1234
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -1700,6 +1800,8 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
1700
1800
|
| Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
|
|
1701
1801
|
| Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
|
|
1702
1802
|
| 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) |
|
|
1803
|
+
| Field encryption with managed key rotation / Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
|
|
1804
|
+
| Deep clone with per-attribute regex/prepend rules or belongs_to graph copying | [`amoeba`](https://github.com/amoeba-rb/amoeba) |
|
|
1703
1805
|
|
|
1704
1806
|
`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.
|
|
1705
1807
|
|
|
@@ -1711,7 +1813,7 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
1711
1813
|
bundle install # install dev dependencies
|
|
1712
1814
|
bundle exec rspec # run the test suite
|
|
1713
1815
|
gem build concerns_on_rails.gemspec # build the gem
|
|
1714
|
-
gem install ./concerns_on_rails-1.
|
|
1816
|
+
gem install ./concerns_on_rails-1.23.0.gem # install locally
|
|
1715
1817
|
```
|
|
1716
1818
|
|
|
1717
1819
|
The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
|
+
require "concerns_on_rails/support/error_envelope"
|
|
2
3
|
|
|
3
4
|
module ConcernsOnRails
|
|
4
5
|
module Controllers
|
|
@@ -54,7 +55,11 @@ module ConcernsOnRails
|
|
|
54
55
|
# a helper_method (which keeps it private on the instance), so the
|
|
55
56
|
# default public-only check would resolve nil and deny everyone.
|
|
56
57
|
actor = respond_to?(via, true) ? send(via) : nil
|
|
57
|
-
|
|
58
|
+
# Array() handles both a scalar role and an array-valued `roles`
|
|
59
|
+
# method — pre-1.22 an actor with roles = ["admin"] stringified to
|
|
60
|
+
# '["admin"]' and was always denied.
|
|
61
|
+
actor.respond_to?(role_method, true) &&
|
|
62
|
+
Array(actor.send(role_method)).any? { |role| wanted.include?(role.to_s) }
|
|
58
63
|
end
|
|
59
64
|
add_authorization_rule(check: check, only: only, except: except, status: status, message: message)
|
|
60
65
|
end
|
|
@@ -87,13 +92,16 @@ module ConcernsOnRails
|
|
|
87
92
|
nil
|
|
88
93
|
end
|
|
89
94
|
|
|
90
|
-
# Public override point for how a denial is rendered.
|
|
95
|
+
# Public override point for how a denial is rendered. Fails CLOSED: when
|
|
96
|
+
# there is no response object to render into, raise — returning nil here
|
|
97
|
+
# (the pre-1.22 behavior) let the action run unauthorized.
|
|
91
98
|
def authorization_denied(status:, message:)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
unless respond_to?(:response) && response
|
|
100
|
+
raise "ConcernsOnRails::Controllers::Authorizable: denial for '#{authorization_action_name}' " \
|
|
101
|
+
"could not be rendered (no response object) — refusing to fail open"
|
|
102
|
+
end
|
|
95
103
|
|
|
96
|
-
render
|
|
104
|
+
ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: status, code: "forbidden")
|
|
97
105
|
end
|
|
98
106
|
|
|
99
107
|
private
|
|
@@ -61,6 +61,13 @@ module ConcernsOnRails
|
|
|
61
61
|
|
|
62
62
|
included do
|
|
63
63
|
class_attribute :cacheable_rules, instance_accessor: false, default: []
|
|
64
|
+
# no_store is applied EARLY as well: rescue_from-rendered errors skip
|
|
65
|
+
# after_action entirely, and for a no_store endpoint (tokens, PII) even
|
|
66
|
+
# the error response must not be stored. Positive freshness policies
|
|
67
|
+
# deliberately do NOT move early — rescued errors already get Rails'
|
|
68
|
+
# safe `max-age=0, private` default, and emitting `public, max-age` on
|
|
69
|
+
# a rescued 500 would let shared caches serve the error.
|
|
70
|
+
prepend_before_action :apply_http_no_store if respond_to?(:prepend_before_action)
|
|
64
71
|
after_action :apply_http_cache_headers
|
|
65
72
|
end
|
|
66
73
|
|
|
@@ -143,12 +150,26 @@ module ConcernsOnRails
|
|
|
143
150
|
nil
|
|
144
151
|
end
|
|
145
152
|
|
|
153
|
+
# Early half of the policy (see the included block): only no_store, and
|
|
154
|
+
# idempotent with apply_http_cache_headers re-applying it after render.
|
|
155
|
+
def apply_http_no_store
|
|
156
|
+
rule = http_cache_rule_for_action
|
|
157
|
+
return unless rule && rule[:no_store]
|
|
158
|
+
return unless respond_to?(:response) && response
|
|
159
|
+
|
|
160
|
+
response.set_header("Cache-Control", "no-store")
|
|
161
|
+
end
|
|
162
|
+
|
|
146
163
|
# Set ETag/Last-Modified validators for the resource, then — for a safe
|
|
147
164
|
# request whose precondition matches — send 304 and return false. Returns
|
|
148
165
|
# true when the client must be sent a fresh body. Mirrors Rails `stale?`.
|
|
149
166
|
def stale_resource?(resource = nil, etag: nil, last_modified: nil)
|
|
150
|
-
|
|
167
|
+
# Unsafe methods get neither a 304 nor validators: a POST response must
|
|
168
|
+
# not advertise an ETag a client could replay against GET (pre-1.22 the
|
|
169
|
+
# validators were written before the safe-method check).
|
|
151
170
|
return true unless http_cache_safe_request?
|
|
171
|
+
|
|
172
|
+
validators = set_cache_validators(resource, etag: etag, last_modified: last_modified)
|
|
152
173
|
return true unless request_matches_cache?(etag: validators[:etag], last_modified: validators[:last_modified])
|
|
153
174
|
|
|
154
175
|
http_cache_send_not_modified
|
|
@@ -225,13 +246,10 @@ module ConcernsOnRails
|
|
|
225
246
|
end
|
|
226
247
|
|
|
227
248
|
def http_cache_send_not_modified
|
|
228
|
-
|
|
249
|
+
return head(:not_modified) if respond_to?(:head)
|
|
229
250
|
|
|
230
|
-
if respond_to?(:
|
|
231
|
-
|
|
232
|
-
else
|
|
233
|
-
render(status: :not_modified)
|
|
234
|
-
end
|
|
251
|
+
response.status = 304 if respond_to?(:response) && response
|
|
252
|
+
render(status: :not_modified)
|
|
235
253
|
end
|
|
236
254
|
|
|
237
255
|
def http_cache_safe_request?
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
|
+
require "concerns_on_rails/support/error_envelope"
|
|
3
|
+
require "concerns_on_rails/support/scalar_param"
|
|
2
4
|
require "json"
|
|
3
5
|
require "time" # Time#iso8601(fraction_digits) lives in the stdlib time library
|
|
4
6
|
|
|
@@ -56,6 +58,9 @@ module ConcernsOnRails
|
|
|
56
58
|
CURSOR_DIRECTIONS = %w[next prev].freeze
|
|
57
59
|
# Adapters whose SQL supports row-value (tuple) comparison: (a, b) > (x, y).
|
|
58
60
|
ROW_PREDICATE_ADAPTERS = /postgres|mysql|trilogy|sqlite/i
|
|
61
|
+
# Deliberately mutable: memoizes adapter row-predicate support per model
|
|
62
|
+
# class — stable for the life of the process, benign write race.
|
|
63
|
+
ROW_PREDICATE_SUPPORT_CACHE = {} # rubocop:disable Style/MutableConstant
|
|
59
64
|
|
|
60
65
|
# Raised when params[:cursor] is malformed, tampered with, or was minted
|
|
61
66
|
# under a different table/order configuration. Auto-rescued to a 400 when
|
|
@@ -195,16 +200,16 @@ module ConcernsOnRails
|
|
|
195
200
|
# Public override point (mirrors ErrorHandleable's public handlers):
|
|
196
201
|
# delegates to Respondable#render_error when available.
|
|
197
202
|
def render_invalid_cursor(error)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
ConcernsOnRails::Support::ErrorEnvelope.render(
|
|
204
|
+
self, message: error.message, status: :bad_request, code: "invalid_cursor"
|
|
205
|
+
)
|
|
201
206
|
end
|
|
202
207
|
|
|
203
208
|
# Same override contract for unknown ?order= preset names.
|
|
204
209
|
def render_invalid_order_preset(error)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
210
|
+
ConcernsOnRails::Support::ErrorEnvelope.render(
|
|
211
|
+
self, message: error.message, status: :bad_request, code: "invalid_order_preset"
|
|
212
|
+
)
|
|
208
213
|
end
|
|
209
214
|
|
|
210
215
|
private
|
|
@@ -311,7 +316,9 @@ module ConcernsOnRails
|
|
|
311
316
|
end
|
|
312
317
|
|
|
313
318
|
def cursor_per_page(override)
|
|
314
|
-
|
|
319
|
+
# ScalarParam: `?per_page[]=50` arrives as an Array and .to_i on it was
|
|
320
|
+
# a 500 (mirrors Paginatable).
|
|
321
|
+
requested = ConcernsOnRails::Support::ScalarParam.to_i(override || params[:per_page], default: 0)
|
|
315
322
|
requested = self.class.cursor_paginatable_per_page if requested < 1
|
|
316
323
|
cap = self.class.cursor_paginatable_max_per_page
|
|
317
324
|
cap.positive? ? [requested, cap].min : requested
|
|
@@ -462,7 +469,15 @@ module ConcernsOnRails
|
|
|
462
469
|
|
|
463
470
|
return true
|
|
464
471
|
end
|
|
465
|
-
uniform &&
|
|
472
|
+
uniform && cursor_adapter_row_predicate?(model)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def cursor_adapter_row_predicate?(model)
|
|
476
|
+
key = model.name || model.table_name
|
|
477
|
+
cached = ROW_PREDICATE_SUPPORT_CACHE[key]
|
|
478
|
+
return cached unless cached.nil?
|
|
479
|
+
|
|
480
|
+
ROW_PREDICATE_SUPPORT_CACHE[key] = model.connection.adapter_name.match?(ROW_PREDICATE_ADAPTERS)
|
|
466
481
|
end
|
|
467
482
|
|
|
468
483
|
def cursor_row_predicate(model, pairs, values)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
|
+
require "concerns_on_rails/support/error_envelope"
|
|
2
3
|
require "active_support/notifications"
|
|
3
4
|
require "date"
|
|
4
5
|
require "time"
|
|
@@ -122,9 +123,14 @@ module ConcernsOnRails
|
|
|
122
123
|
|
|
123
124
|
def validate_deprecation_link!(name, value)
|
|
124
125
|
return if value.nil?
|
|
125
|
-
return if value.is_a?(String) && !value.strip.empty?
|
|
126
126
|
|
|
127
|
-
raise ArgumentError, "#{LABEL}: :#{name} must be a non-blank String"
|
|
127
|
+
raise ArgumentError, "#{LABEL}: :#{name} must be a non-blank String" unless value.is_a?(String) && !value.strip.empty?
|
|
128
|
+
return unless value.match?(/[[:cntrl:]<>]/)
|
|
129
|
+
|
|
130
|
+
# These values are interpolated into the Link header: a control
|
|
131
|
+
# character would split the header (response splitting), and <> would
|
|
132
|
+
# terminate or forge the URI-Reference delimiters.
|
|
133
|
+
raise ArgumentError, "#{LABEL}: :#{name} must not contain control characters or '<'/'>'"
|
|
128
134
|
end
|
|
129
135
|
|
|
130
136
|
# Eager parse to a UTC Time. A bare Date (or date-only String) becomes
|
|
@@ -194,14 +200,17 @@ module ConcernsOnRails
|
|
|
194
200
|
private
|
|
195
201
|
|
|
196
202
|
def deprecation_rule_for_action
|
|
197
|
-
|
|
198
|
-
|
|
203
|
+
# Memoized (nil included) — the before_action plus the two public
|
|
204
|
+
# predicates each re-scanned the rule list every request.
|
|
205
|
+
return @deprecation_rule_for_action if defined?(@deprecation_rule_for_action)
|
|
199
206
|
|
|
207
|
+
action = deprecation_action_name
|
|
200
208
|
# Last match wins — see the module comment. reverse_each.find returns the
|
|
201
209
|
# most recently declared rule covering this action (catch-all or not).
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
@deprecation_rule_for_action =
|
|
211
|
+
action && self.class.deprecatable_rules.reverse_each.find do |rule|
|
|
212
|
+
rule[:actions].empty? || rule[:actions].include?(action)
|
|
213
|
+
end
|
|
205
214
|
end
|
|
206
215
|
|
|
207
216
|
def emit_deprecation_headers(rule)
|
|
@@ -238,10 +247,9 @@ module ConcernsOnRails
|
|
|
238
247
|
return unless deprecation_sunset_reached?(rule)
|
|
239
248
|
|
|
240
249
|
message = "This endpoint was sunset on #{rule[:sunset_at].httpdate}."
|
|
241
|
-
return
|
|
242
|
-
return unless respond_to?(:response) && response
|
|
250
|
+
return unless respond_to?(:render_error) || (respond_to?(:response) && response)
|
|
243
251
|
|
|
244
|
-
render
|
|
252
|
+
ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: :gone, code: "endpoint_sunset")
|
|
245
253
|
end
|
|
246
254
|
|
|
247
255
|
# Inclusive: the boundary instant itself counts as sunset.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
|
+
require "concerns_on_rails/support/error_envelope"
|
|
2
3
|
|
|
3
4
|
module ConcernsOnRails
|
|
4
5
|
module Controllers
|
|
@@ -64,11 +65,9 @@ module ConcernsOnRails
|
|
|
64
65
|
private
|
|
65
66
|
|
|
66
67
|
def render_error_envelope(message:, code:, status:, errors: nil)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
error[:details] = errors if errors
|
|
71
|
-
render json: { success: false, error: error }, status: status
|
|
68
|
+
ConcernsOnRails::Support::ErrorEnvelope.render(
|
|
69
|
+
self, message: message, code: code, status: status, details: errors
|
|
70
|
+
)
|
|
72
71
|
end
|
|
73
72
|
end
|
|
74
73
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
|
+
require "concerns_on_rails/support/scalar_param"
|
|
2
3
|
|
|
3
4
|
module ConcernsOnRails
|
|
4
5
|
module Controllers
|
|
@@ -70,13 +71,12 @@ module ConcernsOnRails
|
|
|
70
71
|
end
|
|
71
72
|
end
|
|
72
73
|
|
|
73
|
-
# Scalars (and arrays, which AR turns into `IN (...)`) are safe
|
|
74
|
-
# .where; a Hash / ActionController::Parameters is not
|
|
74
|
+
# Scalars (and arrays of scalars, which AR turns into `IN (...)`) are safe
|
|
75
|
+
# to pass to .where; a Hash / ActionController::Parameters is not — and
|
|
76
|
+
# since 1.22 neither is an array CONTAINING one (`?status[][x]=1` used to
|
|
77
|
+
# slip through as [Parameters] and 500 with a TypeError).
|
|
75
78
|
def filterable_scalar?(value)
|
|
76
|
-
|
|
77
|
-
return false if defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters)
|
|
78
|
-
|
|
79
|
-
true
|
|
79
|
+
ConcernsOnRails::Support::ScalarParam.where_safe?(value)
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
82
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "active_support/concern"
|
|
2
|
+
require "concerns_on_rails/support/error_envelope"
|
|
2
3
|
require "digest"
|
|
3
4
|
require "json"
|
|
4
5
|
|
|
@@ -136,9 +137,7 @@ module ConcernsOnRails
|
|
|
136
137
|
def idempotency_error_response(message:, status:, code:)
|
|
137
138
|
return unless respond_to?(:response) && response
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
render json: { success: false, error: { message: message, code: code } }, status: status
|
|
140
|
+
ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: status, code: code)
|
|
142
141
|
end
|
|
143
142
|
|
|
144
143
|
private
|
|
@@ -209,15 +208,25 @@ module ConcernsOnRails
|
|
|
209
208
|
|
|
210
209
|
# In flight — or the claim expired between our failed write and this
|
|
211
210
|
# read (rare); answering 409 is the conservative, retry-safe choice.
|
|
212
|
-
idempotency_conflict_response(rule)
|
|
211
|
+
idempotency_conflict_response(rule, record)
|
|
213
212
|
end
|
|
214
213
|
|
|
215
|
-
def idempotency_conflict_response(rule)
|
|
216
|
-
response.set_header("Retry-After", rule
|
|
214
|
+
def idempotency_conflict_response(rule, record = nil)
|
|
215
|
+
response.set_header("Retry-After", idempotency_retry_after(rule, record).to_s) if respond_to?(:response) && response
|
|
217
216
|
idempotency_error_response(message: "A request with this #{rule[:header]} is already in progress.",
|
|
218
217
|
status: :conflict, code: "idempotency_conflict")
|
|
219
218
|
end
|
|
220
219
|
|
|
220
|
+
# Remaining lock time (floored at 1s), not the full lock TTL — the claim
|
|
221
|
+
# record carries when it was taken, so clients don't back off for a lock
|
|
222
|
+
# that is about to expire.
|
|
223
|
+
def idempotency_retry_after(rule, record)
|
|
224
|
+
claimed_at = record.is_a?(Hash) ? record["claimed_at"] : nil
|
|
225
|
+
return rule[:lock_ttl] unless claimed_at
|
|
226
|
+
|
|
227
|
+
[rule[:lock_ttl] - (Time.now.to_i - claimed_at.to_i), 1].max
|
|
228
|
+
end
|
|
229
|
+
|
|
221
230
|
def read_idempotency_header(rule)
|
|
222
231
|
return nil unless respond_to?(:request) && request.respond_to?(:headers) && request.headers
|
|
223
232
|
|
|
@@ -65,7 +65,11 @@ module ConcernsOnRails
|
|
|
65
65
|
return {} unless raw.respond_to?(:each_pair)
|
|
66
66
|
|
|
67
67
|
allowed = self.class.includable_fields
|
|
68
|
-
|
|
68
|
+
# Iterate via each_pair: in a real controller `raw` is an
|
|
69
|
+
# ActionController::Parameters, which has each_pair but no Enumerable —
|
|
70
|
+
# calling each_with_object directly on it was a guaranteed
|
|
71
|
+
# NoMethodError 500 for every ?fields[...]= request.
|
|
72
|
+
raw.each_pair.with_object({}) do |(table, cols), memo|
|
|
69
73
|
key = table.to_sym
|
|
70
74
|
next unless allowed.key?(key)
|
|
71
75
|
|