permittable 0.10.0 → 0.11.1
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 +72 -0
- data/README.md +435 -32
- data/lib/permittable/audit.rb +241 -24
- data/lib/permittable/authored_values.rb +70 -10
- data/lib/permittable/column_guard.rb +31 -11
- data/lib/permittable/contract.rb +50 -1
- data/lib/permittable/error_envelope.rb +60 -6
- data/lib/permittable/generator.rb +335 -66
- data/lib/permittable/json_schema.rb +102 -15
- data/lib/permittable/open_api.rb +470 -81
- data/lib/permittable/railtie.rb +87 -0
- data/lib/permittable/rspec.rb +49 -7
- data/lib/permittable/tasks/generate.rake +11 -6
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +367 -69
- 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)
|
|
@@ -195,7 +197,7 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
|
|
|
195
197
|
- **[Adopting on a live API](#adopting-on-a-live-api)**
|
|
196
198
|
- [Monitor mode](#monitor-mode-roll-out-without-rejecting)
|
|
197
199
|
- [Generating draft contracts](#generating-draft-contracts-permittablegenerate)
|
|
198
|
-
- [Auditing coverage](#auditing-coverage-permittableaudit)
|
|
200
|
+
- [Auditing coverage](#auditing-coverage-permittableaudit) · [Ignoring controllers and actions](#ignoring-controllers-and-actions)
|
|
199
201
|
- **[Beyond the controller](#beyond-the-controller)**
|
|
200
202
|
- [Testing contracts](#testing-contracts-rspec-matchers)
|
|
201
203
|
- [Standalone contracts](#standalone-contracts-no-controller)
|
|
@@ -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 (a plain `Array`, `Set`, `Enumerator`, or `Hash` read as its keys — so `in: Post.statuses` works), or any other object answering `include?` (used as given, and read on every request) — 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 |
|
|
611
|
+
| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`), a list (a plain `Array`, `Set`, `Enumerator`, or `Hash` read as its keys — so `in: Post.statuses` works), 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
|
|
|
@@ -361,7 +676,7 @@ Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`.
|
|
|
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
678
|
| `: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 "` |
|
|
679
|
+
| `:decimal` | `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 `Rational` no decimal holds exactly (`Rational(1, 3)`) |
|
|
365
680
|
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
|
|
366
681
|
| `: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, and **incomplete** ones (`"09/2026"`, `"5th"`, `"Sept"`) |
|
|
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"`) |
|
|
@@ -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. 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,7 +904,7 @@ 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
|
|
|
@@ -716,7 +1046,7 @@ A value outside the enum would pass the contract and then raise on assignment.
|
|
|
716
1046
|
Declare it with in: Order.statuses.keys.
|
|
717
1047
|
```
|
|
718
1048
|
|
|
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`.
|
|
1049
|
+
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
1050
|
|
|
721
1051
|
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
1052
|
|
|
@@ -755,7 +1085,12 @@ Two mechanisms, because neither covers the ground alone.
|
|
|
755
1085
|
|
|
756
1086
|
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
1087
|
|
|
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.)
|
|
1088
|
+
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.)
|
|
1089
|
+
|
|
1090
|
+
Two things for an app that logs SQL, because `filter_attributes` is ActiveRecord's list and the gem only feeds it parameter names:
|
|
1091
|
+
|
|
1092
|
+
- **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.
|
|
1093
|
+
- **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
1094
|
|
|
760
1095
|
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
1096
|
|
|
@@ -819,6 +1154,8 @@ On a violating request in monitor mode:
|
|
|
819
1154
|
- `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
1155
|
- `permittable_violations` returns the recorded details (`[]` when the request was clean), if the action wants to branch on or tag the traffic.
|
|
821
1156
|
|
|
1157
|
+
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.
|
|
1158
|
+
|
|
822
1159
|
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
1160
|
|
|
824
1161
|
[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,6 +1169,10 @@ bin/rails permittable:generate # every controller without a
|
|
|
832
1169
|
bin/rails "permittable:generate[UsersController]" # one controller, even if covered
|
|
833
1170
|
```
|
|
834
1171
|
|
|
1172
|
+
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.
|
|
1173
|
+
|
|
1174
|
+
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.
|
|
1175
|
+
|
|
835
1176
|
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:
|
|
836
1177
|
|
|
837
1178
|
```ruby
|
|
@@ -840,7 +1181,7 @@ For each controller the task infers the model from `controller_name` (columns gi
|
|
|
840
1181
|
permit_params :create, root: :user, model: User, mode: :monitor do
|
|
841
1182
|
required :name, :string
|
|
842
1183
|
optional :age, :integer
|
|
843
|
-
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:
|
|
1184
|
+
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 }
|
|
844
1185
|
optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
|
|
845
1186
|
array :tag_names, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded
|
|
846
1187
|
end
|
|
@@ -849,7 +1190,7 @@ end
|
|
|
849
1190
|
permit_params :update, root: :user, model: User, mode: :monitor do
|
|
850
1191
|
optional :name, :string
|
|
851
1192
|
optional :age, :integer
|
|
852
|
-
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:
|
|
1193
|
+
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 }
|
|
853
1194
|
optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
|
|
854
1195
|
array :tag_names, of: :string # TODO: confirm the element type, and declare length: — an array without one is unbounded
|
|
855
1196
|
end
|
|
@@ -861,15 +1202,18 @@ The generator's one rule is **draft, don't guess** — everything it cannot know
|
|
|
861
1202
|
|
|
862
1203
|
- Drafts come out in **monitor mode**, so pasting one changes nothing until you flip it.
|
|
863
1204
|
- 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.
|
|
1205
|
+
- 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.
|
|
864
1206
|
- **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.
|
|
1207
|
+
- **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.
|
|
865
1208
|
- 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.
|
|
866
1209
|
- 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.
|
|
867
1210
|
- 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.
|
|
868
1211
|
- 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.
|
|
869
|
-
- `key: [:a, :b]` in a permit call drafts as a nested block, with a TODO noting it may be an array of hashes
|
|
1212
|
+
- `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.
|
|
1213
|
+
- 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.
|
|
870
1214
|
- **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:
|
|
871
1215
|
1. When the model is known and any call uses its envelope (`post` for `Post`), that envelope wins outright, even `require(:post).permit(*PERMITTED)`.
|
|
872
|
-
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)`.
|
|
1216
|
+
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)`.
|
|
873
1217
|
3. With a model that no envelope matches, the rootless calls, taken together, compete too. An envelope wins a tie with them.
|
|
874
1218
|
|
|
875
1219
|
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:
|
|
@@ -921,9 +1265,9 @@ Three things it tells you that nothing else does:
|
|
|
921
1265
|
|
|
922
1266
|
- **Which write actions are unguarded.** A `GET` without a contract is usually fine; a `POST` without one is untrusted input reaching the action unchecked. That count is the number `[strict]` fails on, which makes the task a CI gate: *no new unguarded write endpoint*.
|
|
923
1267
|
- **Which contracts aren't enforcing yet.** The audit runs inside the app, so unlike the exported OpenAPI it resolves the **effective** mode — a rule's own `mode:` first, then your app-wide `Permittable.mode`. This is the [monitor-mode](#monitor-mode-roll-out-without-rejecting) rollout dashboard.
|
|
924
|
-
- **Which contracts have gone stale.** A contract declared for an action no route reaches, or for a routed action Rails would 404, is a renamed or deleted action that left its contract behind.
|
|
1268
|
+
- **Which contracts have gone stale.** A contract declared for an action no route reaches, or for a routed action Rails would 404, is a renamed or deleted action that left its contract behind. A contract on a shared base controller is stale only when no controller that inherits it reaches the action, and it is listed once, under the class that declares it.
|
|
925
1269
|
|
|
926
|
-
A route that lists several verbs is audited once per verb. A `match ... via: :all` route is expanded into exactly GET, POST, PUT, PATCH and DELETE, and listed once for each. `resources` routes all seven actions whether or not they exist. A route that Rails would 404 reads `action not found`: no method (inherited ones count), no `action_missing`, and no template to render implicitly. It is not counted against `[strict]` or as coverage. It stays in the table rather than disappearing. The template check uses the class-level view paths and the default lookup details. So a template that is only found at request time reads `action not found`, for example one behind a `prepend_view_path` in a `before_action`, or one that exists only as a variant.
|
|
1270
|
+
A route that lists several verbs is audited once per verb. A `match ... via: :all` route is expanded into exactly GET, POST, PUT, PATCH and DELETE, and listed once for each. Rails sends a request to the first route that matches its path and verb, and the audit lists what Rails dispatches. With `get "hooks", to: "webhooks#index"` before `match "hooks", to: "webhooks#receive", via: :all`, `GET /hooks` is listed under `index` only, and `receive` gets the other four verbs. Turn the two around and the `get` route is never reached, so it is not listed, and a contract on `index` shows as stale. The same rule removes a route declared twice, and the OpenAPI export applies it too. Only an unconstrained route hides the routes behind it, and only on the identical path. A route with a constraint can pass a request on: a segment requirement (`id: /\d+/`), a subdomain or host, or a constraint object or lambda. So the routes after it stay listed, even when the constraint happens to admit every request. With `constraints(AdminConstraint) { post "settings", ... }` ahead of `post "settings", to: "settings#update"`, every non-admin `POST` reaches `settings#update`, so its row stays and `[strict]` still counts it. The audit errs towards an extra row rather than a hidden route. Paths that differ at all, even only in a variable's name (`users/:id` and `users/:slug`), are both listed. `resources` routes all seven actions whether or not they exist. A route that Rails would 404 reads `action not found`: no method (inherited ones count), no `action_missing`, and no template to render implicitly. It is not counted against `[strict]` or as coverage. It stays in the table rather than disappearing. The template check uses the class-level view paths and the default lookup details. So a template that is only found at request time reads `action not found`, for example one behind a `prepend_view_path` in a `before_action`, or one that exists only as a variant.
|
|
927
1271
|
|
|
928
1272
|
A catch-all 404 route (`match "*path", to: "application#not_found", via: :all`) shows its POST, PUT and PATCH rows as accepting a body. They do accept one: every stray body reaches the controller. Route only GET to the controller (Rails answers HEAD from it). Send the other verbs to a plain Rack endpoint, which never parses the body and which the audit does not list:
|
|
929
1273
|
|
|
@@ -934,11 +1278,60 @@ match "*path", to: ->(_env) { [404, { "content-type" => "text/plain" }, ["Not Fo
|
|
|
934
1278
|
|
|
935
1279
|
A `config.exceptions_app = routes` setup (`match "/404", to: "errors#not_found", via: :all`) shows the same rows. It needs only `via: :get`, because on 6.1 and later `ShowExceptions` re-dispatches the error request as a GET.
|
|
936
1280
|
|
|
937
|
-
Under `[strict]` the task aborts on these rows
|
|
1281
|
+
Under `[strict]` the task aborts on these rows. Either route the catch-all GET-only, as above, or add `application#not_found` to the [ignore list](#ignoring-controllers-and-actions). Don't declare a contract on the catch-all to silence the gate. Under monitor mode it validates eagerly, so a malformed JSON POST answers 400 instead of 404. The OpenAPI export would also gain a fake `/{path}` endpoint.
|
|
938
1282
|
|
|
939
1283
|
The table lists a row for every verb on every path; the summary counts routes. A route with an optional segment, such as anything under `scope "(:locale)"`, lists each path it expands to (`/users` and `/{locale}/users`), but it is one route, so one unguarded `POST` counts once and the summary line says `N routed actions in M rows`. Rows are collapsed by controller, action, route index and verb; the index is a position within one `rails_routes` call, so audit concatenated route lists (an app's and an engine's) separately, or give them distinct `route:` values. Two separate routes to the same action (`post "/users"` and `post "/admin/users"`) count as two, because each one is a way in.
|
|
940
1284
|
|
|
941
|
-
Controllers that never included `Permittable` are audited too — those are the ones worth finding. Everything is plain Ruby over the frozen registry plus route descriptors, so `Permittable::Audit.entries(controllers:, routes:)` works without Rails.
|
|
1285
|
+
Controllers that never included `Permittable` are audited too — those are the ones worth finding. Everything is plain Ruby over the frozen registry plus route descriptors, so `Permittable::Audit.entries(controllers:, routes:)` works without Rails. It takes `ignore:` and `root:` too, which default to `Permittable.audit_ignore` and `Rails.root` (see below).
|
|
1286
|
+
|
|
1287
|
+
#### Ignoring controllers and actions
|
|
1288
|
+
|
|
1289
|
+
Some routed endpoints are not yours to guard. Two are common enough to fail almost every `[strict]` run: ActiveStorage's direct uploads controller (a `POST` with no contract), and the catch-all 404 above, whose `via: :all` adds `POST`, `PUT` and `PATCH` rows. List them in an initializer:
|
|
1290
|
+
|
|
1291
|
+
```ruby
|
|
1292
|
+
# config/initializers/permittable.rb
|
|
1293
|
+
Permittable.audit_ignore = [
|
|
1294
|
+
"active_storage/direct_uploads", # every action of the controller
|
|
1295
|
+
"application#not_found" # one action, on every verb it is routed on
|
|
1296
|
+
]
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
An entry is a controller path the way Rails routes it (the controller half of `bin/rails routes`, such as `admin/users`), or `controller#action`. Entries must be strings, checked when you assign the list:
|
|
1300
|
+
|
|
1301
|
+
- A class name raises `ArgumentError` naming the path you meant: `"ActiveStorage::DirectUploadsController"` → did you mean `"active_storage/direct_uploads"`?
|
|
1302
|
+
- A class raises too. Naming one in an initializer would autoload the controller during boot, which Zeitwerk refuses for a reloadable controller.
|
|
1303
|
+
- A `Regexp` raises. A pattern would also ignore controllers added after it, and catching a new unguarded endpoint is the point of the gate.
|
|
1304
|
+
|
|
1305
|
+
Assign the whole list. It is frozen, so add to it with `Permittable.audit_ignore += ["webhooks#receive"]`, which is validated too.
|
|
1306
|
+
|
|
1307
|
+
**Controllers outside `Rails.root` are ignored by default.** These are engines and gems: ActiveStorage, ActionMailbox, Devise, and Rails' own `rails/*` controllers. An app can't keep a contract in a gem's controller. The audit locates a controller by the file that defines its class (`Module#const_source_location`). A gem installed inside the app still counts as a gem; `bundle config path vendor/bundle` does this, and so do CI caches. A controller whose source can't be located is audited. To audit gem controllers like your own:
|
|
1308
|
+
|
|
1309
|
+
```ruby
|
|
1310
|
+
Permittable.audit_ignore_outside_root = false
|
|
1311
|
+
```
|
|
1312
|
+
|
|
1313
|
+
That default already covers ActiveStorage. Naming it, as above, keeps it ignored if you turn the default off. To put a gem's endpoint under the gate, subclass its controller in `app/controllers` (for Devise, `devise_for :users, controllers: { sessions: "users/sessions" }`). The subclass is the app's, so it is audited.
|
|
1314
|
+
|
|
1315
|
+
Ignored endpoints leave the counts and never fail `[strict]`, but they stay in the report. Each reason gets its own section, with one line per action and the verbs it is routed on. An entry that matches no routed action (a typo, or a route since removed) is listed as well. Otherwise a typo would ignore nothing, and nothing would say so. With the initializer above plus a mistyped `"admin/report"`, the end of the report reads:
|
|
1316
|
+
|
|
1317
|
+
```
|
|
1318
|
+
Ignored by Permittable.audit_ignore (left out of the counts above):
|
|
1319
|
+
active_storage/direct_uploads#create POST
|
|
1320
|
+
application#not_found DELETE, GET, PATCH, POST, PUT
|
|
1321
|
+
|
|
1322
|
+
Ignored as outside the app root (set Permittable.audit_ignore_outside_root = false to audit them):
|
|
1323
|
+
active_storage/blobs/redirect#show GET
|
|
1324
|
+
active_storage/disk#show GET
|
|
1325
|
+
active_storage/disk#update PUT
|
|
1326
|
+
rails/health#show GET
|
|
1327
|
+
|
|
1328
|
+
Permittable.audit_ignore entries that match no routed action (a typo, or a route since removed?):
|
|
1329
|
+
admin/report
|
|
1330
|
+
```
|
|
1331
|
+
|
|
1332
|
+
An unmatched entry is reported, not failed: a mistyped `application#not_foud` leaves the catch-all's rows in the count, so `[strict]` fails on those, and this list says why.
|
|
1333
|
+
|
|
1334
|
+
`permittable:generate` honours the same list (see [generating draft contracts](#generating-draft-contracts-permittablegenerate)).
|
|
942
1335
|
|
|
943
1336
|
---
|
|
944
1337
|
|
|
@@ -1018,9 +1411,11 @@ CreateUser.json_schema # the contract as JSON Schema (draft 2020
|
|
|
1018
1411
|
CreateUser.rule # the frozen, introspectable rule data
|
|
1019
1412
|
```
|
|
1020
1413
|
|
|
1021
|
-
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:`.
|
|
1414
|
+
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:`.
|
|
1022
1415
|
|
|
1023
|
-
**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`. 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.
|
|
1416
|
+
**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.
|
|
1417
|
+
|
|
1418
|
+
**`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.
|
|
1024
1419
|
|
|
1025
1420
|
Three differences from the controller concern, all deliberate:
|
|
1026
1421
|
|
|
@@ -1037,7 +1432,7 @@ bin/rails permittable:openapi # JSON to stdout
|
|
|
1037
1432
|
bin/rails "permittable:openapi[openapi/api.json]" # write to a file
|
|
1038
1433
|
```
|
|
1039
1434
|
|
|
1040
|
-
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. `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.
|
|
1435
|
+
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.
|
|
1041
1436
|
|
|
1042
1437
|
Or build fragments programmatically — no Rails required:
|
|
1043
1438
|
|
|
@@ -1050,7 +1445,9 @@ Permittable::OpenAPI.document(controllers: [...], info: { "title" => "My API" })
|
|
|
1050
1445
|
|
|
1051
1446
|
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.
|
|
1052
1447
|
|
|
1053
|
-
**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
|
|
1448
|
+
**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.
|
|
1449
|
+
|
|
1450
|
+
**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}`.
|
|
1054
1451
|
|
|
1055
1452
|
**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:
|
|
1056
1453
|
|
|
@@ -1059,10 +1456,14 @@ Every operation references shared components for the [error envelope](#violation
|
|
|
1059
1456
|
| One operation under two verbs (the separate PATCH and PUT routes `resources` draws to `update`, or one `match ..., via: [:patch, :put]` route) | `users_update` on PATCH, `users_update_put` on PUT |
|
|
1060
1457
|
| The pair again at a second path (`resources :orgs { resources :users }`) | `users_update_2` on the second PATCH, `users_update_3` on the second PUT |
|
|
1061
1458
|
| One operation under every verb (with `via: :all` routes, which the exporter documents under each verb) | `webhooks_receive` on GET, then `webhooks_receive_post`, `_put`, `_patch`, `_delete` |
|
|
1062
|
-
| One operation at two paths under one verb (with the optional-segment expansion: `(/:locale)/posts` is documented at `/posts` and `/{locale}/posts`) | `posts_create` on the
|
|
1063
|
-
| Two controllers that fold to one id (`admin/users` and `admin_users`, both GET) | `admin_users_index` on
|
|
1459
|
+
| One operation at two paths under one verb (with the optional-segment expansion: `(/:locale)/posts` is documented at `/posts` and `/{locale}/posts`) | `posts_create` on the shallower path, `posts_create_2` on the other |
|
|
1460
|
+
| Two controllers that fold to one id (`admin/users` and `admin_users`, both GET) | `admin_users_index` on `admin/users`, whose controller path sorts first, and `admin_users_index_2` on `admin_users` |
|
|
1461
|
+
|
|
1462
|
+
One place keeps the plain id. The exporter ranks the places by what they are, never by the order it finds them in. A routed operation comes before one under `x-permittable-controllers`, which takes part because it is in the same document. Then operations are ranked by controller path, then action, compared as strings, whatever their verbs. Within one operation, the shallowest path comes first (the fewest segments, then alphabetical order), then the verb: GET, POST, PATCH, PUT, DELETE. So PATCH comes before PUT whichever the route lists first, and `match via: [:put, :patch]` and `resources` name the PATCH method the same way. Every other place gets its verb appended when that verb differs from the plain id's verb and the result is free. Otherwise it gets the next free number in rank order, from `_2`. So a second PATCH is `users_update_2`, not `users_update_patch`, and a second POST is `posts_create_2`. A route declared twice is placed once, and a controller passed twice is documented once, so neither collides with itself.
|
|
1463
|
+
|
|
1464
|
+
**The stability rules.** An id that only one operation would carry never changes, even when a suffix elsewhere would spell it; that suffix is numbered instead. Reordering `routes.rb`, or a controller that loads earlier, renames nothing, because the rank never reads that order. (The exception is two routes that answer the same path and verb. Their order decides which one Rails dispatches to, and the export follows Rails. When the first of them is constrained, both are kept, controller order picks which operation takes the slot, and the other lands under `x-permittable-controllers`.)
|
|
1064
1465
|
|
|
1065
|
-
|
|
1466
|
+
**What can still rename a generated method: a change to a colliding group.** No scheme can keep every id when a new collider arrives, because one of them must take a suffix. A new route, action or controller whose id collides gets a suffix. If it ranks first, it takes the plain id from the place that had it instead. Every numbered place ranked after it moves along by one. Removing or renaming a collider moves the others the same way. Only the members of the colliding group can be renamed, never an id that stands alone. If a client must never change, remove the collision: give the operations distinct controller paths or action names.
|
|
1066
1467
|
|
|
1067
1468
|
Output is deterministic (fixed key order, declaration-order properties), so the generated file can be committed and reviewed as a diff — a contract change shows up in the same PR as its documentation change.
|
|
1068
1469
|
|
|
@@ -1085,7 +1486,7 @@ Six cases go the other way, and are worth knowing before you hand the document t
|
|
|
1085
1486
|
- **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.
|
|
1086
1487
|
- **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`.
|
|
1087
1488
|
|
|
1088
|
-
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.
|
|
1489
|
+
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.
|
|
1089
1490
|
|
|
1090
1491
|
|
|
1091
1492
|
<details>
|
|
@@ -1095,7 +1496,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
|
|
|
1095
1496
|
|
|
1096
1497
|
| Contract | Emitted schema |
|
|
1097
1498
|
|---|---|
|
|
1098
|
-
| `required` / `optional` | the object's `required:` array; required strings also get `minLength: 1` (`""` is absent) |
|
|
1499
|
+
| `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) |
|
|
1099
1500
|
| `:string` `:integer` `:float` `:boolean` | `string` / `integer` / `number` / `boolean` |
|
|
1100
1501
|
| `:date` / `:datetime` | `string` + `format: date` / `date-time` |
|
|
1101
1502
|
| `:decimal` | `type: ["string", "number"]` + `format: decimal` (string is the precision-safe encoding) |
|
|
@@ -1106,7 +1507,7 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
|
|
|
1106
1507
|
| nested block / `array` | `object` + `properties` / `array` + `items` |
|
|
1107
1508
|
| `unknown: :error` | `additionalProperties: false`, at every nesting level |
|
|
1108
1509
|
| `root:` | the wrapping object, itself required |
|
|
1109
|
-
| `sensitive: true` | `writeOnly: true` (never echoed in responses); the field's own `default:`/`example:` are omitted rather than published |
|
|
1510
|
+
| `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 |
|
|
1110
1511
|
|
|
1111
1512
|
</details>
|
|
1112
1513
|
|
|
@@ -1144,12 +1545,14 @@ A `format:` regexp that does not translate to ECMA-262 is looser in the same way
|
|
|
1144
1545
|
| `Permittable.error_format` / `=` | `:envelope` (default) or `:problem` — see [RFC 9457 problem+json](#rfc-9457-problemjson) |
|
|
1145
1546
|
| `Permittable.problem_base_uri` / `=` | Base URI for problem `type` members |
|
|
1146
1547
|
| `Permittable.check_column_types` / `=` | Opt in to the [type half of the drift guard](#checking-types-too-opt-in) (default `false`) |
|
|
1548
|
+
| `Permittable.audit_ignore` / `=` | Controller paths and `controller#action` strings the audit and the generator [leave out](#ignoring-controllers-and-actions) (default `[]`) |
|
|
1549
|
+
| `Permittable.audit_ignore_outside_root` / `=` | Also leave out controllers whose source is outside `Rails.root` (default `true`) |
|
|
1147
1550
|
| `Permittable.fields(&block)` | A reusable [field group](#reusing-fields-permittablefields-and-use) — splice it into a contract with `use` |
|
|
1148
1551
|
| `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
|
|
1149
1552
|
| `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
|
|
1150
1553
|
| `Permittable::OpenAPI` | OpenAPI 3.1 assembly (`.document`, `.operations_for`, `.request_body_for`, `.components`) |
|
|
1151
|
-
| `Permittable::Generator` | Contract drafting (`.draft`, `.for_controller`, `.scan`) — see [generating draft contracts](#generating-draft-contracts-permittablegenerate) |
|
|
1152
|
-
| `Permittable::Audit` | Coverage across the route set (`.entries`, `.summary`, `.stale`, `.format`) — see [auditing coverage](#auditing-coverage-permittableaudit) |
|
|
1554
|
+
| `Permittable::Generator` | Contract drafting (`.draft`, `.for_controller`, `.scan`, `.targets`) — see [generating draft contracts](#generating-draft-contracts-permittablegenerate) |
|
|
1555
|
+
| `Permittable::Audit` | Coverage across the route set (`.entries`, `.summary`, `.stale`, `.format`, `.ignore_reason`, `.unmatched_ignores`) — see [auditing coverage](#auditing-coverage-permittableaudit) |
|
|
1153
1556
|
| `Permittable::Contract` | [Standalone contracts](#standalone-contracts-no-controller) (`.define`, `#call`, `#call!`, `#json_schema`, `#rule`, `#fields`) |
|
|
1154
1557
|
| `Permittable::FieldGroup` | A [reusable field list](#reusing-fields-permittablefields-and-use) (`#fields`, `#names`) — built by `Permittable.fields` |
|
|
1155
1558
|
| `Permittable::Matchers` | RSpec matchers via `require "permittable/rspec"` — `permit_param` for the declaration, `accept_params`/`reject_params` for the behaviour. See [testing contracts](#testing-contracts-rspec-matchers) |
|
|
@@ -1202,7 +1605,7 @@ A bad contract is a programmer error, so it fails when the class loads — never
|
|
|
1202
1605
|
|
|
1203
1606
|
`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.
|
|
1204
1607
|
|
|
1205
|
-
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 —
|
|
1608
|
+
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.
|
|
1206
1609
|
|
|
1207
1610
|
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.
|
|
1208
1611
|
|