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 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/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
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).