active_typed_params 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 +5 -0
- data/LICENSE.txt +20 -0
- data/README.md +534 -0
- data/lib/active_typed_params/builder.rb +357 -0
- data/lib/active_typed_params/compiler.rb +360 -0
- data/lib/active_typed_params/controller.rb +29 -0
- data/lib/active_typed_params/declaration.rb +90 -0
- data/lib/active_typed_params/enum_adapter.rb +28 -0
- data/lib/active_typed_params/errors.rb +78 -0
- data/lib/active_typed_params/inference.rb +264 -0
- data/lib/active_typed_params/inline.rb +94 -0
- data/lib/active_typed_params/node.rb +70 -0
- data/lib/active_typed_params/railtie.rb +19 -0
- data/lib/active_typed_params/runtime.rb +182 -0
- data/lib/active_typed_params/schema.rb +176 -0
- data/lib/active_typed_params/version.rb +5 -0
- data/lib/active_typed_params.rb +148 -0
- metadata +92 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5178427f8af782d964fd6c1708e19b54d4d70a87ddaf401f7b541bab81666aca
|
|
4
|
+
data.tar.gz: f5d41a2ede52d8db0dbee9083bf34b5305058d5959e7976842d0f93b14f43217
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8f4f7cc1a999615a3b91728173c7f2272b7d6b3305c92bf33adcb1a82829593ede047b3ee5edad340fe96b11471f39246d7561919e4985ced8cd43e977889bbb
|
|
7
|
+
data.tar.gz: 1f316e7115eef1ce4b28ebf8180825516f9e2068e91b35ee090ffc6103824edb05a8bea166d0ae6a577ec2f6740f20e454f0543b75eef3fbc891518829250eb2
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2026 Máximo Mussini
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
Active Typed Params
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://github.com/ElMassimo/active_typed_params/actions/workflows/ci.yml">
|
|
5
|
+
<img alt="Build Status" src="https://github.com/ElMassimo/active_typed_params/actions/workflows/ci.yml/badge.svg"/>
|
|
6
|
+
</a>
|
|
7
|
+
<a href="https://rubygems.org/gems/active_typed_params">
|
|
8
|
+
<img alt="Gem Version" src="https://img.shields.io/gem/v/active_typed_params.svg?colorB=e9573f"/>
|
|
9
|
+
</a>
|
|
10
|
+
<a href="https://github.com/ElMassimo/active_typed_params/blob/main/LICENSE.txt">
|
|
11
|
+
<img alt="License" src="https://img.shields.io/badge/license-MIT-428F7E.svg"/>
|
|
12
|
+
</a>
|
|
13
|
+
</p>
|
|
14
|
+
</h1>
|
|
15
|
+
|
|
16
|
+
[strong parameters]: https://api.rubyonrails.org/classes/ActionController/StrongParameters.html
|
|
17
|
+
[ActiveInteraction]: https://github.com/AaronLasseigne/active_interaction
|
|
18
|
+
[StoreModel]: https://github.com/DmitryTsepelev/store_model
|
|
19
|
+
[benchmarks]: #benchmarks-
|
|
20
|
+
[inference]: #inference-
|
|
21
|
+
[inline]: #inline-reads-%EF%B8%8F
|
|
22
|
+
[errors]: #errors-
|
|
23
|
+
[config]: #configuration-%EF%B8%8F
|
|
24
|
+
|
|
25
|
+
Typed [strong parameters] for Rails controllers, compiled into plain Ruby methods.
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
typed_params(:contact_params) {
|
|
29
|
+
required(:name)
|
|
30
|
+
param(:email, :relationship, event_ids: array(:id))
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Why? 🤔
|
|
35
|
+
|
|
36
|
+
[Strong parameters] tell Rails which keys a request can set, but not what they should contain.
|
|
37
|
+
|
|
38
|
+
If a request sends a hash where a string is expected, strong parameters silently drop it. A number passes through as an `Integer`. Either can cause a `NoMethodError` later, returning a `500` instead of a validation error.
|
|
39
|
+
|
|
40
|
+
Manual type checks are repetitive, and [ActiveInteraction] only validates inputs once the controller calls it.
|
|
41
|
+
|
|
42
|
+
`typed_params` declares keys and types next to the action that reads them. Parameters of the wrong type are rejected with a `400` that names the parameter, before the action passes them to a model or interaction.
|
|
43
|
+
|
|
44
|
+
Each declaration is compiled into a single Ruby method, which makes it [5 to 10 times faster][benchmarks] than `params.permit`.
|
|
45
|
+
|
|
46
|
+
## Features ⚡️
|
|
47
|
+
|
|
48
|
+
- Reads like a permit list: `required(:email, tags: array(:string))`
|
|
49
|
+
- Numbers, booleans, dates, ids, enums, files, nested hashes, arrays, maps, and values of several types
|
|
50
|
+
- [Infers types][inference] from interaction inputs and model columns
|
|
51
|
+
- Default length limits for strings, arrays and maps, with a higher limit for text columns
|
|
52
|
+
- [Errors][errors] name the parameter but never echo the value: `delivery.address_id must be an id`
|
|
53
|
+
- A `400` for a malformed request, and a `422` for validation errors such as blank or out-of-range values
|
|
54
|
+
- Up to 10x faster than `params.permit`, with fewer allocations
|
|
55
|
+
|
|
56
|
+
## Installation 💿
|
|
57
|
+
|
|
58
|
+
Add this line to your application's Gemfile:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
gem "active_typed_params"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
And then run:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
bundle install
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
In a Rails app, `typed_params` is available in every controller.
|
|
71
|
+
|
|
72
|
+
Outside of Rails, or in a controller that doesn't inherit from `ActionController::Base` or `ActionController::API`, include `ActiveTypedParams::Controller`.
|
|
73
|
+
|
|
74
|
+
## Usage 🚀
|
|
75
|
+
|
|
76
|
+
Declare a schema in the controller, and call the method it defines:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
class ContactsController < ApplicationController
|
|
80
|
+
def create
|
|
81
|
+
contact = current_user.contacts.create!(contact_params)
|
|
82
|
+
...
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
typed_params(:contact_params) {
|
|
88
|
+
required(:name)
|
|
89
|
+
param(:email, :relationship, event_ids: array(:id))
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The method returns a plain `Hash` with symbol keys, including only the keys the request sent:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# POST /contacts { "name": "Ana", "relationship": "friend", "event_ids": ["12", "34"] }
|
|
98
|
+
contact_params
|
|
99
|
+
# => { name: "Ana", relationship: "friend", event_ids: [12, 34] }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A request that sends `{ "name": { "$ne": "" } }` is rejected before the model is called, with a `400` and `name must be a string`.
|
|
103
|
+
|
|
104
|
+
> **Note**
|
|
105
|
+
>
|
|
106
|
+
> Keys are optional and accept `null` by default.
|
|
107
|
+
|
|
108
|
+
## Types 🏷
|
|
109
|
+
|
|
110
|
+
A bare symbol is a string, and `key: type` declares anything else:
|
|
111
|
+
|
|
112
|
+
| Type | Accepts | Returns |
|
|
113
|
+
| --- | --- | --- |
|
|
114
|
+
| `:string` | A string of up to 512 characters | `String` |
|
|
115
|
+
| `:text` | A string of up to [`text_max_length`][config] characters | `String` |
|
|
116
|
+
| `:integer` | `5` or `"5"` | `Integer` |
|
|
117
|
+
| `:id` | A positive integer, or its string | `Integer` |
|
|
118
|
+
| `:decimal` | `1.5` or `"1.5"` | `BigDecimal` |
|
|
119
|
+
| `:float` | `1.5`, `"1.5"` or `"1e3"` | `Float` |
|
|
120
|
+
| `:boolean` | `true`, `false`, `"true"`, `"false"`, `"1"` or `"0"` | `true` or `false` |
|
|
121
|
+
| `:date` | `"2026-06-05"` | `Date` |
|
|
122
|
+
| `:time` | An ISO 8601 time | `Time`, in `Time.zone` |
|
|
123
|
+
| `:file` | An uploaded file | The file |
|
|
124
|
+
| `:direct_upload` | A signed blob id, or `null` to remove the file; the attachment object a form posts back for an unchanged file is dropped | `String` |
|
|
125
|
+
| `:any` | Anything | The value, with symbol keys |
|
|
126
|
+
| `enum(%w[draft live])` | One of the values | The value |
|
|
127
|
+
| `[:name, zip: :string]` | A hash with those keys | `Hash` |
|
|
128
|
+
| `{}` | A hash of anything | `Hash` |
|
|
129
|
+
|
|
130
|
+
Forms send an empty string for a cleared input, so `""` reads as `nil` for every type but strings.
|
|
131
|
+
|
|
132
|
+
> **Warning**
|
|
133
|
+
>
|
|
134
|
+
> `{}`, `open: true` and `:any` accept keys you didn't declare, just like `permit(key: {})`.
|
|
135
|
+
>
|
|
136
|
+
> Use them for values stored as a whole, such as a JSON column, and never pass them to `update` or `assign_attributes` as the attributes themselves. You can [list them in a spec](#inspecting-schemas-) to check for unintended additions.
|
|
137
|
+
|
|
138
|
+
### Options
|
|
139
|
+
|
|
140
|
+
To set options on a key, use the method form:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
typed_params(:search_params) {
|
|
144
|
+
string :term, max_length: 100
|
|
145
|
+
integer :page, in: 1..500, default: 1
|
|
146
|
+
string :sort, in: %w[name created_at], as: :order
|
|
147
|
+
boolean :archived, allow_if: :admin?
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
or a descriptor inside a literal:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
param(note: string(max_length: 2_500), page: integer(in: 1..500))
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- `max_length`: the most characters in a string (512 by default), or items in an array or map (1,000 by default); `nil` removes the cap
|
|
158
|
+
- `in`: an `Array`, `Set` or `Range` of allowed values
|
|
159
|
+
- `default`: the value when the key is omitted, or when it's `null` and `null` isn't allowed
|
|
160
|
+
- `allow_nil`, `allow_blank`: accept `null` or an empty string
|
|
161
|
+
- `allow_if`, `allow_unless`: a method on the controller; when the key isn't allowed, it's ignored as if it wasn't declared
|
|
162
|
+
- `as`: a different key for the result
|
|
163
|
+
- `invalid: :drop`: treat a value of the wrong type as omitted, the way `permit` would
|
|
164
|
+
- `json: true`: accept a hash or array as a JSON string, useful in query strings
|
|
165
|
+
|
|
166
|
+
### Required Keys
|
|
167
|
+
|
|
168
|
+
`required` keys must be present, can't be `null`, and strings can't be blank:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
typed_params(:login_params) {
|
|
172
|
+
required(:email, :password)
|
|
173
|
+
param(remember_me: :boolean)
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
You can also wrap several declarations in a block, or make every key required:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
typed_params(:address_params, required: true) {
|
|
181
|
+
param(:street, :city, :zip)
|
|
182
|
+
optional { string :apartment }
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Hashes, Arrays and Maps
|
|
187
|
+
|
|
188
|
+
A list declares a nested hash, and `array` declares a list of values:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
typed_params(:order_params) {
|
|
192
|
+
param(
|
|
193
|
+
delivery: [:name, :email, date: :date],
|
|
194
|
+
place_ids: array(id(bytes: 4)),
|
|
195
|
+
recipients: array([:name, :email]),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
hash :address do
|
|
199
|
+
required(:street, :city)
|
|
200
|
+
string :zip, max_length: 10
|
|
201
|
+
end
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A `map` is a hash keyed by data, such as ids, so its keys stay strings:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
param(opt_outs: map([email: :boolean, sms: :boolean]))
|
|
209
|
+
# => { opt_outs: { "12" => { email: true, sms: false } } }
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Several Types
|
|
213
|
+
|
|
214
|
+
When a value can take more than one shape, `one_of` picks the type by the value's JSON type, without converting between them:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
param(value: one_of(:string, :integer, array(:string)))
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
A string goes to `:string`, a number to `:integer`, and an array to `array(:string)`. Anything else, such as a hash, is a `400`.
|
|
221
|
+
|
|
222
|
+
Each alternative needs a different JSON type, so `one_of(:string, :date)` raises when the schema is declared.
|
|
223
|
+
|
|
224
|
+
> **Note**
|
|
225
|
+
>
|
|
226
|
+
> Query strings only carry strings, so `one_of` is meant for JSON bodies and `json: true` values.
|
|
227
|
+
|
|
228
|
+
### Enums
|
|
229
|
+
|
|
230
|
+
`enum` accepts one of a list of values, or the keys of a hash, and returns the value they map to:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
typed_params(:post_params) {
|
|
234
|
+
enum :status, %w[draft published]
|
|
235
|
+
enum :visibility, {"public" => 0, "private" => 1}
|
|
236
|
+
param(kind: enum(%w[article video]))
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
A value outside the list returns a `422` validation error.
|
|
241
|
+
|
|
242
|
+
To pass your own enum classes, register an [enum adapter](#enum_adapters).
|
|
243
|
+
|
|
244
|
+
## Inference 🔮
|
|
245
|
+
|
|
246
|
+
`typed_params` can infer types from a model or an [ActiveInteraction].
|
|
247
|
+
|
|
248
|
+
### From an interaction
|
|
249
|
+
|
|
250
|
+
`inputs_from` declares a key for each input that can come from a request:
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
typed_params(:order_params) {
|
|
254
|
+
inputs_from UpdateOrder, model: Order
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
- Every input is optional and accepts `null`, so the interaction can report a missing value as a form error
|
|
259
|
+
- Records and objects are skipped, along with inputs declared with `from_params: false`
|
|
260
|
+
- `only:` and `except:` pick the inputs
|
|
261
|
+
- `model:` caps strings by the columns of the same name
|
|
262
|
+
|
|
263
|
+
### From a model
|
|
264
|
+
|
|
265
|
+
`attributes_from` declares the attributes you name, using the database columns:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
typed_params(:role_params, root: :role) {
|
|
269
|
+
attributes_from Role, :name, :permissions, :description
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
String limits, text columns, enums, array columns, `NOT NULL` constraints, [StoreModel] attributes, and attachments are all taken into account.
|
|
274
|
+
|
|
275
|
+
To infer a single attribute inside a literal, use `attribute_from`:
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
param(
|
|
279
|
+
:notes,
|
|
280
|
+
shipping_address: attribute_from(Order),
|
|
281
|
+
preferences: attribute_from(Order),
|
|
282
|
+
)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Overrides
|
|
286
|
+
|
|
287
|
+
Both `inputs_from` and `attributes_from` accept per-key overrides to add options or replace the type:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
inputs_from UpdateUserAccess, super_admin: {allow_if: :super_admin?}
|
|
291
|
+
inputs_from UpdateContact, event_ids: array(:id)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Inline Reads ✍️
|
|
295
|
+
|
|
296
|
+
For one or two keys, you can read them inside the action:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
def index
|
|
300
|
+
page = typed_params.optional(page: :integer) || 1
|
|
301
|
+
ids = typed_params.optional(ids: typed_params.array(:id)) || []
|
|
302
|
+
email = typed_params.require(:email)
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`require` and `optional` return the value for a single key, and a hash for several. `slice` always returns a hash containing only keys present in the request, so updates leave omitted attributes unchanged.
|
|
307
|
+
|
|
308
|
+
Each distinct declaration is compiled once and cached, so an inline read costs a little more than a named schema: about 1.4 µs for `typed_params.require(:email)`.
|
|
309
|
+
|
|
310
|
+
> **Note**
|
|
311
|
+
>
|
|
312
|
+
> Prefer a named schema for actions that read several keys.
|
|
313
|
+
|
|
314
|
+
## Schema Options 🔧
|
|
315
|
+
|
|
316
|
+
### `root`
|
|
317
|
+
|
|
318
|
+
Reads the keys of one required hash, like `params.require(:contact).permit(...)`, and returns the inner hash:
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
typed_params(:contact_params, root: :contact) {
|
|
322
|
+
param(:name, :email)
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### `source`
|
|
327
|
+
|
|
328
|
+
Reads the hash a proc returns instead of all the `params`:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
typed_params(:address_params, source: -> { params.require(:order).require(:shipping_address) }) {
|
|
332
|
+
param(:street, :city, notes: string(max_length: 2_500))
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### `memoize`
|
|
337
|
+
|
|
338
|
+
Keeps the result for the rest of the request, for a schema read more than once.
|
|
339
|
+
|
|
340
|
+
### `required`
|
|
341
|
+
|
|
342
|
+
Makes every key required unless explicitly declared optional.
|
|
343
|
+
|
|
344
|
+
### `unknown`
|
|
345
|
+
|
|
346
|
+
Keys a schema doesn't declare are ignored by default. Use `unknown: :report` to publish them as an `unknown_parameters.typed_params` event, or `unknown: :reject` to answer `400`.
|
|
347
|
+
|
|
348
|
+
## Sharing Schemas 🧱
|
|
349
|
+
|
|
350
|
+
A fragment defines keys shared by several schemas. You can define it alongside the model:
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
class Address
|
|
354
|
+
PARAMS = ActiveTypedParams.fragment {
|
|
355
|
+
param(:street, :city, :zip, notes: :text)
|
|
356
|
+
}
|
|
357
|
+
end
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
`use` merges it into a schema:
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
typed_params(:order_params) {
|
|
364
|
+
attributes_from Order, :notes, :amount
|
|
365
|
+
param(shipping_address: hash { use Address::PARAMS })
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
`use` also takes the name of a schema in the same controller or one of its ancestors, as in `use :pagination`.
|
|
370
|
+
|
|
371
|
+
To validate params outside of a controller, define a schema and call `validate`:
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
REQUEST_PARAMS = ActiveTypedParams.define(:search_request) {
|
|
375
|
+
param(:term, page: :integer)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
REQUEST_PARAMS.validate(params)
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## Errors 🚨
|
|
382
|
+
|
|
383
|
+
| Problem | Error | Status |
|
|
384
|
+
| --- | --- | --- |
|
|
385
|
+
| Wrong type, missing required key, unknown key with `unknown: :reject` | `ActiveTypedParams::InvalidParameter` | `400` |
|
|
386
|
+
| Too long, blank, or not one of the allowed values | `ActiveTypedParams::UnprocessableParameter` | `422` |
|
|
387
|
+
|
|
388
|
+
Messages name the parameter and never echo the value, such as `delivery.address_id must be an id`.
|
|
389
|
+
|
|
390
|
+
Both are `ActionController::BadRequest` subclasses, so existing rescues keep working, and Rails answers with the right status in controllers without a `rescue_from`.
|
|
391
|
+
|
|
392
|
+
Each error has a `path`, such as `["delivery", "address_id"]`, and a `code`, which you can use to render the error next to its field:
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
rescue_from ActiveTypedParams::InvalidParameter do |error|
|
|
396
|
+
render json: {errors: {error.path.join(".") => error.message}}, status: error.status
|
|
397
|
+
end
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Instrumentation
|
|
401
|
+
|
|
402
|
+
Every rejected parameter publishes an `invalid_parameters.typed_params` event with the `path`, `code`, `controller`, `action` and `request`.
|
|
403
|
+
|
|
404
|
+
To log `400` errors by endpoint and parameter path without including `422` validation errors:
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
ActiveSupport::Notifications.subscribe("invalid_parameters.typed_params") do |*, payload|
|
|
408
|
+
next if ActiveTypedParams::UnprocessableParameter::CODES.include?(payload[:code])
|
|
409
|
+
|
|
410
|
+
Rails.logger.warn("Invalid parameter #{payload[:path]} (#{payload[:code]}) in #{payload[:controller]}##{payload[:action]}")
|
|
411
|
+
end
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
> **Note**
|
|
415
|
+
>
|
|
416
|
+
> Handle exceptions in subscribers; otherwise, they replace the original parameter error.
|
|
417
|
+
|
|
418
|
+
## Configuration ⚙️
|
|
419
|
+
|
|
420
|
+
You can configure the library in an initializer:
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
ActiveTypedParams.configure do |config|
|
|
424
|
+
config.text_max_length = 30_000
|
|
425
|
+
end
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### `text_max_length`
|
|
429
|
+
|
|
430
|
+
_Default:_ `65_535`
|
|
431
|
+
|
|
432
|
+
The length limit for `:text` and for inferred strings whose length is validated by the model or interaction.
|
|
433
|
+
|
|
434
|
+
### `ignored_filters`
|
|
435
|
+
|
|
436
|
+
_Default:_ `[]`
|
|
437
|
+
|
|
438
|
+
Interaction inputs that `inputs_from` never declares, for inputs that controllers set from the request context, such as `ip_address`.
|
|
439
|
+
|
|
440
|
+
### `enum_adapters`
|
|
441
|
+
|
|
442
|
+
_Default:_ `[]`
|
|
443
|
+
|
|
444
|
+
Procs that receive the class given to an enum and return a `Hash` from request value to returned value, or `nil` when they don't handle it.
|
|
445
|
+
|
|
446
|
+
```ruby
|
|
447
|
+
config.enum_adapters << lambda { |source|
|
|
448
|
+
source.values.to_h { |value| [value.key, value] } if source.is_a?(Class) && source < ApplicationEnum
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Once an adapter handles a class, it can be used as a type, as in `param(role: Role)`, and `inputs_from` declares interaction inputs of that class as enums.
|
|
453
|
+
|
|
454
|
+
### `type_inferrers` and `filter_inferrers`
|
|
455
|
+
|
|
456
|
+
_Default:_ `[]`
|
|
457
|
+
|
|
458
|
+
Procs that receive a model attribute type or an interaction filter and return a declaration, for custom types inference doesn't know about.
|
|
459
|
+
|
|
460
|
+
```ruby
|
|
461
|
+
config.type_inferrers << lambda { |type|
|
|
462
|
+
ActiveTypedParams::Declaration.build(:array, item: ActiveTypedParams::Declaration.build(:string)) if type.is_a?(TagListType)
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### `file_classes`
|
|
467
|
+
|
|
468
|
+
_Default:_ `[ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile]`
|
|
469
|
+
|
|
470
|
+
The classes that `:file` accepts.
|
|
471
|
+
|
|
472
|
+
## Inspecting Schemas 🔍
|
|
473
|
+
|
|
474
|
+
`describe` prints what a request can set, which is useful in a snapshot spec:
|
|
475
|
+
|
|
476
|
+
```ruby
|
|
477
|
+
puts ActiveTypedParams.schema(ContactsController, :contact_params).describe
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
```
|
|
481
|
+
name: string
|
|
482
|
+
email: string?
|
|
483
|
+
relationship: string?
|
|
484
|
+
event_ids: array(id)?
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
`ActiveTypedParams.all_schemas` lists every schema in the app, which you can use to review the keys that accept anything:
|
|
488
|
+
|
|
489
|
+
```ruby
|
|
490
|
+
it "accepts undeclared keys only where they were reviewed" do
|
|
491
|
+
Rails.application.eager_load!
|
|
492
|
+
|
|
493
|
+
open_paths = lambda { |nodes, path = []|
|
|
494
|
+
nodes.flat_map { |node|
|
|
495
|
+
key = [*path, node.key || "[]"]
|
|
496
|
+
own = (node.open || node.type == :any) ? [key.join(".")] : []
|
|
497
|
+
own + open_paths.call([*node.children, *node.item], key)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
found = ActiveTypedParams.all_schemas.flat_map { |schema|
|
|
501
|
+
open_paths.call(schema.nodes).map { |path| "#{schema.owner}##{schema.name} #{path}" }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
expect(found).to match_array([
|
|
505
|
+
"OrdersController#order_params preferences",
|
|
506
|
+
])
|
|
507
|
+
end
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
## Design 📐
|
|
511
|
+
|
|
512
|
+
Each schema is compiled into one straight-line Ruby method: nested hashes are inlined, arrays become loops, and allowlists become constants. This avoids allocating intermediate validation objects.
|
|
513
|
+
|
|
514
|
+
With eager loading, every schema is compiled at boot rather than during a request. In development, schemas are compiled on first use and recompiled after code reloading.
|
|
515
|
+
|
|
516
|
+
### Benchmarks 📊
|
|
517
|
+
|
|
518
|
+
Time per call on Ruby 4.0 with YJIT, 20,000 iterations, using params like the ones Rails passes to a controller:
|
|
519
|
+
|
|
520
|
+
| Request | `typed_params` | `params.permit` | ActiveInteraction |
|
|
521
|
+
| --- | --- | --- | --- |
|
|
522
|
+
| Login (2 keys) | 0.6 µs, 6 allocations | 5.7 µs, 48 | 10 µs, 65 |
|
|
523
|
+
| Search (pagination, sort, 3 filters) | 3.4 µs, 13 | 32 µs, 190 | 37 µs, 221 |
|
|
524
|
+
| Checkout (40 keys, 3 levels) | 17 µs, 27 | 168 µs, 845 | 183 µs, 978 |
|
|
525
|
+
|
|
526
|
+
You can run them with:
|
|
527
|
+
|
|
528
|
+
```
|
|
529
|
+
RUBY_YJIT_ENABLE=1 RUN_TYPED_PARAMS_BENCHMARKS=1 bundle exec rspec spec/performance
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
## License
|
|
533
|
+
|
|
534
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|