permittable 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6009aed769f45203bb8053f4310d4ac986ec941815704e613f63bddf819bf92
4
- data.tar.gz: 721cead2003e010c773e4bab9de20830d85f40182a049b8e4db6fa82d1f377d7
3
+ metadata.gz: 59d816740d54375ea14045959a2afb70dffee709e21350b914d69fcc115655c4
4
+ data.tar.gz: 233c21f86657a7643c5a8de70b83001e8f66b5e0ad3d1ae71e25a79b22883753
5
5
  SHA512:
6
- metadata.gz: 5ff703088a1c1389cc49150521380c56f937d4ba0f99de0137e7390a91fa66a16bdad09092f0c65964b94e332bee170b17bdc6d7e9d03c11a7b2020f95953c78
7
- data.tar.gz: 64b5097368d5df03d8dfacb4c9eb79cf6ecb08e2c0b933a80281201139e496f143f0336d886d4a4d33a15dfc0f2c7642f10ee5641e625874a80526717386d869
6
+ metadata.gz: 249dfeaf789c471b84589e5e49ee70148774fb03a5eaa3906a479088f7da68bc6d3f8a9b8d513e19c9f1fba18a62be1a6d9a68d1390b96c22c906e1ae208f002
7
+ data.tar.gz: a17c6c12cf879384624cd4f1eb654a05fc9cb5e641d821ee99461d459156744eb372bd51ced705588c6af7a5f2f713ee8285fb9f1c38a7eb42f2aaf6667a5867
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.5.0 (2026-09-02)
4
+ <!-- title: the adoption on-ramp -->
5
+
6
+ The adoption on-ramp. Writing the first contract for a legacy controller used to start from a blank page; now the gem drafts it from what the app already knows, and the contract can be asserted on in specs without dispatching a request.
7
+
8
+ ### Added
9
+ - **`Permittable::Generator` and `bin/rails permittable:generate[controller]`** — drafts a `permit_params` contract for every controller that doesn't declare one (or one named controller), from the model's columns (type, NOT NULL, database default) plus any `params.require(...).permit(...)` calls found in the controller source. Drafts are emitted in **monitor mode**, so pasting one changes no behaviour; everything the generator cannot know for sure becomes a `# TODO` comment instead of a guess (non-column keys get `virtual: true`, unmappable column types and unparseable permit arguments stay visible as comments, database defaults are noted but deliberately **not** copied into `default:` — a contract default would overwrite columns on partial updates). Programmatic API (`Generator.draft(model:)`, `Generator.for_controller`, `Generator.scan`) works without Rails.
10
+ - **RSpec matchers (`require "permittable/rspec"`)** — `permit_param(:age).for_action(:create).as(:integer).within(18..120)` asserts on the same frozen rule the validator enforces, so contracts are testable without a request. Chains: `for_action`, `as`, `as_array(of:)`, `required`/`optional`, `within`, `matching`, `with_length`, `with_default`, `virtual`, `sensitive`; dotted paths (`"address.zip"`, `"line_items.sku"`) walk nested and array blocks. Ambiguity fails loudly: `for_action` may be omitted only when the controller declares exactly one contract.
11
+ - **`Permittable::Contract` — standalone contracts, no controller required.** `Contract.define(root: :user) { ... }` takes the identical field DSL and returns a callable object: `#call(hash)` never raises and returns a `Result` (`valid?` / `params` / `violations`); `#call!` returns the validated params or raises `InvalidParameters` with the same 400/422 status semantics a controller sees; `#json_schema` emits the contract as JSON Schema; `#rule` exposes the frozen data. Built for webhook payloads, job arguments, and service objects. Three deliberate differences from the concern: a `Contract` always enforces (the app-wide monitor mode is a request-rollout switch and is ignored), the router bookkeeping keys get no `unknown:` exemption, and nothing is memoized so one frozen contract is reusable everywhere.
12
+ - **I18n fallback for violation messages** — a violation without a field-level `message:` now resolves copy from `permittable.errors.<code>` (covering the built-in codes, Symbol codes from `validate:`, missing `root:` keys, `unknown` keys, and `violate!` codes in `finalize`) before falling back to the bare `{ param:, code: }` shape. Resolution order: field `message:` → I18n → bare. Only String translations count; apps without I18n or without the keys are byte-for-byte unchanged.
13
+ - **`docs/comparison.md`** — an honest comparison against `params.permit`, Rails 8's `params.expect`, rails_param, dry-validation, typed_params, and rswag, including the cases where each of those is the better choice, plus migration costs.
14
+ - **`benchmark/overhead.rb`** — measures a full contract validation against the bare `params.permit` filter it replaces (on the reference payload the contract, casting and validating included, ran ~1.7× faster).
15
+
16
+ All are additive — no behaviour of existing contracts changes.
17
+
3
18
  ## 0.4.0 (2026-08-24)
4
19
  <!-- title: monitor mode -->
5
20
 
