concerns_on_rails 1.31.0 → 1.32.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 493ce0d913905625ffaabf033052a0ccc631aa22fe3c9a06a04c776ad4a80f67
4
- data.tar.gz: 022bb7a0230e1c9c8ebc210c43245cf458c9248f3810f24f02c731695c0c046d
3
+ metadata.gz: a5638a56a20ce9b346dd013448af31656fe6925eaefbf8bd87ee8906c17825b4
4
+ data.tar.gz: b3feeb0feaab7f14bf5d03ea46a9e7a1a69f0d087173cf2fb0baa1efb2c86daa
5
5
  SHA512:
6
- metadata.gz: 55cd86cfb3b04af3972060c2d4455dca7fe2ee7abfdccfa029c75ee6a9caee4b6700eeb128dd3f15aec5c52460af7ca5936935185593788d02df9c050044e3fa
7
- data.tar.gz: 750dda120eb24d9e3d121a8e4ec1828e2d26303677f5758bc415f138fa4497845e1b67219203dc593ad5fe55a1a406e77d544b292f1639d4aa56546bb855010a
6
+ metadata.gz: 25623c6d25c7fafc8e8c4dfd5f16350df059a5f0bb2999c0d457b986cfcff1c55365f8e072ab5dd01cb9ae48e6226ff335a546f02c1521ca0c3934898e92c296
7
+ data.tar.gz: ad5dd6aa2c310c9d711fd9513e01488e79f164df54aa2accdbca8ab9a46f3c2fe296c0733c6b0c404c0df09147c28160f7c5177e0938b6637eb3037d71f1e363
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.32.0 (2026-10-07)
4
+
5
+ A dependency release (#127): the `permittable` floor rises to `>= 0.8`, and the Permittable docs
6
+ are rewritten against permittable 0.10. It is a minor release because hosts still on permittable
7
+ 0.1–0.7 must upgrade it (the same rule as the Rails floor raise in 1.29.0). No code change, no
8
+ new concerns and no migrations. 2723 examples, 0 failures, green on PostgreSQL, MySQL and every
9
+ Rails line from 6.0 to 8.1.
10
+
11
+ ### Changed
12
+ - **`permittable` dependency floor raised from `~> 0.1` to `>= 0.8, < 1`.** The Permittable
13
+ documentation (README section and docs page) was rewritten against permittable 0.10 — monitor
14
+ mode, `:json` fields, `nullable:`, `message:`/I18n copy, `format:` presets, field groups,
15
+ RFC 9457 output, the type-checking drift guard, the `permittable:generate`/`:audit`/`:openapi`
16
+ tasks, RSpec matchers and standalone contracts — and the old pin let a host resolve a 0.x that
17
+ had none of it. 0.8 is the floor because it is the newest line that still supports Rails 6.0
18
+ (0.9+ requires ActiveSupport >= 6.1), so the 6.0 CI line keeps resolving; the docs carry a
19
+ heads-up listing what a 0.8 host lacks and how Respondable's `:problem_details` and the gem's
20
+ own `Permittable.error_format = :problem` interact. No code change; the gem README stays the
21
+ canonical Permittable reference.
22
+
3
23
  ## 1.31.0 (2026-10-05)
4
24
 
5
25
  The fixes from two audits, each fix put through adversarial review rounds until a reviewer
data/README.md CHANGED
@@ -101,7 +101,7 @@ Every concern below links to its section in this README **and** to its standalon
101
101
  | [🪝 WebhookVerifiable](#-webhookverifiable) | HMAC verification for inbound webhooks | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/webhook-verifiable) |
102
102
  | [🌅 Deprecatable](#-deprecatable) | RFC `Deprecation` / `Sunset` headers + 410 | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/deprecatable) |
103
103
  | [🗄️ Cacheable](#-cacheable) | HTTP conditional GET (ETag / 304) + `Cache-Control` | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/cacheable) |
104
- | [🛂 Permittable](#-permittable) | Typed, validated params contracts + schema-drift guard | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/permittable) |
104
+ | [🛂 Permittable](#-permittable) | Typed, validated params contracts, schema-drift guard, OpenAPI export | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/permittable) |
105
105
 
106
106
  ---
107
107
 
@@ -148,7 +148,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
148
148
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
149
149
  - **Schema-validated configuration** — every macro checks that the configured columns exist and raises `ArgumentError` early — listing *every* missing column at once, with one ready-to-paste `rails generate migration` command that adds them all
150
150
  - **Composable** — concerns are independent; mix and match per model
151
- - **Tested like an app, not a snippet** — **2,483 RSpec examples** run against a real database on every CI build
151
+ - **Tested like an app, not a snippet** — **2,723 RSpec examples** run against a real database on every CI build
152
152
  - **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
153
153
 
154
154
  ---
@@ -158,7 +158,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
158
158
  Add to your application's `Gemfile`:
159
159
 
160
160
  ```ruby
161
- gem "concerns_on_rails", "~> 1.31"
161
+ gem "concerns_on_rails", "~> 1.32"
162
162
  ```
163
163
 
164
164
  Or pull the latest from GitHub:
@@ -2583,7 +2583,12 @@ etag_with { current_user&.role } # => Cache-Control: private, max-ag
2583
2583
 
2584
2584
  ## 🛂 Permittable
2585
2585
 
2586
- Declarative, **typed params contracts** — what strong parameters would be if it also knew types, bounds, defaults, and *why* a request was bad. `params.permit` (and Rails 8's `params.expect`) only answer "which keys may pass"; a Permittable contract additionally **casts** each field, **validates** it, applies **defaults**, and turns every failure into a machine-readable 422. Because the contract is class-level data (not code inside the action), it is introspectable — and can be checked against a model's schema at boot.
2586
+ Declarative, **typed params contracts** — what strong parameters would be if it also knew types, bounds, defaults, and *why* a request was bad. `params.permit` (and Rails 8's `params.expect`) only answer "which keys may pass"; a Permittable contract additionally **casts** each field, **validates** it, applies **defaults**, optionally **reshapes** the output, and turns every failure into a machine-readable 422. Because the contract is class-level data (not code inside the action), it has readers beyond the validator: a boot-time **schema-drift guard**, an **OpenAPI 3.1** exporter, **RSpec matchers**, a route-set **coverage audit**, a **draft generator**, and a standalone `Contract` for any Hash.
2587
+
2588
+ > **Heads up — two things to know before relying on this section**
2589
+ >
2590
+ > 1. **Your lockfile, not this page, decides which features you have.** The pin is `>= 0.8, < 1`, so everything up to 0.8 (monitor mode, `:json`, `nullable:`, `message:`, field groups, matchers, generator, OpenAPI) is guaranteed, and an app that locked 0.8 keeps it until `bundle update permittable`. permittable 0.9+ requires ActiveSupport >= 6.1, so on Rails 6.0 Bundler stops at permittable **0.8**. That version lacks `format:` presets, `Permittable.check_column_types` and the enum rule, `Permittable.error_format = :problem`, `permittable:audit`, `params.expect` scanning in the generator, and the 0.10 canonical-numeric tightening (`"1_8"` and `" 99 "` still cast there). Everything else here applies. To make the newer surface a hard requirement, pin `gem "permittable", ">= 0.10"` in the app's Gemfile.
2591
+ > 2. **Two RFC 9457 switches exist — use one.** With [Respondable](#-respondable) included, Permittable's 422s render through `render_error`, so `respondable_by error_format: :problem_details` turns them into problem documents together with every other concern. The gem's own `Permittable.error_format = :problem` also renders RFC 9457 but deliberately **bypasses** `render_error`, so a controller with both set follows the gem's setting and ignores Respondable's envelope customisations.
2587
2592
 
2588
2593
  ```ruby
2589
2594
  class UsersController < ApplicationController
@@ -2591,10 +2596,12 @@ class UsersController < ApplicationController
2591
2596
 
2592
2597
  permit_params :create, :update, root: :user, model: User do
2593
2598
  required :name, :string, length: 1..80, normalize: :squish
2594
- required :email, :string, format: URI::MailTo::EMAIL_REGEXP, normalize: :email
2599
+ required :email, :string, format: :email, normalize: :email
2595
2600
  optional :age, :integer, in: 18..120
2596
2601
  optional :ssn, :string, sensitive: true # auto-redacted from logs
2597
2602
  optional :plan, :string, in: %w[free pro], default: "free"
2603
+ optional :nickname, :string, nullable: true # explicit null clears the column
2604
+ optional :metadata, :json, max_depth: 3, length: 0..32
2598
2605
  array :tag_names, of: :string, length: 0..10
2599
2606
  optional :address do
2600
2607
  required :city, :string
@@ -2615,19 +2622,23 @@ end
2615
2622
  # { param: "user.age", code: "inclusion" }] } }
2616
2623
  ```
2617
2624
 
2618
- **The schema-drift guard** is the headline: with `model:` (a class, or `true` to infer from the controller name), every non-`virtual:` scalar field is checked against the model's columns **at controller class load** through the same `ColumnGuard` all model concerns use. A column dropped by a migration fails the deploy (production eager-loads controllers) with a teaching error — the missing-column message plus a ready-to-paste migration command plus the `virtual: true` escape hatch. Nested/array fields are implicitly virtual; the check skips gracefully when the schema is unreachable (`db:create`, `assets:precompile`). In CI, one `Rails.application.eager_load!` spec exercises every contract in the app.
2625
+ **The schema-drift guard** is the headline: with `model:` (a class, or `true` to infer from the controller name), every non-`virtual:` scalar field is checked against the model's columns **at controller class load**. A column dropped by a migration fails the deploy (production eager-loads controllers) with a teaching error — the missing-column message plus a ready-to-paste migration command plus the `virtual: true` escape hatch. Nested/array fields are implicitly virtual; the check skips gracefully when the schema is unreachable (`db:create`, `assets:precompile`). Opt into the type half with `Permittable.check_column_types = true` (families: text / numeric incl. boolean / temporal; a Rails `enum` must be declared `:string, in: Model.statuses.keys`). In CI, one `Rails.application.eager_load!` spec exercises every contract in the app.
2626
+
2627
+ **Options** (`permit_params *actions, …`, repeatable; no actions = catch-all; **last matching rule wins**, subclasses inherit copy-on-write): `root:` (key to unwrap, `require(:user)`-style; missing root → **400**; default `false` = top-level params), `model:` (schema-drift guard), `unknown:` (`:ignore` default / `:log` / `:error` — undeclared keys at every nesting level; Rails' router/form/CSRF keys, the route's path params and ParamsWrapper's copy are exempt at the top level), `enforce:` (`false` = validate lazily on first `permitted_params` call, memoized per action; `true` = validate in a `before_action`), `mode:` (`:monitor` reports instead of rejecting — see below), `desc:` (OpenAPI operation description).
2628
+
2629
+ **Field DSL**: `required`/`optional` `name, type` (`:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, and `:json` — a free-form hash for `jsonb` columns, bounded by `length:` keys and `max_depth:`) with `in:` (Range, or a list snapshotted and cast at load — `in: Post.statuses` works), `format:` (Regexp or a preset: `:email`/`:uuid`/`:url`/`:slug`/`:hostname`), `length:`/`normalize:` (`:squish`/`:strip`/`:downcase`/`:upcase`/`:email` or a Proc; string fields only), `default:` (validated against the field's own contract at load time), `nullable:` (an explicit `null`/`""` yields `nil` instead of counting as absent — the one way to clear a column; wins over `default:`), `message:` (per-field copy, String or code → String; app-wide copy via I18n `permittable.errors.<code>`), `validate:` (Proc — falsy fails as `"invalid"`, a returned Symbol becomes the violation code), `transform:` (see below), `virtual:`, `sensitive:` (registers with the gem-wide filter_parameters registry, same pipe as Encryptable; cascades into nested blocks, `sensitive: false` opts a sub-field out), `desc:`/`example:` (docs). Nested hashes via a block; `array :name, of: :type` (or a block for arrays of hashes) with `length:` as element count — short-circuiting, and there is no default cap — and per-index violation paths (`items[1]`). Reusable **field groups**: `Permittable.fields { … }` + `use Group` (`optional: true` turns a create contract into a PATCH contract; `only:`/`except:`).
2619
2630
 
2620
- **Options** (`permit_params *actions, …`, repeatable; no actions = catch-all; **last matching rule wins**, subclasses inherit copy-on-write): `root:` (key to unwrap, `require(:user)`-style; missing root → **400**; default `false` = top-level params), `model:` (schema-drift guard), `unknown:` (`:ignore` default / `:log` / `:error` — undeclared keys at every nesting level; Rails' `controller`/`action`/`format` keys are exempt at the top level), `enforce:` (`false` = validate lazily on first `permitted_params` call; `true` = validate in a `before_action` so the action body never runs on bad input).
2631
+ **Output reshaping** — the safe replacement for params-mutating before_actions, operating on the validated copy only (the request's `params` is never touched). `transform:` applies a callable **after** cast + validation to reshape that field's output — `transform: ->(v) { v.split(",") }` turns a validated delimited String into an Array (absent fields stay absent, a `default:` on a transformed field is handed out as authored, a partially-invalid array is never transformed). `finalize do |p| … end` (once per contract, top level only) runs after every field validated cleanly, receives the result hash, and returns the final shape — zip parallel fields into value objects, drop scaffolding keys. It executes on a bare runner, **not** the controller, so contracts stay pure; its one extra verb, `violate!(param, code, message: nil)`, records a violation and halts the block immediately — doubling as the cross-field validation seam ("ends_at after starts_at").
2621
2632
 
2622
- **Field DSL**: `required`/`optional` `name, type` (`:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`) with `in:` (Range/Array), `format:`/`length:`/`normalize:` (`:squish`/`:strip`/`:downcase`/`:upcase`/`:email` or a Proc; string fields only), `default:` (validated against the field's own contract at load time), `validate:` (Proc — falsy fails as `"invalid"`, a returned Symbol becomes the violation code), `transform:` (see below), `virtual:`, `sensitive:` (registers with the gem-wide filter_parameters registry, same pipe as Encryptable). Nested hashes via a block; `array :name, of: :type` (or a block for arrays of hashes) with `length:` as element count and per-index violation paths (`items[1]`).
2633
+ **Coercion is strict** — deliberately not `ActiveModel::Type` ("abc".to_i == 0 and `Boolean.cast("abc") == true` silently corrupt untrusted input): a value the type can't faithfully represent is an `invalid_type` violation, numeric strings must be canonical (`"1_8"` and `" 99 "` are refused), dates must be complete (never filled in from today), booleans accept only `true/false/"true"/"false"/"1"/"0"/1/0`, and array/hash type-confusion (`?age[]=1`) is rejected instead of 500ing. `nil` and `""` are both **absent**: absent optional fields are *omitted* from the result (partial updates never nil-out columns), absent required fields violate, `default:` fills absence.
2623
2634
 
2624
- **Output reshaping** — the safe replacement for params-mutating before_actions, operating on the validated copy only (the request's `params` is never touched). `transform:` (scalar/array fields) applies a callable **after** cast + validation to reshape that field's output — `transform: ->(v) { v.split(",") }` turns a validated delimited String into an Array (absent fields and `default:` values are untouched; a partially-invalid array is never transformed). `finalize do |p| … end` (once per contract, top level only) runs after every field validated cleanly, receives the result hash, and returns the final shape — zip parallel fields into value objects, drop scaffolding keys. It executes on a bare runner, **not** the controller, so contracts stay pure; its one extra verb, `violate!(param, code)`, records a violation and halts the block immediately — doubling as the cross-field validation seam ("ends_at after starts_at").
2635
+ **Adopting on a live API**: `bin/rails permittable:generate` drafts a contract per controller from the model's columns and the `params.permit`/`params.expect` calls already in the source (in monitor mode, every guess a visible TODO); `mode: :monitor` / `Permittable.mode = :monitor` runs the pipeline but **reports instead of rejecting** (notification with `mode: :monitor`, raw params passed through, `permittable_violations` for the action to branch on); `bin/rails permittable:audit` lists every routed action with its effective mode and flags write actions that accept a body with no contract (`audit[strict]` exits 1 — a CI gate); then flip to enforce controller by controller.
2625
2636
 
2626
- **Coercion is strict** — deliberately not `ActiveModel::Type` ("abc".to_i == 0 and `Boolean.cast("abc") == true` silently corrupt untrusted input): a value the type can't faithfully represent is an `invalid_type` violation, booleans accept only `true/false/"true"/"false"/"1"/"0"/1/0`, and array/hash type-confusion (`?age[]=1`) is rejected instead of 500ing. `nil` and `""` are both **absent**: absent optional fields are *omitted* from the result (partial updates never nil-out columns), absent required fields violate, `default:` fills absence.
2637
+ **Beyond the controller**: `require "permittable/rspec"` gives `permit_param(:age).for_action(:create).as(:integer).within(18..120)` (the declaration) and `accept_params(payload).returning(hash)` / `reject_params(payload).with_violation("user.age", :inclusion)` (the behaviour) without dispatching a request; `Permittable::Contract.define(root: :user) { … }` runs the same DSL on any Hash (`call` → a `Result`, `call!` raises); `bin/rails permittable:openapi[path]` exports an **OpenAPI 3.1** document from the same frozen data the server enforces — anything JSON Schema can't express stays visible as an `x-permittable-*` extension instead of being mistranslated, and the output is deterministic so it can be committed.
2627
2638
 
2628
- **Failure surface**: violations raise `Permittable::InvalidParameters` (carries `details` + `status`), auto-`rescue_from`d into the shared error envelope (`Respondable#render_error` when included, the identical inline JSON otherwise) and instrumented as `invalid_parameters.permittable` for dashboards. Introspect contracts via `permittable_contracts` / `permit_rule_for(action)`. Naming note: legacy InheritedResources controllers also define `permitted_params` — don't mix the two on one controller.
2639
+ **Failure surface**: violations raise `Permittable::InvalidParameters` (carries `details` + `status`; a missing/malformed `root:` is **400**, field violations **422**), auto-`rescue_from`d into the shared error envelope — `Respondable#render_error` when included (so `respondable_by error_format: :problem_details` makes them RFC 9457 along with the rest of the app), the identical inline JSON otherwise; the gem's own `Permittable.error_format = :problem` renders RFC 9457 too but bypasses `render_error`, so pick one switch. Instrumented as `invalid_parameters.permittable` (`controller`, `action`, `mode`, `details`), exactly once per action per request. Introspect contracts via `permittable_contracts` / `permit_rule_for(action)`. Naming note: legacy InheritedResources controllers also define `permitted_params` — don't mix the two on one controller.
2629
2640
 
2630
- > Permittable ships as the standalone [`permittable` gem](https://github.com/VSN2015/permittable) (a runtime dependency of concerns_on_rails); `ConcernsOnRails::Controllers::Permittable` is an alias for `::Permittable`, with `sensitive:` registrations pooled into this gem's shared filter_parameters registry.
2641
+ > Permittable ships as the standalone [`permittable` gem](https://github.com/VSN2015/permittable) (a runtime dependency, `>= 0.8, < 1`; this section describes 0.10); `ConcernsOnRails::Controllers::Permittable` is an alias for `::Permittable`, with `sensitive:` registrations pooled into this gem's shared filter_parameters registry. Full reference: [the gem README](https://github.com/VSN2015/permittable#readme) · [docs page](https://vsn2015.github.io/concerns_on_rails/#/c/permittable).
2631
2642
 
2632
2643
  ---
2633
2644
 
@@ -2699,7 +2710,7 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2699
2710
  bundle install # install dev dependencies
2700
2711
  bundle exec rspec # run the test suite (2,723 examples)
2701
2712
  gem build concerns_on_rails.gemspec # build the gem
2702
- gem install ./concerns_on_rails-1.31.0.gem # install locally
2713
+ gem install ./concerns_on_rails-1.32.0.gem # install locally
2703
2714
 
2704
2715
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2705
2716
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.31.0".freeze
2
+ VERSION = "1.32.0".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.31.0
4
+ version: 1.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-05 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -108,16 +108,22 @@ dependencies:
108
108
  name: permittable
109
109
  requirement: !ruby/object:Gem::Requirement
110
110
  requirements:
111
- - - "~>"
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0.8'
114
+ - - "<"
112
115
  - !ruby/object:Gem::Version
113
- version: '0.1'
116
+ version: '1'
114
117
  type: :runtime
115
118
  prerelease: false
116
119
  version_requirements: !ruby/object:Gem::Requirement
117
120
  requirements:
118
- - - "~>"
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0.8'
124
+ - - "<"
119
125
  - !ruby/object:Gem::Version
120
- version: '0.1'
126
+ version: '1'
121
127
  description: A collection of plug-and-play ActiveSupport concerns for Rails models
122
128
  and Rails controllers
123
129
  email: