permittable 0.1.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 +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +128 -0
- data/lib/permittable/column_guard.rb +51 -0
- data/lib/permittable/error_envelope.rb +25 -0
- data/lib/permittable/filter_parameter_registry.rb +68 -0
- data/lib/permittable/railtie.rb +16 -0
- data/lib/permittable/version.rb +3 -0
- data/lib/permittable.rb +830 -0
- metadata +79 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: deed1778579c6d35f58ff18111114b00566ad0d6d2a92db5d4a9bfc3a730c6a6
|
|
4
|
+
data.tar.gz: 57c83b026226096fcc96552d2df3a51577da12e704b5883fb4ba8c1110704433
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1c89bc7b9ff00e4246f0ac9bcc2937b97fb5ba8cdcfda1cbb5308d7d9cff5b4a7410eb48c5a185acd2b8feb82f595f771319edc33badd64ad1155f3d5e691b51
|
|
7
|
+
data.tar.gz: a0087bd69e30d6739a9fc7af090b35cdbc268fbdad7cb57666d3fc71fc140a7b9e5304827e7d1789ff43ccc3f99789b576fbc8340d23595db38712e27abdb0db
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!-- CHANGELOG.md -->
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-08-16)
|
|
4
|
+
|
|
5
|
+
Initial extraction from [concerns_on_rails](https://github.com/VSN2015/concerns_on_rails) (developed there on `feature/permittable` as `ConcernsOnRails::Controllers::Permittable`; concerns_on_rails now depends on this gem and aliases that constant to `::Permittable`).
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **`Permittable`** — declarative, typed params contracts for Rails controllers. `permit_params *actions, root:, model:, unknown:, enforce:` declares a per-action contract (repeatable; no actions = catch-all; last matching rule wins; inherited copy-on-write) whose block DSL (`required`/`optional`/`array`, nested blocks) types every field (`:string :integer :float :decimal :boolean :date :datetime`) and validates it (`in:`, `format:`, `length:`, `normalize:` presets/Proc, `default:` — itself contract-checked at class load — and custom `validate:` with symbol violation codes). `permitted_params` returns the cast/validated/defaulted hash (lazy; `enforce: true` moves the check to a before_action); violations raise `Permittable::InvalidParameters`, auto-rescued into a JSON error envelope as 422 (400 for a missing `root:` key) with machine-readable `details:`, and instrument `invalid_parameters.permittable`.
|
|
9
|
+
- **Schema-drift guard**: `model:` (a class, or `true` to infer from `controller_name`) checks every non-`virtual:` scalar field against the model's columns at controller class load — a column dropped by a migration fails the deploy with a copy-paste migration hint, not the request. Skips gracefully when the schema is unreachable.
|
|
10
|
+
- **Strict coercion**: no ActiveModel::Type leniency — `"abc"` is never `0`, `?age[]=1` type confusion is a violation, not a 500. `nil`/`""` are ABSENT (absent optionals omitted, so partial updates never nil-out columns; `default:` fills absence).
|
|
11
|
+
- **Output reshaping**: per-field `transform:` (a callable applied AFTER cast + validation; defaults and absent fields untouched, partially-invalid arrays never transformed) and a once-per-contract `finalize do |p| ... end` (runs only when every field validated, on a bare runner — controller state unreachable — must return the final Hash) with `violate!(param, code)` as the cross-field validation seam. The request's `params` is never mutated.
|
|
12
|
+
- **`sensitive: true`** registers field names with `Permittable.filter_parameter_registry` (swappable, duck-typed), consulted at filter time by the proc `Permittable::Railtie` appends to `config.filter_parameters`.
|
|
13
|
+
- Sole runtime dependency: activesupport (>= 5.0, < 9). actionpack/activerecord are optional integration points.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ethan Nguyen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Permittable
|
|
2
|
+
|
|
3
|
+
**Typed, validated params contracts for Rails controllers — plus a schema-drift guard.**
|
|
4
|
+
|
|
5
|
+
`Permittable` is what strong parameters would be if it also knew types, bounds, defaults, and *why* a request was bad. `params.permit` (and Rails 8's `params.expect`) only answer "which keys may pass"; a Permittable contract additionally **casts** each field, **validates** it, applies **defaults**, reshapes the output, and turns every failure into a machine-readable 422. Because the contract is class-level data rather than code inside the action, it is introspectable — and can be checked against a model's schema at boot.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class UsersController < ApplicationController
|
|
9
|
+
include Permittable
|
|
10
|
+
|
|
11
|
+
permit_params :create, :update, root: :user, model: User do
|
|
12
|
+
required :name, :string, length: 1..80, normalize: :squish
|
|
13
|
+
required :email, :string, format: URI::MailTo::EMAIL_REGEXP, normalize: :email
|
|
14
|
+
optional :age, :integer, in: 18..120
|
|
15
|
+
optional :ssn, :string, sensitive: true # auto-redacted from logs
|
|
16
|
+
optional :plan, :string, in: %w[free pro], default: "free"
|
|
17
|
+
array :tag_names, of: :string, length: 0..10, virtual: true
|
|
18
|
+
optional :address do
|
|
19
|
+
required :city, :string
|
|
20
|
+
optional :zip, :string, format: /\A\d{5}\z/
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
user = User.create!(permitted_params) # cast, validated, defaulted
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A violating request renders:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{ "success": false,
|
|
34
|
+
"error": { "message": "Invalid parameters: user.age (inclusion)",
|
|
35
|
+
"code": "invalid_parameters",
|
|
36
|
+
"details": [{ "param": "user.age", "code": "inclusion" }] } }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
gem "permittable"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The only runtime dependency is `activesupport`. `actionpack` (rescue_from / before_action / `ActionController::Parameters`) and `activerecord` (the `model:` schema-drift guard) are optional — every touchpoint is guarded, so your app brings what it already has.
|
|
46
|
+
|
|
47
|
+
## The schema-drift guard
|
|
48
|
+
|
|
49
|
+
With `model:` (a class, or `true` to infer from the controller name), every non-`virtual:` scalar field is checked against the model's columns **at controller class load**. Production eager-loads controllers, so a column dropped by a migration fails the deploy, not the request:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
Permittable: 'nickname' does not exist in the database (table: users).
|
|
53
|
+
Add it with: bin/rails generate migration AddNicknameToUsers nickname:string
|
|
54
|
+
If this parameter is not backed by a column, declare it with virtual: true.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Nested and array fields are implicitly virtual. The check skips gracefully when the schema is unreachable (`db:create`, `assets:precompile`). In CI, one spec running `Rails.application.eager_load!` exercises every contract in the app.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
### `permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, &contract)`
|
|
62
|
+
|
|
63
|
+
Repeatable; rules are inherited by subclasses copy-on-write. **No positional actions = catch-all**, and **the last matching rule wins** (contracts are configuration overrides).
|
|
64
|
+
|
|
65
|
+
| Option | Default | Meaning |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `*actions` | — | Actions the contract covers; **none = catch-all** |
|
|
68
|
+
| `root:` | `false` | Key to unwrap first (`require(:user)` equivalent); missing/non-hash root → **400** |
|
|
69
|
+
| `model:` | `nil` | Model class (or `true` to infer from `controller_name`) enabling the schema-drift guard |
|
|
70
|
+
| `unknown:` | `:ignore` | `:ignore` / `:log` / `:error` — undeclared keys, at every nesting level (`controller`/`action`/`format` exempt at top level) |
|
|
71
|
+
| `enforce:` | `false` | `false` = validate lazily on first `permitted_params` call; `true` = validate in a `before_action` |
|
|
72
|
+
|
|
73
|
+
### Field DSL
|
|
74
|
+
|
|
75
|
+
- `required :name, :type, **opts` / `optional :name, :type, **opts` — type defaults to `:string`; types: `:string`, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`.
|
|
76
|
+
- A block instead of a type declares a **nested hash**; violation paths are dotted (`user.address.zip`).
|
|
77
|
+
- `array :name, of: :type` (or a block for arrays of hashes) — `length:` constrains the element **count**, element failures carry the index (`items[1]`), `required: true` opts in.
|
|
78
|
+
|
|
79
|
+
Per-field options: `in:` (Range/Array), `format:` / `length:` / `normalize:` (`:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc; string fields only), `default:` (validated against the field's own contract at class load), `validate:` (Proc — falsy fails as `"invalid"`, a returned Symbol becomes the violation code), `transform:` (below), `virtual:`, `sensitive:`.
|
|
80
|
+
|
|
81
|
+
Every bad declaration raises a teaching `ArgumentError` at class load.
|
|
82
|
+
|
|
83
|
+
## Output reshaping (`transform:` / `finalize`)
|
|
84
|
+
|
|
85
|
+
The safe replacement for params-mutating before_actions — both layers operate on the validated **copy**; the request's `params` is never touched.
|
|
86
|
+
|
|
87
|
+
- **`transform:`** (scalar and array fields) — a callable applied **after** cast and validation: `transform: ->(v) { v.split(",") }` turns a validated delimited String into an Array. Absent fields stay absent, `default:` values are authored in final shape, and a partially-invalid array is never transformed.
|
|
88
|
+
- **`finalize do |p| … end`** (once per contract, top level only) — runs after every field validated cleanly, receives the result hash, and must return the final Hash. It executes on a bare runner, **not** the controller, so contracts stay pure; its one extra verb, `violate!(param, code)`, records a violation and halts the block immediately — the cross-field validation seam.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
permit_params :create, root: :lease_addendum_form do
|
|
92
|
+
required :resident_signatures, :string, transform: ->(v) { v.split("<<delimiter>>") }
|
|
93
|
+
required :signer_names, :string, transform: ->(v) { v.split(",") }
|
|
94
|
+
|
|
95
|
+
finalize do |p|
|
|
96
|
+
violate!("lease_addendum_form.signer_names", :length_mismatch) unless p[:signer_names].length == p[:resident_signatures].length
|
|
97
|
+
p[:signatures] = p[:resident_signatures].zip(p[:signer_names]).map { |image, name| Signature.new(image:, full_name: name) }
|
|
98
|
+
p.except(:resident_signatures, :signer_names)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Methods
|
|
104
|
+
|
|
105
|
+
- `permitted_params(action = action_name)` — the cast/validated/defaulted `HashWithIndifferentAccess`. Absent optional fields are **omitted** (partial updates never nil-out columns). Memoized per action. Raises `Permittable::InvalidParameters` on violation; `ArgumentError` when no contract covers the action (programmer error).
|
|
106
|
+
- `enforce_params_contract` — the `before_action` entry point (skip with `skip_before_action`); only validates rules declared with `enforce: true`.
|
|
107
|
+
- `render_invalid_parameters(error)` — the `rescue_from` target; renders via the host's `render_error` when defined, the identical inline envelope otherwise.
|
|
108
|
+
- Class-side introspection: `permittable_contracts` and `permit_rule_for(action)`.
|
|
109
|
+
- `Permittable.filter_parameter_registry` — duck-typed, swappable sink for `sensitive:` field names; `Permittable::Railtie` appends its live filter proc to `config.filter_parameters`.
|
|
110
|
+
|
|
111
|
+
## Semantics worth knowing
|
|
112
|
+
|
|
113
|
+
- **Coercion is strict** — deliberately not `ActiveModel::Type` (`"abc".to_i == 0` silently corrupts untrusted input). `"4.5"` is not an integer; booleans accept only `true/false/"true"/"false"/"1"/"0"/1/0`; unparseable dates are `invalid_type`; zoneless datetime strings parse as **UTC**.
|
|
114
|
+
- **Type confusion is a violation, not a 500**: `?age[]=1` where a scalar is declared yields `invalid_type`.
|
|
115
|
+
- `nil` and `""` are both **absent**; boolean `false` is present.
|
|
116
|
+
- Every violation instruments `invalid_parameters.permittable` for dashboards.
|
|
117
|
+
- Used inside [concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)? `include ConcernsOnRails::Controllers::Permittable` is an alias for this module, and `sensitive:` registrations pool into that gem's shared filter registry.
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
bundle install
|
|
123
|
+
bundle exec rspec
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "active_support/core_ext/string/inflections"
|
|
2
|
+
|
|
3
|
+
module Permittable
|
|
4
|
+
# Schema validation behind the drift guard. When the schema is unreachable —
|
|
5
|
+
# no database yet (`db:create`, a fresh `db:migrate`, `assets:precompile`,
|
|
6
|
+
# CI bootstrap) or the table not yet migrated — the check is skipped and
|
|
7
|
+
# `false` is returned instead of raising, so controller classes stay
|
|
8
|
+
# loadable. A missing column with a *reachable* schema still raises: the
|
|
9
|
+
# rescue is scoped to ActiveRecord::ActiveRecordError precisely so real bugs
|
|
10
|
+
# (NameError from a typo etc.) keep surfacing. Skipping is self-healing:
|
|
11
|
+
# once the migration runs and classes reload, validation happens for real.
|
|
12
|
+
module ColumnGuard
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# `types:` teaches the error message: a Symbol/String applies to every
|
|
16
|
+
# listed field, a Hash maps field => type. The raised ArgumentError then
|
|
17
|
+
# appends a ready-to-paste migration command.
|
|
18
|
+
def ensure_columns_on!(label, klass, *fields, types: nil)
|
|
19
|
+
return false unless schema_reachable?(klass)
|
|
20
|
+
|
|
21
|
+
fields.flatten.compact.each do |field|
|
|
22
|
+
next if klass.column_names.include?(field.to_s)
|
|
23
|
+
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"#{label}: '#{field}' does not exist in the database (table: #{klass.table_name})." \
|
|
26
|
+
"#{column_migration_hint(klass, field, types)}"
|
|
27
|
+
end
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def column_migration_hint(klass, field, types)
|
|
32
|
+
type = types.is_a?(Hash) ? types[field.to_sym] : types
|
|
33
|
+
column = [field, type].compact.join(":")
|
|
34
|
+
" Add it with: bin/rails generate migration " \
|
|
35
|
+
"Add#{field.to_s.camelize}To#{klass.table_name.to_s.camelize} #{column}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# True when the class's table can actually be inspected. Connection errors
|
|
39
|
+
# (ConnectionNotEstablished, NoDatabaseError, adapter errors) all inherit
|
|
40
|
+
# from ActiveRecord::ActiveRecordError; the defined? guard keeps this gem
|
|
41
|
+
# loadable without activerecord (a host without it cannot pass `model:`
|
|
42
|
+
# anyway).
|
|
43
|
+
def schema_reachable?(klass)
|
|
44
|
+
klass.table_exists?
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
raise unless defined?(ActiveRecord::ActiveRecordError) && e.is_a?(ActiveRecord::ActiveRecordError)
|
|
47
|
+
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Permittable
|
|
2
|
+
# One home for the error envelope: prefer the host's #render_error when the
|
|
3
|
+
# controller defines one (e.g. concerns_on_rails' Respondable), otherwise
|
|
4
|
+
# render the identical inline shape — and the single place to change when
|
|
5
|
+
# e.g. an RFC 9457 problem+json mode lands.
|
|
6
|
+
module ErrorEnvelope
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def render(controller, message:, status:, code: nil, details: nil)
|
|
10
|
+
if controller.respond_to?(:render_error)
|
|
11
|
+
# errors: only when there are details — a host may document its
|
|
12
|
+
# render_error contract as `(message:, status:, code:)`, and an
|
|
13
|
+
# unconditional errors: kwarg would break those implementations.
|
|
14
|
+
kwargs = { message: message, code: code, status: status }
|
|
15
|
+
kwargs[:errors] = details if details
|
|
16
|
+
controller.render_error(**kwargs)
|
|
17
|
+
else
|
|
18
|
+
error = { message: message }
|
|
19
|
+
error[:code] = code if code
|
|
20
|
+
error[:details] = details if details
|
|
21
|
+
controller.render(json: { success: false, error: error }, status: status)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module Permittable
|
|
4
|
+
# Registry of sensitive parameter names (populated by `sensitive: true`
|
|
5
|
+
# contract fields) surfaced to Rails' log filtering. Appending plain symbols
|
|
6
|
+
# to `config.filter_parameters` at class-load time misses every consumer
|
|
7
|
+
# that snapshots the list at boot (ActiveRecord's `filter_attributes` copy,
|
|
8
|
+
# lograge-style initializers, precompiled filters). A proc appended once at
|
|
9
|
+
# boot by Permittable::Railtie consults this live registry at *filter time*,
|
|
10
|
+
# so fields registered when a controller class loads later (lazy loading in
|
|
11
|
+
# development) are still redacted.
|
|
12
|
+
#
|
|
13
|
+
# Matching mirrors Rails symbol-filter semantics: case-insensitive substring
|
|
14
|
+
# match on the parameter key. The whole object is duck-typed (#add,
|
|
15
|
+
# #include?, #to_proc, #reset!) so a host can swap in its own registry via
|
|
16
|
+
# `Permittable.filter_parameter_registry=` and pool registrations.
|
|
17
|
+
class FilterParameterRegistry
|
|
18
|
+
FILTERED = "[FILTERED]".freeze
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@fields = Set.new
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@pattern = nil
|
|
24
|
+
# Stable object so the Railtie's idempotence check (`include?` before
|
|
25
|
+
# `<<`) holds across repeated initializer runs. ActiveSupport's
|
|
26
|
+
# ParameterFilter dups values before invoking proc filters, so in-place
|
|
27
|
+
# String#replace is the supported redaction mechanism.
|
|
28
|
+
@proc = lambda do |key, value|
|
|
29
|
+
value.replace(FILTERED) if value.is_a?(String) && include?(key)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add(field)
|
|
34
|
+
name = field.to_s.downcase
|
|
35
|
+
return if name.empty?
|
|
36
|
+
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@pattern = nil if @fields.add?(name)
|
|
39
|
+
end
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def include?(key)
|
|
44
|
+
regexp = pattern
|
|
45
|
+
!regexp.nil? && regexp.match?(key.to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def pattern
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
next nil if @fields.empty?
|
|
51
|
+
|
|
52
|
+
@pattern ||= Regexp.new(@fields.map { |f| Regexp.escape(f) }.join("|"), Regexp::IGNORECASE)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_proc
|
|
57
|
+
@proc
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Spec hygiene — the registry is process-global.
|
|
61
|
+
def reset!
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@fields.clear
|
|
64
|
+
@pattern = nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "rails/railtie"
|
|
2
|
+
|
|
3
|
+
module Permittable
|
|
4
|
+
# Boot-time integration, loaded only when Rails is present (see the
|
|
5
|
+
# conditional require at the bottom of lib/permittable.rb): appends the
|
|
6
|
+
# live-registry filter proc before ActiveRecord copies
|
|
7
|
+
# `config.filter_parameters` into `filter_attributes` (a `+=` snapshot), so
|
|
8
|
+
# `sensitive: true` params are redacted from both request logs and #inspect.
|
|
9
|
+
class Railtie < Rails::Railtie
|
|
10
|
+
initializer "permittable.filter_parameters",
|
|
11
|
+
before: "active_record.set_filter_attributes" do |app|
|
|
12
|
+
filter = ::Permittable.filter_parameter_registry.to_proc
|
|
13
|
+
app.config.filter_parameters << filter unless app.config.filter_parameters.include?(filter)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/permittable.rb
ADDED
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
require "active_support"
|
|
2
|
+
require "active_support/concern"
|
|
3
|
+
require "active_support/notifications"
|
|
4
|
+
require "active_support/hash_with_indifferent_access"
|
|
5
|
+
require "active_support/core_ext/class/attribute"
|
|
6
|
+
require "active_support/core_ext/string/inflections"
|
|
7
|
+
require "active_support/core_ext/string/filters"
|
|
8
|
+
require "bigdecimal"
|
|
9
|
+
require "date"
|
|
10
|
+
require "time"
|
|
11
|
+
|
|
12
|
+
require "permittable/version"
|
|
13
|
+
require "permittable/error_envelope"
|
|
14
|
+
require "permittable/column_guard"
|
|
15
|
+
require "permittable/filter_parameter_registry"
|
|
16
|
+
|
|
17
|
+
# Declarative, typed params contracts — what strong parameters would be if it
|
|
18
|
+
# also knew types, bounds, defaults, and why a request was bad. Strong
|
|
19
|
+
# parameters (and Rails 8's `params.expect`) only answer "which keys may
|
|
20
|
+
# pass"; a Permittable contract additionally casts each field, validates it,
|
|
21
|
+
# applies defaults, and turns every failure into a machine-readable 422 — and,
|
|
22
|
+
# because the contract is class-level data rather than code inside the action,
|
|
23
|
+
# it is introspectable (`permittable_contracts`) and can be checked against a
|
|
24
|
+
# model's schema at class-load time.
|
|
25
|
+
#
|
|
26
|
+
# class UsersController < ApplicationController
|
|
27
|
+
# include Permittable
|
|
28
|
+
#
|
|
29
|
+
# permit_params :create, :update, root: :user, model: User do
|
|
30
|
+
# required :name, :string, length: 1..80, normalize: :squish
|
|
31
|
+
# required :email, :string, format: URI::MailTo::EMAIL_REGEXP, normalize: :email
|
|
32
|
+
# optional :age, :integer, in: 18..120
|
|
33
|
+
# optional :ssn, :string, sensitive: true
|
|
34
|
+
# optional :plan, :string, in: %w[free pro], default: "free"
|
|
35
|
+
# array :tag_names, of: :string, length: 0..10, virtual: true
|
|
36
|
+
# optional :address do
|
|
37
|
+
# required :city, :string
|
|
38
|
+
# optional :zip, :string, format: /\A\d{5}\z/
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# def create
|
|
43
|
+
# user = User.create!(permitted_params) # cast, validated, defaulted
|
|
44
|
+
# end
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# THE LAST MATCHING RULE WINS: contracts are configuration, so a base
|
|
48
|
+
# controller's catch-all (a rule declared with no actions) is overridden by a
|
|
49
|
+
# later action-specific declaration in a subclass. Rules accumulate via
|
|
50
|
+
# reassignment, never mutation, so subclasses inherit copy-on-write.
|
|
51
|
+
#
|
|
52
|
+
# Schema-drift guard — the reason `model:` exists. Every non-virtual scalar
|
|
53
|
+
# field is checked against the model's columns when the macro runs, i.e. at
|
|
54
|
+
# controller class load. Production eager-loads controllers, so a column
|
|
55
|
+
# dropped by a migration fails the deploy, not the request; the error carries
|
|
56
|
+
# a copy-paste migration hint. Fields not backed by a column
|
|
57
|
+
# (password_confirmation, terms flags) opt out with `virtual: true`; nested
|
|
58
|
+
# and array fields are implicitly virtual. When the schema is unreachable
|
|
59
|
+
# (db:create, assets:precompile) the check skips. In CI, one
|
|
60
|
+
# `Rails.application.eager_load!` spec exercises every contract in the app.
|
|
61
|
+
#
|
|
62
|
+
# Validation is LAZY: it runs on the first `permitted_params` call, so an
|
|
63
|
+
# action that never reads params never pays. `enforce: true` installs the
|
|
64
|
+
# check as a before_action instead (reject before the action body runs).
|
|
65
|
+
#
|
|
66
|
+
# Coercion is deliberately STRICT — ActiveModel::Type is not used, because its
|
|
67
|
+
# casts are lenient by design ("abc".to_i == 0, Boolean.cast("abc") == true)
|
|
68
|
+
# and silently corrupting untrusted input is exactly what a contract must not
|
|
69
|
+
# do. A value the type cannot faithfully represent is a violation, not a
|
|
70
|
+
# guess. nil and "" are both treated as ABSENT (the query-param convention):
|
|
71
|
+
# absent optional fields are OMITTED from the result (so partial updates never
|
|
72
|
+
# nil-out columns), absent required fields violate, and `default:` fills
|
|
73
|
+
# absence. Clearing a column to NULL is therefore outside a contract's
|
|
74
|
+
# vocabulary — do that explicitly.
|
|
75
|
+
#
|
|
76
|
+
# Failures raise Permittable::InvalidParameters, rescued (on a real
|
|
77
|
+
# controller) into the shared ErrorEnvelope shape with `details:` entries of
|
|
78
|
+
# `{ param: "user.address.zip", code: "format" }`; a missing `root:` key
|
|
79
|
+
# renders 400, field violations 422. Every violation also instruments
|
|
80
|
+
# "invalid_parameters.permittable" so failures can be dashboarded.
|
|
81
|
+
#
|
|
82
|
+
# `sensitive: true` registers the field name with
|
|
83
|
+
# Permittable.filter_parameter_registry (swappable — a host gem can point it
|
|
84
|
+
# at its own registry), consulted at filter time by the proc
|
|
85
|
+
# Permittable::Railtie appends to `config.filter_parameters`.
|
|
86
|
+
#
|
|
87
|
+
# OUTPUT RESHAPING — the safe replacement for params-mutating before_actions.
|
|
88
|
+
# Two layers, both operating on the validated COPY (the request's `params` is
|
|
89
|
+
# never touched):
|
|
90
|
+
# * `transform:` (scalar and array fields) — a callable applied AFTER cast
|
|
91
|
+
# and validation to reshape that field's output, e.g.
|
|
92
|
+
# `transform: ->(v) { v.split(",") }` turns a validated delimited String
|
|
93
|
+
# into an Array. Runs only on request-supplied values: absent fields stay
|
|
94
|
+
# absent and `default:` values are authored in final shape.
|
|
95
|
+
# * `finalize do |p| ... end` (once per contract) — runs after every field
|
|
96
|
+
# validated cleanly, receives the result hash, and must return the
|
|
97
|
+
# (possibly restructured) Hash: combine parallel fields, build value
|
|
98
|
+
# objects, drop scaffolding keys. It executes on a bare runner — NOT the
|
|
99
|
+
# controller — so contracts stay pure data + pure functions; the only
|
|
100
|
+
# extra vocabulary is `violate!(param, code)`, which records one violation
|
|
101
|
+
# and halts the block immediately (the whole contract then fails as a
|
|
102
|
+
# normal 422), making finalize double as the cross-field validation seam
|
|
103
|
+
# ("ends_at after starts_at").
|
|
104
|
+
#
|
|
105
|
+
# Naming note: some legacy stacks (InheritedResources) define their own
|
|
106
|
+
# `permitted_params`; don't include both on one controller.
|
|
107
|
+
module Permittable
|
|
108
|
+
extend ActiveSupport::Concern
|
|
109
|
+
|
|
110
|
+
LABEL = "Permittable".freeze
|
|
111
|
+
SCALAR_TYPES = %i[string integer float decimal boolean date datetime].freeze
|
|
112
|
+
UNKNOWN_MODES = %i[ignore log error].freeze
|
|
113
|
+
# Rails merges routing bookkeeping into params; a top-level (root: false)
|
|
114
|
+
# unknown-keys check must not flag them.
|
|
115
|
+
ROUTING_KEYS = %w[controller action format].freeze
|
|
116
|
+
|
|
117
|
+
NORMALIZERS = {
|
|
118
|
+
squish: ->(v) { v.squish },
|
|
119
|
+
strip: ->(v) { v.strip },
|
|
120
|
+
downcase: ->(v) { v.downcase },
|
|
121
|
+
upcase: ->(v) { v.upcase },
|
|
122
|
+
email: ->(v) { v.strip.downcase },
|
|
123
|
+
}.freeze
|
|
124
|
+
|
|
125
|
+
@registry_mutex = Mutex.new
|
|
126
|
+
|
|
127
|
+
class << self
|
|
128
|
+
# Duck-typed sink for `sensitive:` field names (#add / #include? /
|
|
129
|
+
# #to_proc / #reset!). Swappable so a host gem can pool registrations into
|
|
130
|
+
# its own registry (concerns_on_rails does exactly this).
|
|
131
|
+
def filter_parameter_registry
|
|
132
|
+
@filter_parameter_registry || @registry_mutex.synchronize do
|
|
133
|
+
@filter_parameter_registry ||= FilterParameterRegistry.new
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
attr_writer :filter_parameter_registry
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Raised when the request violates the matching contract. `details` is an
|
|
141
|
+
# array of { param:, code: } hashes; `status` is :bad_request for a missing
|
|
142
|
+
# root key, :unprocessable_entity for field violations.
|
|
143
|
+
class InvalidParameters < StandardError
|
|
144
|
+
attr_reader :details, :status
|
|
145
|
+
|
|
146
|
+
def initialize(message, details: [], status: :unprocessable_entity)
|
|
147
|
+
super(message)
|
|
148
|
+
@details = details
|
|
149
|
+
@status = status
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
included do
|
|
154
|
+
class_attribute :permittable_contracts, instance_accessor: false, default: []
|
|
155
|
+
|
|
156
|
+
rescue_from InvalidParameters, with: :render_invalid_parameters if respond_to?(:rescue_from)
|
|
157
|
+
before_action :enforce_params_contract if respond_to?(:before_action)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Strict params-shaped coercion, shared by request-time validation and
|
|
161
|
+
# macro-time `default:` checking. Every entry point returns
|
|
162
|
+
# [:ok, cast_value] or [:error, code_string].
|
|
163
|
+
module Coercion
|
|
164
|
+
module_function
|
|
165
|
+
|
|
166
|
+
TRUE_VALUES = [true, "true", "1", 1].freeze
|
|
167
|
+
FALSE_VALUES = [false, "false", "0", 0].freeze
|
|
168
|
+
|
|
169
|
+
# Full pipeline for one scalar field: normalize → cast → in / format /
|
|
170
|
+
# length / validate.
|
|
171
|
+
def check_scalar(field, value)
|
|
172
|
+
value = apply_normalize(field[:normalize], value)
|
|
173
|
+
status, value = cast(field[:type], value)
|
|
174
|
+
return [status, value] unless status == :ok
|
|
175
|
+
|
|
176
|
+
check_scalar_rules(field, value)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def check_scalar_rules(field, value)
|
|
180
|
+
return [:error, "inclusion"] if field[:in] && !included_in?(field[:in], value)
|
|
181
|
+
return [:error, "format"] if field[:format] && !field[:format].match?(value)
|
|
182
|
+
return [:error, "length"] if field[:length] && !length_ok?(field[:length], value.length)
|
|
183
|
+
|
|
184
|
+
check_custom(field[:validate], value)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A custom validator returning a Symbol fails with that symbol as the
|
|
188
|
+
# violation code; false/nil fails as "invalid"; any other truthy value
|
|
189
|
+
# passes.
|
|
190
|
+
def check_custom(validator, value)
|
|
191
|
+
return [:ok, value] unless validator
|
|
192
|
+
|
|
193
|
+
verdict = validator.call(value)
|
|
194
|
+
return [:error, verdict.to_s] if verdict.is_a?(Symbol)
|
|
195
|
+
return [:error, "invalid"] unless verdict
|
|
196
|
+
|
|
197
|
+
[:ok, value]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def cast(type, value)
|
|
201
|
+
return [:error, "invalid_type"] unless scalar_shaped?(value)
|
|
202
|
+
|
|
203
|
+
public_send("cast_#{type}", value)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Arrays, hashes, and nested ActionController::Parameters
|
|
207
|
+
# (`?age[]=1`, `?age[x]=1`) can never satisfy a scalar type.
|
|
208
|
+
def scalar_shaped?(value)
|
|
209
|
+
return false if value.is_a?(Array) || value.is_a?(Hash)
|
|
210
|
+
return false if defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters)
|
|
211
|
+
|
|
212
|
+
true
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def cast_string(value)
|
|
216
|
+
case value
|
|
217
|
+
when String then [:ok, value]
|
|
218
|
+
when Numeric, true, false then [:ok, value.to_s]
|
|
219
|
+
else [:error, "invalid_type"]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def cast_integer(value)
|
|
224
|
+
case value
|
|
225
|
+
when Integer then [:ok, value]
|
|
226
|
+
when Float then value == value.truncate ? [:ok, value.to_i] : [:error, "invalid_type"]
|
|
227
|
+
when String then [:ok, Integer(value, 10)]
|
|
228
|
+
else [:error, "invalid_type"]
|
|
229
|
+
end
|
|
230
|
+
rescue ArgumentError
|
|
231
|
+
[:error, "invalid_type"]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def cast_float(value)
|
|
235
|
+
case value
|
|
236
|
+
when Numeric then [:ok, value.to_f]
|
|
237
|
+
when String then [:ok, Float(value)]
|
|
238
|
+
else [:error, "invalid_type"]
|
|
239
|
+
end
|
|
240
|
+
rescue ArgumentError
|
|
241
|
+
[:error, "invalid_type"]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def cast_decimal(value)
|
|
245
|
+
case value
|
|
246
|
+
when Numeric, String then [:ok, BigDecimal(value.to_s)]
|
|
247
|
+
else [:error, "invalid_type"]
|
|
248
|
+
end
|
|
249
|
+
rescue ArgumentError
|
|
250
|
+
[:error, "invalid_type"]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def cast_boolean(value)
|
|
254
|
+
return [:ok, true] if TRUE_VALUES.include?(value)
|
|
255
|
+
return [:ok, false] if FALSE_VALUES.include?(value)
|
|
256
|
+
|
|
257
|
+
[:error, "invalid_type"]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def cast_date(value)
|
|
261
|
+
case value
|
|
262
|
+
when Date then [:ok, value]
|
|
263
|
+
when String then [:ok, Date.parse(value)]
|
|
264
|
+
else [:error, "invalid_type"]
|
|
265
|
+
end
|
|
266
|
+
rescue ArgumentError, RangeError
|
|
267
|
+
[:error, "invalid_type"]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# A zoneless String parses as UTC regardless of the host timezone
|
|
271
|
+
# (deterministic); explicit offsets are honoured and normalised to UTC.
|
|
272
|
+
def cast_datetime(value)
|
|
273
|
+
case value
|
|
274
|
+
when ActiveSupport::TimeWithZone, Time then [:ok, value.to_time.utc]
|
|
275
|
+
when DateTime then [:ok, value.to_time.utc]
|
|
276
|
+
when Date then [:ok, Time.utc(value.year, value.month, value.day)]
|
|
277
|
+
when String then [:ok, DateTime.parse(value).to_time.utc]
|
|
278
|
+
else [:error, "invalid_type"]
|
|
279
|
+
end
|
|
280
|
+
rescue ArgumentError, RangeError
|
|
281
|
+
[:error, "invalid_type"]
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Presets only make sense on String input; a non-String value (JSON
|
|
285
|
+
# numbers, booleans) skips normalization and goes straight to the cast.
|
|
286
|
+
def apply_normalize(normalizer, value)
|
|
287
|
+
return value unless normalizer && value.is_a?(String)
|
|
288
|
+
|
|
289
|
+
normalizer.call(value)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Range#include? walks discrete ranges; cover? is the O(1) bounds check
|
|
293
|
+
# and the right semantics for validation.
|
|
294
|
+
def included_in?(allowed, value)
|
|
295
|
+
allowed.is_a?(Range) ? allowed.cover?(value) : allowed.include?(value)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def length_ok?(spec, length)
|
|
299
|
+
spec.is_a?(Range) ? spec.cover?(length) : spec == length
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Builds the frozen field list from the permit_params block. Every
|
|
304
|
+
# declaration is validated eagerly: a bad contract is a programmer error and
|
|
305
|
+
# should fail at class load, not at request time.
|
|
306
|
+
class ContractBuilder
|
|
307
|
+
SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform].freeze
|
|
308
|
+
NESTED_OPTS = %i[virtual sensitive].freeze
|
|
309
|
+
ARRAY_OPTS = %i[of length default validate virtual sensitive required transform].freeze
|
|
310
|
+
|
|
311
|
+
attr_reader :finalizer
|
|
312
|
+
|
|
313
|
+
def initialize
|
|
314
|
+
@fields = []
|
|
315
|
+
@finalizer = nil
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def build(&block)
|
|
319
|
+
instance_eval(&block)
|
|
320
|
+
@fields.map(&:freeze).freeze
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Post-validation reshaping of the whole contract — see the module
|
|
324
|
+
# comment. Once per contract, top level only.
|
|
325
|
+
def finalize(&block)
|
|
326
|
+
raise ArgumentError, "#{LABEL}: finalize requires a block" unless block
|
|
327
|
+
raise ArgumentError, "#{LABEL}: finalize may only be declared once per contract" if @finalizer
|
|
328
|
+
|
|
329
|
+
@finalizer = block
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# `required :name` defaults the type to :string. A block instead of a
|
|
333
|
+
# type declares a nested hash of sub-fields.
|
|
334
|
+
def required(name, type = nil, **opts, &block)
|
|
335
|
+
add_field(name, type, required: true, opts: opts, &block)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def optional(name, type = nil, **opts, &block)
|
|
339
|
+
add_field(name, type, required: false, opts: opts, &block)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Array of scalars (`of:`, default :string) or, with a block, an array
|
|
343
|
+
# of nested hashes. Optional unless `required: true`; `length:`
|
|
344
|
+
# constrains the element COUNT.
|
|
345
|
+
def array(name, **opts, &block)
|
|
346
|
+
name = field_name!(name)
|
|
347
|
+
assert_opts!(name, opts, ARRAY_OPTS)
|
|
348
|
+
required = opts.delete(:required) ? true : false
|
|
349
|
+
|
|
350
|
+
field = { name: name, kind: :array, required: required, **opts }
|
|
351
|
+
if block
|
|
352
|
+
raise ArgumentError, "#{LABEL}: array :#{name} takes of: OR a block, not both" if opts.key?(:of)
|
|
353
|
+
|
|
354
|
+
field[:fields] = nested_fields!(name, &block)
|
|
355
|
+
field.delete(:of)
|
|
356
|
+
else
|
|
357
|
+
field[:of] = scalar_type!(name, opts[:of] || :string)
|
|
358
|
+
end
|
|
359
|
+
validate_length!(name, field[:length]) if field.key?(:length)
|
|
360
|
+
validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
|
|
361
|
+
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
|
|
362
|
+
validate_array_default!(field) if field.key?(:default)
|
|
363
|
+
@fields << field
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
private
|
|
367
|
+
|
|
368
|
+
def add_field(name, type, required:, opts:, &block)
|
|
369
|
+
name = field_name!(name)
|
|
370
|
+
if block
|
|
371
|
+
raise ArgumentError, "#{LABEL}: :#{name} takes a type OR a nested block, not both" if type
|
|
372
|
+
|
|
373
|
+
assert_opts!(name, opts, NESTED_OPTS)
|
|
374
|
+
@fields << { name: name, kind: :nested, required: required,
|
|
375
|
+
fields: nested_fields!(name, &block), **opts }
|
|
376
|
+
else
|
|
377
|
+
assert_opts!(name, opts, SCALAR_OPTS)
|
|
378
|
+
field = { name: name, kind: :scalar, required: required,
|
|
379
|
+
type: scalar_type!(name, type || :string), **opts }
|
|
380
|
+
validate_scalar_opts!(field)
|
|
381
|
+
@fields << field
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def field_name!(name)
|
|
386
|
+
name = name.to_sym
|
|
387
|
+
if @fields.any? { |f| f[:name] == name }
|
|
388
|
+
raise ArgumentError, "#{LABEL}: field :#{name} is declared twice in the same contract"
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
name
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def assert_opts!(name, opts, allowed)
|
|
395
|
+
unknown = opts.keys - allowed
|
|
396
|
+
return if unknown.empty?
|
|
397
|
+
|
|
398
|
+
raise ArgumentError,
|
|
399
|
+
"#{LABEL}: unknown option(s) #{unknown.map(&:inspect).join(', ')} for field :#{name} " \
|
|
400
|
+
"(allowed: #{allowed.map(&:inspect).join(', ')})"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def scalar_type!(name, type)
|
|
404
|
+
type = type.to_sym
|
|
405
|
+
return type if SCALAR_TYPES.include?(type)
|
|
406
|
+
|
|
407
|
+
raise ArgumentError, "#{LABEL}: field :#{name} has unknown type :#{type} " \
|
|
408
|
+
"(supported: #{SCALAR_TYPES.join(', ')})"
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def nested_fields!(name, &block)
|
|
412
|
+
builder = ContractBuilder.new
|
|
413
|
+
fields = builder.build(&block)
|
|
414
|
+
raise ArgumentError, "#{LABEL}: nested field :#{name} declares no sub-fields" if fields.empty?
|
|
415
|
+
if builder.finalizer
|
|
416
|
+
raise ArgumentError, "#{LABEL}: finalize is only available at the top level of a contract (found inside :#{name})"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
fields
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def validate_scalar_opts!(field)
|
|
423
|
+
name = field[:name]
|
|
424
|
+
if field[:required] && field.key?(:default)
|
|
425
|
+
raise ArgumentError, "#{LABEL}: field :#{name} is required and cannot have a :default (default implies optional)"
|
|
426
|
+
end
|
|
427
|
+
if field.key?(:in) && !field[:in].respond_to?(:include?)
|
|
428
|
+
raise ArgumentError, "#{LABEL}: :in for field :#{name} must respond to include? (Range or Array)"
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
validate_string_only_opts!(field)
|
|
432
|
+
validate_length!(name, field[:length]) if field.key?(:length)
|
|
433
|
+
validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
|
|
434
|
+
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
|
|
435
|
+
resolve_normalizer!(field)
|
|
436
|
+
validate_default!(field)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# format / length / normalize reason about characters; on any other
|
|
440
|
+
# type they would silently apply to a cast non-String and mislead.
|
|
441
|
+
def validate_string_only_opts!(field)
|
|
442
|
+
return if field[:type] == :string
|
|
443
|
+
|
|
444
|
+
%i[format length normalize].each do |opt|
|
|
445
|
+
next unless field.key?(opt)
|
|
446
|
+
|
|
447
|
+
raise ArgumentError, "#{LABEL}: :#{opt} is only supported on :string fields (field :#{field[:name]} is :#{field[:type]})"
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def validate_length!(name, length)
|
|
452
|
+
return if length.is_a?(Range) || length.is_a?(Integer)
|
|
453
|
+
|
|
454
|
+
raise ArgumentError, "#{LABEL}: :length for :#{name} must be a Range or Integer"
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def validate_callable!(name, opt, value)
|
|
458
|
+
return if value.respond_to?(:call)
|
|
459
|
+
|
|
460
|
+
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{name} must be callable"
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def resolve_normalizer!(field)
|
|
464
|
+
normalizer = field[:normalize]
|
|
465
|
+
return if normalizer.nil?
|
|
466
|
+
return if normalizer.respond_to?(:call) && !normalizer.is_a?(Symbol)
|
|
467
|
+
|
|
468
|
+
field[:normalize] = NORMALIZERS.fetch(normalizer.to_sym) do
|
|
469
|
+
raise ArgumentError, "#{LABEL}: unknown :normalize preset :#{normalizer} for field :#{field[:name]} " \
|
|
470
|
+
"(presets: #{NORMALIZERS.keys.join(', ')}, or pass a Proc)"
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# A default must satisfy the field's own contract — catching a bad
|
|
475
|
+
# default at class load beats shipping it to every request.
|
|
476
|
+
def validate_default!(field)
|
|
477
|
+
return unless field.key?(:default)
|
|
478
|
+
|
|
479
|
+
status, code = Coercion.check_scalar(field, field[:default])
|
|
480
|
+
return if status == :ok
|
|
481
|
+
|
|
482
|
+
raise ArgumentError, "#{LABEL}: :default for field :#{field[:name]} violates its own contract (#{code})"
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def validate_array_default!(field)
|
|
486
|
+
default = field[:default]
|
|
487
|
+
raise ArgumentError, "#{LABEL}: :default for array :#{field[:name]} must be an Array" unless default.is_a?(Array)
|
|
488
|
+
return unless field[:of]
|
|
489
|
+
|
|
490
|
+
default.each do |element|
|
|
491
|
+
status, code = Coercion.cast(field[:of], element)
|
|
492
|
+
next if status == :ok
|
|
493
|
+
|
|
494
|
+
raise ArgumentError, "#{LABEL}: :default for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# The `self` a finalize block runs on. Deliberately bare — no controller
|
|
500
|
+
# delegation — so a contract cannot grow request-state dependencies; its
|
|
501
|
+
# whole vocabulary is the hash it receives plus `violate!`.
|
|
502
|
+
class FinalizeRunner
|
|
503
|
+
def initialize(violations)
|
|
504
|
+
@violations = violations
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Records ONE violation and halts the finalize block immediately (the
|
|
508
|
+
# code after a violate! call never runs, so it can assume the checked
|
|
509
|
+
# invariant). The contract then fails as a normal 422.
|
|
510
|
+
def violate!(param, code)
|
|
511
|
+
@violations << { param: param.to_s, code: code.to_s }
|
|
512
|
+
throw :permittable_finalize_halt
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
class_methods do
|
|
517
|
+
# Declare a params contract. No positional actions = catch-all for the
|
|
518
|
+
# whole controller. Repeatable; the LAST rule matching the request's
|
|
519
|
+
# action wins.
|
|
520
|
+
#
|
|
521
|
+
# root: key to unwrap first (`require(:user)` equivalent); false
|
|
522
|
+
# (default) reads top-level params. Missing root renders 400.
|
|
523
|
+
# model: a model class (or `true` to infer from controller_name)
|
|
524
|
+
# enabling the schema-drift check on every non-virtual scalar
|
|
525
|
+
# field.
|
|
526
|
+
# unknown: :ignore (default) / :log / :error — what to do with
|
|
527
|
+
# undeclared keys, at every nesting level.
|
|
528
|
+
# enforce: false (default) validates lazily on the first
|
|
529
|
+
# permitted_params call; true validates in a before_action.
|
|
530
|
+
def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, &block)
|
|
531
|
+
raise ArgumentError, "#{LABEL}: permit_params requires a block declaring the contract fields" unless block
|
|
532
|
+
|
|
533
|
+
unknown = unknown.to_sym
|
|
534
|
+
raise ArgumentError, "#{LABEL}: :unknown must be one of #{UNKNOWN_MODES.join(', ')}" unless UNKNOWN_MODES.include?(unknown)
|
|
535
|
+
|
|
536
|
+
builder = ContractBuilder.new
|
|
537
|
+
fields = builder.build(&block)
|
|
538
|
+
raise ArgumentError, "#{LABEL}: a contract must declare at least one field" if fields.empty?
|
|
539
|
+
|
|
540
|
+
model_class = resolve_permit_model(model)
|
|
541
|
+
guard_contract_columns!(model_class, fields) if model_class
|
|
542
|
+
register_sensitive_params(fields)
|
|
543
|
+
|
|
544
|
+
rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
|
|
545
|
+
model: model_class, unknown: unknown, enforce: !!enforce, fields: fields,
|
|
546
|
+
finalize: builder.finalizer }.freeze
|
|
547
|
+
self.permittable_contracts = permittable_contracts + [rule]
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# The LAST declared rule matching `action`, or nil.
|
|
551
|
+
def permit_rule_for(action)
|
|
552
|
+
action = action.to_s
|
|
553
|
+
permittable_contracts.reverse_each.find do |rule|
|
|
554
|
+
rule[:actions].empty? || rule[:actions].include?(action)
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
private
|
|
559
|
+
|
|
560
|
+
def resolve_permit_model(model)
|
|
561
|
+
case model
|
|
562
|
+
when nil, false then nil
|
|
563
|
+
when true then infer_permit_model
|
|
564
|
+
else
|
|
565
|
+
unless model.is_a?(Class) && model.respond_to?(:column_names)
|
|
566
|
+
raise ArgumentError, "#{LABEL}: :model must be an ActiveRecord model class, true (infer from controller name), or nil"
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
model
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def infer_permit_model
|
|
574
|
+
unless respond_to?(:controller_name)
|
|
575
|
+
raise ArgumentError, "#{LABEL}: model: true needs controller_name to infer from — pass the class explicitly (model: SomeModel)"
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
name = controller_name.classify
|
|
579
|
+
name.safe_constantize ||
|
|
580
|
+
raise(ArgumentError, "#{LABEL}: model: true inferred #{name} from '#{controller_name}' but no such class exists — " \
|
|
581
|
+
"pass the class explicitly (model: SomeModel)")
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# The drift guard. Nested/array fields are implicitly virtual — only
|
|
585
|
+
# scalar fields map one-to-one onto columns.
|
|
586
|
+
def guard_contract_columns!(model_class, fields)
|
|
587
|
+
checked = fields.select { |f| f[:kind] == :scalar && !f[:virtual] }
|
|
588
|
+
return if checked.empty?
|
|
589
|
+
|
|
590
|
+
types = checked.to_h { |f| [f[:name], f[:type]] }
|
|
591
|
+
begin
|
|
592
|
+
ColumnGuard.ensure_columns_on!(LABEL, model_class, *checked.map { |f| f[:name] }, types: types)
|
|
593
|
+
rescue ArgumentError => e
|
|
594
|
+
raise ArgumentError, "#{e.message} If this parameter is not backed by a column, declare it with virtual: true."
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def register_sensitive_params(fields)
|
|
599
|
+
fields.each do |field|
|
|
600
|
+
Permittable.filter_parameter_registry.add(field[:name]) if field[:sensitive]
|
|
601
|
+
register_sensitive_params(field[:fields]) if field[:fields]
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# The contract's output: a HashWithIndifferentAccess of cast, validated,
|
|
607
|
+
# defaulted values for the given action (default: the current action).
|
|
608
|
+
# Absent optional fields are omitted. Raises InvalidParameters on
|
|
609
|
+
# violation; raises ArgumentError when no contract covers the action
|
|
610
|
+
# (that is a programmer error, not a client error). Memoized per action.
|
|
611
|
+
def permitted_params(action = nil)
|
|
612
|
+
action = (action || permittable_action_name).to_s
|
|
613
|
+
raise ArgumentError, "#{LABEL}: no action given and action_name is not set" if action.empty?
|
|
614
|
+
|
|
615
|
+
@permittable_validated ||= {}
|
|
616
|
+
return @permittable_validated[action] if @permittable_validated.key?(action)
|
|
617
|
+
|
|
618
|
+
rule = self.class.permit_rule_for(action)
|
|
619
|
+
raise ArgumentError, "#{LABEL}: no params contract declared covering ##{action}" unless rule
|
|
620
|
+
|
|
621
|
+
@permittable_validated[action] = validate_params_contract!(rule)
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# before_action entry point (public so hosts can `skip_before_action
|
|
625
|
+
# :enforce_params_contract`). Only rules that opted in with
|
|
626
|
+
# `enforce: true` validate here.
|
|
627
|
+
def enforce_params_contract
|
|
628
|
+
action = permittable_action_name
|
|
629
|
+
return nil unless action
|
|
630
|
+
|
|
631
|
+
rule = self.class.permit_rule_for(action)
|
|
632
|
+
permitted_params(action) if rule && rule[:enforce]
|
|
633
|
+
nil
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# rescue_from target — renders through the shared envelope (the host's
|
|
637
|
+
# render_error when present, the identical inline shape otherwise).
|
|
638
|
+
def render_invalid_parameters(error)
|
|
639
|
+
ErrorEnvelope.render(
|
|
640
|
+
self, message: error.message, status: error.status,
|
|
641
|
+
code: "invalid_parameters", details: error.details
|
|
642
|
+
)
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
private
|
|
646
|
+
|
|
647
|
+
def validate_params_contract!(rule)
|
|
648
|
+
violations = []
|
|
649
|
+
source = permittable_root_hash(rule, violations)
|
|
650
|
+
result = ActiveSupport::HashWithIndifferentAccess.new
|
|
651
|
+
if source
|
|
652
|
+
result = permittable_check_hash(rule[:fields], source, path: rule[:root] ? rule[:root].to_s : nil,
|
|
653
|
+
unknown: rule[:unknown], top_level: !rule[:root], violations: violations)
|
|
654
|
+
end
|
|
655
|
+
# finalize only sees a hash every field vouched for — never garbage.
|
|
656
|
+
if violations.empty? && rule[:finalize]
|
|
657
|
+
result = permittable_run_finalize(rule[:finalize], result, violations)
|
|
658
|
+
end
|
|
659
|
+
return result if violations.empty?
|
|
660
|
+
|
|
661
|
+
raise_invalid_parameters!(violations, status: source ? :unprocessable_entity : :bad_request)
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def raise_invalid_parameters!(violations, status:)
|
|
665
|
+
violations.each(&:freeze)
|
|
666
|
+
ActiveSupport::Notifications.instrument(
|
|
667
|
+
"invalid_parameters.permittable",
|
|
668
|
+
controller: permittable_controller_name, action: permittable_action_name, details: violations
|
|
669
|
+
)
|
|
670
|
+
summary = violations.map { |v| "#{v[:param]} (#{v[:code]})" }.join(", ")
|
|
671
|
+
raise InvalidParameters.new("Invalid parameters: #{summary}", details: violations, status: status)
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def permittable_run_finalize(finalizer, result, violations)
|
|
675
|
+
runner = FinalizeRunner.new(violations)
|
|
676
|
+
finalized = catch(:permittable_finalize_halt) do
|
|
677
|
+
runner.instance_exec(result, &finalizer)
|
|
678
|
+
end
|
|
679
|
+
return result unless violations.empty?
|
|
680
|
+
unless finalized.is_a?(Hash)
|
|
681
|
+
raise ArgumentError,
|
|
682
|
+
"#{LABEL}: finalize must return the params Hash (got #{finalized.class}) — " \
|
|
683
|
+
"end the block with the hash, e.g. `p` or `p.except(:scaffolding)`"
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
finalized.is_a?(ActiveSupport::HashWithIndifferentAccess) ? finalized : ActiveSupport::HashWithIndifferentAccess.new(finalized)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def permittable_root_hash(rule, violations)
|
|
690
|
+
raw = permittable_plain_params
|
|
691
|
+
return raw unless rule[:root]
|
|
692
|
+
|
|
693
|
+
value = raw[rule[:root].to_s]
|
|
694
|
+
return value if value.is_a?(Hash)
|
|
695
|
+
|
|
696
|
+
violations << { param: rule[:root].to_s, code: "missing" }
|
|
697
|
+
nil
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# One plain HashWithIndifferentAccess view of `params`, whatever the
|
|
701
|
+
# stack: ActionController::Parameters (to_unsafe_h — this concern does
|
|
702
|
+
# its own permitting, that is the point) or a plain hash in tests.
|
|
703
|
+
def permittable_plain_params
|
|
704
|
+
raw = params
|
|
705
|
+
raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
|
|
706
|
+
ActiveSupport::HashWithIndifferentAccess.new(raw)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
def permittable_check_hash(fields, hash, path:, unknown:, top_level:, violations:)
|
|
710
|
+
result = ActiveSupport::HashWithIndifferentAccess.new
|
|
711
|
+
fields.each do |field|
|
|
712
|
+
key = field[:name].to_s
|
|
713
|
+
full = permittable_path(path, key)
|
|
714
|
+
value = hash[key]
|
|
715
|
+
|
|
716
|
+
if permittable_absent?(value, hash, key)
|
|
717
|
+
if field.key?(:default)
|
|
718
|
+
result[key] = field[:default]
|
|
719
|
+
elsif field[:required]
|
|
720
|
+
violations << { param: full, code: "missing" }
|
|
721
|
+
end
|
|
722
|
+
next
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
permittable_check_field(field, value, full, result, unknown: unknown, violations: violations)
|
|
726
|
+
end
|
|
727
|
+
permittable_check_unknown(fields, hash, path: path, unknown: unknown, top_level: top_level, violations: violations)
|
|
728
|
+
result
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def permittable_check_field(field, value, full, result, unknown:, violations:)
|
|
732
|
+
key = field[:name].to_s
|
|
733
|
+
case field[:kind]
|
|
734
|
+
when :scalar
|
|
735
|
+
status, out = Coercion.check_scalar(field, value)
|
|
736
|
+
if status == :ok
|
|
737
|
+
out = field[:transform].call(out) if field[:transform]
|
|
738
|
+
result[key] = out
|
|
739
|
+
else
|
|
740
|
+
violations << { param: full, code: out }
|
|
741
|
+
end
|
|
742
|
+
when :nested
|
|
743
|
+
if value.is_a?(Hash)
|
|
744
|
+
result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
|
|
745
|
+
path: full, unknown: unknown, top_level: false, violations: violations)
|
|
746
|
+
else
|
|
747
|
+
violations << { param: full, code: "invalid_type" }
|
|
748
|
+
end
|
|
749
|
+
when :array
|
|
750
|
+
if value.is_a?(Array)
|
|
751
|
+
result[key] = permittable_check_array(field, value, path: full, unknown: unknown, violations: violations)
|
|
752
|
+
else
|
|
753
|
+
violations << { param: full, code: "invalid_type" }
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def permittable_check_array(field, value, path:, unknown:, violations:)
|
|
759
|
+
before = violations.length
|
|
760
|
+
if field[:length] && !Coercion.length_ok?(field[:length], value.length)
|
|
761
|
+
violations << { param: path, code: "length" }
|
|
762
|
+
end
|
|
763
|
+
out = value.each_with_index.map do |element, index|
|
|
764
|
+
permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
|
|
765
|
+
end
|
|
766
|
+
if field[:validate]
|
|
767
|
+
status, code = Coercion.check_custom(field[:validate], out)
|
|
768
|
+
violations << { param: path, code: code } unless status == :ok
|
|
769
|
+
end
|
|
770
|
+
# Transform only a fully-valid array — a partially-nil one (element
|
|
771
|
+
# violations) would hand user code garbage it never agreed to see.
|
|
772
|
+
out = field[:transform].call(out) if field[:transform] && violations.length == before
|
|
773
|
+
out
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
def permittable_check_element(field, element, path, unknown:, violations:)
|
|
777
|
+
if field[:fields]
|
|
778
|
+
unless element.is_a?(Hash)
|
|
779
|
+
violations << { param: path, code: "invalid_type" }
|
|
780
|
+
return nil
|
|
781
|
+
end
|
|
782
|
+
return permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(element),
|
|
783
|
+
path: path, unknown: unknown, top_level: false, violations: violations)
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
status, out = Coercion.cast(field[:of], element)
|
|
787
|
+
return out if status == :ok
|
|
788
|
+
|
|
789
|
+
violations << { param: path, code: out }
|
|
790
|
+
nil
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# nil and "" are both ABSENT — see the module comment.
|
|
794
|
+
def permittable_absent?(value, hash, key)
|
|
795
|
+
!hash.key?(key) || value.nil? || (value.is_a?(String) && value.empty?)
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def permittable_check_unknown(fields, hash, path:, unknown:, top_level:, violations:)
|
|
799
|
+
return if unknown == :ignore
|
|
800
|
+
|
|
801
|
+
declared = fields.map { |f| f[:name].to_s }
|
|
802
|
+
extra = hash.keys.map(&:to_s) - declared
|
|
803
|
+
extra -= ROUTING_KEYS if top_level
|
|
804
|
+
return if extra.empty?
|
|
805
|
+
|
|
806
|
+
if unknown == :error
|
|
807
|
+
extra.each { |key| violations << { param: permittable_path(path, key), code: "unknown" } }
|
|
808
|
+
elsif respond_to?(:logger) && logger
|
|
809
|
+
logger.warn("#{LABEL}: unknown parameter(s) ignored by the ##{permittable_action_name} contract: " \
|
|
810
|
+
"#{extra.map { |key| permittable_path(path, key) }.join(', ')}")
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
def permittable_path(path, key)
|
|
815
|
+
path ? "#{path}.#{key}" : key
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def permittable_action_name
|
|
819
|
+
respond_to?(:action_name) && action_name ? action_name.to_s : nil
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def permittable_controller_name
|
|
823
|
+
return controller_path if respond_to?(:controller_path)
|
|
824
|
+
|
|
825
|
+
self.class.name
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# Boot-time integration (filter_parameters registration), Rails apps only
|
|
830
|
+
require "permittable/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: permittable
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ethan Nguyen
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-16 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activesupport
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '9'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '5.0'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '9'
|
|
33
|
+
description: 'Declarative per-action params contracts: strict typing/coercion, validation,
|
|
34
|
+
defaults, machine-readable 422s, output reshaping (transform/finalize), and a boot-time
|
|
35
|
+
schema-drift guard that fails the deploy when a permitted field''s column was dropped.
|
|
36
|
+
Strong parameters with types, validation, and drift detection.'
|
|
37
|
+
email:
|
|
38
|
+
- doctorit@gmail.com
|
|
39
|
+
executables: []
|
|
40
|
+
extensions: []
|
|
41
|
+
extra_rdoc_files: []
|
|
42
|
+
files:
|
|
43
|
+
- CHANGELOG.md
|
|
44
|
+
- LICENSE.txt
|
|
45
|
+
- README.md
|
|
46
|
+
- lib/permittable.rb
|
|
47
|
+
- lib/permittable/column_guard.rb
|
|
48
|
+
- lib/permittable/error_envelope.rb
|
|
49
|
+
- lib/permittable/filter_parameter_registry.rb
|
|
50
|
+
- lib/permittable/railtie.rb
|
|
51
|
+
- lib/permittable/version.rb
|
|
52
|
+
homepage: https://github.com/VSN2015/permittable
|
|
53
|
+
licenses:
|
|
54
|
+
- MIT
|
|
55
|
+
metadata:
|
|
56
|
+
license: MIT
|
|
57
|
+
homepage_uri: https://github.com/VSN2015/permittable
|
|
58
|
+
source_code_uri: https://github.com/VSN2015/permittable
|
|
59
|
+
changelog_uri: https://github.com/VSN2015/permittable/blob/master/CHANGELOG.md
|
|
60
|
+
post_install_message:
|
|
61
|
+
rdoc_options: []
|
|
62
|
+
require_paths:
|
|
63
|
+
- lib
|
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 3.2.0
|
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
requirements: []
|
|
75
|
+
rubygems_version: 3.4.10
|
|
76
|
+
signing_key:
|
|
77
|
+
specification_version: 4
|
|
78
|
+
summary: Typed, validated params contracts for Rails controllers + schema-drift guard
|
|
79
|
+
test_files: []
|