data/README.md CHANGED
@@ -54,7 +54,8 @@ A violating request never reaches your action:
54
54
  - [Violations and error responses](#violations-and-error-responses) · [Custom error messages](#custom-error-messages-message) · [Unknown parameters](#unknown-parameters)
55
55
  - [Output reshaping](#output-reshaping-transform-and-finalize) · [The schema-drift guard](#the-schema-drift-guard)
56
56
  - [Sensitive parameters](#sensitive-parameters-and-log-redaction) · [Instrumentation](#instrumentation)
57
- - [Monitor mode](#monitor-mode-roll-out-without-rejecting) · [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
57
+ - [Monitor mode](#monitor-mode-roll-out-without-rejecting) · [Generating draft contracts](#generating-draft-contracts-permittablegenerate) · [Testing contracts](#testing-contracts-rspec-matchers)
58
+ - [Standalone contracts](#standalone-contracts-no-controller) · [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
58
59
  - [API reference](#api-reference) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
59
60
 
60
61
  ---
@@ -73,9 +74,12 @@ A violating request never reaches your action:
73
74
  | Checked against your schema at boot | ❌ | ❌ | ✅ |
74
75
  | Exports OpenAPI / JSON Schema | ❌ | ❌ | ✅ |
75
76
  | Report-only rollout mode | ❌ | ❌ | ✅ |
77
+ | Drafts contracts from your schema | ❌ | ❌ | ✅ |
76
78
 
77
79
  The design rests on one idea: **a contract is data, not code.** It is declared once at the class level, frozen, inheritable, and introspectable. Everything else here follows from that — the drift guard can read it at boot, `finalize` can run on a bare object with no controller state, and the whole contract can be printed or tested without a request.
78
80
 
81
+ A longer, honest comparison — `params.expect`, rails_param, dry-validation, typed_params, rswag, with the cases where each of them is the better choice, plus benchmarks and migration costs — lives in [docs/comparison.md](docs/comparison.md).
82
+
79
83
  ## Installation
80
84
 
81
85
  ```ruby
@@ -289,7 +293,24 @@ The rules:
289
293
  - `violate!` in `finalize` takes the same idea as a keyword: `violate!("user.ends_at", :before_start, message: "must be after starts_at")`.
290
294
  - A `message:` that is neither a String nor a code → String Hash raises at class load, like every other contract mistake.
291
295
 
292
- Fields without a `message:` are untouched — their details keep the bare `{ param:, code: }` shape. For full control over the response body itself (localization, RFC 9457, a different envelope), override `render_invalid_parameters` or define `render_error` as described above; `error.details` gives you the structured violations to build from.
296
+ ### Localizing default messages (I18n)
297
+
298
+ App-wide copy for a violation code — without repeating `message:` on every field — comes from I18n, under `permittable.errors.<code>`:
299
+
300
+ ```yaml
301
+ # config/locales/en.yml
302
+ en:
303
+ permittable:
304
+ errors:
305
+ missing: "is required"
306
+ invalid_type: "is the wrong type"
307
+ inclusion: "is not an allowed value"
308
+ unknown: "is not a recognized parameter"
309
+ ```
310
+
311
+ 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.
312
+
313
+ For full control over the response body itself (RFC 9457, a different envelope), override `render_invalid_parameters` or define `render_error` as described above; `error.details` gives you the structured violations to build from.
293
314
 
294
315
  ## Unknown parameters
295
316
 
@@ -421,13 +442,96 @@ Monitor-mode rules validate **eagerly in the `before_action`, regardless of `enf
421
442
 
422
443
  The rollout recipe:
423
444
 
424
- 1. Write contracts for a legacy controller. The action code stays as-is.
445
+ 1. Write contracts for a legacy controller — or let [`permittable:generate`](#generating-draft-contracts-permittablegenerate) draft them. The action code stays as-is.
425
446
  2. Deploy with `PERMITTABLE_MODE=monitor`. Behaviour is unchanged; telemetry starts.
426
447
  3. Watch the dashboard. Every entry is a real client that would have been rejected — fix the contract, or wait for that traffic to drain.
427
448
  4. Flip to enforce, controller by controller. Every 422 you now return is one you already counted.
428
449
 
429
450
  [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.
430
451
 
452
+ ## Generating draft contracts (`permittable:generate`)
453
+
454
+ The blank-page problem, solved: the first draft of every contract can be generated from what the app already knows — the model's columns, and the `params.permit` calls already sitting in the controller.
455
+
456
+ ```sh
457
+ bin/rails permittable:generate # every controller without a contract
458
+ bin/rails "permittable:generate[UsersController]" # one controller, even if covered
459
+ ```
460
+
461
+ 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(...)` calls (permitted keys give the field list and the `root:`), and prints a paste-ready draft:
462
+
463
+ ```ruby
464
+ # Drafted by permittable:generate — review the TODOs, then deploy: monitor
465
+ # mode reports violations (instrumentation + log) without rejecting requests.
466
+ permit_params :create, :update, root: :user, model: User, mode: :monitor do
467
+ required :name, :string
468
+ optional :age, :integer
469
+ optional :status, :string # database default: "active"
470
+ optional :password_confirmation, :string, virtual: true # TODO: not a database column — confirm the type
471
+ array :tag_names, of: :string # TODO: confirm the element type
472
+ end
473
+ ```
474
+
475
+ The generator's one rule is **draft, don't guess** — everything it cannot know for sure stays visible instead of silently decided:
476
+
477
+ - Drafts come out in **monitor mode**, so pasting one changes nothing until you flip it.
478
+ - A permitted key that isn't a column becomes `virtual: true` with a TODO; a column type with no scalar equivalent (`json`, `binary`) becomes a TODO comment; a permit argument the conservative parser can't read (`*dynamic_keys`) is kept verbatim in a TODO instead of dropped.
479
+ - 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.
480
+ - `key: [:a, :b]` in a permit call drafts as a nested block, with a TODO noting it may be an array of hashes.
481
+
482
+ No Rails required for the core: `Permittable::Generator.draft(model: User)`, `.for_controller(controller, source: File.read(path))`, and `.scan(source)` are plain Ruby.
483
+
484
+ Together with [monitor mode](#monitor-mode-roll-out-without-rejecting) this makes the whole adoption path one afternoon: generate drafts, paste, deploy monitoring, watch the dashboard, flip to enforce.
485
+
486
+ ## Testing contracts (RSpec matchers)
487
+
488
+ Because a contract is data, it can be specified without dispatching a request. `require "permittable/rspec"` (in `spec_helper.rb`) auto-includes the matchers:
489
+
490
+ ```ruby
491
+ RSpec.describe UsersController do
492
+ it "declares the create contract" do
493
+ expect(described_class).to permit_param(:email)
494
+ .for_action(:create).as(:string).matching(URI::MailTo::EMAIL_REGEXP).required
495
+ expect(described_class).to permit_param(:age).for_action(:create).as(:integer).within(18..120)
496
+ expect(described_class).to permit_param(:plan).for_action(:create).with_default("free")
497
+ expect(described_class).to permit_param(:tag_names).for_action(:create).as_array(of: :string)
498
+ expect(described_class).to permit_param("address.zip").for_action(:create).as(:string).optional
499
+ expect(described_class).not_to permit_param(:admin).for_action(:create)
500
+ end
501
+ end
502
+ ```
503
+
504
+ Chains: `for_action`, `as`, `as_array(of:)`, `required` / `optional`, `within` (`in:`), `matching` (`format:`), `with_length`, `with_default`, `virtual`, `sensitive`. Dotted paths walk nested blocks and array-of-hash blocks alike (`"line_items.sku"`).
505
+
506
+ `for_action` picks the rule exactly like a request would (`permit_rule_for`), and may be omitted only when the controller declares a single contract — an ambiguous expectation raises instead of silently checking the wrong rule. Failure messages name what the contract actually declares.
507
+
508
+ ## Standalone contracts (no controller)
509
+
510
+ The same DSL, callable on any Hash — webhook payloads, job arguments, service-object inputs, CSV rows:
511
+
512
+ ```ruby
513
+ CreateUser = Permittable::Contract.define(root: :user) do
514
+ required :email, :string, format: URI::MailTo::EMAIL_REGEXP
515
+ optional :age, :integer, in: 18..120
516
+ optional :plan, :string, in: %w[free pro], default: "free"
517
+ end
518
+
519
+ result = CreateUser.call(payload) # never raises
520
+ result.valid? # => false
521
+ result.violations # => [{ param: "user.age", code: "inclusion" }]
522
+ result.params # validated HashWithIndifferentAccess; nil when invalid
523
+
524
+ CreateUser.call!(payload) # params, or raises Permittable::InvalidParameters
525
+ CreateUser.json_schema # the contract as JSON Schema (draft 2020-12)
526
+ CreateUser.rule # the frozen, introspectable rule data
527
+ ```
528
+
529
+ 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:`. Three differences, all deliberate:
530
+
531
+ - **A `Contract` always enforces.** Monitor mode is a request-rollout switch; standalone callers read the `Result` instead, so the app-wide `Permittable.mode` is ignored here.
532
+ - **No router-key exemption.** `unknown: :error` flags a stray `action` or `controller` key — standalone input has no router to excuse.
533
+ - **No memoization.** Every `#call` validates fresh, so one frozen contract is safely reusable and shareable (assign it to a constant).
534
+
431
535
  ## Exporting OpenAPI (docs that cannot drift)
432
536
 
433
537
  Because a contract is data, it has a third reader beyond the validator and the drift guard: an exporter that emits **OpenAPI 3.1** (whose request bodies are plain JSON Schema). The schema is generated from the same frozen data the server enforces, so — like the drift guard, pointed outward — the docs cannot lie:
@@ -500,6 +604,9 @@ Output is deterministic (fixed key order, declaration-order properties), so the
500
604
  | `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
501
605
  | `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
502
606
  | `Permittable::OpenAPI` | OpenAPI 3.1 assembly (`.document`, `.operations_for`, `.request_body_for`, `.components`) |
607
+ | `Permittable::Generator` | Contract drafting (`.draft`, `.for_controller`, `.scan`) — see [generating draft contracts](#generating-draft-contracts-permittablegenerate) |
608
+ | `Permittable::Contract` | [Standalone contracts](#standalone-contracts-no-controller) (`.define`, `#call`, `#call!`, `#json_schema`, `#rule`) |
609
+ | `Permittable::Matchers` | RSpec matchers via `require "permittable/rspec"` — see [testing contracts](#testing-contracts-rspec-matchers) |
503
610
 
504
611
  ## Errors caught at class load
505
612
 
@@ -0,0 +1,117 @@
1
+ module Permittable
2
+ # A contract without a controller — the same field DSL, coercion, defaults,
3
+ # finalize, and violation vocabulary, callable on any Hash: webhook
4
+ # payloads, job arguments, service-object inputs, CSV rows.
5
+ #
6
+ # CreateUser = Permittable::Contract.define(root: :user) do
7
+ # required :email, :string, format: URI::MailTo::EMAIL_REGEXP
8
+ # optional :age, :integer, in: 18..120
9
+ # optional :plan, :string, in: %w[free pro], default: "free"
10
+ # end
11
+ #
12
+ # result = CreateUser.call(payload) # => Result
13
+ # result.valid? # => false
14
+ # result.violations # => [{ param: "user.age", code: "inclusion" }]
15
+ # result.params # validated HashWithIndifferentAccess, nil when invalid
16
+ #
17
+ # CreateUser.call!(payload) # params, or raises Permittable::InvalidParameters
18
+ #
19
+ # Differences from the controller concern, all deliberate:
20
+ # * A Contract always ENFORCES. Monitor mode is a request-rollout switch;
21
+ # standalone callers read the Result instead, so the app-wide
22
+ # `Permittable.mode` is ignored here.
23
+ # * The router's bookkeeping keys (controller/action/format) get no
24
+ # exemption from `unknown:` checking — standalone input has no router.
25
+ # * No memoization: every #call validates fresh, so one frozen Contract
26
+ # is safely reusable and shareable.
27
+ #
28
+ # Everything else carries over, including `sensitive:` log-redaction
29
+ # registration, `invalid_parameters.permittable` instrumentation, 400
30
+ # semantics for a missing `root:`, and `#json_schema` for documentation.
31
+ class Contract
32
+ ACTION = "call".freeze
33
+
34
+ # The result of one #call: `params` is the cast, validated, defaulted
35
+ # HashWithIndifferentAccess (nil when invalid); `violations` is the same
36
+ # details array a controller's 422 would carry.
37
+ Result = Struct.new(:params, :violations, keyword_init: true) do
38
+ def valid?
39
+ violations.empty?
40
+ end
41
+
42
+ def invalid?
43
+ !valid?
44
+ end
45
+ end
46
+
47
+ class << self
48
+ alias define new
49
+ end
50
+
51
+ def initialize(root: false, unknown: :ignore, model: nil, desc: nil, &)
52
+ @host_class = Class.new do
53
+ include Permittable
54
+
55
+ attr_accessor :params
56
+
57
+ # Standalone input has no router, so nothing is exempt from the
58
+ # unknown-keys check (the concern exempts controller/action/format
59
+ # at the top level of request params).
60
+ def permittable_check_unknown(fields, hash, path:, unknown:, top_level:, violations:) # rubocop:disable Lint/UnusedMethodArgument
61
+ super(fields, hash, path: path, unknown: unknown, top_level: false, violations: violations)
62
+ end
63
+
64
+ # Instrumentation payload label (anonymous classes have no name).
65
+ def permittable_controller_name
66
+ "Permittable::Contract"
67
+ end
68
+ end
69
+ @host_class.permit_params(root: root, unknown: unknown, model: model, mode: :enforce, desc: desc, &)
70
+ end
71
+
72
+ # The frozen rule — same introspectable data a controller's
73
+ # `permit_rule_for` returns, readable by every contract consumer
74
+ # (JsonSchema, OpenAPI, the RSpec matchers' internals).
75
+ def rule
76
+ @host_class.permittable_contracts.last
77
+ end
78
+
79
+ # RSpec-matcher parity with controllers: `expect(MyContract).to
80
+ # permit_param(:email)` reads the registry through these. A standalone
81
+ # contract covers every "action", so the argument is irrelevant.
82
+ def permit_rule_for(_action = nil)
83
+ rule
84
+ end
85
+
86
+ def permittable_contracts
87
+ [rule]
88
+ end
89
+
90
+ def call(input)
91
+ Result.new(params: call!(input), violations: [].freeze)
92
+ rescue InvalidParameters => e
93
+ Result.new(params: nil, violations: e.details)
94
+ end
95
+
96
+ def call!(input)
97
+ host = @host_class.new
98
+ host.params = normalize_input(input)
99
+ host.permitted_params(ACTION)
100
+ end
101
+
102
+ # The request-body schema for this contract — JSON Schema draft 2020-12,
103
+ # identical to what the OpenAPI exporter emits for a controller rule.
104
+ def json_schema
105
+ JsonSchema.rule(rule)
106
+ end
107
+
108
+ private
109
+
110
+ def normalize_input(input)
111
+ return {} if input.nil?
112
+ return input if input.is_a?(Hash) || input.respond_to?(:to_unsafe_h)
113
+
114
+ raise ArgumentError, "#{LABEL}: Contract#call expects a Hash (got #{input.class})"
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,218 @@
1
+ module Permittable
2
+ # Drafts a permit_params contract from what the app already knows: the
3
+ # model's columns (types, NOT NULL, database defaults) and, when the
4
+ # controller source is available, the strong-parameters calls already in it
5
+ # (`params.require(:user).permit(:name, tags: [])`). The draft is a
6
+ # STARTING POINT, not an oracle — everything the generator cannot know for
7
+ # sure is marked with a TODO comment instead of guessed, and the whole
8
+ # contract is emitted in monitor mode so pasting it changes nothing until
9
+ # the TODOs are reviewed and the mode is flipped.
10
+ #
11
+ # Permittable::Generator.for_controller(UsersController, source: File.read(path))
12
+ # Permittable::Generator.draft(model: User)
13
+ #
14
+ # Rails apps get the same thing as a rake task:
15
+ #
16
+ # bin/rails permittable:generate # every uncovered controller
17
+ # bin/rails "permittable:generate[UsersController]" # one controller
18
+ module Generator
19
+ DEFAULT_ACTIONS = %i[create update].freeze
20
+ SKIPPED_COLUMNS = %w[created_at updated_at].freeze
21
+
22
+ # Column type => contract type. Anything absent here (json, jsonb, hstore,
23
+ # binary, ...) has no faithful scalar representation and becomes a TODO
24
+ # comment rather than a guess.
25
+ COLUMN_TYPES = {
26
+ string: :string, text: :string, citext: :string, uuid: :string,
27
+ integer: :integer, bigint: :integer, float: :float, decimal: :decimal,
28
+ boolean: :boolean, date: :date, datetime: :datetime,
29
+ timestamp: :datetime, timestamptz: :datetime
30
+ }.freeze
31
+
32
+ # What a source scan recovered from existing permit calls. `scalars` are
33
+ # plain `:key` arguments, `arrays` are `key: []`, `nested` maps `key:
34
+ # [:a, :b]` onto its sub-keys, and `unparsed` keeps verbatim anything the
35
+ # conservative parser would otherwise have silently dropped.
36
+ Scan = Struct.new(:root, :scalars, :arrays, :nested, :unparsed, :calls, keyword_init: true) do
37
+ def found?
38
+ calls.positive?
39
+ end
40
+ end
41
+
42
+ # One permit call, with an optional leading `.require(:root)`. The args
43
+ # capture tolerates brackets and newlines but not parentheses — a call
44
+ # whose arguments contain a method call is skipped entirely rather than
45
+ # half-read.
46
+ PERMIT_CALL = /params\s*(?:\.\s*require\(\s*:(\w+)\s*\))?\s*\.\s*permit\(([^()]*)\)/m
47
+
48
+ # A permit key: `:name`, `"name"`, or `'name'` (quotes must match —
49
+ # anything else stays unparsed rather than guessed).
50
+ SCALAR_KEY = /\A(?::(\w+)|"(\w+)"|'(\w+)')\z/
51
+ ARRAY_ARG = /\A(\w+):\s*\[\s*\]\z/m
52
+ NESTED_ARG = /\A(\w+):\s*\[([^\[\]]*)\]\z/m
53
+
54
+ module_function
55
+
56
+ # Merge every permit call found in `source` into one Scan. The first
57
+ # `.require(:root)` seen wins, matching how a controller normally sticks
58
+ # to one envelope across actions.
59
+ def scan(source)
60
+ result = Scan.new(root: nil, scalars: [], arrays: [], nested: {}, unparsed: [], calls: 0)
61
+ (source || "").scan(PERMIT_CALL) do |root, args|
62
+ result.calls += 1
63
+ result.root ||= root&.to_sym
64
+ split_args(args).each { |arg| classify_arg(result, arg) }
65
+ end
66
+ result
67
+ end
68
+
69
+ # Draft a contract for one controller: model inferred from
70
+ # controller_name (or passed explicitly), permit calls scanned from
71
+ # `source:` when given. Returns nil when there is nothing to draft from.
72
+ def for_controller(controller, source: nil, model: nil)
73
+ draft(model: model || infer_model(controller), scan: scan(source))
74
+ end
75
+
76
+ # The core: knowledge in (columns and/or a scan), snippet out. Returns a
77
+ # String of valid Ruby, or nil when neither source of knowledge exists.
78
+ def draft(model: nil, scan: nil)
79
+ columns = columns_for(model)
80
+ scan = nil unless scan&.found?
81
+ return nil unless columns || scan
82
+
83
+ root = scan ? scan.root : default_root(model)
84
+ body = scan ? scanned_lines(scan, columns) : column_lines(columns.values)
85
+ render(signature(root: root, model: columns && model), body)
86
+ end
87
+
88
+ def infer_model(controller)
89
+ return nil unless controller.respond_to?(:controller_name)
90
+
91
+ model = controller.controller_name.classify.safe_constantize
92
+ model.respond_to?(:columns) ? model : nil
93
+ end
94
+
95
+ # The columns a contract should cover, keyed by name — or nil when there
96
+ # is no model or its schema is unreachable (same philosophy as the drift
97
+ # guard: never let generation crash on a half-migrated database).
98
+ def columns_for(model)
99
+ return nil unless model.respond_to?(:columns)
100
+ return nil unless model.table_exists?
101
+
102
+ # Array() flattens a composite primary key (an Array in Rails 7.1+)
103
+ # into its column names; a nil primary key becomes [].
104
+ skipped = SKIPPED_COLUMNS + Array(model.primary_key).map(&:to_s)
105
+ model.columns.reject { |c| skipped.include?(c.name) }.to_h { |c| [c.name, c] }
106
+ rescue StandardError
107
+ nil
108
+ end
109
+
110
+ # -- scan parsing -------------------------------------------------------
111
+
112
+ # Split a permit argument list on top-level commas only, so `address:
113
+ # [:city, :zip]` stays one argument.
114
+ def split_args(args)
115
+ parts = [+""]
116
+ depth = 0
117
+ args.each_char do |char|
118
+ depth += 1 if "[{".include?(char)
119
+ depth -= 1 if "]}".include?(char)
120
+ next parts << +"" if char == "," && depth.zero?
121
+
122
+ parts.last << char
123
+ end
124
+ parts.map(&:strip).reject(&:empty?)
125
+ end
126
+
127
+ def classify_arg(result, arg)
128
+ if (key = scalar_key(arg))
129
+ result.scalars |= [key]
130
+ elsif (match = ARRAY_ARG.match(arg))
131
+ result.arrays |= [match[1].to_sym]
132
+ elsif (match = NESTED_ARG.match(arg))
133
+ classify_nested(result, match, arg)
134
+ else
135
+ result.unparsed |= [arg.gsub(/\s+/, " ")]
136
+ end
137
+ end
138
+
139
+ def classify_nested(result, match, arg)
140
+ keys = split_args(match[2]).map { |part| scalar_key(part) }
141
+ return result.unparsed |= [arg.gsub(/\s+/, " ")] if keys.any?(&:nil?)
142
+
143
+ result.nested[match[1].to_sym] = (result.nested[match[1].to_sym] || []) | keys
144
+ end
145
+
146
+ def scalar_key(part)
147
+ match = SCALAR_KEY.match(part)
148
+ match && (match[1] || match[2] || match[3]).to_sym
149
+ end
150
+
151
+ # -- drafting -----------------------------------------------------------
152
+
153
+ def default_root(model)
154
+ model.name.demodulize.underscore.to_sym
155
+ end
156
+
157
+ def signature(root:, model:)
158
+ parts = ["permit_params #{DEFAULT_ACTIONS.map(&:inspect).join(', ')}"]
159
+ parts << "root: :#{root}" if root
160
+ parts << "model: #{model.name}" if model
161
+ parts << "mode: :monitor do"
162
+ parts.join(", ")
163
+ end
164
+
165
+ def column_lines(columns)
166
+ columns.map { |column| column_line(column) }
167
+ end
168
+
169
+ def column_line(column)
170
+ type = COLUMN_TYPES[column.type]
171
+ return "# TODO: #{column.name} (#{column.type}) has no scalar contract type — declare it as a nested block or an array" unless type
172
+
173
+ line = "#{required_column?(column) ? 'required' : 'optional'} :#{column.name}, :#{type}"
174
+ line += " # database default: #{column.default.inspect}" unless column.default.nil?
175
+ line
176
+ end
177
+
178
+ # NOT NULL without a database default is the only case a client truly
179
+ # must send the field. A database default is deliberately NOT copied into
180
+ # the contract as `default:` — a contract default is injected on every
181
+ # request that omits the field, which would overwrite columns on partial
182
+ # updates; the database already handles creation.
183
+ def required_column?(column)
184
+ return false if column.null
185
+ return false unless column.default.nil?
186
+
187
+ column.respond_to?(:default_function) && column.default_function ? false : true
188
+ end
189
+
190
+ def scanned_lines(scan, columns)
191
+ lines = scan.scalars.map { |name| scanned_scalar_line(name, columns) }
192
+ lines += scan.arrays.map { |name| "array :#{name}, of: :string # TODO: confirm the element type" }
193
+ scan.nested.each { |name, keys| lines += nested_lines(name, keys) }
194
+ lines + scan.unparsed.map { |arg| "# TODO: could not parse from the permit call: #{arg}" }
195
+ end
196
+
197
+ def scanned_scalar_line(name, columns)
198
+ column = columns && columns[name.to_s]
199
+ return column_line(column) if column
200
+ return "optional :#{name}, :string, virtual: true # TODO: not a database column — confirm the type" if columns
201
+
202
+ "optional :#{name}, :string # TODO: confirm the type"
203
+ end
204
+
205
+ def nested_lines(name, keys)
206
+ ["optional :#{name} do # TODO: drafted from `#{name}: [...]` — if this is an array of hashes, use `array :#{name} do`"] +
207
+ keys.map { |key| " optional :#{key}, :string # TODO: confirm the type" } +
208
+ ["end"]
209
+ end
210
+
211
+ HEADER = "# Drafted by permittable:generate — review the TODOs, then deploy: monitor\n" \
212
+ "# mode reports violations (instrumentation + log) without rejecting requests.\n".freeze
213
+
214
+ def render(signature, body)
215
+ "#{HEADER}#{signature}\n#{body.map { |line| " #{line}\n" }.join}end\n"
216
+ end
217
+ end
218
+ end
@@ -15,6 +15,7 @@ module Permittable
15
15
 
16
16
  rake_tasks do
17
17
  load File.expand_path("tasks/openapi.rake", __dir__)
18
+ load File.expand_path("tasks/generate.rake", __dir__)
18
19
  end
19
20
  end
20
21
  end
@@ -0,0 +1,249 @@
1
+ require "permittable"
2
+
3
+ module Permittable
4
+ # RSpec matchers for asserting on declared contracts — the testing
5
+ # counterpart of "a contract is data": the matcher reads the same frozen
6
+ # rule the validator enforces, so a contract can be specified without
7
+ # dispatching a single request.
8
+ #
9
+ # # spec_helper.rb (matchers auto-include when RSpec is defined)
10
+ # require "permittable/rspec"
11
+ #
12
+ # expect(UsersController).to permit_param(:age)
13
+ # .for_action(:create).as(:integer).within(18..120)
14
+ # expect(UsersController).to permit_param("address.zip").as(:string).optional
15
+ # expect(UsersController).not_to permit_param(:admin).for_action(:create)
16
+ #
17
+ # `for_action` picks the rule exactly like a request would
18
+ # (`permit_rule_for`); it may be omitted only when the controller declares
19
+ # a single contract, so an ambiguous expectation fails loudly instead of
20
+ # silently checking the wrong rule.
21
+ module Matchers
22
+ def permit_param(path)
23
+ PermitParamMatcher.new(path)
24
+ end
25
+
26
+ class PermitParamMatcher
27
+ OPTION_LABELS = { in: "in:", format: "format:", length: "length:", default: "default:" }.freeze
28
+
29
+ def initialize(path)
30
+ @path = path.to_s
31
+ @action = nil
32
+ @expected = {}
33
+ @mismatches = []
34
+ end
35
+
36
+ # -- chains -----------------------------------------------------------
37
+
38
+ def for_action(action)
39
+ @action = action.to_s
40
+ self
41
+ end
42
+
43
+ def as(type)
44
+ @expected[:type] = type.to_sym
45
+ self
46
+ end
47
+
48
+ def as_array(of: nil)
49
+ @expected[:array] = true
50
+ @expected[:of] = of.to_sym if of
51
+ self
52
+ end
53
+
54
+ def required
55
+ @expected[:required] = true
56
+ self
57
+ end
58
+
59
+ def optional
60
+ @expected[:required] = false
61
+ self
62
+ end
63
+
64
+ def within(allowed)
65
+ @expected[:in] = allowed
66
+ self
67
+ end
68
+
69
+ def matching(regexp)
70
+ @expected[:format] = regexp
71
+ self
72
+ end
73
+
74
+ def with_length(spec)
75
+ @expected[:length] = spec
76
+ self
77
+ end
78
+
79
+ def with_default(value)
80
+ @expected[:default] = value
81
+ self
82
+ end
83
+
84
+ def virtual
85
+ @expected[:virtual] = true
86
+ self
87
+ end
88
+
89
+ def sensitive
90
+ @expected[:sensitive] = true
91
+ self
92
+ end
93
+
94
+ # -- RSpec protocol ---------------------------------------------------
95
+
96
+ def matches?(subject)
97
+ @subject = resolve_subject(subject)
98
+ rule = resolve_rule(@subject)
99
+ return false unless rule
100
+
101
+ @field = resolve_field(rule[:fields], @path.split("."))
102
+ return false unless @field
103
+
104
+ @mismatches = collect_mismatches(@field)
105
+ @mismatches.empty?
106
+ end
107
+
108
+ def failure_message
109
+ return "expected #{subject_name} to permit #{path_label}#{action_label}, but it #{@problem}" if @problem
110
+
111
+ if @field.nil?
112
+ declared = (@missing_among || []).map { |f| f[:name] }.join(", ")
113
+ return "expected #{subject_name} to permit #{path_label}#{action_label}, " \
114
+ "but it is not declared (declared: #{declared})"
115
+ end
116
+
117
+ "expected #{subject_name} to permit #{path_label}#{action_label}, but:\n #{@mismatches.join("\n ")}"
118
+ end
119
+
120
+ def failure_message_when_negated
121
+ "expected #{subject_name} not to permit #{path_label}#{action_label}, but the contract declares it"
122
+ end
123
+
124
+ def description
125
+ descriptors = @expected.filter_map { |key, value| describe_check(key, value) }
126
+ label = "permit #{path_label}"
127
+ label += " (for ##{@action})" if @action
128
+ label += " #{descriptors.join(', ')}" unless descriptors.empty?
129
+ label
130
+ end
131
+
132
+ def supports_block_expectations?
133
+ false
134
+ end
135
+
136
+ private
137
+
138
+ # A controller CLASS carries the contract registry; an instance (a
139
+ # controller spec's `controller` / `subject`) resolves through its
140
+ # class. Anything answering permit_rule_for itself is used as-is.
141
+ def resolve_subject(subject)
142
+ return subject if subject.respond_to?(:permit_rule_for)
143
+ return subject.class if subject.class.respond_to?(:permit_rule_for)
144
+
145
+ raise ArgumentError, "#{LABEL}: the subject of permit_param must include Permittable (got #{subject.inspect})"
146
+ end
147
+
148
+ def resolve_rule(subject)
149
+ return resolve_rule_for_action(subject) if @action
150
+
151
+ contracts = subject.permittable_contracts
152
+ case contracts.length
153
+ when 0 then record_problem("declares no contracts")
154
+ when 1 then contracts.first
155
+ else
156
+ raise ArgumentError, "#{LABEL}: #{subject_name} declares #{contracts.length} contracts — " \
157
+ "disambiguate with permit_param(...).for_action(:action)"
158
+ end
159
+ end
160
+
161
+ def resolve_rule_for_action(subject)
162
+ subject.permit_rule_for(@action) || record_problem("has no contract covering ##{@action}")
163
+ end
164
+
165
+ def record_problem(problem)
166
+ @problem = problem
167
+ nil
168
+ end
169
+
170
+ # Walks a dotted path through nested blocks and array-of-hash blocks
171
+ # alike, since both carry their sub-fields under :fields.
172
+ def resolve_field(fields, segments)
173
+ name = segments.first.to_sym
174
+ field = fields.find { |f| f[:name] == name }
175
+ if field.nil?
176
+ @missing_among = fields
177
+ return nil
178
+ end
179
+ return field if segments.length == 1
180
+
181
+ resolve_field(field[:fields] || [], segments.drop(1))
182
+ end
183
+
184
+ def collect_mismatches(field)
185
+ @expected.filter_map { |key, value| check_mismatch(field, key, value) }
186
+ end
187
+
188
+ def check_mismatch(field, key, value)
189
+ case key
190
+ when :type then type_mismatch(field, value)
191
+ when :array then "expected an array field, but it is declared with `#{field[:kind]}`" unless field[:kind] == :array
192
+ when :of then "expected an array of :#{value}, but it is of: :#{field[:of]}" unless field[:of] == value
193
+ when :required then required_mismatch(field, value)
194
+ when :virtual, :sensitive then "expected the field to be #{key}, but it is not" unless field[key]
195
+ else option_mismatch(field, key, value)
196
+ end
197
+ end
198
+
199
+ def type_mismatch(field, type)
200
+ if field[:kind] == :array
201
+ "expected type :#{type}, but :#{field[:name]} is an array — assert it with as_array(of: ...)"
202
+ elsif field[:kind] == :nested
203
+ "expected type :#{type}, but :#{field[:name]} is a nested hash"
204
+ elsif field[:type] != type
205
+ "expected type #{type.inspect}, but the contract declares #{field[:type].inspect}"
206
+ end
207
+ end
208
+
209
+ def required_mismatch(field, required)
210
+ actual = field[:required] ? "required" : "optional"
211
+ expected = required ? "required" : "optional"
212
+ "expected the field to be #{expected}, but it is #{actual}" unless actual == expected
213
+ end
214
+
215
+ def option_mismatch(field, key, value)
216
+ return if field.key?(key) && field[key] == value
217
+
218
+ label = OPTION_LABELS.fetch(key)
219
+ declared = field.key?(key) ? "declares #{label} #{field[key].inspect}" : "does not declare #{label}"
220
+ "expected #{label} #{value.inspect}, but the contract #{declared}"
221
+ end
222
+
223
+ def describe_check(key, value)
224
+ case key
225
+ when :type then "as :#{value}"
226
+ when :array then "as an array"
227
+ when :of then "of :#{value}"
228
+ when :required then value ? "required" : "optional"
229
+ when :virtual, :sensitive then key.to_s
230
+ else "#{OPTION_LABELS.fetch(key)} #{value.inspect}"
231
+ end
232
+ end
233
+
234
+ def subject_name
235
+ (@subject.respond_to?(:name) && @subject.name) || "the controller"
236
+ end
237
+
238
+ def path_label
239
+ @path.include?(".") ? @path.inspect : ":#{@path}"
240
+ end
241
+
242
+ def action_label
243
+ @action ? " for ##{@action}" : ""
244
+ end
245
+ end
246
+ end
247
+ end
248
+
249
+ RSpec.configure { |config| config.include Permittable::Matchers } if defined?(RSpec) && RSpec.respond_to?(:configure)
@@ -0,0 +1,45 @@
1
+ # Drafts Permittable contracts for controllers that don't declare one yet,
2
+ # from each controller's model columns plus any params.permit calls in its
3
+ # source. Drafts go to stdout (paste-ready); the summary goes to stderr.
4
+ #
5
+ # bin/rails permittable:generate # every uncovered controller
6
+ # bin/rails "permittable:generate[UsersController]" # one controller, even if covered
7
+ namespace :permittable do
8
+ desc "Draft Permittable contracts from models and existing permit calls"
9
+ task :generate, [:controller] => :environment do |_t, task_args|
10
+ Rails.application.eager_load!
11
+
12
+ bases = []
13
+ bases << ActionController::Base if defined?(ActionController::Base)
14
+ bases << ActionController::API if defined?(ActionController::API)
15
+ controllers = bases.flat_map(&:descendants).uniq.select(&:name)
16
+
17
+ if task_args[:controller]
18
+ controllers = controllers.select { |c| c.name == task_args[:controller] }
19
+ abort "Permittable: no controller named #{task_args[:controller]} was found" if controllers.empty?
20
+ else
21
+ controllers = controllers.reject do |c|
22
+ c.respond_to?(:permittable_contracts) && c.permittable_contracts.any?
23
+ end
24
+ end
25
+
26
+ drafted = controllers.sort_by(&:name).count do |controller|
27
+ path = begin
28
+ Object.const_source_location(controller.name)&.first
29
+ rescue StandardError
30
+ nil
31
+ end
32
+ source = path && File.exist?(path) ? File.read(path) : nil
33
+ snippet = Permittable::Generator.for_controller(controller, source: source)
34
+ next false unless snippet
35
+
36
+ puts ["# ====", controller.name, path && "(#{path})", "===="].compact.join(" ")
37
+ puts snippet
38
+ puts
39
+ true
40
+ end
41
+
42
+ warn "Permittable: drafted #{drafted} contract#{'s' unless drafted == 1} — " \
43
+ "paste each into its controller and review the TODOs."
44
+ end
45
+ end
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.4.0".freeze
2
+ VERSION = "0.5.0".freeze
3
3
  end
data/lib/permittable.rb CHANGED
@@ -180,6 +180,21 @@ module Permittable
180
180
 
181
181
  @mode = value
182
182
  end
183
+
184
+ # App-wide fallback copy for a violation code, looked up through I18n
185
+ # under permittable.errors.<code> ("missing", "inclusion", or any Symbol
186
+ # a validate: returned). Consulted only when the field declares no
187
+ # matching `message:` of its own, and only when the host app has I18n —
188
+ # without translations (or without I18n) details keep the bare
189
+ # { param:, code: } shape, so nothing changes for apps that don't opt
190
+ # in. Only a String translation counts; anything else (a nested Hash, a
191
+ # missing-translation object) is ignored rather than leaked to clients.
192
+ def default_message_for(code)
193
+ return nil unless defined?(::I18n) && ::I18n.respond_to?(:t)
194
+
195
+ message = ::I18n.t("permittable.errors.#{code}", default: nil)
196
+ message.is_a?(String) ? message : nil
197
+ end
183
198
  end
184
199
 
185
200
  # Raised when the request violates the matching contract. `details` is an
@@ -580,7 +595,10 @@ module Permittable
580
595
  # `message:` option.
581
596
  def violate!(param, code, message: nil)
582
597
  entry = { param: param.to_s, code: code.to_s }
583
- entry[:message] = message.to_s if message
598
+ # Same resolution order as field violations: explicit message, then
599
+ # the app's I18n copy for the code, then the bare shape.
600
+ resolved = message ? message.to_s : Permittable.default_message_for(code)
601
+ entry[:message] = resolved if resolved
584
602
  @violations << entry
585
603
  throw :permittable_finalize_halt
586
604
  end
@@ -820,11 +838,14 @@ module Permittable
820
838
  entry
821
839
  end
822
840
 
841
+ # Resolution order: the field's own `message:` (String, or Hash entry for
842
+ # this code), then the app's I18n copy (permittable.errors.<code>), then
843
+ # nothing — the bare { param:, code: } shape.
823
844
  def permittable_message_for(field, code)
824
845
  spec = field[:message]
825
- return spec if spec.nil? || spec.is_a?(String)
846
+ return spec if spec.is_a?(String)
826
847
 
827
- spec[code.to_sym]
848
+ (spec && spec[code.to_sym]) || Permittable.default_message_for(code)
828
849
  end
829
850
 
830
851
  def permittable_run_finalize(finalizer, result, violations)
@@ -849,7 +870,9 @@ module Permittable
849
870
  value = raw[rule[:root].to_s]
850
871
  return value if value.is_a?(Hash)
851
872
 
852
- violations << { param: rule[:root].to_s, code: "missing" }
873
+ # No field declares the root, so message resolution can only come from
874
+ # I18n ({} has no :message).
875
+ violations << permittable_violation({}, rule[:root].to_s, "missing")
853
876
  nil
854
877
  end
855
878
 
@@ -958,7 +981,7 @@ module Permittable
958
981
  return if extra.empty?
959
982
 
960
983
  if unknown == :error
961
- extra.each { |key| violations << { param: permittable_path(path, key), code: "unknown" } }
984
+ extra.each { |key| violations << permittable_violation({}, permittable_path(path, key), "unknown") }
962
985
  elsif respond_to?(:logger) && logger
963
986
  logger.warn("#{LABEL}: unknown parameter(s) ignored by the ##{permittable_action_name} contract: " \
964
987
  "#{extra.map { |key| permittable_path(path, key) }.join(', ')}")
@@ -985,6 +1008,13 @@ end
985
1008
  require "permittable/json_schema"
986
1009
  require "permittable/open_api"
987
1010
 
1011
+ # Contract WRITER — drafts permit_params blocks from a model's columns and
1012
+ # existing params.permit calls (the permittable:generate rake task).
1013
+ require "permittable/generator"
1014
+
1015
+ # Standalone contracts — the same DSL callable on any Hash, no controller.
1016
+ require "permittable/contract"
1017
+
988
1018
  # Boot-time integration (filter_parameters registration, the
989
1019
  # permittable:openapi rake task), Rails apps only
990
1020
  require "permittable/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: permittable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-01 00:00:00.000000000 Z
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -48,11 +48,15 @@ files:
48
48
  - README.md
49
49
  - lib/permittable.rb
50
50
  - lib/permittable/column_guard.rb
51
+ - lib/permittable/contract.rb
51
52
  - lib/permittable/error_envelope.rb
52
53
  - lib/permittable/filter_parameter_registry.rb
54
+ - lib/permittable/generator.rb
53
55
  - lib/permittable/json_schema.rb
54
56
  - lib/permittable/open_api.rb
55
57
  - lib/permittable/railtie.rb
58
+ - lib/permittable/rspec.rb
59
+ - lib/permittable/tasks/generate.rake
56
60
  - lib/permittable/tasks/openapi.rake
57
61
  - lib/permittable/version.rb
58
62
  homepage: https://github.com/VSN2015/permittable