permittable 0.4.0 → 0.5.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 +24 -0
- data/README.md +110 -3
- data/lib/permittable/contract.rb +117 -0
- data/lib/permittable/generator.rb +218 -0
- data/lib/permittable/railtie.rb +1 -0
- data/lib/permittable/rspec.rb +249 -0
- data/lib/permittable/tasks/generate.rake +45 -0
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +45 -5
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9b826995301bd0dc72ab69a6560ddfe58e13ae67323a252a9a506c21f61420d
|
|
4
|
+
data.tar.gz: 5e354c9e104237d9116591df08d4c1a58962f98d543d0f5ff6de983b96f3bc51
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c9cf62a2210a42cad9190f25ce259250c268dabf72bb966bae4110d2c627f27ce7a050a64f92c9e1729c9eb088d48077384b901481475a5cae077d1d802684ab
|
|
7
|
+
data.tar.gz: a6db0156d761c972ef0c9f6b41ef96e3fee3f0f48158ce01344b8a6ea39f9a8504dd2df015bcf3c379841420a66e857a42a2d0ff07d2d94902b49d89fec6b570
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.5.1 (2026-09-02)
|
|
4
|
+
<!-- title: nested input outside Rails -->
|
|
5
|
+
|
|
6
|
+
Patch release. Checking how a contract handles a request with several top-level envelopes (`{ user: { ... }, address_attributes: { ... } }`) surfaced one crash and one unhelpful error; both are fixed below, and no behaviour of existing contracts changes.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **Nested hash input crashed outside Rails.** `lib/permittable.rb` required `HashWithIndifferentAccess` but not the Hash core extension it needs to convert nested plain Hashes, so a standalone `Permittable::Contract` (or any host that loads only `permittable`) raised `NoMethodError: undefined method 'nested_under_indifferent_access'` on payloads like `{ user: { ... }, address_attributes: { ... } }`. Rails apps and the spec suite loaded the extension indirectly, which is why it went unnoticed; a spec now exercises a nested contract in a bare subprocess.
|
|
10
|
+
- **`root:` rejects anything but one key at class load.** `root: [:user, :address_attributes]` used to leak `NoMethodError: undefined method 'to_sym' for Array`; it now raises a descriptive `ArgumentError` pointing at the recipe for several top-level envelopes — a rootless contract with one nested block per key. Specs pin that recipe, and pin that a rooted contract never sees the root's siblings (even under `unknown: :error`), matching `require(:user).permit`.
|
|
11
|
+
|
|
12
|
+
## 0.5.0 (2026-09-02)
|
|
13
|
+
<!-- title: the adoption on-ramp -->
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **`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.
|
|
19
|
+
- **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.
|
|
20
|
+
- **`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.
|
|
21
|
+
- **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.
|
|
22
|
+
- **`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.
|
|
23
|
+
- **`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).
|
|
24
|
+
|
|
25
|
+
All are additive — no behaviour of existing contracts changes.
|
|
26
|
+
|
|
3
27
|
## 0.4.0 (2026-08-24)
|
|
4
28
|
<!-- title: monitor mode -->
|
|
5
29
|
|
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) · [
|
|
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
|
-
|
|
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
|
data/lib/permittable/railtie.rb
CHANGED
|
@@ -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
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -2,6 +2,7 @@ require "active_support"
|
|
|
2
2
|
require "active_support/concern"
|
|
3
3
|
require "active_support/notifications"
|
|
4
4
|
require "active_support/hash_with_indifferent_access"
|
|
5
|
+
require "active_support/core_ext/hash/indifferent_access" # nested plain Hashes inside HWIA.new
|
|
5
6
|
require "active_support/core_ext/class/attribute"
|
|
6
7
|
require "active_support/core_ext/string/inflections"
|
|
7
8
|
require "active_support/core_ext/string/filters"
|
|
@@ -180,6 +181,21 @@ module Permittable
|
|
|
180
181
|
|
|
181
182
|
@mode = value
|
|
182
183
|
end
|
|
184
|
+
|
|
185
|
+
# App-wide fallback copy for a violation code, looked up through I18n
|
|
186
|
+
# under permittable.errors.<code> ("missing", "inclusion", or any Symbol
|
|
187
|
+
# a validate: returned). Consulted only when the field declares no
|
|
188
|
+
# matching `message:` of its own, and only when the host app has I18n —
|
|
189
|
+
# without translations (or without I18n) details keep the bare
|
|
190
|
+
# { param:, code: } shape, so nothing changes for apps that don't opt
|
|
191
|
+
# in. Only a String translation counts; anything else (a nested Hash, a
|
|
192
|
+
# missing-translation object) is ignored rather than leaked to clients.
|
|
193
|
+
def default_message_for(code)
|
|
194
|
+
return nil unless defined?(::I18n) && ::I18n.respond_to?(:t)
|
|
195
|
+
|
|
196
|
+
message = ::I18n.t("permittable.errors.#{code}", default: nil)
|
|
197
|
+
message.is_a?(String) ? message : nil
|
|
198
|
+
end
|
|
183
199
|
end
|
|
184
200
|
|
|
185
201
|
# Raised when the request violates the matching contract. `details` is an
|
|
@@ -580,7 +596,10 @@ module Permittable
|
|
|
580
596
|
# `message:` option.
|
|
581
597
|
def violate!(param, code, message: nil)
|
|
582
598
|
entry = { param: param.to_s, code: code.to_s }
|
|
583
|
-
|
|
599
|
+
# Same resolution order as field violations: explicit message, then
|
|
600
|
+
# the app's I18n copy for the code, then the bare shape.
|
|
601
|
+
resolved = message ? message.to_s : Permittable.default_message_for(code)
|
|
602
|
+
entry[:message] = resolved if resolved
|
|
584
603
|
@violations << entry
|
|
585
604
|
throw :permittable_finalize_halt
|
|
586
605
|
end
|
|
@@ -593,6 +612,10 @@ module Permittable
|
|
|
593
612
|
#
|
|
594
613
|
# root: key to unwrap first (`require(:user)` equivalent); false
|
|
595
614
|
# (default) reads top-level params. Missing root renders 400.
|
|
615
|
+
# Exactly one key: a rooted contract never sees the root's
|
|
616
|
+
# siblings (like `require(:user).permit`), so to accept
|
|
617
|
+
# several top-level envelopes stay rootless and declare one
|
|
618
|
+
# nested block per key.
|
|
596
619
|
# model: a model class (or `true` to infer from controller_name)
|
|
597
620
|
# enabling the schema-drift check on every non-virtual scalar
|
|
598
621
|
# field.
|
|
@@ -609,6 +632,11 @@ module Permittable
|
|
|
609
632
|
def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, mode: nil, desc: nil, &block)
|
|
610
633
|
raise ArgumentError, "#{LABEL}: permit_params requires a block declaring the contract fields" unless block
|
|
611
634
|
|
|
635
|
+
unless root.nil? || root == false || root.is_a?(Symbol) || root.is_a?(String)
|
|
636
|
+
raise ArgumentError, "#{LABEL}: :root must be one key (Symbol or String) or false, got #{root.inspect} — " \
|
|
637
|
+
"to accept several top-level keys, declare a rootless contract with one nested block per key"
|
|
638
|
+
end
|
|
639
|
+
|
|
612
640
|
unknown = unknown.to_sym
|
|
613
641
|
raise ArgumentError, "#{LABEL}: :unknown must be one of #{UNKNOWN_MODES.join(', ')}" unless UNKNOWN_MODES.include?(unknown)
|
|
614
642
|
|
|
@@ -820,11 +848,14 @@ module Permittable
|
|
|
820
848
|
entry
|
|
821
849
|
end
|
|
822
850
|
|
|
851
|
+
# Resolution order: the field's own `message:` (String, or Hash entry for
|
|
852
|
+
# this code), then the app's I18n copy (permittable.errors.<code>), then
|
|
853
|
+
# nothing — the bare { param:, code: } shape.
|
|
823
854
|
def permittable_message_for(field, code)
|
|
824
855
|
spec = field[:message]
|
|
825
|
-
return spec if spec.
|
|
856
|
+
return spec if spec.is_a?(String)
|
|
826
857
|
|
|
827
|
-
spec[code.to_sym]
|
|
858
|
+
(spec && spec[code.to_sym]) || Permittable.default_message_for(code)
|
|
828
859
|
end
|
|
829
860
|
|
|
830
861
|
def permittable_run_finalize(finalizer, result, violations)
|
|
@@ -849,7 +880,9 @@ module Permittable
|
|
|
849
880
|
value = raw[rule[:root].to_s]
|
|
850
881
|
return value if value.is_a?(Hash)
|
|
851
882
|
|
|
852
|
-
|
|
883
|
+
# No field declares the root, so message resolution can only come from
|
|
884
|
+
# I18n ({} has no :message).
|
|
885
|
+
violations << permittable_violation({}, rule[:root].to_s, "missing")
|
|
853
886
|
nil
|
|
854
887
|
end
|
|
855
888
|
|
|
@@ -958,7 +991,7 @@ module Permittable
|
|
|
958
991
|
return if extra.empty?
|
|
959
992
|
|
|
960
993
|
if unknown == :error
|
|
961
|
-
extra.each { |key| violations << {
|
|
994
|
+
extra.each { |key| violations << permittable_violation({}, permittable_path(path, key), "unknown") }
|
|
962
995
|
elsif respond_to?(:logger) && logger
|
|
963
996
|
logger.warn("#{LABEL}: unknown parameter(s) ignored by the ##{permittable_action_name} contract: " \
|
|
964
997
|
"#{extra.map { |key| permittable_path(path, key) }.join(', ')}")
|
|
@@ -985,6 +1018,13 @@ end
|
|
|
985
1018
|
require "permittable/json_schema"
|
|
986
1019
|
require "permittable/open_api"
|
|
987
1020
|
|
|
1021
|
+
# Contract WRITER — drafts permit_params blocks from a model's columns and
|
|
1022
|
+
# existing params.permit calls (the permittable:generate rake task).
|
|
1023
|
+
require "permittable/generator"
|
|
1024
|
+
|
|
1025
|
+
# Standalone contracts — the same DSL callable on any Hash, no controller.
|
|
1026
|
+
require "permittable/contract"
|
|
1027
|
+
|
|
988
1028
|
# Boot-time integration (filter_parameters registration, the
|
|
989
1029
|
# permittable:openapi rake task), Rails apps only
|
|
990
1030
|
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
|
+
version: 0.5.1
|
|
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-
|
|
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
|