concerns_on_rails 1.21.1 → 1.22.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 +47 -0
- data/README.md +48 -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/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/auditable.rb +37 -1
- data/lib/concerns_on_rails/models/counter_cacheable.rb +35 -18
- 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 +29 -2
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7414e40bd04fc80a1b87b4c27b908ff169c5a9c2f9c50e66d8ab8d75198b9f40
|
|
4
|
+
data.tar.gz: 6f458ab30c720e633cd50f34515b7941ec9b6b8a8ef208f3beed909860ac1d9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dbbcf38ca1645b801c05e8949f869e587752607bb0d679c279eeac2bc8904c446d7da7ee865498b8578ca5d1d8212477b60cb840d0e3c53e1a1d9593065805af
|
|
7
|
+
data.tar.gz: 524c14d7bd3e5824c0d266a3ab84e13cd21d622d7637dc4bde38225ff40e88d24559eb36055c16eaa60edb53b99c025c96effa4a458e1701b36a0826bd8a12b5
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.22.0 (2026-07-26)
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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).
|
|
11
|
+
- **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`.)
|
|
12
|
+
- **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.
|
|
13
|
+
- **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).
|
|
14
|
+
- **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).
|
|
15
|
+
- **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.
|
|
16
|
+
- **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`.
|
|
17
|
+
- **Controllers::Deprecatable**: `link:`/`successor:` values are validated against control characters and `<`/`>` at macro time (Link-header injection).
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
- **Models::Monetizable**: the money setter casts garbage input (`"abc"`, `""`) to nil — ActiveModel convention — instead of raising `ArgumentError` out of a form assignment.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.
|
|
23
|
+
- **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).
|
|
24
|
+
- **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.
|
|
25
|
+
- **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`).
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- **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`.
|
|
29
|
+
- **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.
|
|
30
|
+
- **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.
|
|
31
|
+
- **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).
|
|
32
|
+
- **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.
|
|
33
|
+
|
|
34
|
+
### Performance
|
|
35
|
+
- **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.
|
|
36
|
+
- **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.
|
|
37
|
+
- **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).
|
|
38
|
+
- **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.
|
|
39
|
+
- **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.)
|
|
40
|
+
- **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.
|
|
41
|
+
- **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).
|
|
42
|
+
- **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.
|
|
43
|
+
- **Support::RandomValue / Money**: batched CSPRNG draws with rejection sampling (one `SecureRandom` call per token instead of per character); single-pass thousands delimiter.
|
|
44
|
+
|
|
45
|
+
### Internal
|
|
46
|
+
- 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`.
|
|
47
|
+
- `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`).
|
|
48
|
+
- 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.
|
|
49
|
+
|
|
3
50
|
## 1.21.1 (2026-07-18)
|
|
4
51
|
|
|
5
52
|
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,7 @@ 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 |
|
|
68
69
|
|
|
69
70
|
### 🎮 Controller concerns
|
|
70
71
|
|
|
@@ -91,7 +92,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
91
92
|
|
|
92
93
|
## ✨ Why this gem?
|
|
93
94
|
|
|
94
|
-
- **Twenty-
|
|
95
|
+
- **Twenty-four model concerns + sixteen controller concerns**, all production-ready
|
|
95
96
|
- **One include, one macro** — no boilerplate, no glue code
|
|
96
97
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
97
98
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -104,7 +105,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
104
105
|
Add to your application's `Gemfile`:
|
|
105
106
|
|
|
106
107
|
```ruby
|
|
107
|
-
gem "concerns_on_rails", "~> 1.
|
|
108
|
+
gem "concerns_on_rails", "~> 1.22"
|
|
108
109
|
```
|
|
109
110
|
|
|
110
111
|
Or pull the latest from GitHub:
|
|
@@ -124,7 +125,7 @@ bundle install
|
|
|
124
125
|
## 🧪 Compatibility
|
|
125
126
|
|
|
126
127
|
- **Ruby**: 3.2+
|
|
127
|
-
- **Rails**: 5.0 through 8.x
|
|
128
|
+
- **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
129
|
|
|
129
130
|
---
|
|
130
131
|
|
|
@@ -325,13 +326,17 @@ User.unscoped # everything (deleted + non-deleted)
|
|
|
325
326
|
**Bulk operations**
|
|
326
327
|
|
|
327
328
|
```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
|
|
329
|
+
User.soft_delete_all # soft-deletes all matching records; returns the count (explicit — preferred)
|
|
330
|
+
User.destroy_all # alias of soft_delete_all (kept for backwards compatibility; returns a count, not records)
|
|
331
|
+
User.really_destroy_all # hard-deletes the records matching the CURRENT relation (soft-deleted included)
|
|
332
|
+
User.restore_all # restores the matching soft-deleted records; returns the count
|
|
332
333
|
```
|
|
333
334
|
|
|
334
|
-
|
|
335
|
+
A record that fails to transition raises `ActiveRecord::RecordNotSaved` and rolls the whole
|
|
336
|
+
batch back. With `touch: false` and no overridden hooks, `soft_delete_all` / `restore_all`
|
|
337
|
+
collapse to a single `UPDATE`. Note that `really_destroy_all` peels the soft-delete
|
|
338
|
+
predicate off the relation, so `only_deleted.really_destroy_all` widens to the whole
|
|
339
|
+
relation — purge trash with `User.soft_deleted.delete_all` instead.
|
|
335
340
|
|
|
336
341
|
**Lifecycle hooks** — override these methods on the model:
|
|
337
342
|
|
|
@@ -1129,6 +1134,39 @@ Counters are adjusted with `update_counters` (a single atomic SQL `COALESCE(col,
|
|
|
1129
1134
|
|
|
1130
1135
|
---
|
|
1131
1136
|
|
|
1137
|
+
## 🔏 Encryptable
|
|
1138
|
+
|
|
1139
|
+
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.
|
|
1140
|
+
|
|
1141
|
+
```ruby
|
|
1142
|
+
ConcernsOnRails.configure_encryption { |c| c.key = ENV["ENCRYPTION_KEY"] }
|
|
1143
|
+
|
|
1144
|
+
class Patient < ApplicationRecord
|
|
1145
|
+
include ConcernsOnRails::Models::Encryptable
|
|
1146
|
+
|
|
1147
|
+
encryptable :ssn, :notes # transparent string encryption
|
|
1148
|
+
encryptable :dob, type: :date # decrypts back to a Date
|
|
1149
|
+
encryptable :email, blind_index: true # + a queryable fingerprint column
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
patient.ssn # => "123-45-6789" (plaintext in memory)
|
|
1153
|
+
patient.ssn_ciphertext # => "AQEA..." (what's actually at rest)
|
|
1154
|
+
patient.ssn_encrypted? # => true
|
|
1155
|
+
Patient.find_by_email("a@b.com") # exact-match lookup via the blind index
|
|
1156
|
+
Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
**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).
|
|
1160
|
+
|
|
1161
|
+
**Notes**
|
|
1162
|
+
- 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.
|
|
1163
|
+
- Ciphertext is non-deterministic (random IV), so `where(ssn: ...)` matches nothing — query through a blind index. `nil` stays `nil`; presence checks work normally.
|
|
1164
|
+
- 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).
|
|
1165
|
+
- 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.
|
|
1166
|
+
- 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).
|
|
1167
|
+
|
|
1168
|
+
---
|
|
1169
|
+
|
|
1132
1170
|
# 🎮 Controller Concerns
|
|
1133
1171
|
|
|
1134
1172
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -1700,6 +1738,7 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
1700
1738
|
| Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
|
|
1701
1739
|
| Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
|
|
1702
1740
|
| 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) |
|
|
1741
|
+
| Field encryption with managed key rotation / Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
|
|
1703
1742
|
|
|
1704
1743
|
`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
1744
|
|
|
@@ -1711,7 +1750,7 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
1711
1750
|
bundle install # install dev dependencies
|
|
1712
1751
|
bundle exec rspec # run the test suite
|
|
1713
1752
|
gem build concerns_on_rails.gemspec # build the gem
|
|
1714
|
-
gem install ./concerns_on_rails-1.
|
|
1753
|
+
gem install ./concerns_on_rails-1.22.0.gem # install locally
|
|
1715
1754
|
```
|
|
1716
1755
|
|
|
1717
1756
|
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
|
|
|
@@ -46,12 +46,16 @@ module ConcernsOnRails
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
# The locale chosen for this request — always one I18n can switch to.
|
|
49
|
+
# Memoized per request (the wrapper plus any helpers may read it several
|
|
50
|
+
# times; parsing the Accept-Language header repeatedly is waste).
|
|
49
51
|
def resolved_locale
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
@resolved_locale ||= begin
|
|
53
|
+
opts = self.class.localizable_options
|
|
54
|
+
allowed = opts[:available].presence || I18n.available_locales
|
|
55
|
+
candidate = locale_from_param(opts, allowed) || locale_from_header(opts, allowed) || opts[:default]
|
|
53
56
|
|
|
54
|
-
|
|
57
|
+
candidate && I18n.available_locales.include?(candidate.to_sym) ? candidate.to_sym : I18n.default_locale
|
|
58
|
+
end
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
private
|
|
@@ -78,21 +82,26 @@ module ConcernsOnRails
|
|
|
78
82
|
|
|
79
83
|
def parse_accept_language(header, allowed)
|
|
80
84
|
ranked_accept_languages(header).each do |lang|
|
|
81
|
-
|
|
85
|
+
# Full tag first (fr-CA matches an available :"fr-CA"), then the
|
|
86
|
+
# primary subtag (fr). Pre-1.22 the region was discarded outright,
|
|
87
|
+
# so a regional locale in available: could never match its own
|
|
88
|
+
# Accept-Language tag.
|
|
89
|
+
match = match_locale(lang, allowed) || match_locale(lang.split("-").first, allowed)
|
|
82
90
|
return match if match
|
|
83
91
|
end
|
|
84
92
|
nil
|
|
85
93
|
end
|
|
86
94
|
|
|
87
|
-
#
|
|
88
|
-
#
|
|
95
|
+
# Language tags from an Accept-Language header (kept whole — see
|
|
96
|
+
# parse_accept_language for region handling), q=0 dropped, highest-q
|
|
97
|
+
# first (RFC 7231 preference order).
|
|
89
98
|
def ranked_accept_languages(header)
|
|
90
99
|
pairs = header.split(",").filter_map do |part|
|
|
91
100
|
token, *params = part.split(";").map(&:strip)
|
|
92
101
|
quality = accept_language_quality(params)
|
|
93
102
|
next if quality <= 0.0
|
|
94
103
|
|
|
95
|
-
lang = token.to_s.
|
|
104
|
+
lang = token.to_s.strip
|
|
96
105
|
[lang, quality] if lang.present?
|
|
97
106
|
end
|
|
98
107
|
pairs.sort_by { |(_lang, quality)| -quality }.map(&:first)
|