permittable 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +367 -46
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +71 -18
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 577c3a6c767e85dd2b3fc0ad8049e1d0ec77d2b87bc1f6eedca351161f589ef9
|
|
4
|
+
data.tar.gz: bed2c92d56a70abf45f8f9cc4b779788886623b442054d63727893f891eb0c13
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd955e930e66036f2bffd9f006996ad0713e3582eda8386e51bc668d42432db7f941bef9724ba5c6589e85be4ce91827cc8536f49ca6c0d698562de0aee65520
|
|
7
|
+
data.tar.gz: a61f63950413801e405a6690e141a35616d974cc347bdae86e9a6f239a75e89225604b973acb4daf4b7daa25492e767175f674de6376df62eb0128e27f16fa62
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (2026-08-18)
|
|
4
|
+
<!-- title: custom error messages -->
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
- `message:` field option for customizing violation messages, on every field kind (scalar, array, nested). A String covers every violation code on the field; a Hash of code → String targets specific codes — including Symbol codes returned by `validate:` — while unmatched codes keep the default rendering. A resolved message is carried in the violation detail as `message:` and replaces the `(code)` part of the `InvalidParameters` summary, so it flows into the error envelope and the `invalid_parameters.permittable` instrumentation payload unchanged. An array's message also covers its elements' violations; a malformed `message:` raises at class load like every other contract mistake.
|
|
8
|
+
- `violate!(param, code, message: nil)` — finalize's violation verb accepts the same optional human-readable message.
|
|
9
|
+
|
|
10
|
+
Contracts that don't opt in are byte-for-byte unaffected: details keep the bare `{ param:, code: }` shape and the summary keeps its `param (code)` rendering.
|
|
11
|
+
|
|
3
12
|
## 0.1.2 (2026-08-16)
|
|
4
13
|
<!-- title: gem metadata -->
|
|
5
14
|
|
data/README.md
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# Permittable
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://rubygems.org/gems/permittable)
|
|
4
|
+
[](https://github.com/VSN2015/permittable/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE.txt)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
**Strong parameters for Rails that also know types, bounds, and defaults.**
|
|
8
|
+
|
|
9
|
+
`params.permit` answers exactly one question: *which keys may pass?* Everything else — is `age` really a number, is `email` shaped like an email, what should `plan` be when the client omits it, and *why* was this request rejected — is left to you, usually as hand-written checks scattered through the action.
|
|
10
|
+
|
|
11
|
+
A Permittable contract answers those questions too. It **casts** each field to a declared type, **validates** it, applies **defaults**, optionally **reshapes** the output, and turns every failure into a machine-readable 422 that names the offending parameter.
|
|
12
|
+
|
|
13
|
+
And because a contract is *class-level data* rather than code inside the action, it can be inspected — and checked against your database when the controller loads, so a column dropped by a migration fails the deploy instead of the request.
|
|
6
14
|
|
|
7
15
|
```ruby
|
|
8
16
|
class UsersController < ApplicationController
|
|
@@ -22,12 +30,12 @@ class UsersController < ApplicationController
|
|
|
22
30
|
end
|
|
23
31
|
|
|
24
32
|
def create
|
|
25
|
-
|
|
33
|
+
User.create!(permitted_params) # cast, validated, defaulted
|
|
26
34
|
end
|
|
27
35
|
end
|
|
28
36
|
```
|
|
29
37
|
|
|
30
|
-
A violating request
|
|
38
|
+
A violating request never reaches your action:
|
|
31
39
|
|
|
32
40
|
```json
|
|
33
41
|
{ "success": false,
|
|
@@ -36,56 +44,275 @@ A violating request renders:
|
|
|
36
44
|
"details": [{ "param": "user.age", "code": "inclusion" }] } }
|
|
37
45
|
```
|
|
38
46
|
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Contents
|
|
50
|
+
|
|
51
|
+
- [Why](#why) · [Installation](#installation) · [How a request flows](#how-a-request-flows)
|
|
52
|
+
- [Declaring a contract](#declaring-a-contract) · [The field DSL](#the-field-dsl) · [Field options](#field-options)
|
|
53
|
+
- [Types and strict coercion](#types-and-strict-coercion) · [Absence, defaults, and partial updates](#absence-defaults-and-partial-updates)
|
|
54
|
+
- [Violations and error responses](#violations-and-error-responses) · [Custom error messages](#custom-error-messages-message) · [Unknown parameters](#unknown-parameters)
|
|
55
|
+
- [Output reshaping](#output-reshaping-transform-and-finalize) · [The schema-drift guard](#the-schema-drift-guard)
|
|
56
|
+
- [Sensitive parameters](#sensitive-parameters-and-log-redaction) · [Instrumentation](#instrumentation)
|
|
57
|
+
- [API reference](#api-reference) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Why
|
|
62
|
+
|
|
63
|
+
| | `params.permit` | `params.expect` (Rails 8) | Permittable |
|
|
64
|
+
|---|---|---|---|
|
|
65
|
+
| Filters unknown keys | ✅ | ✅ | ✅ |
|
|
66
|
+
| Requires a root key | via `require` | ✅ | ✅ |
|
|
67
|
+
| Casts to a declared type | ❌ | ❌ | ✅ |
|
|
68
|
+
| Validates bounds, formats, sets | ❌ | ❌ | ✅ |
|
|
69
|
+
| Supplies defaults | ❌ | ❌ | ✅ |
|
|
70
|
+
| Machine-readable error details | ❌ | ❌ | ✅ |
|
|
71
|
+
| Reshapes output | ❌ | ❌ | ✅ |
|
|
72
|
+
| Checked against your schema at boot | ❌ | ❌ | ✅ |
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
39
76
|
## Installation
|
|
40
77
|
|
|
41
78
|
```ruby
|
|
42
79
|
gem "permittable"
|
|
43
80
|
```
|
|
44
81
|
|
|
45
|
-
|
|
82
|
+
Then include it wherever you need it — typically once in `ApplicationController`:
|
|
46
83
|
|
|
47
|
-
|
|
84
|
+
```ruby
|
|
85
|
+
class ApplicationController < ActionController::Base
|
|
86
|
+
include Permittable
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The **only runtime dependency is `activesupport`**. `actionpack` (for `rescue_from`, `before_action`, and `ActionController::Parameters`) and `activerecord` (for the `model:` schema-drift guard) are optional — every touchpoint is guarded with `respond_to?`/`defined?`, so your app brings whatever it already has. The concern works on a plain Ruby object that responds to `params`, which is what makes it straightforward to unit-test.
|
|
48
91
|
|
|
49
|
-
|
|
92
|
+
> **Naming note:** some legacy stacks (InheritedResources) define their own `permitted_params`. Don't include both on one controller.
|
|
93
|
+
|
|
94
|
+
## How a request flows
|
|
50
95
|
|
|
51
96
|
```
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
97
|
+
params
|
|
98
|
+
│
|
|
99
|
+
├─ 1. unwrap root: params[:user] → 400 if missing or not a hash
|
|
100
|
+
├─ 2. per field: normalize → cast → validate → transform
|
|
101
|
+
├─ 3. unknown-key check at every nesting level
|
|
102
|
+
├─ 4. finalize only if nothing violated
|
|
103
|
+
│
|
|
104
|
+
└─ permitted_params → HashWithIndifferentAccess (or raises InvalidParameters)
|
|
55
105
|
```
|
|
56
106
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
## Configuration
|
|
107
|
+
Validation is **lazy by default**: it runs on the first `permitted_params` call, so an action that never reads params never pays for it. Pass `enforce: true` to run it in a `before_action` instead, rejecting bad requests before the action body executes. Results are **memoized per action**.
|
|
60
108
|
|
|
61
|
-
|
|
109
|
+
## Declaring a contract
|
|
62
110
|
|
|
63
|
-
|
|
111
|
+
```ruby
|
|
112
|
+
permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, &contract)
|
|
113
|
+
```
|
|
64
114
|
|
|
65
115
|
| Option | Default | Meaning |
|
|
66
116
|
|---|---|---|
|
|
67
|
-
| `*actions` | — | Actions the contract covers
|
|
68
|
-
| `root:` | `false` | Key to unwrap first (`require(:user)` equivalent)
|
|
69
|
-
| `model:` | `nil` | Model class
|
|
70
|
-
| `unknown:` | `:ignore` | `:ignore` / `:log` / `:error` —
|
|
71
|
-
| `enforce:` | `false` | `false`
|
|
117
|
+
| `*actions` | — | Actions the contract covers. **No actions = catch-all** for the controller |
|
|
118
|
+
| `root:` | `false` | Key to unwrap first (the `require(:user)` equivalent). Missing or non-hash root → **400** |
|
|
119
|
+
| `model:` | `nil` | Model class, or `true` to infer from `controller_name`, enabling the [drift guard](#the-schema-drift-guard) |
|
|
120
|
+
| `unknown:` | `:ignore` | `:ignore` / `:log` / `:error` — how to treat undeclared keys |
|
|
121
|
+
| `enforce:` | `false` | `false` validates lazily on first use; `true` validates in a `before_action` |
|
|
122
|
+
|
|
123
|
+
`permit_params` is **repeatable**, and **the last matching rule wins**. Contracts behave like configuration: a base controller declares a catch-all, and a subclass overrides it for specific actions.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
class ApiController < ApplicationController
|
|
127
|
+
permit_params(unknown: :error) { optional :page, :integer, in: 1..1000 } # catch-all
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
class ReportsController < ApiController
|
|
131
|
+
permit_params :export, root: :report do # wins for #export
|
|
132
|
+
required :format, :string, in: %w[csv pdf]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Rules accumulate by **reassignment, never mutation**, so subclasses inherit copy-on-write and can never corrupt a parent's contract.
|
|
138
|
+
|
|
139
|
+
## The field DSL
|
|
140
|
+
|
|
141
|
+
### Scalars
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
required :name, :string # type defaults to :string
|
|
145
|
+
optional :age, :integer
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Nested hashes
|
|
149
|
+
|
|
150
|
+
Pass a block instead of a type. Violation paths are dotted (`user.address.zip`).
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
optional :address do
|
|
154
|
+
required :city, :string
|
|
155
|
+
optional :zip, :string, format: /\A\d{5}\z/
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Arrays
|
|
160
|
+
|
|
161
|
+
`of:` declares an array of scalars; a block declares an array of hashes. Arrays are **optional unless `required: true`**, `length:` constrains the element **count**, and element failures carry their index (`items[1]`).
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
array :tag_names, of: :string, length: 0..10
|
|
165
|
+
array :line_items, required: true do
|
|
166
|
+
required :sku, :string
|
|
167
|
+
required :quantity, :integer, in: 1..99
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Field options
|
|
172
|
+
|
|
173
|
+
Which options are legal depends on the field kind — anything else raises at class load.
|
|
174
|
+
|
|
175
|
+
| Option | Scalar | Array | Nested | Meaning |
|
|
176
|
+
|---|:---:|:---:|:---:|---|
|
|
177
|
+
| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`) or an `Array` |
|
|
178
|
+
| `format:` | ✅¹ | — | — | Regexp the value must match |
|
|
179
|
+
| `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays |
|
|
180
|
+
| `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **before** the cast |
|
|
181
|
+
| `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load |
|
|
182
|
+
| `validate:` | ✅ | ✅ | — | Callable. Falsy fails as `"invalid"`; a returned `Symbol` becomes the violation code |
|
|
183
|
+
| `transform:` | ✅ | ✅ | — | Callable applied **after** cast and validation — see [output reshaping](#output-reshaping-transform-and-finalize) |
|
|
184
|
+
| `virtual:` | ✅ | ✅ | ✅ | Exempt this field from the schema-drift guard |
|
|
185
|
+
| `sensitive:` | ✅ | ✅ | ✅ | Register the field name for [log redaction](#sensitive-parameters-and-log-redaction) |
|
|
186
|
+
| `message:` | ✅ | ✅ | ✅ | Human-readable copy for violations on this field — a String, or a Hash of code → String. See [custom messages](#custom-error-messages-message) |
|
|
187
|
+
| `of:` | — | ✅ | — | Element type for an array of scalars (default `:string`) |
|
|
188
|
+
| `required:` | — | ✅ | — | Arrays are optional unless this is `true` |
|
|
189
|
+
|
|
190
|
+
¹ `format:`, `length:`, and `normalize:` reason about characters and are **only valid on `:string` fields**. On any other type they would silently apply to an already-cast value, so declaring them raises at class load.
|
|
191
|
+
|
|
192
|
+
`validate:` is the escape hatch for anything the built-ins don't cover:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
optional :slug, :string, validate: ->(v) { v.match?(/\A[a-z0-9-]+\z/) || :malformed_slug }
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Types and strict coercion
|
|
199
|
+
|
|
200
|
+
Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`. Rails' casts are lenient by design — `"abc".to_i` is `0`, `Boolean.cast("abc")` is `true` — and silently corrupting untrusted input is precisely what a contract must not do. A value the type cannot faithfully represent is a **violation, not a guess**.
|
|
201
|
+
|
|
202
|
+
| Type | Accepts | Rejects (`invalid_type`) |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| `:string` | `String`; `Numeric`/`true`/`false` are stringified | Arrays, hashes |
|
|
205
|
+
| `:integer` | `Integer`; whole `Float`s (`4.0`); base-10 numeric strings | `"4.5"`, `"abc"`, `4.5` |
|
|
206
|
+
| `:float` | `Numeric`; any `Float()`-parseable string | `"abc"` |
|
|
207
|
+
| `:decimal` | `Numeric` or `String` → `BigDecimal` | Unparseable strings |
|
|
208
|
+
| `:boolean` | `true`/`false`, `"true"`/`"false"`, `"1"`/`"0"`, `1`/`0` | `"yes"`, `"on"`, `2` |
|
|
209
|
+
| `:date` | `Date`; any `Date.parse`-able string | Unparseable strings |
|
|
210
|
+
| `:datetime` | `Time`, `DateTime`, `ActiveSupport::TimeWithZone`, `Date`, parseable strings | Unparseable strings |
|
|
211
|
+
|
|
212
|
+
Two behaviours worth committing to memory:
|
|
213
|
+
|
|
214
|
+
- **Type confusion is a violation, not a 500.** A request of `?age[]=1` against a scalar `:integer` field yields `invalid_type`. Arrays, hashes, and nested `ActionController::Parameters` can never satisfy a scalar type, so the classic "`NoMethodError` on `[]`" crash is impossible.
|
|
215
|
+
- **Datetimes are normalised to UTC.** A zoneless string parses as UTC regardless of the host timezone, which keeps behaviour deterministic across machines; explicit offsets are honoured and converted.
|
|
216
|
+
|
|
217
|
+
## Absence, defaults, and partial updates
|
|
218
|
+
|
|
219
|
+
`nil` and `""` are **both treated as absent** — the query-parameter convention, where an untouched form field arrives as an empty string. Boolean `false` is present.
|
|
220
|
+
|
|
221
|
+
That single rule produces the behaviour you want from a `PATCH`:
|
|
222
|
+
|
|
223
|
+
- An **absent optional field is omitted** from the result, so partial updates never nil-out columns.
|
|
224
|
+
- An **absent required field violates** with `missing`.
|
|
225
|
+
- An absent field **with a `default:` gets the default** — so a defaulted field can never report `missing`. (Declaring `required:` alongside `default:` is a class-load error, since a default implies optionality.)
|
|
226
|
+
|
|
227
|
+
Because absence and `nil` are the same thing here, **clearing a column to NULL is outside a contract's vocabulary**. Do that explicitly in the action.
|
|
228
|
+
|
|
229
|
+
Defaults are checked against the field's own contract when the class loads, so `default: "gold"` on a field declared `in: %w[free pro]` fails at boot rather than on every request.
|
|
72
230
|
|
|
73
|
-
|
|
231
|
+
## Violations and error responses
|
|
74
232
|
|
|
75
|
-
|
|
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.
|
|
233
|
+
Every failure raises `Permittable::InvalidParameters`, carrying `details` (an array of `{ param:, code: }`, plus a `message:` when the field [declares one](#custom-error-messages-message)) and a `status`. On a real controller it is auto-rescued into the error envelope.
|
|
78
234
|
|
|
79
|
-
|
|
235
|
+
| Code | Raised when |
|
|
236
|
+
|---|---|
|
|
237
|
+
| `missing` | A required field is absent, or the `root:` key is missing (this one is a **400**) |
|
|
238
|
+
| `invalid_type` | The value cannot be faithfully cast to the declared type |
|
|
239
|
+
| `inclusion` | The value is outside `in:` |
|
|
240
|
+
| `format` | The value doesn't match `format:` |
|
|
241
|
+
| `length` | A string's length, or an array's element count, is outside `length:` |
|
|
242
|
+
| `unknown` | An undeclared key was sent while `unknown: :error` |
|
|
243
|
+
| `invalid` | A `validate:` callable returned a falsy value |
|
|
244
|
+
| *your symbol* | A `validate:` callable returned a `Symbol`, or `violate!` was called in `finalize` |
|
|
80
245
|
|
|
81
|
-
|
|
246
|
+
Paths are fully qualified: `user.address.zip`, `line_items[1].sku`.
|
|
82
247
|
|
|
83
|
-
|
|
248
|
+
**Status codes:** a missing root key renders **400** (the request is malformed — the envelope you asked for isn't there); field-level violations render **422** (well-formed, semantically wrong).
|
|
84
249
|
|
|
85
|
-
|
|
250
|
+
**Custom rendering:** if your controller defines `render_error`, the envelope delegates to it as `render_error(message:, code:, status:, errors:)` — the `errors:` key is passed only when details exist, so hosts documenting a three-keyword contract keep working. Otherwise the inline JSON shape shown at the top of this README is rendered. Either way, `render_invalid_parameters` is a normal method you can override.
|
|
86
251
|
|
|
87
|
-
|
|
88
|
-
|
|
252
|
+
## Custom error messages (`message:`)
|
|
253
|
+
|
|
254
|
+
Violations stay machine-first — the `code` is the contract — but any field can attach human-readable copy with `message:`. A **String** covers every code on the field; a **Hash of code → String** targets specific codes, and codes without an entry keep the default rendering:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
permit_params :create, root: :user do
|
|
258
|
+
required :email, :string, format: URI::MailTo::EMAIL_REGEXP,
|
|
259
|
+
message: { missing: "is required", format: "must be a valid email address" }
|
|
260
|
+
optional :age, :integer, in: 18..120, message: "must be between 18 and 120"
|
|
261
|
+
array :tags, of: :string, length: 0..10, message: "must be at most ten tags"
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
A resolved message rides into the violation detail and replaces the `(code)` part of the exception's summary line, so both the envelope's `message` and its `details` read naturally:
|
|
266
|
+
|
|
267
|
+
```json
|
|
268
|
+
{ "success": false,
|
|
269
|
+
"error": { "message": "Invalid parameters: user.email must be a valid email address",
|
|
270
|
+
"code": "invalid_parameters",
|
|
271
|
+
"details": [{ "param": "user.email", "code": "format",
|
|
272
|
+
"message": "must be a valid email address" }] } }
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The rules:
|
|
276
|
+
|
|
277
|
+
- Messages are written to read after the param name: `"is required"`, not `"Email is required"`.
|
|
278
|
+
- A Hash key matches the violation code, **including Symbol codes returned by `validate:`** — `validate: ->(v) { v.even? || :must_be_even }, message: { must_be_even: "must be an even number" }`.
|
|
279
|
+
- An array's message covers the array's own violations (`length`, `invalid_type`, `missing`) **and** its elements' (`tags[3]`); sub-fields of a nested block resolve their own `message:` declarations.
|
|
280
|
+
- `violate!` in `finalize` takes the same idea as a keyword: `violate!("user.ends_at", :before_start, message: "must be after starts_at")`.
|
|
281
|
+
- A `message:` that is neither a String nor a code → String Hash raises at class load, like every other contract mistake.
|
|
282
|
+
|
|
283
|
+
Fields without a `message:` are untouched — their details keep the bare `{ param:, code: }` shape. For full control over the response body itself (localization, RFC 9457, a different envelope), override `render_invalid_parameters` or define `render_error` as described above; `error.details` gives you the structured violations to build from.
|
|
284
|
+
|
|
285
|
+
## Unknown parameters
|
|
286
|
+
|
|
287
|
+
`unknown:` decides what happens to keys you never declared, **at every nesting level**.
|
|
288
|
+
|
|
289
|
+
| Mode | Behaviour |
|
|
290
|
+
|---|---|
|
|
291
|
+
| `:ignore` (default) | Silently dropped, exactly like strong parameters |
|
|
292
|
+
| `:log` | Dropped, with a `logger.warn` naming the full paths |
|
|
293
|
+
| `:error` | Each undeclared key becomes an `unknown` violation |
|
|
294
|
+
|
|
295
|
+
Rails merges `controller`, `action`, and `format` into `params`; these are exempt at the top level so `unknown: :error` doesn't flag the router's own bookkeeping. Inside a `root:` or a nested hash there is no such exemption, because nothing legitimately injects keys there.
|
|
296
|
+
|
|
297
|
+
## Output reshaping (`transform:` and `finalize`)
|
|
298
|
+
|
|
299
|
+
This is the safe replacement for params-mutating `before_action`s. **Both layers operate on the validated copy — the request's `params` is never touched.**
|
|
300
|
+
|
|
301
|
+
### `transform:` — per field
|
|
302
|
+
|
|
303
|
+
A callable applied **after** cast and validation, reshaping one field's output:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
required :tags, :string, transform: ->(v) { v.split(",") }
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
It runs only on request-supplied values. Absent fields stay absent, `default:` values are authored in their final shape, and a **partially-invalid array is never transformed** — user code is never handed garbage it didn't agree to see.
|
|
310
|
+
|
|
311
|
+
### `finalize` — per contract
|
|
312
|
+
|
|
313
|
+
Declared once, at the top level only. It runs after every field has validated cleanly, receives the result hash, and must return the final `Hash`. Use it to combine parallel fields, build value objects, or drop scaffolding keys.
|
|
314
|
+
|
|
315
|
+
It executes on a **bare runner, not the controller**, so contracts stay pure data plus pure functions and can never grow a dependency on request state. Its one extra verb is `violate!(param, code, message: nil)`, which records a violation (the optional [`message:`](#custom-error-messages-message) rides into the detail) and **halts the block immediately** — so the code after a `violate!` may assume the invariant it just checked. That makes `finalize` the natural home for cross-field validation (`ends_at` after `starts_at`, matching array lengths).
|
|
89
316
|
|
|
90
317
|
```ruby
|
|
91
318
|
permit_params :create, root: :lease_addendum_form do
|
|
@@ -93,36 +320,130 @@ permit_params :create, root: :lease_addendum_form do
|
|
|
93
320
|
required :signer_names, :string, transform: ->(v) { v.split(",") }
|
|
94
321
|
|
|
95
322
|
finalize do |p|
|
|
96
|
-
|
|
97
|
-
|
|
323
|
+
unless p[:signer_names].length == p[:resident_signatures].length
|
|
324
|
+
violate!("lease_addendum_form.signer_names", :length_mismatch)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
p[:signatures] = p[:resident_signatures].zip(p[:signer_names]).map do |image, name|
|
|
328
|
+
Signature.new(image: image, full_name: name)
|
|
329
|
+
end
|
|
98
330
|
p.except(:resident_signatures, :signer_names)
|
|
99
331
|
end
|
|
100
332
|
end
|
|
101
333
|
```
|
|
102
334
|
|
|
103
|
-
|
|
335
|
+
Forgetting to return the hash raises an `ArgumentError` telling you exactly that.
|
|
104
336
|
|
|
105
|
-
|
|
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`.
|
|
337
|
+
## The schema-drift guard
|
|
110
338
|
|
|
111
|
-
|
|
339
|
+
This is why `model:` exists. Pass a model class (or `true` to infer it from `controller_name`) and **every non-virtual scalar field is checked against the model's columns when the macro runs** — that is, at controller class load.
|
|
112
340
|
|
|
113
|
-
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
341
|
+
Production eager-loads controllers, so a column dropped by a migration **fails the deploy, not the request**:
|
|
342
|
+
|
|
343
|
+
```
|
|
344
|
+
Permittable: 'nickname' does not exist in the database (table: users).
|
|
345
|
+
Add it with: bin/rails generate migration AddNicknameToUsers nickname:string
|
|
346
|
+
If this parameter is not backed by a column, declare it with virtual: true.
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
The error carries a ready-to-paste migration command, typed from your own field declaration.
|
|
350
|
+
|
|
351
|
+
- **Fields not backed by a column** — `password_confirmation`, terms checkboxes, search filters — opt out with `virtual: true`.
|
|
352
|
+
- **Nested and array fields are implicitly virtual**, since only scalars map one-to-one onto columns.
|
|
353
|
+
- **The check skips when the schema is unreachable** (`db:create`, a fresh `db:migrate`, `assets:precompile`, CI bootstrap), so controller classes stay loadable. Skipping is self-healing: once the migration runs and classes reload, the check happens for real. A missing column with a *reachable* schema still raises — the rescue is scoped to `ActiveRecord::ActiveRecordError` precisely so real bugs keep surfacing.
|
|
354
|
+
|
|
355
|
+
In CI, one spec calling `Rails.application.eager_load!` exercises every contract in the whole app.
|
|
356
|
+
|
|
357
|
+
## Sensitive parameters and log redaction
|
|
358
|
+
|
|
359
|
+
Mark a field `sensitive: true` and its name is registered with `Permittable.filter_parameter_registry`; `Permittable::Railtie` appends a filter proc to `config.filter_parameters` at boot.
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
optional :ssn, :string, sensitive: true
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
The indirection is deliberate. Appending plain symbols to `config.filter_parameters` at class-load time misses every consumer that snapshots the list at boot — ActiveRecord's `filter_attributes` copy, lograge-style initializers, precompiled filters. A **single proc appended once at boot, consulting a live registry at filter time**, means fields registered when a controller loads later (lazy loading in development) are still redacted. The initializer runs before `active_record.set_filter_attributes`, so values are redacted from both request logs and `#inspect`.
|
|
366
|
+
|
|
367
|
+
Matching mirrors Rails' own symbol-filter semantics: case-insensitive substring match on the parameter key. The registry is fully duck-typed (`#add`, `#include?`, `#to_proc`, `#reset!`) and swappable via `Permittable.filter_parameter_registry=`, so a host gem can pool registrations into its own.
|
|
368
|
+
|
|
369
|
+
## Instrumentation
|
|
370
|
+
|
|
371
|
+
Every violation emits an `ActiveSupport::Notifications` event, so rejected requests can be dashboarded and alerted on:
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*, payload|
|
|
375
|
+
payload[:controller] # "users"
|
|
376
|
+
payload[:action] # "create"
|
|
377
|
+
payload[:details] # [{ param: "user.age", code: "inclusion" }]
|
|
378
|
+
end
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## API reference
|
|
382
|
+
|
|
383
|
+
### Instance methods
|
|
384
|
+
|
|
385
|
+
| Method | Purpose |
|
|
386
|
+
|---|---|
|
|
387
|
+
| `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`. Memoized per action. Raises `InvalidParameters` on violation, or `ArgumentError` when no contract covers the action |
|
|
388
|
+
| `enforce_params_contract` | The `before_action` entry point. Only validates rules declared `enforce: true`. Public, so hosts can `skip_before_action` it |
|
|
389
|
+
| `render_invalid_parameters(error)` | The `rescue_from` target. Renders via the host's `render_error` when defined, the inline envelope otherwise |
|
|
390
|
+
|
|
391
|
+
### Class methods
|
|
392
|
+
|
|
393
|
+
| Method | Purpose |
|
|
394
|
+
|---|---|
|
|
395
|
+
| `permit_params(*actions, **opts, &contract)` | Declare a contract |
|
|
396
|
+
| `permittable_contracts` | The frozen array of every declared rule — introspectable, testable |
|
|
397
|
+
| `permit_rule_for(action)` | The last rule matching `action`, or `nil` |
|
|
398
|
+
|
|
399
|
+
### Module
|
|
400
|
+
|
|
401
|
+
| | |
|
|
402
|
+
|---|---|
|
|
403
|
+
| `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
|
|
404
|
+
| `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
|
|
405
|
+
| `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
|
|
406
|
+
|
|
407
|
+
## Errors caught at class load
|
|
408
|
+
|
|
409
|
+
A bad contract is a programmer error, so it fails when the class loads — never at request time. Every message names the field and explains the fix.
|
|
410
|
+
|
|
411
|
+
- A field declared twice in one contract
|
|
412
|
+
- An unknown option for the field's kind, listing what *is* allowed
|
|
413
|
+
- An unknown type, listing the supported ones
|
|
414
|
+
- An unknown `normalize:` preset, listing the presets
|
|
415
|
+
- `format:`, `length:`, or `normalize:` on a non-`:string` field
|
|
416
|
+
- `length:` that isn't a `Range` or `Integer`; `in:` that doesn't respond to `include?`
|
|
417
|
+
- `validate:` or `transform:` that isn't callable
|
|
418
|
+
- A `default:` that violates its own field's contract, or an array `default:` whose elements violate `of:`
|
|
419
|
+
- `required: true` combined with `default:`
|
|
420
|
+
- A field given both a type and a nested block; an array given both `of:` and a block
|
|
421
|
+
- An empty contract, or a nested block declaring no sub-fields
|
|
422
|
+
- `finalize` declared twice, without a block, or inside a nested block
|
|
423
|
+
- `permit_params` without a block, or an invalid `unknown:` mode
|
|
424
|
+
- A `model:` that isn't an ActiveRecord class, or `model: true` that can't be inferred
|
|
425
|
+
|
|
426
|
+
## Compatibility
|
|
427
|
+
|
|
428
|
+
| | |
|
|
429
|
+
|---|---|
|
|
430
|
+
| Ruby | >= 3.2 |
|
|
431
|
+
| Rails / ActiveSupport | >= 5.0, < 9 |
|
|
432
|
+
| Required dependency | `activesupport` only |
|
|
433
|
+
| Optional | `actionpack` (rendering, `before_action`), `activerecord` (drift guard) |
|
|
434
|
+
|
|
435
|
+
Using [concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)? `ConcernsOnRails::Controllers::Permittable` is an alias for this module, and `sensitive:` registrations pool into that gem's shared filter registry.
|
|
118
436
|
|
|
119
437
|
## Development
|
|
120
438
|
|
|
121
439
|
```sh
|
|
122
440
|
bundle install
|
|
123
|
-
bundle exec rspec
|
|
441
|
+
bundle exec rspec # 76 examples
|
|
442
|
+
bundle exec rubocop
|
|
124
443
|
```
|
|
125
444
|
|
|
445
|
+
Releases are automated: bump `lib/permittable/version.rb`, add a `CHANGELOG.md` section, then push a `vX.Y.Z` tag. CI publishes to RubyGems via trusted publishing (OIDC — no API keys stored) and creates the GitHub release.
|
|
446
|
+
|
|
126
447
|
## License
|
|
127
448
|
|
|
128
|
-
MIT.
|
|
449
|
+
[MIT](LICENSE.txt).
|
data/lib/permittable/version.rb
CHANGED
data/lib/permittable.rb
CHANGED
|
@@ -79,6 +79,15 @@ require "permittable/filter_parameter_registry"
|
|
|
79
79
|
# renders 400, field violations 422. Every violation also instruments
|
|
80
80
|
# "invalid_parameters.permittable" so failures can be dashboarded.
|
|
81
81
|
#
|
|
82
|
+
# Violation MESSAGES stay machine-first (the code is the contract), but a
|
|
83
|
+
# field can attach human-readable copy with `message:` — one String for every
|
|
84
|
+
# code (`message: "must be a valid email"`) or a Hash per code
|
|
85
|
+
# (`message: { missing: "is required", format: "must be a valid email" }`).
|
|
86
|
+
# A resolved message rides into the detail entry as `message:` and replaces
|
|
87
|
+
# the "(code)" rendering in the exception's summary line; codes without a
|
|
88
|
+
# message keep the bare shape, so nothing changes for contracts that don't
|
|
89
|
+
# opt in. `violate!` in finalize accepts the same via `message:`.
|
|
90
|
+
#
|
|
82
91
|
# `sensitive: true` registers the field name with
|
|
83
92
|
# Permittable.filter_parameter_registry (swappable — a host gem can point it
|
|
84
93
|
# at its own registry), consulted at filter time by the proc
|
|
@@ -304,9 +313,9 @@ module Permittable
|
|
|
304
313
|
# declaration is validated eagerly: a bad contract is a programmer error and
|
|
305
314
|
# should fail at class load, not at request time.
|
|
306
315
|
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
|
|
316
|
+
SCALAR_OPTS = %i[in format length default normalize validate virtual sensitive transform message].freeze
|
|
317
|
+
NESTED_OPTS = %i[virtual sensitive message].freeze
|
|
318
|
+
ARRAY_OPTS = %i[of length default validate virtual sensitive required transform message].freeze
|
|
310
319
|
|
|
311
320
|
attr_reader :finalizer
|
|
312
321
|
|
|
@@ -360,6 +369,7 @@ module Permittable
|
|
|
360
369
|
validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
|
|
361
370
|
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
|
|
362
371
|
validate_array_default!(field) if field.key?(:default)
|
|
372
|
+
validate_message!(field)
|
|
363
373
|
@fields << field
|
|
364
374
|
end
|
|
365
375
|
|
|
@@ -371,15 +381,16 @@ module Permittable
|
|
|
371
381
|
raise ArgumentError, "#{LABEL}: :#{name} takes a type OR a nested block, not both" if type
|
|
372
382
|
|
|
373
383
|
assert_opts!(name, opts, NESTED_OPTS)
|
|
374
|
-
|
|
375
|
-
|
|
384
|
+
field = { name: name, kind: :nested, required: required,
|
|
385
|
+
fields: nested_fields!(name, &block), **opts }
|
|
386
|
+
validate_message!(field)
|
|
376
387
|
else
|
|
377
388
|
assert_opts!(name, opts, SCALAR_OPTS)
|
|
378
389
|
field = { name: name, kind: :scalar, required: required,
|
|
379
390
|
type: scalar_type!(name, type || :string), **opts }
|
|
380
391
|
validate_scalar_opts!(field)
|
|
381
|
-
@fields << field
|
|
382
392
|
end
|
|
393
|
+
@fields << field
|
|
383
394
|
end
|
|
384
395
|
|
|
385
396
|
def field_name!(name)
|
|
@@ -432,6 +443,7 @@ module Permittable
|
|
|
432
443
|
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
|
|
433
444
|
resolve_normalizer!(field)
|
|
434
445
|
validate_default!(field)
|
|
446
|
+
validate_message!(field)
|
|
435
447
|
end
|
|
436
448
|
|
|
437
449
|
# format / length / normalize reason about characters; on any other
|
|
@@ -492,6 +504,26 @@ module Permittable
|
|
|
492
504
|
raise ArgumentError, "#{LABEL}: :default for array :#{field[:name]} contains an element violating of: :#{field[:of]} (#{code})"
|
|
493
505
|
end
|
|
494
506
|
end
|
|
507
|
+
|
|
508
|
+
# `message:` customizes what the client reads for a violation on this
|
|
509
|
+
# field: one String covering every code, or a Hash of code => String
|
|
510
|
+
# (codes without an entry keep the default rendering). Keys are
|
|
511
|
+
# normalized to Symbols here so request-time resolution is a plain
|
|
512
|
+
# lookup.
|
|
513
|
+
def validate_message!(field)
|
|
514
|
+
spec = field[:message]
|
|
515
|
+
return if spec.nil?
|
|
516
|
+
return if spec.is_a?(String)
|
|
517
|
+
|
|
518
|
+
valid_hash = spec.is_a?(Hash) && !spec.empty? &&
|
|
519
|
+
spec.all? { |code, text| (code.is_a?(Symbol) || code.is_a?(String)) && text.is_a?(String) }
|
|
520
|
+
unless valid_hash
|
|
521
|
+
raise ArgumentError, "#{LABEL}: :message for field :#{field[:name]} must be a String " \
|
|
522
|
+
"or a Hash of violation code => String (e.g. { missing: \"is required\" })"
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
field[:message] = spec.transform_keys(&:to_sym).freeze
|
|
526
|
+
end
|
|
495
527
|
end
|
|
496
528
|
|
|
497
529
|
# The `self` a finalize block runs on. Deliberately bare — no controller
|
|
@@ -504,9 +536,13 @@ module Permittable
|
|
|
504
536
|
|
|
505
537
|
# Records ONE violation and halts the finalize block immediately (the
|
|
506
538
|
# code after a violate! call never runs, so it can assume the checked
|
|
507
|
-
# invariant). The contract then fails as a normal 422.
|
|
508
|
-
|
|
509
|
-
|
|
539
|
+
# invariant). The contract then fails as a normal 422. An optional
|
|
540
|
+
# message: rides along into the violation detail, same as a field's
|
|
541
|
+
# `message:` option.
|
|
542
|
+
def violate!(param, code, message: nil)
|
|
543
|
+
entry = { param: param.to_s, code: code.to_s }
|
|
544
|
+
entry[:message] = message.to_s if message
|
|
545
|
+
@violations << entry
|
|
510
546
|
throw :permittable_finalize_halt
|
|
511
547
|
end
|
|
512
548
|
end
|
|
@@ -663,10 +699,27 @@ module Permittable
|
|
|
663
699
|
"invalid_parameters.permittable",
|
|
664
700
|
controller: permittable_controller_name, action: permittable_action_name, details: violations
|
|
665
701
|
)
|
|
666
|
-
summary = violations.map { |v| "#{v[:param]} (#{v[:code]})" }.join(", ")
|
|
702
|
+
summary = violations.map { |v| v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})" }.join(", ")
|
|
667
703
|
raise InvalidParameters.new("Invalid parameters: #{summary}", details: violations, status: status)
|
|
668
704
|
end
|
|
669
705
|
|
|
706
|
+
# One violation detail entry. A field's `message:` (String, or Hash keyed
|
|
707
|
+
# by code) attaches a human-readable message; entries without one keep
|
|
708
|
+
# the bare { param:, code: } shape, so existing consumers see no change.
|
|
709
|
+
def permittable_violation(field, param, code)
|
|
710
|
+
entry = { param: param, code: code.to_s }
|
|
711
|
+
message = permittable_message_for(field, code)
|
|
712
|
+
entry[:message] = message if message
|
|
713
|
+
entry
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def permittable_message_for(field, code)
|
|
717
|
+
spec = field[:message]
|
|
718
|
+
return spec if spec.nil? || spec.is_a?(String)
|
|
719
|
+
|
|
720
|
+
spec[code.to_sym]
|
|
721
|
+
end
|
|
722
|
+
|
|
670
723
|
def permittable_run_finalize(finalizer, result, violations)
|
|
671
724
|
runner = FinalizeRunner.new(violations)
|
|
672
725
|
finalized = catch(:permittable_finalize_halt) do
|
|
@@ -713,7 +766,7 @@ module Permittable
|
|
|
713
766
|
if field.key?(:default)
|
|
714
767
|
result[key] = field[:default]
|
|
715
768
|
elsif field[:required]
|
|
716
|
-
violations <<
|
|
769
|
+
violations << permittable_violation(field, full, "missing")
|
|
717
770
|
end
|
|
718
771
|
next
|
|
719
772
|
end
|
|
@@ -733,33 +786,33 @@ module Permittable
|
|
|
733
786
|
out = field[:transform].call(out) if field[:transform]
|
|
734
787
|
result[key] = out
|
|
735
788
|
else
|
|
736
|
-
violations <<
|
|
789
|
+
violations << permittable_violation(field, full, out)
|
|
737
790
|
end
|
|
738
791
|
when :nested
|
|
739
792
|
if value.is_a?(Hash)
|
|
740
793
|
result[key] = permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(value),
|
|
741
794
|
path: full, unknown: unknown, top_level: false, violations: violations)
|
|
742
795
|
else
|
|
743
|
-
violations <<
|
|
796
|
+
violations << permittable_violation(field, full, "invalid_type")
|
|
744
797
|
end
|
|
745
798
|
when :array
|
|
746
799
|
if value.is_a?(Array)
|
|
747
800
|
result[key] = permittable_check_array(field, value, path: full, unknown: unknown, violations: violations)
|
|
748
801
|
else
|
|
749
|
-
violations <<
|
|
802
|
+
violations << permittable_violation(field, full, "invalid_type")
|
|
750
803
|
end
|
|
751
804
|
end
|
|
752
805
|
end
|
|
753
806
|
|
|
754
807
|
def permittable_check_array(field, value, path:, unknown:, violations:)
|
|
755
808
|
before = violations.length
|
|
756
|
-
violations <<
|
|
809
|
+
violations << permittable_violation(field, path, "length") if field[:length] && !Coercion.length_ok?(field[:length], value.length)
|
|
757
810
|
out = value.each_with_index.map do |element, index|
|
|
758
811
|
permittable_check_element(field, element, "#{path}[#{index}]", unknown: unknown, violations: violations)
|
|
759
812
|
end
|
|
760
813
|
if field[:validate]
|
|
761
814
|
status, code = Coercion.check_custom(field[:validate], out)
|
|
762
|
-
violations <<
|
|
815
|
+
violations << permittable_violation(field, path, code) unless status == :ok
|
|
763
816
|
end
|
|
764
817
|
# Transform only a fully-valid array — a partially-nil one (element
|
|
765
818
|
# violations) would hand user code garbage it never agreed to see.
|
|
@@ -770,7 +823,7 @@ module Permittable
|
|
|
770
823
|
def permittable_check_element(field, element, path, unknown:, violations:)
|
|
771
824
|
if field[:fields]
|
|
772
825
|
unless element.is_a?(Hash)
|
|
773
|
-
violations <<
|
|
826
|
+
violations << permittable_violation(field, path, "invalid_type")
|
|
774
827
|
return nil
|
|
775
828
|
end
|
|
776
829
|
return permittable_check_hash(field[:fields], ActiveSupport::HashWithIndifferentAccess.new(element),
|
|
@@ -780,7 +833,7 @@ module Permittable
|
|
|
780
833
|
status, out = Coercion.cast(field[:of], element)
|
|
781
834
|
return out if status == :ok
|
|
782
835
|
|
|
783
|
-
violations <<
|
|
836
|
+
violations << permittable_violation(field, path, out)
|
|
784
837
|
nil
|
|
785
838
|
end
|
|
786
839
|
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-18 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|