interactor_support 1.0.2 β 1.0.4
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/.prettierignore +1 -0
- data/.yardopts +19 -0
- data/CHANGELOG.md +10 -0
- data/README.md +302 -319
- data/lib/interactor_support/actions.rb +33 -0
- data/lib/interactor_support/concerns/findable.rb +64 -20
- data/lib/interactor_support/concerns/organizable.rb +194 -0
- data/lib/interactor_support/concerns/skippable.rb +42 -0
- data/lib/interactor_support/concerns/transactionable.rb +37 -0
- data/lib/interactor_support/concerns/transformable.rb +64 -10
- data/lib/interactor_support/concerns/updatable.rb +43 -1
- data/lib/interactor_support/configuration.rb +32 -9
- data/lib/interactor_support/core.rb +19 -1
- data/lib/interactor_support/request_object.rb +132 -19
- data/lib/interactor_support/validations.rb +89 -9
- data/lib/interactor_support/version.rb +1 -1
- data/lib/interactor_support.rb +44 -0
- metadata +5 -2
data/README.md
CHANGED
|
@@ -315,9 +315,29 @@ If any validation fails, context.fail!(errors: errors.full_messages) will automa
|
|
|
315
315
|
|
|
316
316
|
#### πΉ **`InteractorSupport::RequestObject`**
|
|
317
317
|
|
|
318
|
-
|
|
318
|
+
A flexible, form-like abstraction for service object inputs, built on top of ActiveModel. InteractorSupport::RequestObject extends ActiveModel::Model and ActiveModel::Validations to provide structured, validated, and transformed input objects. It adds first-class support for nested objects, type coercion, attribute transformation, and array handling. It's ideal for use with any architecture that benefits from strong input modeling.
|
|
319
319
|
|
|
320
|
-
|
|
320
|
+
_RequestObject Enforces Input Integrity, and π allow-lists attributes by default_
|
|
321
|
+
|
|
322
|
+
**Features**
|
|
323
|
+
|
|
324
|
+
- Define attributes with types and transformation pipelines
|
|
325
|
+
- Supports primitive and custom object types
|
|
326
|
+
- Deeply nested input coercion and validation
|
|
327
|
+
- Array support for any type
|
|
328
|
+
- Auto-generated context hashes or structs
|
|
329
|
+
- Key rewriting for internal/external mapping
|
|
330
|
+
- Full ActiveModel validation support
|
|
331
|
+
|
|
332
|
+
Rather than manually massaging and validating hashes or params in your services, define intent-driven objects that:
|
|
333
|
+
|
|
334
|
+
- clean incoming values
|
|
335
|
+
- validate data structure and content
|
|
336
|
+
- expose clean interfaces for business logic
|
|
337
|
+
|
|
338
|
+
π Getting Started
|
|
339
|
+
|
|
340
|
+
1. Define a Request Object
|
|
321
341
|
|
|
322
342
|
```rb
|
|
323
343
|
class GenreRequest
|
|
@@ -328,344 +348,307 @@ class GenreRequest
|
|
|
328
348
|
|
|
329
349
|
validates :title, :description, presence: true
|
|
330
350
|
end
|
|
351
|
+
```
|
|
331
352
|
|
|
332
|
-
|
|
333
|
-
include InteractorSupport::RequestObject
|
|
334
|
-
|
|
335
|
-
attribute :city, transform: [:downcase, :strip]
|
|
336
|
-
attribute :country_code, transform: [:strip, :upcase]
|
|
337
|
-
attribute :state_code, transform: [:strip, :upcase]
|
|
338
|
-
attribute :postal_code, transform: [:strip, :clean_postal_code]
|
|
339
|
-
attribute :address, transform: :strip
|
|
353
|
+
2. Use it in your Interactor, Service, or Controller
|
|
340
354
|
|
|
341
|
-
|
|
342
|
-
|
|
355
|
+
```rb
|
|
356
|
+
class GenresController < ApplicationController
|
|
357
|
+
def create
|
|
358
|
+
context = SomeOrganizerForCreatingGenres.call(
|
|
359
|
+
GenreRequest.new(params.permit!) # π request objects are a safe and powerful replacement for strong params
|
|
360
|
+
)
|
|
343
361
|
|
|
344
|
-
|
|
345
|
-
value.to_s.gsub(/\D/, '')
|
|
346
|
-
value.first(5)
|
|
362
|
+
# render context.genre & handle success? vs failure?
|
|
347
363
|
end
|
|
348
364
|
end
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Attribute Features
|
|
368
|
+
|
|
369
|
+
#### Transformations:
|
|
370
|
+
|
|
371
|
+
Apply one or more transformations when values are assigned.
|
|
349
372
|
|
|
373
|
+
```rb
|
|
374
|
+
attribute :email, transform: [:strip, :downcase]
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
- You can use any transform that the value can `respond_to?`
|
|
378
|
+
- Define custom transforms as instance methods.
|
|
379
|
+
|
|
380
|
+
Type Casting:
|
|
381
|
+
Cast inputs to expected types automatically:
|
|
382
|
+
|
|
383
|
+
```rb
|
|
384
|
+
attribute :age, type: :integer
|
|
385
|
+
attribute :tags, type: :string, array: true
|
|
386
|
+
attribute :config, type: Hash
|
|
387
|
+
attribute :published_at, type: :datetime
|
|
388
|
+
attribute :user, type: User
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
If the value is already of the expected type, it will just pass through. Otherwise, it will try to cast it.
|
|
392
|
+
If casting fails, or you specify an unsupported type, it will raise an `InteractorSupport::RequestObject::TypeError`
|
|
393
|
+
|
|
394
|
+
Supported types are
|
|
395
|
+
|
|
396
|
+
- Any ActiveModel::Type, provided as a symbol.
|
|
397
|
+
- The following primitives, Array, Hash, Symbol
|
|
398
|
+
- RequestObject subclasses (for nesting request objects)
|
|
399
|
+
|
|
400
|
+
#### Nesting Request Objects
|
|
401
|
+
|
|
402
|
+
```rb
|
|
350
403
|
class AuthorRequest
|
|
351
404
|
include InteractorSupport::RequestObject
|
|
352
405
|
|
|
353
|
-
attribute :name
|
|
354
|
-
attribute :email, transform: [:strip, :downcase]
|
|
355
|
-
attribute :age, transform: :to_i
|
|
406
|
+
attribute :name
|
|
356
407
|
attribute :location, type: LocationRequest
|
|
357
|
-
validates_format_of :email, with: URI::MailTo::EMAIL_REGEXP
|
|
358
408
|
end
|
|
359
409
|
|
|
360
410
|
class PostRequest
|
|
361
411
|
include InteractorSupport::RequestObject
|
|
362
412
|
|
|
363
|
-
attribute :user_id
|
|
364
|
-
attribute :title, transform: :strip
|
|
365
|
-
attribute :content, transform: :strip
|
|
366
|
-
attribute :genre, type: GenreRequest
|
|
367
413
|
attribute :authors, type: AuthorRequest, array: true
|
|
368
414
|
end
|
|
415
|
+
```
|
|
369
416
|
|
|
370
|
-
|
|
371
|
-
describe 'attribute transformation' do
|
|
372
|
-
before do
|
|
373
|
-
InteractorSupport.configure do |config|
|
|
374
|
-
config.request_object_behavior = :returns_self
|
|
375
|
-
end
|
|
376
|
-
end
|
|
377
|
-
context 'when using a single transform' do
|
|
378
|
-
it 'strips the value for a symbol transform' do
|
|
379
|
-
genre = GenreRequest.new(title: ' Science Fiction ', description: ' A genre of speculative fiction ')
|
|
380
|
-
expect(genre.title).to(eq('Science Fiction'))
|
|
381
|
-
expect(genre.description).to(eq('A genre of speculative fiction'))
|
|
382
|
-
end
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
context 'when using an array of transforms' do
|
|
386
|
-
it 'applies all transform methods in order' do
|
|
387
|
-
buyer = LocationRequest.new(
|
|
388
|
-
city: ' New York ',
|
|
389
|
-
country_code: ' us ',
|
|
390
|
-
state_code: ' ny ',
|
|
391
|
-
postal_code: ' 10001-5432 ',
|
|
392
|
-
address: ' 123 Main St. ',
|
|
393
|
-
)
|
|
394
|
-
expect(buyer.city).to(eq('new york'))
|
|
395
|
-
expect(buyer.country_code).to(eq('US'))
|
|
396
|
-
expect(buyer.state_code).to(eq('NY'))
|
|
397
|
-
expect(buyer.postal_code).to(eq('10001'))
|
|
398
|
-
expect(buyer.address).to(eq('123 Main St.'))
|
|
399
|
-
end
|
|
400
|
-
end
|
|
401
|
-
|
|
402
|
-
context 'when the value does not respond to a transform method' do
|
|
403
|
-
it 'raises and argument error if the transform method is not defined' do
|
|
404
|
-
class DummyRequest
|
|
405
|
-
include InteractorSupport::RequestObject
|
|
406
|
-
attribute :number, transform: :strip
|
|
407
|
-
validates :number, presence: true
|
|
408
|
-
end
|
|
409
|
-
|
|
410
|
-
expect { DummyRequest.new(number: 1234) }.to(raise_error(ArgumentError))
|
|
411
|
-
end
|
|
412
|
-
end
|
|
413
|
-
end
|
|
417
|
+
Nested objects are instantiated recursively and validated automatically.
|
|
414
418
|
|
|
415
|
-
|
|
416
|
-
before do
|
|
417
|
-
InteractorSupport.configure do |config|
|
|
418
|
-
config.request_object_behavior = :returns_self
|
|
419
|
-
end
|
|
420
|
-
end
|
|
421
|
-
context 'when using a type without array' do
|
|
422
|
-
it "wraps the given hash in the type's new instance" do
|
|
423
|
-
post = PostRequest.new(
|
|
424
|
-
user_id: 1,
|
|
425
|
-
title: ' My First Post ',
|
|
426
|
-
content: ' This is the content of my first post ',
|
|
427
|
-
genre: { title: ' Science Fiction ', description: ' A genre of speculative fiction ' },
|
|
428
|
-
authors: [
|
|
429
|
-
{
|
|
430
|
-
name: ' John Doe ',
|
|
431
|
-
email: 'me@mail.com',
|
|
432
|
-
age: ' 25 ',
|
|
433
|
-
location: {
|
|
434
|
-
city: ' New York ',
|
|
435
|
-
country_code: ' us ',
|
|
436
|
-
state_code: ' ny ',
|
|
437
|
-
postal_code: ' 10001-5432 ',
|
|
438
|
-
address: ' 123 Main St. ',
|
|
439
|
-
},
|
|
440
|
-
},
|
|
441
|
-
{
|
|
442
|
-
name: ' Jane Doe ',
|
|
443
|
-
email: 'you@mail.com',
|
|
444
|
-
age: ' 25 ',
|
|
445
|
-
location: {
|
|
446
|
-
city: ' Los Angeles ',
|
|
447
|
-
country_code: ' us ',
|
|
448
|
-
state_code: ' ca ',
|
|
449
|
-
postal_code: ' 90001 ',
|
|
450
|
-
address: ' 456 Elm St. ',
|
|
451
|
-
},
|
|
452
|
-
},
|
|
453
|
-
],
|
|
454
|
-
)
|
|
455
|
-
|
|
456
|
-
expect(post).to(be_valid)
|
|
457
|
-
expect(post.user_id).to(eq(1))
|
|
458
|
-
expect(post.title).to(eq('My First Post'))
|
|
459
|
-
expect(post.content).to(eq('This is the content of my first post'))
|
|
460
|
-
expect(post.genre).to(be_a(GenreRequest))
|
|
461
|
-
expect(post.genre.title).to(eq('Science Fiction'))
|
|
462
|
-
expect(post.genre.description).to(eq('A genre of speculative fiction'))
|
|
463
|
-
expect(post.authors).to(be_an(Array))
|
|
464
|
-
expect(post.authors.size).to(eq(2))
|
|
465
|
-
post.authors.each do |author|
|
|
466
|
-
expect(author).to(be_a(AuthorRequest))
|
|
467
|
-
expect(author).to(be_valid)
|
|
468
|
-
expect(author.location).to(be_a(LocationRequest))
|
|
469
|
-
expect(author.location).to(be_valid)
|
|
470
|
-
expect(author.location.city).to(be_in(['new york', 'los angeles']))
|
|
471
|
-
expect(author.location.country_code).to(eq('US'))
|
|
472
|
-
expect(author.location.state_code).to(be_in(['NY', 'CA']))
|
|
473
|
-
expect(author.location.postal_code).to(be_in(['10001', '90001']))
|
|
474
|
-
expect(author.location.address).to(be_in(['123 Main St.', '456 Elm St.']))
|
|
475
|
-
end
|
|
476
|
-
end
|
|
477
|
-
end
|
|
478
|
-
end
|
|
419
|
+
## Rewrite Keys
|
|
479
420
|
|
|
480
|
-
|
|
481
|
-
before do
|
|
482
|
-
InteractorSupport.configure do |config|
|
|
483
|
-
config.request_object_behavior = :returns_self
|
|
484
|
-
end
|
|
485
|
-
end
|
|
486
|
-
it 'returns a struct and includes nested attributes' do
|
|
487
|
-
context = PostRequest.new(
|
|
488
|
-
user_id: 1,
|
|
489
|
-
title: ' My First Post ',
|
|
490
|
-
content: ' This is the content of my first post ',
|
|
491
|
-
genre: { title: ' Science Fiction ', description: ' A genre of speculative fiction ' },
|
|
492
|
-
authors: [
|
|
493
|
-
{
|
|
494
|
-
name: ' John Doe ',
|
|
495
|
-
email: 'a@b.com',
|
|
496
|
-
age: ' 25 ',
|
|
497
|
-
location: {
|
|
498
|
-
city: ' New York ',
|
|
499
|
-
country_code: ' us ',
|
|
500
|
-
state_code: ' ny ',
|
|
501
|
-
postal_code: ' 10001-5432 ',
|
|
502
|
-
address: ' 123 Main St. ',
|
|
503
|
-
},
|
|
504
|
-
},
|
|
505
|
-
],
|
|
506
|
-
).to_context
|
|
507
|
-
|
|
508
|
-
expect(context).to(be_a(Hash))
|
|
509
|
-
expect(context[:user_id]).to(eq(1))
|
|
510
|
-
expect(context[:title]).to(eq('My First Post'))
|
|
511
|
-
expect(context[:content]).to(eq('This is the content of my first post'))
|
|
512
|
-
expect(context[:genre]).to(be_a(Hash))
|
|
513
|
-
expect(context.dig(:genre, :title)).to(eq('Science Fiction'))
|
|
514
|
-
expect(context.dig(:genre, :description)).to(eq('A genre of speculative fiction'))
|
|
515
|
-
expect(context[:authors]).to(be_an(Array))
|
|
516
|
-
expect(context[:authors].size).to(eq(1))
|
|
517
|
-
author = context[:authors].first
|
|
518
|
-
expect(author).to(be_a(Hash))
|
|
519
|
-
expect(author[:name]).to(eq('John Doe'))
|
|
520
|
-
expect(author[:email]).to(eq('a@b.com'))
|
|
521
|
-
expect(author[:age]).to(eq(25))
|
|
522
|
-
expect(author[:location]).to(be_a(Hash))
|
|
523
|
-
expect(author[:location][:city]).to(eq('new york'))
|
|
524
|
-
expect(author[:location][:country_code]).to(eq('US'))
|
|
525
|
-
end
|
|
526
|
-
end
|
|
421
|
+
Rename external keys for internal use.
|
|
527
422
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
423
|
+
```rb
|
|
424
|
+
attribute :image, rewrite: :image_url, transform: :strip
|
|
425
|
+
|
|
426
|
+
request = ImageUploadRequest.new(image: ' https://url.com ')
|
|
427
|
+
request.image_url # => "https://url.com"
|
|
428
|
+
request.respond_to?(:image) # => false
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## to_context Output
|
|
432
|
+
|
|
433
|
+
Return a nested Hash, Struct, or self:
|
|
434
|
+
|
|
435
|
+
```rb
|
|
436
|
+
# Default
|
|
437
|
+
PostRequest.new(authors: [{ name: "Ruby", location: { city: "Seattle" }}])
|
|
438
|
+
# returns a hash with symbol keys => {:authors=>[{:name=>"Ruby", :location=>{:city=>"Seattle"}}]}
|
|
439
|
+
|
|
440
|
+
# Configure globally
|
|
441
|
+
InteractorSupport.configure do |config|
|
|
442
|
+
config.request_object_behavior = :returns_context # or :returns_self
|
|
443
|
+
config.request_object_key_type = :symbol # or :string, :struct
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# request_object_behavior = :returns_context, request_object_key_type = :string
|
|
447
|
+
PostRequest.new(authors: [{ name: "Ruby", location: { city: "Seattle" }}])
|
|
448
|
+
# returns a hash with string keys => {"authors"=>[{"name"=>"Ruby", "location"=>{"city"=>"Seattle"}}]}
|
|
449
|
+
|
|
450
|
+
# request_object_behavior = :returns_context, request_object_key_type = :struct
|
|
451
|
+
PostRequest.new(authors: [{ name: "Ruby", location: { city: "Seattle" }}])
|
|
452
|
+
# returns a Struct => #<struct authors=[{:name=>"Ruby", :location=>{:city=>"Seattle"}}]>
|
|
453
|
+
|
|
454
|
+
# request_object_behavior = :returns_self, request_object_key_type = :symbol
|
|
455
|
+
request = PostRequest.new(authors: [{ name: "Ruby", location: { city: "Seattle" }}])
|
|
456
|
+
# returns the request object => #<PostRequest authors=[{:name=>"Ruby", :location=>{:city=>"Seattle"}}]>
|
|
457
|
+
# request.authors.first.location.city => "Seattle"
|
|
458
|
+
# request.to_context => {:authors=>[{:name=>"Ruby", :location=>{:city=>"Seattle"}}]}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
π‘ Replacing Strong Parameters Safely
|
|
462
|
+
|
|
463
|
+
InteractorSupport::RequestObject is a safe, testable, and expressive alternative to Railsβ strong_parameters. While strong_params are great for sanitizing controller input, they tend to:
|
|
464
|
+
|
|
465
|
+
- Leak into your business logic
|
|
466
|
+
- Lack structure and type safety
|
|
467
|
+
- Require repetitive permit/require declarations
|
|
468
|
+
- Get clumsy with nesting and arrays
|
|
469
|
+
|
|
470
|
+
Instead, RequestObject defines the expected shape and behavior of input once, and gives you:
|
|
471
|
+
|
|
472
|
+
- Input sanitization via transform:
|
|
473
|
+
- Validation via ActiveModel
|
|
474
|
+
- Type coercion (including arrays and nesting)
|
|
475
|
+
- Reusable, composable input classes
|
|
476
|
+
|
|
477
|
+
StrongParams Example
|
|
478
|
+
|
|
479
|
+
```rb
|
|
480
|
+
def user_params
|
|
481
|
+
params.require(:user).permit(:name, :email, :age)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def create
|
|
485
|
+
user = User.new(user_params)
|
|
486
|
+
...
|
|
487
|
+
end
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Even with this, you still have to:
|
|
491
|
+
β’ Validate formats (like email)
|
|
492
|
+
β’ Coerce types (:age is still a string!)
|
|
493
|
+
β’ Repeat this logic elsewhere
|
|
494
|
+
|
|
495
|
+
**Request Object Equivelent**
|
|
496
|
+
|
|
497
|
+
```rb
|
|
498
|
+
class UserRequest
|
|
499
|
+
include InteractorSupport::RequestObject
|
|
500
|
+
|
|
501
|
+
attribute :name, transform: :strip
|
|
502
|
+
attribute :email, transform: [:strip, :downcase]
|
|
503
|
+
attribute :age, type: :integer # or transform: [:to_i]
|
|
504
|
+
|
|
505
|
+
validates :name, presence: true
|
|
506
|
+
validates_format_of :email, with: URI::MailTo::EMAIL_REGEXP
|
|
507
|
+
end
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
**Why replace Strong Params?**
|
|
511
|
+
| Feature | Strong Params | Request Object |
|
|
512
|
+
|----------------------------------|---------------------|----------------------|
|
|
513
|
+
| Requires manual permit/require | β
Yes | β Not needed |
|
|
514
|
+
| Validates types/formats | β No | β
Yes |
|
|
515
|
+
| Handles nested objects | π¬ With effort | β
First-class support |
|
|
516
|
+
| Works outside controllers | β Not cleanly | β
Perfect for services/interactors |
|
|
517
|
+
| Self-documenting input shape | β No | β
Defined via attribute DSL |
|
|
518
|
+
| Testable as a unit | β Not directly | β
Easily tested like a form object |
|
|
519
|
+
|
|
520
|
+
π‘ Tip
|
|
521
|
+
|
|
522
|
+
You can still use params.require(...).permit(...) in the controller if you want to restrict top-level keys, then pass that sanitized hash to your RequestObject:
|
|
523
|
+
|
|
524
|
+
```rb
|
|
525
|
+
UserRequest.new(params.require(:user).permit(:name, :email, :age))
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
But with RequestObject, thatβs often unnecessary because youβre already defining a schema.
|
|
529
|
+
|
|
530
|
+
## InteractorSupport::Organizable
|
|
531
|
+
|
|
532
|
+
The Organizable concern provides utility methods to simplify working with interactors and request objects. It gives you a clean and consistent pattern for extracting, transforming, and preparing parameters for use in service objects or interactors.
|
|
533
|
+
|
|
534
|
+
Features
|
|
535
|
+
|
|
536
|
+
- organize: Call interactors with request objects, optionally namespaced under a context_key.
|
|
537
|
+
- request_params: Extract, shape, filter, rename, flatten, and merge incoming params in a clear and declarative way.
|
|
538
|
+
- Built for controllers or service entry points.
|
|
539
|
+
- Rails-native feel β works seamlessly with strong params.
|
|
540
|
+
|
|
541
|
+
#### API Reference
|
|
542
|
+
|
|
543
|
+
**#organize(interactor, params:, request_object:, context_key: nil)**
|
|
544
|
+
Calls the given interactor with a request object built from the provided params.
|
|
545
|
+
|
|
546
|
+
| Argument | Type | Description |
|
|
547
|
+
| -------------- | ------------- | --------------------------------------------------------------------------- |
|
|
548
|
+
| interactor | Class | The interactor to call (.call must be defined). |
|
|
549
|
+
| params | Hash | Parameters passed to the request object. |
|
|
550
|
+
| request_object | Class | A request object class that accepts params in its initializer. |
|
|
551
|
+
| context_key | Symbol or nil | Optional key to namespace the request object inside the interactor context. |
|
|
552
|
+
|
|
553
|
+
Examples
|
|
554
|
+
|
|
555
|
+
```rb
|
|
556
|
+
organize(MyInteractor, params: request_params, request_object: MyRequest)
|
|
557
|
+
|
|
558
|
+
# => MyInteractor.call(MyRequest.new(params))
|
|
559
|
+
|
|
560
|
+
organize(MyInteractor, params: request_params, request_object: MyRequest, context_key: :request)
|
|
561
|
+
|
|
562
|
+
# => MyInteractor.call({ request: MyRequest.new(params) })
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
#### #request_params(\*top_level_keys, merge: {}, except: [], rewrite: [])
|
|
566
|
+
|
|
567
|
+
Returns a shaped parameter hash derived from params.permit!. You can extract specific top-level keys, rename them, flatten values, apply defaults, and remove unwanted fields.
|
|
568
|
+
|
|
569
|
+
| Argument | Type | Description |
|
|
570
|
+
| ----------------- | -------------------------------- | ------------------------------------------------------------------------- |
|
|
571
|
+
| `*top_level_keys` | `Symbol...` | Optional list of top-level keys to include. If omitted, includes all. |
|
|
572
|
+
| `merge:` | `Hash` | Extra values to merge into the result. |
|
|
573
|
+
| `except:` | `Array<Symbol or Array<Symbol>>` | Keys or nested key paths to exclude. |
|
|
574
|
+
| `rewrite:` | `Array<Hash>` | Rules for renaming, flattening, filtering, merging, or defaulting values. |
|
|
575
|
+
|
|
576
|
+
Rewrite Options
|
|
577
|
+
|
|
578
|
+
Each rewrite entry is a hash in the form { key => options }, where options may include:
|
|
579
|
+
| Option | Type | Description |
|
|
580
|
+
|-----------|---------------------------|-------------------------------------------------------------------|
|
|
581
|
+
| `as` | `Symbol` | Rename the key to a new top-level key. |
|
|
582
|
+
| `only` | `Array<Symbol>` | Include only these subkeys in the result. |
|
|
583
|
+
| `except` | `Array<Symbol>` | Remove these subkeys from the result. |
|
|
584
|
+
| `flatten` | `true` or `Array<Symbol>` | Flatten all subkeys into top-level (or just the specified ones). |
|
|
585
|
+
| `default` | `Hash` | Use this value if the original key is missing or nil. |
|
|
586
|
+
| `merge` | `Hash` | Merge this hash into the result (after filtering and flattening). |
|
|
587
|
+
|
|
588
|
+
Example: full usage
|
|
589
|
+
|
|
590
|
+
```rb
|
|
591
|
+
# Incoming params:
|
|
592
|
+
params = {
|
|
593
|
+
order: {
|
|
594
|
+
product_id: 1,
|
|
595
|
+
quantity: 2,
|
|
596
|
+
internal: "should be removed"
|
|
597
|
+
},
|
|
598
|
+
metadata: {
|
|
599
|
+
source: "mobile",
|
|
600
|
+
internal: "hidden",
|
|
601
|
+
location: { ip: "1.2.3.4" }
|
|
602
|
+
},
|
|
603
|
+
flags: {
|
|
604
|
+
foo: true
|
|
605
|
+
},
|
|
606
|
+
internal: "global_internal",
|
|
607
|
+
session: nil
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
# Incantation:
|
|
611
|
+
request_params(:order, :metadata, :flags, :session,
|
|
612
|
+
merge: { user: current_user }, # <- Add the user
|
|
613
|
+
except: [[:order, :internal], :internal], # <- remove `order.internal`, and the top level key `internal`
|
|
614
|
+
rewrite: [
|
|
615
|
+
{ order: { flatten: true } }, # <- moves all the values from order to top level keys
|
|
616
|
+
{ metadata: { as: :meta, only: [:source, :location], flatten: [:location] } }, # <- Rename metadata to meta, pluck source and location, move location's values to meta
|
|
617
|
+
{ flags: { merge: { debug: true } } }, # <- add flags.debug = true
|
|
618
|
+
{ session: { default: { id: nil } } } # <- create a default value for session
|
|
619
|
+
]
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
# Result
|
|
623
|
+
{
|
|
624
|
+
product_id: 1,
|
|
625
|
+
quantity: 2,
|
|
626
|
+
meta: {
|
|
627
|
+
source: "mobile",
|
|
628
|
+
ip: "1.2.3.4"
|
|
629
|
+
},
|
|
630
|
+
flags: {
|
|
631
|
+
foo: true,
|
|
632
|
+
debug: true
|
|
633
|
+
},
|
|
634
|
+
session: {
|
|
635
|
+
id: nil
|
|
636
|
+
},
|
|
637
|
+
user: current_user
|
|
638
|
+
}
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
β οΈ Array flattening is not supported
|
|
642
|
+
|
|
643
|
+
Flattening arrays of hashes (e.g., { events: [{ id: 1 }] }) is intentionally not supported to avoid accidental key collisions. If needed, transform such structures manually before passing to request_params.
|
|
644
|
+
|
|
645
|
+
**Usage**
|
|
646
|
+
|
|
647
|
+
Include in a controller or service base class
|
|
648
|
+
|
|
649
|
+
```rb
|
|
650
|
+
class ApplicationController < ActionController::Base
|
|
651
|
+
include InteractorSupport::Concerns::Organizable
|
|
669
652
|
end
|
|
670
653
|
```
|
|
671
654
|
|