permittable 0.11.0 → 0.12.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 +82 -0
- data/README.md +394 -36
- data/lib/permittable/audit.rb +11 -5
- data/lib/permittable/authored_values.rb +87 -13
- data/lib/permittable/column_guard.rb +60 -11
- data/lib/permittable/contract.rb +54 -2
- data/lib/permittable/error_envelope.rb +97 -8
- data/lib/permittable/field_group.rb +2 -1
- data/lib/permittable/generator.rb +333 -61
- data/lib/permittable/json_schema.rb +116 -17
- data/lib/permittable/open_api.rb +41 -2
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +201 -36
- data/lib/permittable/tasks/generate.rake +6 -4
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +597 -97
- metadata +2 -2
data/README.md
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
<p align="center">
|
|
14
14
|
<a href="#quick-start">Quick start</a> ·
|
|
15
|
+
<a href="#complete-example">Complete example</a> ·
|
|
15
16
|
<a href="#guide">Guide</a> ·
|
|
16
17
|
<a href="#adopting-on-a-live-api">Adopting on a live API</a> ·
|
|
17
18
|
<a href="#beyond-the-controller">Beyond the controller</a> ·
|
|
@@ -167,12 +168,13 @@ end
|
|
|
167
168
|
|
|
168
169
|
That is the whole integration. Violations render the 422 envelope automatically, `model: Order` verifies the fields against the `orders` table when the class loads, and every rejected request emits an `invalid_parameters.permittable` notification.
|
|
169
170
|
|
|
170
|
-
Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live API](#adopting-on-a-live-api): the gem can draft the contracts for you, and run them in a report-only mode until you are ready to enforce.
|
|
171
|
+
Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live API](#adopting-on-a-live-api): the gem can draft the contracts for you, and run them in a report-only mode until you are ready to enforce. Want to see every option at once? See the [complete example](#complete-example).
|
|
171
172
|
|
|
172
173
|
> **Naming note:** some legacy stacks (InheritedResources) define their own `permitted_params`. Don't include both on one controller.
|
|
173
174
|
|
|
174
175
|
## Contents
|
|
175
176
|
|
|
177
|
+
- **[Complete example](#complete-example)**: every option in one events API
|
|
176
178
|
- **[Guide](#guide)**
|
|
177
179
|
- [How a request flows](#how-a-request-flows)
|
|
178
180
|
- [Declaring a contract](#declaring-a-contract)
|
|
@@ -206,6 +208,317 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
|
|
|
206
208
|
|
|
207
209
|
---
|
|
208
210
|
|
|
211
|
+
## Complete example
|
|
212
|
+
|
|
213
|
+
Every feature in one place: a small events API that uses every field type, every field option, every `permit_params` option and every app-wide setting. Nothing here is required. Copy the parts you need, and follow the links into the [Guide](#guide) for the details.
|
|
214
|
+
|
|
215
|
+
**1. App-wide settings.** All of them are optional.
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
# config/initializers/permittable.rb
|
|
219
|
+
|
|
220
|
+
# Roll out without rejecting: PERMITTABLE_MODE=monitor reports violations and lets the request through.
|
|
221
|
+
Permittable.mode = ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym
|
|
222
|
+
|
|
223
|
+
# Render RFC 9457 problem+json instead of the default { success:, error: } envelope.
|
|
224
|
+
Permittable.error_format = :problem
|
|
225
|
+
Permittable.problem_base_uri = "https://api.example.com/problems"
|
|
226
|
+
|
|
227
|
+
# The drift guard always checks that a field's column exists. This also checks the column's type.
|
|
228
|
+
Permittable.check_column_types = true
|
|
229
|
+
|
|
230
|
+
# Endpoints that permittable:audit and permittable:generate leave alone.
|
|
231
|
+
Permittable.audit_ignore = %w[active_storage/direct_uploads application#not_found]
|
|
232
|
+
Permittable.audit_ignore_outside_root = true # the default: controllers from gems and engines are skipped
|
|
233
|
+
|
|
234
|
+
# One event per rejected request, and per would-be rejection in monitor mode.
|
|
235
|
+
ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*, payload|
|
|
236
|
+
Rails.logger.info("params #{payload[:mode]}: #{payload[:controller]}##{payload[:action]} #{payload[:details]}")
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
```yaml
|
|
241
|
+
# config/locales/en.yml: app-wide copy for any violation code a field gives no message: for
|
|
242
|
+
en:
|
|
243
|
+
permittable:
|
|
244
|
+
errors:
|
|
245
|
+
missing: "is required"
|
|
246
|
+
invalid_type: "is the wrong type"
|
|
247
|
+
unknown: "is not a recognized parameter"
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**2. Reusable field groups.** See [reusing fields](#reusing-fields-permittablefields-and-use).
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
# app/contracts/address_fields.rb
|
|
254
|
+
AddressFields = Permittable.fields do
|
|
255
|
+
required :line1, :string, length: 1..100, normalize: :squish # collapses runs of whitespace
|
|
256
|
+
optional :line2, :string, length: 0..100, normalize: :strip
|
|
257
|
+
required :city, :string, length: 1..80
|
|
258
|
+
required :country, :string, length: 2, normalize: :upcase # an Integer length is exact
|
|
259
|
+
optional :postcode, :string, format: /\A[A-Z0-9 -]{3,10}\z/, normalize: :upcase # any Regexp
|
|
260
|
+
optional :latitude, :float, in: -90.0..90.0
|
|
261
|
+
optional :longitude, :float, in: -180.0..180.0
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
# app/contracts/event_fields.rb: shared by the create and update contracts below
|
|
267
|
+
EventFields = Permittable.fields do
|
|
268
|
+
# Scalars. Checks run in a fixed order: normalize → cast → length → in → format → validate.
|
|
269
|
+
required :title, :string, length: 1..120, normalize: :squish,
|
|
270
|
+
desc: "Shown as the page heading", # documentation only (OpenAPI)
|
|
271
|
+
example: "RubyConf 2026" # checked against the field at boot
|
|
272
|
+
required :slug, :string, length: 3..60, format: :slug, normalize: :downcase
|
|
273
|
+
optional :subtitle, :string, length: 1..140, nullable: true # null or "" clears the column
|
|
274
|
+
required :organizer_email, :string, format: :email, normalize: :email, # :email strips and downcases
|
|
275
|
+
message: { missing: "is required", format: "must be a valid email address" }
|
|
276
|
+
optional :website, :string, format: :url
|
|
277
|
+
optional :custom_domain, :string, format: :hostname, normalize: :downcase
|
|
278
|
+
optional :venue_id, :string, format: :uuid
|
|
279
|
+
required :capacity, :integer, in: 1..10_000, message: "must be between 1 and 10,000"
|
|
280
|
+
optional :ticket_price, :decimal, in: BigDecimal("0")..BigDecimal("9999.99"),
|
|
281
|
+
transform: ->(price) { price.round(2) } # runs after cast and checks
|
|
282
|
+
optional :waitlist_enabled, :boolean
|
|
283
|
+
required :starts_at, :datetime # converted to UTC
|
|
284
|
+
optional :ends_at, :datetime
|
|
285
|
+
optional :registration_closes_on, :date
|
|
286
|
+
optional :metadata, :json, length: 0..32, max_depth: 3 # any hash: ≤ 32 keys, ≤ 3 levels
|
|
287
|
+
|
|
288
|
+
# Arrays. length: bounds the element count, and is checked before any element is.
|
|
289
|
+
array :tags, of: :string, length: 0..10, transform: ->(tags) { tags.map(&:downcase).uniq }
|
|
290
|
+
array :co_host_ids, of: :integer, length: 0..5
|
|
291
|
+
array :ticket_tiers, required: true, length: 1..5, # arrays are optional by default
|
|
292
|
+
validate: ->(tiers) { tiers.map { |t| t[:name] }.uniq.size == tiers.size || :duplicate_names },
|
|
293
|
+
message: { duplicate_names: "must have unique names" } do # a Symbol from validate: is the code
|
|
294
|
+
required :name, :string, length: 1..40
|
|
295
|
+
required :price, :decimal, in: BigDecimal("0")..BigDecimal("9999.99")
|
|
296
|
+
optional :quantity, :integer, in: 1..10_000
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# A nested hash. Violation paths are dotted: event.venue_address.city
|
|
300
|
+
optional :venue_address do
|
|
301
|
+
use AddressFields # groups compose
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
**3. The controller.** See [declaring a contract](#declaring-a-contract) and [field options](#field-options).
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
# app/controllers/events_controller.rb
|
|
310
|
+
class EventsController < ApplicationController
|
|
311
|
+
include Permittable # or once, in ApplicationController
|
|
312
|
+
|
|
313
|
+
# No actions listed: a catch-all for every action without its own rule (index, show, destroy, ...).
|
|
314
|
+
# The last matching rule wins, so the catch-all goes first.
|
|
315
|
+
permit_params(unknown: :log) do # undeclared keys are dropped, and logged
|
|
316
|
+
optional :page, :integer, in: 1..1000, default: 1
|
|
317
|
+
optional :per_page, :integer, in: 1..100, default: 25
|
|
318
|
+
optional :q, :string, length: 0..100, normalize: :squish
|
|
319
|
+
optional :status, :string, in: Event.statuses.keys
|
|
320
|
+
optional :from, :date
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
permit_params :create,
|
|
324
|
+
root: :event, # unwrap params[:event]; missing or not a hash → 400
|
|
325
|
+
model: Event, # drift guard: each non-virtual scalar must be a column
|
|
326
|
+
unknown: :error, # an undeclared key is a violation, at every level
|
|
327
|
+
enforce: true, # validate in a before_action, not on first read
|
|
328
|
+
desc: "Create an event" do # documentation only (OpenAPI)
|
|
329
|
+
use EventFields
|
|
330
|
+
|
|
331
|
+
# Create-only fields. On an update rule, a default would overwrite the column on every PATCH that omits it.
|
|
332
|
+
optional :status, :string, in: Event.statuses.keys, default: "draft" # a Rails enum, sent by name
|
|
333
|
+
optional :currency, :string, in: %w[USD EUR GBP], default: "USD",
|
|
334
|
+
normalize: ->(code) { code.strip.upcase } # a Proc works too
|
|
335
|
+
required :terms_accepted, :boolean,
|
|
336
|
+
virtual: true, # not a column: no drift check
|
|
337
|
+
validate: ->(accepted) { accepted || :not_accepted },
|
|
338
|
+
message: { not_accepted: "must be accepted" }
|
|
339
|
+
|
|
340
|
+
optional :payout, sensitive: true do # cascades: every sub-field is redacted from logs...
|
|
341
|
+
required :iban, :string, length: 15..34
|
|
342
|
+
required :account_holder # the type defaults to :string
|
|
343
|
+
optional :scheme, :string, in: %w[sepa swift], sensitive: false # ...except an explicit opt-out
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Runs once every field is valid: cross-field rules, and reshaping the result.
|
|
347
|
+
finalize do |event|
|
|
348
|
+
if event[:ends_at] && event[:ends_at] <= event[:starts_at]
|
|
349
|
+
violate!("event.ends_at", :before_start, message: "must be after starts_at") # halts the block
|
|
350
|
+
end
|
|
351
|
+
event.except(:terms_accepted) # must return the final Hash
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# PATCH: the same fields with nothing mandatory, and the slug fixed once created.
|
|
356
|
+
permit_params :update, root: :event, model: Event, unknown: :error, desc: "Edit an event" do
|
|
357
|
+
use EventFields, optional: true, except: %i[slug]
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# A legacy action mid-rollout: violations are reported, nothing is rejected.
|
|
361
|
+
permit_params :reschedule, root: :event, model: Event, mode: :monitor do
|
|
362
|
+
use EventFields, only: %i[starts_at ends_at], optional: true
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def index
|
|
366
|
+
render json: Events::Search.call(permitted_params) # page: 1, per_page: 25 when nothing is sent
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def create
|
|
370
|
+
render json: Events::Create.call(permitted_params), status: :created
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def update
|
|
374
|
+
Events::Update.call(params[:id], permitted_params) # holds only the keys the client sent
|
|
375
|
+
head :no_content
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def reschedule
|
|
379
|
+
# The before_action has already validated. permittable_violations is [] for a clean request.
|
|
380
|
+
Rails.logger.info("reschedule would reject: #{permittable_violations}") if permittable_violations.any?
|
|
381
|
+
Event.find(params[:id]).update!(params.require(:event).permit(:starts_at, :ends_at)) # legacy code, unchanged
|
|
382
|
+
head :no_content
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Because of `model: Event` and `check_column_types`, a migration that drops the `capacity` column, or changes it to a string, fails at boot, not on a request. See [the schema-drift guard](#the-schema-drift-guard).
|
|
388
|
+
|
|
389
|
+
**4. What the action receives.** This request to `POST /events`:
|
|
390
|
+
|
|
391
|
+
```json
|
|
392
|
+
{ "event": {
|
|
393
|
+
"title": " RubyConf 2026 ", "slug": "RubyConf-2026", "organizer_email": " Ana@Example.COM ",
|
|
394
|
+
"capacity": "800", "ticket_price": "249.999", "waitlist_enabled": "true",
|
|
395
|
+
"starts_at": "2026-11-12T09:00:00-05:00", "ends_at": "2026-11-14T18:00:00-05:00",
|
|
396
|
+
"tags": ["Ruby", "rails", "ruby"], "co_host_ids": ["12", "31"],
|
|
397
|
+
"ticket_tiers": [{ "name": "Early bird", "price": "199", "quantity": "100" }],
|
|
398
|
+
"venue_address": { "line1": " 1 Main St ", "city": "Chicago", "country": "us" },
|
|
399
|
+
"currency": " eur ", "terms_accepted": "1",
|
|
400
|
+
"payout": { "iban": "DE89370400440532013000", "account_holder": "Ruby Central" } } }
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
gives `#create` this `permitted_params`:
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
{
|
|
407
|
+
"title" => "RubyConf 2026", # squished
|
|
408
|
+
"slug" => "rubyconf-2026", # downcased, then format-checked
|
|
409
|
+
"organizer_email" => "ana@example.com",
|
|
410
|
+
"capacity" => 800, # cast from "800"
|
|
411
|
+
"ticket_price" => 0.25e3, # BigDecimal, rounded by transform:
|
|
412
|
+
"waitlist_enabled" => true,
|
|
413
|
+
"starts_at" => 2026-11-12 14:00:00 UTC, # a Time, converted to UTC
|
|
414
|
+
"ends_at" => 2026-11-14 23:00:00 UTC,
|
|
415
|
+
"tags" => ["ruby", "rails"], # transform: downcased, duplicates removed
|
|
416
|
+
"co_host_ids" => [12, 31],
|
|
417
|
+
"ticket_tiers" => [{ "name" => "Early bird", "price" => 0.199e3, "quantity" => 100 }],
|
|
418
|
+
"venue_address" => { "line1" => "1 Main St", "city" => "Chicago", "country" => "US" },
|
|
419
|
+
"status" => "draft", # the default
|
|
420
|
+
"currency" => "EUR", # the normalize: Proc
|
|
421
|
+
"payout" => { "iban" => "DE89370400440532013000", "account_holder" => "Ruby Central" }
|
|
422
|
+
} # terms_accepted was checked, then removed by finalize
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
A request with a bad email, a `capacity` of `"0"`, two tiers with the same name, no `terms_accepted` and an extra `admin` key never reaches the action:
|
|
426
|
+
|
|
427
|
+
```http
|
|
428
|
+
HTTP/1.1 422 Unprocessable Entity
|
|
429
|
+
Content-Type: application/problem+json
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```json
|
|
433
|
+
{
|
|
434
|
+
"type": "https://api.example.com/problems/invalid-parameters",
|
|
435
|
+
"title": "Invalid parameters",
|
|
436
|
+
"status": 422,
|
|
437
|
+
"detail": "Invalid parameters: event.organizer_email must be a valid email address, event.capacity must be between 1 and 10,000, event.ticket_tiers must have unique names, event.terms_accepted is required, event.admin is not a recognized parameter",
|
|
438
|
+
"instance": "/events",
|
|
439
|
+
"errors": [
|
|
440
|
+
{ "param": "event.organizer_email", "code": "format", "message": "must be a valid email address" },
|
|
441
|
+
{ "param": "event.capacity", "code": "inclusion", "message": "must be between 1 and 10,000" },
|
|
442
|
+
{ "param": "event.ticket_tiers", "code": "duplicate_names", "message": "must have unique names" },
|
|
443
|
+
{ "param": "event.terms_accepted", "code": "missing", "message": "is required" },
|
|
444
|
+
{ "param": "event.admin", "code": "unknown", "message": "is not a recognized parameter" }
|
|
445
|
+
]
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
The first three messages come from the fields' own `message:`, the last two from the locale file. `finalize` did not run, because it runs only when every field is valid.
|
|
450
|
+
|
|
451
|
+
**5. The same fields outside a controller.** See [standalone contracts](#standalone-contracts-no-controller).
|
|
452
|
+
|
|
453
|
+
```ruby
|
|
454
|
+
# app/contracts/event_webhook.rb: webhooks, jobs, CSV rows, any Hash
|
|
455
|
+
EventWebhook = Permittable::Contract.define(root: :event, unknown: :error, desc: "Partner event feed") do
|
|
456
|
+
required :external_id, :string, format: :uuid
|
|
457
|
+
use EventFields, only: %i[title starts_at ends_at capacity]
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
result = EventWebhook.call(payload) # bad client data is a violation, never an exception
|
|
461
|
+
result.valid? # => false
|
|
462
|
+
result.violations # => [{ param: "event.capacity", code: "inclusion", message: "must be between 1 and 10,000" }]
|
|
463
|
+
result.params # the validated HashWithIndifferentAccess; nil when invalid
|
|
464
|
+
|
|
465
|
+
EventWebhook.call!(payload) # the params, or raises Permittable::InvalidParameters
|
|
466
|
+
EventWebhook.json_schema # JSON Schema (draft 2020-12)
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**6. Tests.** See [testing contracts](#testing-contracts-rspec-matchers).
|
|
470
|
+
|
|
471
|
+
```ruby
|
|
472
|
+
# spec/controllers/events_contract_spec.rb
|
|
473
|
+
require "permittable/rspec"
|
|
474
|
+
|
|
475
|
+
RSpec.describe EventsController do
|
|
476
|
+
it "declares the contract" do
|
|
477
|
+
expect(described_class).to permit_param(:title).for_action(:create).as(:string).with_length(1..120).required
|
|
478
|
+
expect(described_class).to permit_param(:organizer_email).for_action(:create).matching(:email)
|
|
479
|
+
expect(described_class).to permit_param(:capacity).for_action(:create).as(:integer).within(1..10_000)
|
|
480
|
+
expect(described_class).to permit_param(:status).for_action(:create).with_default("draft")
|
|
481
|
+
expect(described_class).to permit_param(:tags).for_action(:create).as_array(of: :string)
|
|
482
|
+
expect(described_class).to permit_param("ticket_tiers.price").for_action(:create).as(:decimal)
|
|
483
|
+
expect(described_class).to permit_param(:terms_accepted).for_action(:create).virtual
|
|
484
|
+
expect(described_class).to permit_param("payout.iban").for_action(:create).sensitive
|
|
485
|
+
expect(described_class).to permit_param(:subtitle).for_action(:update).nullable.optional
|
|
486
|
+
expect(described_class).not_to permit_param(:slug).for_action(:update)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
it "casts what it accepts and names what it rejects" do
|
|
490
|
+
expect(described_class).to accept_params(event: { title: "Ruby Night", capacity: "40" })
|
|
491
|
+
.for_action(:update).returning("title" => "Ruby Night", "capacity" => 40)
|
|
492
|
+
|
|
493
|
+
expect(described_class).to reject_params(event: { title: "Ruby Night", capacity: "0" })
|
|
494
|
+
.for_action(:create)
|
|
495
|
+
.with_violation("event.capacity", :inclusion)
|
|
496
|
+
.with_violation("event.terms_accepted", :missing)
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
**7. Tooling.** See [adopting on a live API](#adopting-on-a-live-api) and [exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift).
|
|
502
|
+
|
|
503
|
+
```sh
|
|
504
|
+
bin/rails permittable:generate # draft contracts for controllers that have none
|
|
505
|
+
bin/rails "permittable:generate[EventsController]" # draft one controller, even a covered one
|
|
506
|
+
bin/rails permittable:audit # every routed action: its contract and its mode
|
|
507
|
+
bin/rails "permittable:audit[strict]" # CI gate: exit 1 on any unguarded write action
|
|
508
|
+
bin/rails "permittable:openapi[openapi/api.json]" # OpenAPI 3.1, from the contracts above
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
```ruby
|
|
512
|
+
EventsController.permittable_contracts # every rule: frozen, introspectable data
|
|
513
|
+
EventsController.permit_rule_for(:destroy) # the rule a request would use (the catch-all)
|
|
514
|
+
Permittable::OpenAPI.request_body_for(EventsController, :create) # one requestBody object
|
|
515
|
+
Permittable::OpenAPI.operations_for(EventsController) # { "create" => ..., "update" => ..., ... }
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Not shown above: a controller's own [`render_error`](#violations-and-error-responses) (used when `error_format` is the default `:envelope`), overriding `render_invalid_parameters`, swapping in your own `Permittable.filter_parameter_registry`, and the plain-Ruby `Permittable::Generator` and `Permittable::Audit` APIs. The [Reference](#reference) lists them all.
|
|
519
|
+
|
|
520
|
+
---
|
|
521
|
+
|
|
209
522
|
## Guide
|
|
210
523
|
|
|
211
524
|
### How a request flows
|
|
@@ -285,6 +598,8 @@ optional :metadata, :json, max_depth: 3, length: 0..32
|
|
|
285
598
|
|
|
286
599
|
Arrays are **optional unless `required: true`**, and `length:` on an array constrains the element **count**.
|
|
287
600
|
|
|
601
|
+
An array of hashes also accepts the shape Rails' `fields_for` sends for nested attributes: `user[addresses_attributes][0][city]` reaches the params as a Hash keyed `"0"`, `"1"`, … and is read as the records it encodes, in the order sent, exactly as strong params and `accepts_nested_attributes_for` read it. Only that shape qualifies: every key an integer and every value a hash.
|
|
602
|
+
|
|
288
603
|
`length:` is a **bound, not a report**: an array outside it is rejected without its elements being examined at all. A 200,000-element payload against `length: 0..10` is refused by its first check, so it costs one violation and a 40-byte body instead of 200,001 violations and several megabytes — milliseconds of contract work instead of seconds. There is **no default cap**: an array with no `length:` is unbounded, and every element of it is cast and checked however many arrive. Declare `length:` on every array you accept.
|
|
289
604
|
|
|
290
605
|
### Field options
|
|
@@ -293,11 +608,11 @@ Which options are legal depends on the field kind — anything else raises at cl
|
|
|
293
608
|
|
|
294
609
|
| Option | Scalar | Array | Nested | Meaning |
|
|
295
610
|
|---|:---:|:---:|:---:|---|
|
|
296
|
-
| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`), a list (
|
|
611
|
+
| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`), a list (an `Array`, `Set`, `Enumerator`, or `Hash` read as its keys — so `in: Post.statuses` works — or a subclass of one that keeps its `include?`, such as the `ActiveSupport::OrderedOptions` that `config_for` returns), or any other object answering `include?` (used as given, and read on every request, so it may be empty when the class loads: a live list filled in an initializer or `after_initialize` is fine, and for the same reason a `default:` or `example:` is not checked against it at class load, though every other rule still is) — including a Hash/Array/Set **subclass that overrides `include?`**, whose override is kept rather than read for its raw contents. A list is cast with the field's own type and **snapshotted** at class load, so `in: %i[draft published]` on a `:string` and `in: %w[1 2 3]` on an `:integer` match what a request casts to — and a later `PLANS << "gold"` is not seen; pass your own `include?` object for a live list. A `nil` member is dropped on a `nullable:` field |
|
|
297
612
|
| `format:` | ✅¹ | — | — | Regexp the value must match, or a [preset name](#format-presets): `:email`, `:uuid`, `:url`, `:slug`, `:hostname` |
|
|
298
613
|
| `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) |
|
|
299
614
|
| `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
|
|
300
|
-
| `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load either way. On a field with **no** `transform:`, stored as a request sending it would read it — normalized, cast (`default: "18"` on an `:integer` is `18`), an array walked at every depth with its sub-fields' own `transform:` applied. On a field **with** `transform:`, stored exactly **as authored** instead — `transform:` never runs on a default (see [output reshaping](#output-reshaping-transform-and-finalize)), and neither does the cast, so the value you write is the value the action receives. Either way it is frozen, and each request gets its own deep copy |
|
|
615
|
+
| `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load either way. On a field with **no** `transform:`, stored as a request sending it would read it — normalized, cast (`default: "18"` on an `:integer` is `18`), an array walked at every depth with its sub-fields' own `transform:` applied. On a field **with** `transform:`, stored exactly **as authored** instead — `transform:` never runs on a default (see [output reshaping](#output-reshaping-transform-and-finalize)), and neither does the cast, so the value you write is the value the action receives. (An array with its own `transform:` still has its default checked as a request would be: a sub-field's `transform:` runs at class load only for a field below an array that declares `validate:`, which receives the transformed values.) Either way it is frozen, and each request gets its own deep copy |
|
|
301
616
|
| `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code. On an array it runs only when no element violated — an undeclared key inside an element does not count — and `transform:` runs only when nothing violated at all |
|
|
302
617
|
| `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
|
|
303
618
|
| `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
|
|
@@ -307,7 +622,7 @@ Which options are legal depends on the field kind — anything else raises at cl
|
|
|
307
622
|
| `max_depth:` | — | — | — | `:json` fields only — maximum container nesting. See [free-form hashes](#free-form-hashes-json) |
|
|
308
623
|
| `required:` | — | ✅ | — | Arrays are optional unless this is `true` |
|
|
309
624
|
| `desc:` | ✅ | ✅ | ✅ | Documentation only — the field's `description` in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
|
|
310
|
-
| `example:` | ✅ | ✅ | — | Documentation only, but **validated against the field's own contract at class load**, like `default:` |
|
|
625
|
+
| `example:` | ✅ | ✅ | — | Documentation only, but **validated against the field's own contract at class load**, like `default:`. Published as a client would send it: cast (`example: "18"` on an `:integer` is `18`), and never a `transform:`'s output, at any depth, even on a field that declares one. An array example is validated as a request would be, but the sub-field `default:`s a request would fill in are not published. A sub-field's `transform:` runs at class load only for a field below an array that declares `validate:` (which receives the transformed values), and its output is never published |
|
|
311
626
|
| `nullable:` | ✅ | ✅ | ✅ | An explicitly-sent empty value yields `nil` instead of counting as absent — see [explicit nulls](#explicit-nulls-nullable) |
|
|
312
627
|
|
|
313
628
|
¹ `format:`, `length:`, and `normalize:` reason about characters and are **only valid on `:string` fields**. On any other type they would silently apply to an already-cast value, so declaring them raises at class load.
|
|
@@ -350,7 +665,7 @@ optional :host, :string, format: :hostname
|
|
|
350
665
|
|
|
351
666
|
A preset carries something a hand-written Regexp cannot: the JSON Schema **`format` keyword** the wider ecosystem understands, so [exported docs](#exporting-openapi-docs-that-cannot-drift) say `"format": "uuid"` rather than only a wall of `pattern`. The `pattern` is still emitted next to it — in draft 2020-12 `format` is an annotation unless a validator opts into asserting it, so the pattern is what actually enforces.
|
|
352
667
|
|
|
353
|
-
An unknown preset name fails at class load, listing the presets. Passing a `Regexp` directly works exactly as before, and the RSpec matcher speaks both spellings: `matching(:email)` asserts the preset, `matching(/re/)` the Regexp.
|
|
668
|
+
An unknown preset name fails at class load, listing the presets. Passing a `Regexp` directly works exactly as before, and the RSpec matcher speaks both spellings: `matching(:email)` (or `matching("email")`, as the contract accepts either) asserts the preset, `matching(/re/)` the Regexp.
|
|
354
669
|
|
|
355
670
|
### Types and strict coercion
|
|
356
671
|
|
|
@@ -360,10 +675,10 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
360
675
|
|---|---|---|
|
|
361
676
|
| `:string` | `String`, returned in the encoding it arrived in; `Numeric`/`true`/`false` are stringified | Arrays, hashes, a `String` whose bytes are invalid in its own encoding (`"caf\xC3"`) |
|
|
362
677
|
| `:integer` | `Integer`; whole `Float`s (`4.0`); canonical base-10 numeric strings (`"-12"`, `"007"`) | `"4.5"`, `"abc"`, `"1_8"`, `" 99 "`, `4.5`, NaN/Infinity |
|
|
363
|
-
| `:float` | `Numeric`; a canonical numeric string (`"1.5"`, `".5"`, `"-2e3"`) | `"abc"`, `"1_8.5"`, `" 1.5 "` |
|
|
364
|
-
| `:decimal` | `Numeric`, or a canonical numeric string → `BigDecimal` | Unparseable strings, `"1_000"`, `" 1.5 "` |
|
|
678
|
+
| `:float` | a real `Numeric`; a canonical numeric string (`"1.5"`, `".5"`, `"-2e3"`) | `"abc"`, `"1_8.5"`, `" 1.5 "`, and any number outside a Float's range, as a `Numeric` or a string alike (`BigDecimal("1e-400")`, `"1e400"`), a `Complex` (even `Complex(1, 0)`), a `Numeric` that cannot be converted |
|
|
679
|
+
| `:decimal` | a real `Numeric`, or a canonical numeric string → `BigDecimal`; a `Rational` whose decimal expansion is exact (`Rational(1, 2)`, `3r`) | Unparseable strings, `"1_000"`, `" 1.5 "`, a `Complex`, a `Rational` no decimal holds exactly (`Rational(1, 3)`), a `Numeric` that cannot be converted |
|
|
365
680
|
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
|
|
366
|
-
| `:date` | `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`) | Unparseable strings,
|
|
681
|
+
| `:date` | `Date`; a `Time`, `DateTime` or `ActiveSupport::TimeWithZone` that is exactly midnight UTC, as that `Date`; a string naming a **complete** date, in any format `Date.parse` understands (`"2026-09-05"`, `"2026/09/05"`, `"Sep 5, 2026"`) | Unparseable strings, **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`), and any other instant — one with a time of day, or a midnight in another zone |
|
|
367
682
|
| `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`; a string naming a complete date, with or without a time | Unparseable strings, and any string without a complete date (`"10:30"`) |
|
|
368
683
|
| `:json` | Any `Hash` — passed through uncast, see [free-form hashes](#free-form-hashes-json) | Arrays, scalars |
|
|
369
684
|
|
|
@@ -371,14 +686,14 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
371
686
|
|
|
372
687
|
**Strings in other encodings are inspected, never converted.** A String whose bytes are not valid in its **own** encoding (`"caf\xC3"` in UTF-8, a lone UTF-16 surrogate) is `invalid_type` for every scalar type, before `normalize:` or `format:` sees it. Any other String keeps its encoding: a `:string` value is handed back exactly as it arrived, so a controller using Rails' `skip_parameter_encoding` or `param_encoding` gets its binary or Shift_JIS text unchanged. The number, boolean and date types parse a UTF-8 **copy** of the text, so UTF-16 `"12"` casts to `12` for an `:integer`; when the text has no UTF-8 reading (a byte Windows-1252 leaves undefined) that is `invalid_type`. `normalize:` and `format:` work on the String in its own encoding. Where a normalizer cannot handle that encoding (`:squish` on UTF-16), the value is left as it is; where a `format:` pattern cannot be applied to it (a non-ASCII pattern against UTF-16 or binary bytes), that is a `format` violation. `in:` compares Strings as Ruby does, encoding included. A `:json` field's contents are not examined.
|
|
373
688
|
|
|
374
|
-
**Numbers must be finite.** `Float("1e400")` is `Infinity` and `Float("1e-400")` is `0.0` — neither represents what was sent, and neither is a value a numeric column can store, so both are `invalid_type`. A genuine zero is unaffected however it is spelled (`"0"`, `"0.0"`, `"0e10"`). `:decimal`
|
|
689
|
+
**Numbers must be finite.** `Float("1e400")` is `Infinity` and `Float("1e-400")` is `0.0` — neither represents what was sent, and neither is a value a numeric column can store, so both are `invalid_type`. A genuine zero is unaffected however it is spelled (`"0"`, `"0.0"`, `"0e10"`). `:decimal`'s exponent range is far wider, so `"1e400"` is fine there, though not unlimited: a string past it (`"1e-99999999999999999999"`) underflows to zero or overflows to Infinity and is `invalid_type` by the same rule. A string whose plain form would run past a thousand digits and past its own length (`"1e4000000000000000000"`, 22 bytes) is `invalid_type` too, because formatting it to save, render or log it would exhaust memory; a number spelled out digit by digit is accepted however long it is. And `BigDecimal("NaN")` and `BigDecimal("Infinity")` *succeed* where `Float()` raises, so those literal strings are rejected explicitly.
|
|
375
690
|
|
|
376
691
|
**Numbers are spelled the way a client spells them.** `Integer()`, `Float()` and `BigDecimal()` all read underscore digit separators and surrounding whitespace — conveniences for a number literal in Ruby source — so `"1_8"` would be eighteen and `" 99 "` ninety-nine. A numeric string must instead be canonical: an optional sign, digits, an optional fraction and an optional exponent (`"-12"`, `"007"`, `".5"`, `"1.5e10"`). Anything else is `invalid_type`.
|
|
377
692
|
|
|
378
693
|
Two more behaviours worth committing to memory:
|
|
379
694
|
|
|
380
695
|
- **Type confusion is a violation, not a 500.** A request of `?age[]=1` against a scalar `:integer` field yields `invalid_type`. Arrays, hashes, and nested `ActionController::Parameters` can never satisfy a scalar type, so the classic "`NoMethodError` on `[]`" crash is impossible.
|
|
381
|
-
- **Datetimes are normalised to UTC.** A zoneless string parses as UTC regardless of the host timezone, which keeps behaviour deterministic across machines; explicit offsets are honoured and converted.
|
|
696
|
+
- **Datetimes are normalised to UTC.** A zoneless string parses as UTC regardless of the host timezone, which keeps behaviour deterministic across machines; explicit offsets are honoured and converted. A zone the parser cannot honour — an offset of a day or more (`+25:00`), or an abbreviation it does not know — is `invalid_type` rather than read as UTC, and dates are read in the proleptic Gregorian calendar ISO 8601 uses, so `"1500-01-01"` is that day whatever the host has loaded.
|
|
382
697
|
|
|
383
698
|
### Free-form hashes (`:json`)
|
|
384
699
|
|
|
@@ -411,7 +726,7 @@ In [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) the field is `{
|
|
|
411
726
|
|
|
412
727
|
### Absence, defaults, and partial updates
|
|
413
728
|
|
|
414
|
-
`nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present. `normalize:` runs *before* this rule, so a field declared `normalize: :squish` treats `" "` as absent too: whitespace cannot satisfy a `required` field by becoming `""`.
|
|
729
|
+
`nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present. `normalize:` runs *before* this rule, so a field declared `normalize: :squish` treats `" "` as absent too: whitespace cannot satisfy a `required` field by becoming `""`. The same goes for a `""` **element** of a scalar `array ..., of:`: it is dropped before casting and before `length:` counts, because `collection_check_boxes` and `select multiple: true` always send a hidden `""` so that unchecking everything still sends the key. A `null` element, which no form sends, is still `invalid_type`.
|
|
415
730
|
|
|
416
731
|
That single rule produces the behaviour you want from a `PATCH`:
|
|
417
732
|
|
|
@@ -475,7 +790,7 @@ Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
|
|
|
475
790
|
|
|
476
791
|
**Status codes.** A bad root key renders **400** — the request is malformed; the envelope you asked for isn't there, or isn't an object. Field-level violations render **422** — well-formed, semantically wrong. The two root failures are told apart by their code: `missing` when the key really is absent (`{}`, `{"user": null}`, `{"user": ""}`), `invalid_type` when the client sent it with the wrong shape.
|
|
477
792
|
|
|
478
|
-
**Custom rendering.** If your controller defines `render_error`, the envelope delegates to it as `render_error(message:, code:, status:, errors:)` — the `errors:` key is passed only when
|
|
793
|
+
**Custom rendering.** If your controller defines `render_error`, the envelope delegates to it as `render_error(message:, code:, status:, errors:)` — the `errors:` key is passed only when the method can take it (it names `errors:`, takes `**kwargs`, or declares no keywords at all), so hosts documenting a three-keyword `(message:, code:, status:)` contract keep working. A **private** `render_error`, the usual spelling on an `ApplicationController` so it never becomes a routable action, is delegated to as well when it takes `message:`, `code:` and `status:` and requires nothing else; a private one with some other signature (`render_error(message, status)`, or `render_error(message:, status:)`) is left alone. Otherwise the inline JSON shape is rendered. Either way, `render_invalid_parameters` is a normal method you can override, and [`Permittable.error_format = :problem`](#rfc-9457-problemjson) swaps the whole shape for RFC 9457 problem details. For full control beyond that, `error.details` gives you the structured violations to build from.
|
|
479
794
|
|
|
480
795
|
### Custom error messages (`message:`)
|
|
481
796
|
|
|
@@ -525,6 +840,21 @@ en:
|
|
|
525
840
|
|
|
526
841
|
Resolution order per violation: the field's own `message:` (String, or the Hash entry for that code) → the app's `permittable.errors.<code>` translation → the bare `{ param:, code: }` shape. The lookup also covers a missing `root:`, `unknown` keys, Symbol codes returned by `validate:` (`permittable.errors.must_be_even`), and `violate!` codes in `finalize` (an explicit `violate!(..., message:)` still wins). Only a String translation counts — a missing key or a nested Hash falls back to the bare shape rather than leaking structure to clients. No I18n, no change: apps without the gem or the keys behave exactly as before.
|
|
527
842
|
|
|
843
|
+
**Per-request locales.** Messages are resolved in the locale that is current when the check runs. A lazy rule runs inside the action, so a locale switch such as the Rails i18n guide's `around_action :switch_locale` covers it as long as nothing reads `permitted_params` before the switch runs. An app `before_action` that runs earlier and reads `permitted_params` or `permittable_violations` (an authorization callback, say) validates the rule before the switch. An `enforce: true` rule and every [monitor-mode](#monitor-mode-roll-out-without-rejecting) rule run earlier, in a `before_action` that `include Permittable` registers. Rails runs callbacks in the order they were declared, so a switch declared *after* the include runs after that check, and the same request is answered in the default locale (`"is required"`) with `enforce: true` and in the request's (`"fehlt"`) without it. Put the switch first, with `prepend_around_action` (which puts it ahead of every callback, the app's own authentication included, which is normally what a locale switch wants), or declare it before the include:
|
|
844
|
+
|
|
845
|
+
```ruby
|
|
846
|
+
class ApplicationController < ActionController::API
|
|
847
|
+
include Permittable
|
|
848
|
+
prepend_around_action :switch_locale
|
|
849
|
+
|
|
850
|
+
private
|
|
851
|
+
|
|
852
|
+
def switch_locale(&action)
|
|
853
|
+
I18n.with_locale(params[:locale] || I18n.default_locale, &action)
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
```
|
|
857
|
+
|
|
528
858
|
### RFC 9457 problem+json
|
|
529
859
|
|
|
530
860
|
For a public API, the standard shape for an error is [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457.html). One app-wide setting renders it:
|
|
@@ -574,10 +904,14 @@ The setting is app-wide, not per-contract, because the error format of an API is
|
|
|
574
904
|
|
|
575
905
|
Under `:log` that bound is the whole record: nothing else names an undeclared key, so beyond the tenth only the count survives. Where you need every name — auditing what a client really sends during a rollout — use `unknown: :error` in monitor mode, which records all of them in `details` and in the instrumentation payload without rejecting the request.
|
|
576
906
|
|
|
577
|
-
Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token` (or whatever `config.action_controller.request_forgery_protection_token` renames it to), `_method`, `utf8`, and
|
|
907
|
+
Rails merges its own keys into `params`: `controller`, `action`, and `format` from the router, plus `authenticity_token` (or whatever `config.action_controller.request_forgery_protection_token` renames it to), `_method`, `utf8`, and the submit button's name from an ordinary form POST: `commit` from `f.submit` / `submit_tag`, `button` from `f.button` / `button_tag` (sent as `button=` when the `<button>` has no value). All eight are exempt at the top level. So is the click point of an image submit: `image_submit_tag("save.png")` renders an `<input type="image">` with no name, and a browser submitting through it posts where it was clicked as `x=12&y=7` (or `save.x=12&save.y=7` when the tag is named `"save"`, which Rack keeps as those literal keys). `x` and `y` are ordinary names a contract may really be sent, so they are exempt only as a pair, both present and both plain digits (never negative or fractional); a lone `x`, or a pair that is not digits, is flagged as usual, and a contract that *declares* `x` or `y` has it validated as usual and sees the other flagged unless it declares that too. A bracketed image-submit name is not covered (`name: "user[save]"` is parsed by Rack as `{"user"=>{"save"=>{".x"=>..}}}`, nested rather than top level), just as only the top-level `commit` and `button` are. So are the route's **path parameters** (`PATCH /users/1` merges `id`, which the exported OpenAPI documents as a path parameter rather than a body field); a contract that *declares* `id` has it validated as usual, since the URL really carried it. **ParamsWrapper's copy of a JSON body** under the controller's wrapper key (`user` for `UsersController`) goes further: when Rails made that copy, a rootless contract does not see the key at all, because the client never sent it. So an undeclared wrapper key is not flagged, and a scalar or array field that happens to share the wrapper's name (`optional :feedback, :string` on `FeedbackController`) is simply absent, rather than failing as `invalid_type` against Rails' copy of the whole body. The one exception is a rootless contract that declares the wrapper key as a hash container — a nested block (`required :user do ... end`) or `:json`. That contract is reading the copy on purpose, like a `root:` spelled as a field, so the copy is kept and validated as that field. A client that sends `user` itself is checked like any other key: validated if declared, flagged if not. That holds whether the wrapper name is configured as a String or as a Symbol (`wrap_parameters :user`). Either way `unknown: :error` flags what the *client* got wrong rather than what the framework added. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there — and a standalone `Contract` exempts nothing at all, having neither a router, a form, nor a request.
|
|
578
908
|
|
|
579
909
|
All of this changes what is *checked* only. Monitor mode still hands back the form keys, the path parameters and the wrapper's copy in its raw pass-through, where behaving exactly like the pre-contract app is the whole promise and a legacy action may read `params[:id]` or `_method` itself; only the router's three are dropped there.
|
|
580
910
|
|
|
911
|
+
**Rails' `date_select`, `datetime_select` and `time_select`** submit one value as several keys, each named for its position: `starts_on(1i)` is the year, `(2i)` the month, `(3i)` the day, `(4i)` the hour, `(5i)` the minute and `(6i)` the second. A `:date` or `:datetime` field reads these parts as its own value whenever its own key is absent. `permitted_params` carries `"starts_on" => Date`, never the parts, and `unknown:` does not report the parts it read. The parts are assembled into the ISO 8601 string the field already accepts (`"2026-10-17"`, or `"2026-10-17T09:30:00"` with seconds `00` unless `(6i)` is sent), and that string goes through the field's cast and rules like any other value. A `:date` or `:datetime` needs `(1i)`–`(3i)`; a `:datetime`'s `(4i)`, `(5i)` and `(6i)` are optional. As in ActiveRecord, the field reads as if it were sent empty (absent, or an explicit null when it is `nullable:`) when any one of `(1i)`–`(3i)` is blank, which is what a select left on its `prompt:` or `include_blank:` sends. It is any one, not all of them, because Rails sends a part it discards through a hidden input: `date_select(:event, :on, discard_day: true, prompt: true)` always sends `on(3i)=1`, so a month and year picker nobody touched arrives as `""`, `""`, `"1"` and is still no date. Once the date is there, a blank or missing `(4i)`, `(5i)` or `(6i)` reads as `00`, as ActiveModel reads it, so `date_select` on a `:datetime` field (which sends the date alone) and `datetime_select(discard_hour: true)` give midnight, as they do in ActiveRecord: a `time_select` left on its prompts for an empty value sends the hidden date `1`, `1`, `1`, and gives `0001-01-01T00:00:00`, as it does in ActiveRecord. A part is a String of digits or a non-negative Integer (a JSON body cannot send the String a form does). Any other part, a missing `(1i)`–`(3i)` that has no blank part beside it (a blank part among those sent reads the field as sent empty first, whichever of the others is missing, as it does in ActiveRecord) or a part that is not plain digits (`"Oct"`, `"-1"`, `"1.5"`, a Float, a nested hash or array), is `invalid_type` on the field itself (`event.starts_on`). `time_select`'s `ignore_date: true` is not supported: it sends only `(4i)` and `(5i)`, a `:datetime` needs a date, and the field is `invalid_type`. If a request sends both `starts_on` and its parts, `starts_on` wins the value and the parts are left alone: they are ordinary undeclared keys, so `unknown: :error` flags them (`event.starts_on(1i)`), `:log` logs them and `:ignore` drops them. They are not hidden from `unknown:`, because a legacy action that still calls `permit(:starts_on)` hands them to ActiveRecord, which assigns them after the plain key. Only a declared `:date` or `:datetime` field reads parts (a `:date` has no `(4i)`); for any other name, they are ordinary undeclared keys. When monitor mode passes a rejected request through raw, the parts come back as they were sent.
|
|
912
|
+
|
|
913
|
+
The parts carry no time zone, so a datetime read from them is UTC, like any zoneless string ([see above](#types-and-strict-coercion)). ActiveRecord reads the same parts in `Time.zone`. So in an app with `config.time_zone = "Tokyo"`, a form showing 09:30 gives the contract 09:30 UTC, where strong parameters would have stored 00:30 UTC. Such an app can read the wall-clock time in its own zone with `transform: ->(t) { Time.zone.local(t.year, t.month, t.day, t.hour, t.min, t.sec) }`. Use that only where clients send zoneless times: a string with an explicit offset has already been converted to UTC, and the transform would shift it again.
|
|
914
|
+
|
|
581
915
|
### Reusing fields (`Permittable.fields` and `use`)
|
|
582
916
|
|
|
583
917
|
A growing API produces two kinds of duplication: the `address` block three controllers want, and the `update` contract that is the `create` contract with nothing mandatory. A **field group** is a reusable field list — the same frozen data a contract's fields are, without the contract around them.
|
|
@@ -604,18 +938,20 @@ class UsersController < ApplicationController
|
|
|
604
938
|
use UserFields
|
|
605
939
|
end
|
|
606
940
|
|
|
607
|
-
# PATCH: the same fields, nothing mandatory.
|
|
941
|
+
# PATCH: the same fields, nothing mandatory, no defaults.
|
|
608
942
|
permit_params :update, root: :user, model: User do
|
|
609
943
|
use UserFields, optional: true
|
|
610
944
|
end
|
|
611
945
|
end
|
|
612
946
|
```
|
|
613
947
|
|
|
948
|
+
The defaults go because a default is filled in for every request that omits the field. On a `PATCH` that would overwrite the column the client left alone: with `plan`'s `default: "free"` kept, `PATCH {"user": {"name": "Jo"}}` would hand the action `{"name" => "Jo", "plan" => "free"}` and downgrade every `"pro"` user who renamed themself. The exported schema of the update rule leaves them out too.
|
|
949
|
+
|
|
614
950
|
`use` splices the group in **at the point of use**, in the group's own order, exactly as if the fields had been typed there — so the request-time behaviour, the [drift guard](#the-schema-drift-guard), `sensitive:` registration and the [exported schema](#exporting-openapi-docs-that-cannot-drift) are all identical to the inline spelling. It works at the top level of a contract, inside a nested or array block, and inside another group.
|
|
615
951
|
|
|
616
952
|
| Option | Meaning |
|
|
617
953
|
|---|---|
|
|
618
|
-
| `optional: true` | Relax every spliced field. **Top level only** — if a client sends an `address` at all, the address's own required sub-fields still hold. Types
|
|
954
|
+
| `optional: true` | Relax every spliced field: nothing is mandatory, and no `default:` is filled in. **Top level only** — if a client sends an `address` at all, the address's own required sub-fields and defaults still hold. Types and bounds are untouched, so `use UserFields, optional: true` is a complete `PATCH` contract |
|
|
619
955
|
| `only:` / `except:` | Select a subset, in the group's own order. Mutually exclusive |
|
|
620
956
|
|
|
621
957
|
Because a group is built by the same builder a contract is, **every declaration is validated when the group is defined** — a typo fails once, at the group, instead of at each contract that uses it. Two more things fail at class load rather than silently: `only:`/`except:` naming a field the group doesn't declare (so a typo can't quietly drop a field), and a field declared twice. That last one makes overriding deliberate:
|
|
@@ -627,7 +963,7 @@ permit_params :create do
|
|
|
627
963
|
end
|
|
628
964
|
```
|
|
629
965
|
|
|
630
|
-
A group is deliberately **not** a contract: it has no `root:`, `unknown:`, `model:` or `mode:` — those describe the request being validated, not a set of fields — and `finalize` is rejected for the same reason. A [standalone `Contract`](#standalone-contracts-no-controller) does answer `#fields`, though, so `use SomeContract` lets a webhook payload and a controller action share one
|
|
966
|
+
A group is deliberately **not** a contract: it has no `root:`, `unknown:`, `model:` or `mode:` — those describe the request being validated, not a set of fields — and `finalize` is rejected for the same reason. A [standalone `Contract`](#standalone-contracts-no-controller) does answer `#fields`, though, so `use SomeContract` lets a webhook payload and a controller action share one set of fields instead of two that drift. Only the fields: `use` cannot carry a contract's `finalize`, so using a contract that declares one raises at class load rather than silently dropping its cross-field rule. To share both, declare the fields in a `Permittable.fields` group that the contract and the controller each `use`, and declare `finalize` in each; passing one proc to both as `finalize(&EndsAfterStart)` keeps the rule itself in one place.
|
|
631
967
|
|
|
632
968
|
### Output reshaping (`transform:` and `finalize`)
|
|
633
969
|
|
|
@@ -708,6 +1044,8 @@ When enabled it compares **groups**, not exact types, so it fires on a genuine c
|
|
|
708
1044
|
|
|
709
1045
|
Any column type **not** in that table — `json`, `jsonb`, `binary`, an adapter's own `inet` or `money` — is never checked. A contract has no faithful type for those, so whatever you improvised is left alone rather than guessed about.
|
|
710
1046
|
|
|
1047
|
+
**A PostgreSQL array column is checked for its shape.** Rails reports `t.string :tags, array: true` as a `string` column, so a scalar `optional :tags, :string` sits in the right group and would pass — yet it rejects as `invalid_type` every array the column stores. A scalar or `:json` declaration over an array column fails at class load and names the fix, `array :tags, of: :string`.
|
|
1048
|
+
|
|
711
1049
|
**A Rails `enum` is compared by what clients send, not what the column stores.** An enum is submitted by name — `status: "shipped"` — so `optional :status, :string, in: Order.statuses.keys` is the right contract for an integer-backed enum, and a text declaration on any attribute in the model's `defined_enums` is not held to the column's group. It **must** carry that `in:`, though: assignment raises `ArgumentError` for a value the enum does not map, so without one `status: "bogus"` would pass the contract and become a 500 in the action. A text declaration with no `in:`, or with an `in:` listing anything the enum would refuse, fails at class load:
|
|
712
1050
|
|
|
713
1051
|
```
|
|
@@ -716,7 +1054,7 @@ A value outside the enum would pass the contract and then raise on assignment.
|
|
|
716
1054
|
Declare it with in: Order.statuses.keys.
|
|
717
1055
|
```
|
|
718
1056
|
|
|
719
|
-
The `in:` may list names and, for a string-backed enum, stored values, since assignment accepts both. It must be a list: a Range is refused because it cannot be checked. String-backed enums follow the same rule. Other declarations are still held to the column's own group: `:integer` on an integer-backed enum passes, and `:datetime` fails with a suggestion of the enum contract rather than `virtual: true`.
|
|
1057
|
+
The `in:` may list names and, for a string-backed enum, stored values, since assignment accepts both. An integer-backed enum's stored integers (`"1"`) are accepted only beside a `transform:` that maps them back to names, which is the recipe `permittable:generate`'s TODO spells out. It must be a list: a Range is refused because it cannot be checked. String-backed enums follow the same rule. Other declarations are still held to the column's own group: `:integer` on an integer-backed enum passes, and `:datetime` fails with a suggestion of the enum contract rather than `virtual: true`.
|
|
720
1058
|
|
|
721
1059
|
The attribute API is **not** treated the same way, by choice. `attribute :starts_at, :datetime` over a string column is still compared against the string column. An enum's mapping says exactly which strings are valid, so the exemption can demand a matching `in:`. An attribute override gives the guard nothing comparable to check the contract against, so exempting it would only switch the check off for that field. Declare such a field to match its column, or leave the check off.
|
|
722
1060
|
|
|
@@ -755,7 +1093,12 @@ Two mechanisms, because neither covers the ground alone.
|
|
|
755
1093
|
|
|
756
1094
|
A **single proc appended once at boot, consulting a live registry at filter time**, is what reaches consumers that snapshot `config.filter_parameters` at boot — ActiveRecord's `filter_attributes` copy, lograge-style initializers — so a field registered when a controller loads later (lazy loading in development) is still redacted there. The initializer runs before `active_record.set_filter_attributes`, so values are redacted from both request logs and `#inspect`.
|
|
757
1095
|
|
|
758
|
-
But a proc filter can only redact **String** values: ActiveSupport dups the value and expects in-place mutation, and for a `Hash` value it never calls the proc at all, recursing into it instead. So each name is **also registered as a name** in `config.filter_parameters`, which redacts a value of any type — an `:integer` field, or a whole sensitive nested block. Appending later still works: Rails' `precompile_filter_parameters` replaces that array *in place*, and `ActionDispatch` reads the same object on every request, so a name registered at class-load time is seen by the next request. (This is also why the mechanism is a name and not a live matcher object: precompilation joins patterns by source, which discards anything whose matching is decided at filter time.)
|
|
1096
|
+
But a proc filter can only redact **String** values: ActiveSupport dups the value and expects in-place mutation, and for a `Hash` value it never calls the proc at all, recursing into it instead. So each name is **also registered as a name** in `config.filter_parameters`, which redacts a value of any type — an `:integer` field, or a whole sensitive nested block. Appending later still works: Rails' `precompile_filter_parameters` replaces that array *in place*, and `ActionDispatch` reads the same object on every request, so a name registered at class-load time is seen by the next request. ActiveRecord is the exception that has to be fed by hand: it copies `config.filter_parameters` into `filter_attributes` once, when `ActiveRecord::Base` loads, which in a real app is before any controller registers its names (the first controller's own `model: Person` loads it). So each name is also appended to `ActiveRecord::Base.filter_attributes` directly, which is what keeps an `:integer` sensitive value out of the SQL bind log (`[["pin_code", 4321]]`), where the proc alone redacts only Strings. (Rails 6.1 does not filter bind values at all.) Every registration also restores any registered name that is missing from that list, so a name lost to an app *assigning* `filter_attributes` is back as soon as the next contract loads or reloads. (This is also why the mechanism is a name and not a live matcher object: precompilation joins patterns by source, which discards anything whose matching is decided at filter time.)
|
|
1097
|
+
|
|
1098
|
+
Two things for an app that logs SQL, because `filter_attributes` is ActiveRecord's list and the gem only feeds it parameter names:
|
|
1099
|
+
|
|
1100
|
+
- **Append to `filter_attributes` (`+=`), never assign it.** `ActiveRecord::Base.filter_attributes = [:password]` in an initializer or a `to_prepare` block discards every name fed in so far. The gem puts them back the next time a contract registers a name (on a code reload, or when the next controller loads), but until then, and for a name that is never registered again, the bind log and `#inspect` show the value.
|
|
1101
|
+
- **A sensitive parameter name is matched against *attribute* names, by Rails' substring rule, so a column the name is not a substring of is not covered.** `optional :pin_alias, :integer, sensitive: true` guards a column called `pin_alias`, but not `alias_attribute :pin_alias, :secret_pin`, whose column is `secret_pin`; nor does `sensitive: true` on a param `pin_code` cover a virtual `attribute :pin` (`"pin"` does not contain `"pin_code"`). Add that attribute name to `filter_parameters` yourself (`config.filter_parameters += [:secret_pin, :pin]` in `config/application.rb`, which ActiveRecord copies when it loads).
|
|
759
1102
|
|
|
760
1103
|
Matching mirrors Rails' own symbol-filter semantics: case-insensitive substring match on the parameter key. The registry is fully duck-typed (`#add`, `#include?`, `#to_proc`, `#names`, `#reset!`) and swappable via `Permittable.filter_parameter_registry=`, so a host gem can pool registrations into its own. `#to_proc` must return a callable of arity 2 (`key, value`) or 3 (`key, value, original_params`), matching what Rails' own parameter filtering accepts; anything that does not respond to `#to_proc` is refused at the point of the swap rather than on the next request.
|
|
761
1104
|
|
|
@@ -819,6 +1162,8 @@ On a violating request in monitor mode:
|
|
|
819
1162
|
- `permitted_params` returns the **raw pass-through**: exactly what the client sent, untouched — no casts, no defaults, no transforms. A missing `root:` passes an empty hash; a rootless contract drops only Rails' routing keys.
|
|
820
1163
|
- `permittable_violations` returns the recorded details (`[]` when the request was clean), if the action wants to branch on or tag the traffic.
|
|
821
1164
|
|
|
1165
|
+
The before-action check that monitors actions which never call `permitted_params` keeps that promise when it cannot check at all. A body Rails cannot parse, or an exception from the contract's own code (`validate:`, `transform:`, `finalize`), is logged as `[monitor] #create could not be checked: <exception class>` and the action runs. An action that then reads `permitted_params` meets the same exception itself, as it would without a contract.
|
|
1166
|
+
|
|
822
1167
|
Monitor-mode rules validate **eagerly in the `before_action`, regardless of `enforce:`** — telemetry must not depend on the action calling `permitted_params`, since legacy actions still reading `params` directly are exactly the ones worth monitoring. (On a plain-Ruby host without `before_action`, validation stays lazy.)
|
|
823
1168
|
|
|
824
1169
|
[Exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) marks operations whose rule declares `mode: :monitor` with `x-permittable-mode: "monitor"` — the docs shouldn't promise a 422 the server doesn't yet send. Only the per-rule declaration is exported: the global `Permittable.mode` is runtime configuration, not contract data.
|
|
@@ -832,7 +1177,9 @@ bin/rails permittable:generate # every controller without a
|
|
|
832
1177
|
bin/rails "permittable:generate[UsersController]" # one controller, even if covered
|
|
833
1178
|
```
|
|
834
1179
|
|
|
835
|
-
The sweep skips what the audit [ignores](#ignoring-controllers-and-actions): controllers whose source is outside `Rails.root` (so ActiveStorage's direct uploads controller is not drafted), and anything in `Permittable.audit_ignore`. A `controller#action` entry leaves only that action out of the controller's draft.
|
|
1180
|
+
The sweep skips what the audit [ignores](#ignoring-controllers-and-actions): controllers whose source is outside `Rails.root` (so ActiveStorage's direct uploads controller is not drafted), and anything in `Permittable.audit_ignore`. A `controller#action` entry leaves only that action out of the controller's draft.
|
|
1181
|
+
|
|
1182
|
+
A draft covers `:create` and `:update`, and only the ones the controller defines, read the way `permittable:audit` reads them (an `action_missing` handler or a template answers an action too): a `SessionsController` with `create` and `destroy` gets a `:create` rule and no `:update`, which would guard nothing and which the audit would list as stale. A controller that defines neither, like `ApplicationController`, is not drafted at all — a rule there would be inherited by every controller below it. Naming a controller drafts it regardless, the same way it drafts a covered one, and leaves out an action it does not define in the same way; only a controller that defines neither is drafted with both.
|
|
836
1183
|
|
|
837
1184
|
For each controller the task infers the model from `controller_name` (columns give types, NOT NULL gives `required`), scans the controller source for `params.require(...).permit(...)` and `params.expect(...)` calls (permitted keys give the field list and the `root:`), and prints a paste-ready draft:
|
|
838
1185
|
|
|
@@ -842,7 +1189,7 @@ For each controller the task infers the model from `controller_name` (columns gi
|
|
|
842
1189
|
permit_params :create, root: :user, model: User, mode: :monitor do
|
|
843
1190
|
required :name, :string
|
|
844
1191
|
optional :age, :integer
|
|
845
|
-
optional :status, :string, in: User.statuses.keys # database default: "active"; TODO: Rails also assigns the stored integers (status: 1) — if API clients send them, add User.statuses.values.map(&:to_s) to in: and map them back to keys with transform:
|
|
1192
|
+
optional :status, :string, in: User.statuses.keys # database default: "active"; TODO: Rails also assigns the stored integers (status: 1) — if API clients send them, add User.statuses.values.map(&:to_s) to in: and map them back to keys with transform: ->(v) { User.statuses.key(Integer(v, exception: false)) || v }
|
|
846
1193
|
optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
|
|
847
1194
|
array :tag_names, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded
|
|
848
1195
|
end
|
|
@@ -851,7 +1198,7 @@ end
|
|
|
851
1198
|
permit_params :update, root: :user, model: User, mode: :monitor do
|
|
852
1199
|
optional :name, :string
|
|
853
1200
|
optional :age, :integer
|
|
854
|
-
optional :status, :string, in: User.statuses.keys # database default: "active"; TODO: Rails also assigns the stored integers (status: 1) — if API clients send them, add User.statuses.values.map(&:to_s) to in: and map them back to keys with transform:
|
|
1201
|
+
optional :status, :string, in: User.statuses.keys # database default: "active"; TODO: Rails also assigns the stored integers (status: 1) — if API clients send them, add User.statuses.values.map(&:to_s) to in: and map them back to keys with transform: ->(v) { User.statuses.key(Integer(v, exception: false)) || v }
|
|
855
1202
|
optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
|
|
856
1203
|
array :tag_names, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded
|
|
857
1204
|
end
|
|
@@ -862,16 +1209,19 @@ A scanned draft keeps the root its permit call names (a rootless `params.permit(
|
|
|
862
1209
|
The generator's one rule is **draft, don't guess** — everything it cannot know for sure stays visible instead of silently decided:
|
|
863
1210
|
|
|
864
1211
|
- Drafts come out in **monitor mode**, so pasting one changes nothing until you flip it.
|
|
865
|
-
- A permitted key that isn't a column becomes `virtual: true` with a TODO; a column type with no faithful representation (`binary`, geometry types) becomes a TODO comment; a permit argument the conservative parser can't read (`*dynamic_keys`) is kept verbatim in a TODO instead of dropped.
|
|
1212
|
+
- A permitted key that isn't a column becomes `virtual: true` with a TODO; a PostgreSQL array column drafts as `array :tags, of: :string` with a `length:` TODO (an array of `json`/`jsonb` elements, which `of:` cannot express, is a TODO, and so is a `json`/`jsonb` column whose default is an array, since `:json` accepts only an object); a column type with no faithful representation (`binary`, geometry types) becomes a TODO comment; a permit argument the conservative parser can't read (`*dynamic_keys`) is kept verbatim in a TODO instead of dropped.
|
|
1213
|
+
- Drafted from the columns alone, `created_at`/`updated_at` and a primary key the database generates are left out: Rails or the database assigns them. A generated key is an auto-increment column (as the adapter reports it, even as one part of a composite key) or one with a default or default function, such as a uuid. A permit call that **lists** one keeps it, typed from its real column (`optional :created_at, :datetime`) and always `optional`, even though the column is NOT NULL — it is a column like any other, so the schema-drift guard stays on for it. A primary key the client must send is no such column: a natural key (`sku`, from `create_table :items, primary_key: :sku, id: :string`) or a part of a composite key that is not auto-increment is drafted like any other, `required` on `:create` when it is NOT NULL with no default.
|
|
866
1214
|
- **Comments are not code.** A commented-out `params.require(:admin).permit(:superuser)` kept for reference is skipped, so it can't contribute a root or a field to the draft. The source is tokenised with `Ripper` for this, because `#` is only sometimes a comment — a permit call inside `#{'#{...}'}` interpolation is live code and is still read, and quoted keys like `permit("name")` still work.
|
|
1215
|
+
- **Parentheses are optional.** A call is read with or without them (`permit :name, :email`, `expect user: [...]`), to the end of its statement, and a root spelled as a String (`require("user")`) is read like a Symbol.
|
|
867
1216
|
- NOT NULL is only true of a **create**. When a column makes a field `required`, the draft splits into a `:create` rule and an `:update` rule with every field optional, so a PATCH carrying only the edited field is not rejected for what it left out. With nothing required it stays one `:create, :update` rule. A default the **model** declares (`attribute :plan, default: "free"`, `enum ..., default: :pending`) keeps a NOT NULL column optional just as a database default does, and is shown as `# model default:`, written as declared rather than cast through the attribute type — a `Proc` default is named, never called.
|
|
868
1217
|
- A Rails `enum` drafts as the keys a form sends (`:string, in: User.statuses.keys`), not the integer it is stored as — and reads them from the model, so a new enum value cannot leave the contract behind. For an integer-backed enum, Rails also accepts the stored integer (`status: 1`), which JSON clients sometimes send; a TODO on the line says how to admit it.
|
|
869
1218
|
- The STI inheritance column (`type`, when the model actually uses STI) and the optimistic-locking column (`lock_version`, when `lock_optimistically` is on) are **not** drafted from the columns alone: assigning `type` changes the record's class. Each is named in a TODO saying why, so the omission is visible. When the controller's own permit call lists one, it **stays a field**, with a TODO — an edit form that round-trips `lock_version` is how Rails detects a stale update, and dropping it would switch that off the day the draft is enforced.
|
|
870
1219
|
- A database default is noted in a comment but **not** copied into `default:` — a contract default is injected on every request that omits the field, which would overwrite columns on partial updates. The database already handles creation.
|
|
871
|
-
- `key: [:a, :b]` in a permit call drafts as a nested block, with a TODO noting it may be an array of hashes
|
|
1220
|
+
- `key: [:a, :b]` in a permit call drafts as a nested block, with a TODO noting it may be an array of hashes, unless it is a `has_many`'s nested attributes (`addresses_attributes: [...]` on a model whose `addresses` is a collection), which drafts as `array ... do`. In a `params.expect` call the two shapes are distinguishable — `key: [:a]` is a nested hash, `key: [[:a]]` is an array of hashes — so that draft carries no TODO at all.
|
|
1221
|
+
- A **word list** reads as the list it spells, as Rails reads it: `expect(user: %i[name email])` is the `user` envelope with two fields, `address: %w[city zip]` a nested hash, and `permit(%i[name email])` or `permit(*%i[name email])` two scalars (Rails flattens a list among the filters). `%I` and `%W` count, with any delimiter but a parenthesis or a comma. A list among other fields is flattened as Rails flattens it: `expect(user: [%i[name email], :age])` has three fields, and only `[[...]]` as the outer list's one element is an array of hashes. A word list holding anything but plain keys (`%i[first-name]`, an interpolation) is kept verbatim in a TODO, and as an `expect` envelope's value it still roots the draft.
|
|
872
1222
|
- **One contract, one envelope.** A contract has one `root:`, so the generator picks it from every call in the file before it drafts any field:
|
|
873
1223
|
1. When the model is known and any call uses its envelope (`post` for `Post`), that envelope wins outright, even `require(:post).permit(*PERMITTED)`.
|
|
874
|
-
2. With no model known, an envelope beats the rootless calls if it has at least one **parsed** field (`*PERMITTED` counts for nothing). An envelope with no parsed field, such as `expect(search: FILTERS)`, beats only rootless calls with no parsed field either. Among the envelopes, the one with more parsed fields wins. Every envelope of an `expect(post: [...], comment: [...])` call counts, and so does `expect(post: PERMITTED_PARAMS)`.
|
|
1224
|
+
2. With no model known, an envelope beats the rootless calls if it has at least one **parsed** field (`*PERMITTED` counts for nothing). An envelope with no parsed field, such as `expect(search: FILTERS)`, beats only rootless calls with no parsed field either. Among the envelopes, the one with more parsed fields wins. Every envelope of an `expect(post: [...], comment: [...])` call counts, and so does one whose fields cannot be read, such as `expect(post: PERMITTED_PARAMS)` or `expect(post: permitted_keys)`.
|
|
875
1225
|
3. With a model that no envelope matches, the rootless calls, taken together, compete too. An envelope wins a tie with them.
|
|
876
1226
|
|
|
877
1227
|
Remaining ties go to the first call in the source. A single-key `expect` lookup of `:id` or a `*_id` key, like a Rails 8 scaffold's `Post.find(params.expect(:id))`, is a route param. It never scores and is never drafted as a field. A single-key `params.permit(:group_id)` is mass assignment and is drafted as usual. Only the winner's calls become fields, and a key the winner drafts is never also a TODO. Everything else stays visible as a TODO that says why:
|
|
@@ -1017,7 +1367,7 @@ end
|
|
|
1017
1367
|
|
|
1018
1368
|
Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (`in:`, cast by the field's type just as the contract's list is, so `within(%i[draft published])` repeats the declaration as written), `matching` (`format:`), `with_length`, `with_default`, `virtual`, `sensitive`, `nullable`. Dotted paths walk nested blocks and array-of-hash blocks alike (`"line_items.sku"`).
|
|
1019
1369
|
|
|
1020
|
-
The negated form asserts one thing: **the contract does not declare the param**. It therefore takes no qualifiers — `not_to permit_param(:admin).required` would pass both when `:admin` is undeclared and when it is declared optional, a false positive in exactly the kind of assertion that guards a security property, so it raises and names the positive form to write instead (`to permit_param(:admin).for_action(:create).optional`). It needs a rule to check against: when no rule covers the action, it fails and says so rather than passing for any param whatsoever. `for_action` resolves exactly as a request would, though, so a mistyped `for_action(:craete)` is only caught when the controller has no catch-all: a rule declared with no actions (`permit_params { ... }`, including one inherited from a base controller) covers `#craete` too, and the assertion is then checked against that rule. A standalone `Permittable::Contract` covers every action. It also fails where the contract lets a key through without declaring it — a path running into an opaque `:json` field (`"meta.admin"` under `optional :meta, :json`), within the field's `max_depth:` — or where the path repeats the `root:` (`"user.email"` under `root: :user`; paths are relative to the root, so that one is `permit_param(:email)`). Paths may be written in the runtime's own violation form, `"line_items[0].sku"`.
|
|
1370
|
+
The negated form asserts one thing: **the contract does not declare the param**. It therefore takes no qualifiers — `not_to permit_param(:admin).required` would pass both when `:admin` is undeclared and when it is declared optional, a false positive in exactly the kind of assertion that guards a security property, so it raises and names the positive form to write instead (`to permit_param(:admin).for_action(:create).optional`). It needs a rule to check against: when no rule covers the action, it fails and says so rather than passing for any param whatsoever. `for_action` resolves exactly as a request would, though, so a mistyped `for_action(:craete)` is only caught when the controller has no catch-all: a rule declared with no actions (`permit_params { ... }`, including one inherited from a base controller) covers `#craete` too, and the assertion is then checked against that rule. A standalone `Permittable::Contract` covers every action. It also fails where the contract lets a key through without declaring it — a path running into an opaque `:json` field (`"meta.admin"` under `optional :meta, :json`), within the field's `max_depth:` — or where the path repeats the `root:` (`"user.email"` under `root: :user`; paths are relative to the root, so that one is `permit_param(:email)`). Paths may be written in the runtime's own violation form, `"line_items[0].sku"`. An `[n]` must index what the runtime indexes: an array field, once, since an array is one level deep. So `"address[0].zip"` on a nested `address` block fails, and its negated form passes, because the runtime refuses an Array there.
|
|
1021
1371
|
|
|
1022
1372
|
It reads the contract, not the runtime mode. In enforce mode an undeclared key never reaches `permitted_params` — it is dropped from it, or the request is rejected under `unknown: :error` — so "not declared" means "not permitted" for code that reads `permitted_params`. The raw `params` still carries every key, as it always does. A rule in [monitor mode](#monitor-mode-roll-out-without-rejecting) is different: `permitted_params` hands back the raw params, so an undeclared `:admin` comes through it until the rule is switched to enforce. The matcher does not fail for that, because a monitor-mode rule is a rollout stage and the spec describes the contract being rolled out; the enforce switch is what makes the assertion hold at runtime.
|
|
1023
1373
|
|
|
@@ -1036,7 +1386,7 @@ expect(described_class).to reject_params(user: { name: "Jo", email: "nope" })
|
|
|
1036
1386
|
.for_action(:create).with_violation("user.email", :format)
|
|
1037
1387
|
```
|
|
1038
1388
|
|
|
1039
|
-
`returning` asserts the **cast, defaulted, transformed** output — the part `permit_param` can't reach, since it only reads the declaration. `with_violation` is repeatable and its code is optional.
|
|
1389
|
+
`returning` asserts the **cast, defaulted, transformed** output — the part `permit_param` can't reach, since it only reads the declaration. `with_violation` is repeatable and its code is optional. Each belongs to one matcher: `returning` to `accept_params` and `with_violation` to `reject_params`, and chaining either onto the other raises `ArgumentError` rather than being silently ignored.
|
|
1040
1390
|
|
|
1041
1391
|
Failure messages name what actually happened:
|
|
1042
1392
|
|
|
@@ -1048,6 +1398,10 @@ expected UsersController to reject those params with user.age (inclusion), but t
|
|
|
1048
1398
|
|
|
1049
1399
|
Both work on a controller class, a controller instance, or a [standalone `Contract`](#standalone-contracts-no-controller), and both read the **contract** rather than the rollout mode — a [monitor-mode](#monitor-mode-roll-out-without-rejecting) rule still `reject_params`, because the question is what the contract says, not what the deploy currently does with it.
|
|
1050
1400
|
|
|
1401
|
+
The negated forms follow the same rules as `not_to permit_param`: `not_to accept_params(...)` passes only when the contract rejects the payload, and `not_to reject_params(...)` only when it accepts it. Neither takes a qualifier — `not_to accept_params(...).returning(x)` would pass both on a rejection and on an acceptance returning anything else, so it raises and names the positive form — and both fail, rather than passing, when no rule covers the action or the subject declares no contracts.
|
|
1402
|
+
|
|
1403
|
+
The matchers run **without a request**, so they know the controller's settings but not its route. The CSRF parameter name set at class level with `request_forgery_protection_token` is exempt from `unknown: :error` as it is at runtime, but a per-instance override is not seen, and neither are a route's **path parameters**: a payload `id` on a member action under `unknown: :error` is reported `unknown`, although a real `PATCH /users/1` carries it in the URL and is accepted. Leave path parameters out of the payload you pass to `accept_params` / `reject_params`.
|
|
1404
|
+
|
|
1051
1405
|
### Standalone contracts (no controller)
|
|
1052
1406
|
|
|
1053
1407
|
The same DSL, callable on any Hash — webhook payloads, job arguments, service-object inputs, CSV rows:
|
|
@@ -1069,9 +1423,11 @@ CreateUser.json_schema # the contract as JSON Schema (draft 2020
|
|
|
1069
1423
|
CreateUser.rule # the frozen, introspectable rule data
|
|
1070
1424
|
```
|
|
1071
1425
|
|
|
1072
|
-
Everything carries over — strict coercion, `""`/`nil` absence, defaults, `finalize` with `violate!`, `sensitive:` log-redaction registration, `invalid_parameters.permittable` instrumentation, 400-vs-422 status semantics for a missing `root:`.
|
|
1426
|
+
Everything carries over — strict coercion, `""`/`nil` absence, defaults, `finalize` with `violate!`, `sensitive:` log-redaction registration, `invalid_parameters.permittable` instrumentation (with `payload[:controller]` `"Permittable::Contract"` and `payload[:action]` `"call"`), 400-vs-422 status semantics for a missing `root:`.
|
|
1427
|
+
|
|
1428
|
+
**What `#call` still raises.** Client data never raises out of the gem's own checks. Wrong types, non-finite numbers, Strings in any encoding (valid or not) and undeclared keys in any encoding all come back as violations in the `Result`. Two things do raise, on purpose, because neither is the client's mistake. An input that is not a Hash, `nil` (read as `{}`) or an object answering `to_unsafe_h` (such as `ActionController::Parameters`) raises `ArgumentError`. `ActionController::Parameters` nested inside a Hash, as in `CreateUser.call(user: params[:user])`, are converted the same way. And an exception raised by your own code (a `validate:`, `transform:` or `normalize:` proc, or `finalize`) reaches the caller unchanged, since swallowing it would hide a bug. The one exception is a `normalize:` proc that raises `ArgumentError` or an encoding error on a String that is neither UTF-8 nor ASCII-only; that value is left as it is, like a preset's.
|
|
1073
1429
|
|
|
1074
|
-
|
|
1430
|
+
**`unknown: :log` warns here too.** It writes through `Rails.logger` (read on every call, so a logger assigned after the contract's constant loaded is still found) or, with no Rails or no `Rails.logger`, to `$stderr`, so a plain script still hears about undeclared keys rather than having them dropped silently.
|
|
1075
1431
|
|
|
1076
1432
|
Three differences from the controller concern, all deliberate:
|
|
1077
1433
|
|
|
@@ -1088,7 +1444,7 @@ bin/rails permittable:openapi # JSON to stdout
|
|
|
1088
1444
|
bin/rails "permittable:openapi[openapi/api.json]" # write to a file
|
|
1089
1445
|
```
|
|
1090
1446
|
|
|
1091
|
-
The task eager-loads the app (also exercising the drift guard), collects every controller with contracts, and maps documented actions onto `paths` via the route set. Each path and verb is documented under the action Rails dispatches it to, which is the first route that matches. So an explicit `get` before a `via: :all` route on the same path keeps `GET`, and a route behind an earlier, unconstrained one with the same path and verb is not documented under that verb. A constrained route hides nothing ([the audit](#auditing-coverage-permittableaudit) explains why). When two routes then claim one path and verb, the second operation lands under `x-permittable-controllers`. `OPENAPI_TITLE` / `OPENAPI_VERSION` override the `info` block. Pipe the output through Swagger UI, Redoc, Postman, or [`openapi-typescript`](https://github.com/openapi-ts/openapi-typescript) and your frontend gets compile-time types for every request body.
|
|
1447
|
+
The task eager-loads the app (also exercising the drift guard), collects every controller with contracts, and maps documented actions onto `paths` via the route set. Each path and verb is documented under the action Rails dispatches it to, which is the first route that matches. So an explicit `get` before a `via: :all` route on the same path keeps `GET`, and a route behind an earlier, unconstrained one with the same path and verb is not documented under that verb. A constrained route hides nothing ([the audit](#auditing-coverage-permittableaudit) explains why). When two routes then claim one path and verb, the second operation lands under `x-permittable-controllers`. Only OpenAPI's eight verbs are documented under `paths`: a route on another verb Rails accepts (`via: :report`, WebDAV's and CalDAV's) places nothing, since a Path Item has no field for it, and an operation reached only that way lands under `x-permittable-controllers` too. `OPENAPI_TITLE` / `OPENAPI_VERSION` override the `info` block. Pipe the output through Swagger UI, Redoc, Postman, or [`openapi-typescript`](https://github.com/openapi-ts/openapi-typescript) and your frontend gets compile-time types for every request body.
|
|
1092
1448
|
|
|
1093
1449
|
Or build fragments programmatically — no Rails required:
|
|
1094
1450
|
|
|
@@ -1101,9 +1457,9 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
|
|
|
1101
1457
|
|
|
1102
1458
|
Every operation references shared components for the [error envelope](#violations-and-error-responses): a `422` response always, plus a `400` when the contract declares a `root:`. So consumers get typed *errors*, not just typed inputs.
|
|
1103
1459
|
|
|
1104
|
-
**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a construct with no faithful ECMA-262 spelling is exported as `x-permittable-pattern` rather than a mistranslated `pattern`: a Ruby-only escape or flag, one ECMA-262 reads differently or refuses to compile at all (`{,3}`, `&&` in a class, a backreference, the word boundary `\b`), or one that cannot be carried once folded into the rest of its own character class (`\S` beside a member other than `\s`) — but **not** a hyphen right after a completed range (`[a-c-e]`), which both dialects read the same way and export unchanged. This also covers a regexp anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed
|
|
1460
|
+
**What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a construct with no faithful ECMA-262 spelling is exported as `x-permittable-pattern` rather than a mistranslated `pattern`: a Ruby-only escape or flag, one ECMA-262 reads differently or refuses to compile at all (`{,3}`, `&&` in a class, a backreference, the word boundary `\b`), or one that cannot be carried once folded into the rest of its own character class (`\S` beside a member other than `\s`) — but **not** a hyphen right after a completed range (`[a-c-e]`), which both dialects read the same way and export unchanged. This also covers a regexp anchored with `^`/`$`, which in Ruby anchor a **line** and in ECMA-262 anchor the whole string, so `/^\d{5}$/` accepts `"evil\n12345"` at runtime and publishing that source would promise a stricter rule than the server enforces (use `\A`/`\z`, which translate exactly); `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`, and a rule's `finalize` block `x-permittable-finalize` (on the rule's own object, inside the `root:` wrapper when there is one, and on the operation too, so the marker survives an operation that has no request body); actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route — or whose path-and-verb slot another controller already claimed, which one document cannot represent twice, or whose route an earlier one shadows (below) — land in `x-permittable-controllers` instead of being dropped. A templated path segment is declared as a path `parameter` of type `string`, because the route set doesn't say what an `:id` is and the exporter won't invent it. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
|
|
1105
1461
|
|
|
1106
|
-
**Paths are the URLs Rails dispatches.** An optional segment expands into each path it stands for, read the way Rails matches it: present before absent, left to right, constraints included. So `get "x(/:a)(/new)"` documents no `/x/new`, because Rails reads that URL as `a = "new"`, unless a constraint on `:a` rejects `"new"`. OpenAPI forbids two paths that differ only in their variable names, and two routes can produce them: under `scope "(:locale)"` a root is `/{locale}` and a `get ":slug"` is `/{slug}`. Such paths are one path, spelled as the route Rails tries first spells it. A later route under another verb is placed there too, and carries its own spelling in `x-permittable-path`. One under the same verb is shadowed: Rails sends that URL to the earlier route, and reaches the later one only when the earlier route's constraints turn the request away. It is listed in the earlier operation's `x-permittable-shadows` instead of being emitted as a second path, and its operation lands in `x-permittable-controllers` if it has no other path. A variant of one route that a constraint keeps reachable is listed the same way: `x(/:a)(/:b)` with `a: /\d+/` sends `/x/foo` to `:b`, so `/x/{a}` lists `/x/{b}`.
|
|
1462
|
+
**Paths are the URLs Rails dispatches.** An optional segment expands into each path it stands for, read the way Rails matches it: present before absent, left to right, constraints included. So `get "x(/:a)(/new)"` documents no `/x/new`, because Rails reads that URL as `a = "new"`, unless a constraint on `:a` rejects `"new"`. Rails squeezes a run of slashes to one before it matches, so a variant an absent leading group leaves with two in a row is no URL: `get "(:locale)(/:theme)"` documents `/`, `/{locale}` and `/{locale}/{theme}`, never `//{theme}`, and `get "(:locale(/:region))"` documents no `/`, which Rails answers with a 404. OpenAPI forbids two paths that differ only in their variable names, and two routes can produce them: under `scope "(:locale)"` a root is `/{locale}` and a `get ":slug"` is `/{slug}`. Such paths are one path, spelled as the route Rails tries first spells it. A later route under another verb is placed there too, and carries its own spelling in `x-permittable-path`. One under the same verb is shadowed: Rails sends that URL to the earlier route, and reaches the later one only when the earlier route's constraints turn the request away. It is listed in the earlier operation's `x-permittable-shadows` instead of being emitted as a second path, and its operation lands in `x-permittable-controllers` if it has no other path. A variant of one route that a constraint keeps reachable is listed the same way: `x(/:a)(/:b)` with `a: /\d+/` sends `/x/foo` to `:b`, so `/x/{a}` lists `/x/{b}`.
|
|
1107
1463
|
|
|
1108
1464
|
**Every `operationId` is unique across the document, and only a collision is ever renamed.** An operation's id is its controller path with `/` folded to `_`, then its action: `users_create`, `admin_users_index`. Client generators name a method after the id, so the scheme itself never changes. Where two places in the document would carry one id, the exporter renames all but one of them:
|
|
1109
1465
|
|
|
@@ -1142,7 +1498,7 @@ Six cases go the other way, and are worth knowing before you hand the document t
|
|
|
1142
1498
|
- **A Range of non-numbers.** `in: "a".."m"` has no `minimum`/`maximum` equivalent (those constrain numbers only) and rides along as `x-permittable-range` instead. A value outside it still passes the docs.
|
|
1143
1499
|
- **Strings whose validity is a `format`.** `:decimal`, `:date` and `:datetime` are sent as strings, and what makes such a string valid is its `format` (`"decimal"`, `"date"`, `"date-time"`) — which draft 2020-12 treats as an annotation unless a validator opts into asserting it. So `"abc"` or `"NaN"` for a `:decimal` and `"2026-02-30"` for a `:date` pass most validators and fail the server's cast with `invalid_type`.
|
|
1144
1500
|
|
|
1145
|
-
A `format:` regexp that does not translate to ECMA-262 is looser in the same way — it publishes as `x-permittable-pattern` rather than a `pattern` that would enforce something else, so a value it refuses still passes the docs — but it is not one of the six above: `spec/schema_conformance_spec.rb` does not yet assert a case for it, since the Ruby → ECMA-262 translation it would depend on is being reworked separately. Everything else the exporter cannot translate stays visible as an `x-permittable-*` extension rather than being guessed at.
|
|
1501
|
+
A `format:` regexp that does not translate to ECMA-262 is looser in the same way — it publishes as `x-permittable-pattern` rather than a `pattern` that would enforce something else, so a value it refuses still passes the docs — but it is not one of the six above: `spec/schema_conformance_spec.rb` does not yet assert a case for it, since the Ruby → ECMA-262 translation it would depend on is being reworked separately. A `finalize` block is looser too, and has no field to sit on: its `violate!` cross-field checks (`ends_on` before `starts_on`) run only once every field has passed, so a payload each field's schema accepts can still earn a 422. The rule's own object carries `x-permittable-finalize: true` to say the schema alone is not the whole verdict, and so does the operation, so the marker is still there when an operation has no request body to hold the schema. Everything else the exporter cannot translate stays visible as an `x-permittable-*` extension rather than being guessed at.
|
|
1146
1502
|
|
|
1147
1503
|
|
|
1148
1504
|
<details>
|
|
@@ -1152,7 +1508,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
|
|
|
1152
1508
|
|
|
1153
1509
|
| Contract | Emitted schema |
|
|
1154
1510
|
|---|---|
|
|
1155
|
-
| `required` / `optional` | the object's `required:` array; required strings also get `minLength: 1` (`""` is absent) |
|
|
1511
|
+
| `required` / `optional` | the object's `required:` array; required strings, and the string encoding of a required `:decimal`/`:date`/`:datetime`, also get `minLength: 1` (`""` is absent; a `nullable:` `:decimal`/`:date`/`:datetime` reads it as a null, so it gets none) |
|
|
1156
1512
|
| `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` |
|
|
1157
1513
|
| `:date` / `:datetime` | `string` + `format: date` / `date-time` |
|
|
1158
1514
|
| `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
|
|
@@ -1163,7 +1519,8 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
|
|
|
1163
1519
|
| nested block / `array` | `object` + `properties` / `array` + `items` |
|
|
1164
1520
|
| `unknown: :error` | `additionalProperties: false`, at every nesting level |
|
|
1165
1521
|
| `root:` | the wrapping object, itself required |
|
|
1166
|
-
| `
|
|
1522
|
+
| `finalize` | `x-permittable-finalize: true` on the rule's object (inside the `root:` wrapper) and on the operation; the cross-field checks themselves are opaque |
|
|
1523
|
+
| `sensitive: true` | `writeOnly: true` (never echoed in responses); the field's own `default:`/`example:` are omitted rather than published, and so are those of an array that holds it at any depth, since they would carry its value, and so are its `in:` (list or Range) and a hand-written `format:` regexp, which would publish the secret itself. A field that declares either is flagged `x-permittable-custom-validation`, so the schema is looser than the server there; a `format:` preset such as `:email` is public and is still published |
|
|
1167
1524
|
|
|
1168
1525
|
</details>
|
|
1169
1526
|
|
|
@@ -1228,9 +1585,10 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
1228
1585
|
- An unknown `normalize:` or `format:` preset, listing the presets
|
|
1229
1586
|
- A `format:` that is neither a `Regexp` nor a preset name
|
|
1230
1587
|
- `format:`, `length:`, or `normalize:` on a non-`:string` field
|
|
1231
|
-
- `length:` that isn't a non-negative `Integer` or a `Range`; an `in:` that is a `String` (`String#include?` would match any substring — `in: "free pro"` accepted `"e"`), or that is neither a `Range` nor answers `include?`
|
|
1588
|
+
- `length:` that isn't a non-negative `Integer` or a `Range`, or a `Range` with a negative or fractional end (`..-1`, `-2..3`, `1.5..3.5`; a whole-number Float such as `1..10.0` is fine; leave an end off, or use `Float::INFINITY`, for no bound); an `in:` that is a `String` (`String#include?` would match any substring — `in: "free pro"` accepted `"e"`), or that is neither a `Range` nor answers `include?`
|
|
1232
1589
|
- An `in:` member the field's own type can't cast (`in: %w[1 two]` on an `:integer`, `nil` on a field that isn't `nullable:`, or a `Time` on a `:date` field that isn't exactly midnight UTC), or an `in:` `Range` whose endpoints a value of the field's type can't be compared with (`in: "1".."5"` on an `:integer`) — either would reject every request as `inclusion`
|
|
1233
1590
|
- A bound **no value could satisfy**: a reversed or empty `Range` (`in: 65..18`, `length: 5..2`, `length: 3...3`), an empty `in:` set, or a `length:` of 0 on a `required` field (where `""` already violates as `missing`)
|
|
1591
|
+
- A `Time` `in:` member on a `:datetime` field finer than a nanosecond (`Time.at(1.1)`, a third of a second): a request's fraction is a finite decimal and the export prints nine digits, so no request could equal it
|
|
1234
1592
|
- `validate:` or `transform:` that isn't callable
|
|
1235
1593
|
- A `default:` or `example:` that violates its own field's contract, or an array `default:`/`example:` whose elements violate `of:` — or, for an array declared with a **block**, an element that isn't a hash the block would accept
|
|
1236
1594
|
- A `default: nil` or `example: nil` on a field that isn't `nullable:`
|
|
@@ -1261,7 +1619,7 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
1261
1619
|
|
|
1262
1620
|
`actionpack` and `activerecord` are optional because every touchpoint is guarded with `respond_to?`/`defined?` — your app brings whatever it already has. The concern works on a plain Ruby object that responds to `params`, which is what makes it straightforward to unit-test.
|
|
1263
1621
|
|
|
1264
|
-
Both claims are **tested rather than asserted**. CI runs the full suite against every ActiveSupport line in the range — 6.1, 7.0, 7.1, 7.2, 8.0, 8.1 —
|
|
1622
|
+
Both claims are **tested rather than asserted**. CI runs the full suite against every ActiveSupport line in the range — 6.1, 7.0, 7.1, 7.2, 8.0, 8.1 — on every supported Ruby, 3.2 through 4.0 (see [`gemfiles/`](gemfiles/README.md)), with Rails `main` and Ruby `head` as a non-blocking early warning, and a separate job installs the built gem with *nothing but activesupport* and exercises every controller-free surface, so "activesupport is the only runtime dependency" cannot quietly stop being true.
|
|
1265
1623
|
|
|
1266
1624
|
The 6.1 floor isn't arbitrary. `class_attribute ... default:`, which declares the contract registry, arrived in Rails 5.2 — on 5.0 and 5.1 a contract cannot be declared at all — and 5.2/6.0 predate Ruby 3.x support, which this gem's own Ruby floor requires.
|
|
1267
1625
|
|