interactor_support 1.0.2 β 1.0.3
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 +6 -0
- data/README.md +177 -319
- data/lib/interactor_support/actions.rb +31 -0
- data/lib/interactor_support/concerns/findable.rb +64 -20
- 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 +131 -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 +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2b479b98d9029e0865ab4526efcc08ec47736ecba96547d2ab9be294f54d2880
|
|
4
|
+
data.tar.gz: 74f5d2a0024afb6b7c49ff153a339d6373d25072d37e546827407e65cfd0bc8d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1d24d1b7fe20316f26d14f029387d90e1c88e2dc9654500dd40ae9546e030121efcb5de991d091b752308f0f976533a4dabaebc3cb2bebc269b7f5ca70ab7d3
|
|
7
|
+
data.tar.gz: 32c320144c9f90dc53769bd104a308d17ce663e8618cc0f55f8435d89835ed3aa2cb3049df023422de0d34068ced12795e12127e8eb976f18a3db8a56c0e759a
|
data/.prettierignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.yardopts
|
data/.yardopts
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -11,3 +11,9 @@
|
|
|
11
11
|
## [1.0.2] - 2025-03-28
|
|
12
12
|
|
|
13
13
|
- Added support for mixing symbols and procs in the transformable concern
|
|
14
|
+
|
|
15
|
+
## [1.0.3] - 2025-04-02
|
|
16
|
+
|
|
17
|
+
- Added support for rewriting attribute names in a request object
|
|
18
|
+
- Better support for type coersion, using Active model + Array, Hash, and Symbol
|
|
19
|
+
- Better support for `AnyClass` type validations
|
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,347 +348,185 @@ 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.
|
|
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.
|
|
349
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
|
-
city: ' New York ',
|
|
593
|
-
country_code: ' us ',
|
|
594
|
-
state_code: ' ny ',
|
|
595
|
-
postal_code: ' 10001-5432 ',
|
|
596
|
-
address: ' 123 Main St. ',
|
|
597
|
-
},
|
|
598
|
-
},
|
|
599
|
-
],
|
|
600
|
-
)
|
|
601
|
-
|
|
602
|
-
context = post
|
|
603
|
-
expect(context).to(be_a(Hash))
|
|
604
|
-
expect(context['user_id']).to(eq(1))
|
|
605
|
-
expect(context['title']).to(eq('My First Post'))
|
|
606
|
-
expect(context['content']).to(eq('This is the content of my first post'))
|
|
607
|
-
expect(context['genre']).to(be_a(Hash))
|
|
608
|
-
expect(context.dig('genre', 'title')).to(eq('Science Fiction'))
|
|
609
|
-
expect(context.dig('genre', 'description')).to(eq('A genre of speculative fiction'))
|
|
610
|
-
expect(context['authors']).to(be_an(Array))
|
|
611
|
-
expect(context['authors'].size).to(eq(1))
|
|
612
|
-
author = context['authors'].first
|
|
613
|
-
expect(author).to(be_a(Hash))
|
|
614
|
-
expect(author['name']).to(eq('John Doe'))
|
|
615
|
-
expect(author['email']).to(eq('j@j.com'))
|
|
616
|
-
expect(author['age']).to(eq(25))
|
|
617
|
-
expect(author['location']).to(be_a(Hash))
|
|
618
|
-
expect(author['location']['city']).to(eq('new york'))
|
|
619
|
-
expect(author['location']['country_code']).to(eq('US'))
|
|
620
|
-
end
|
|
621
|
-
|
|
622
|
-
it 'returns a context struct when configured to do so' do
|
|
623
|
-
InteractorSupport.configure do |config|
|
|
624
|
-
config.request_object_behavior = :returns_context
|
|
625
|
-
config.request_object_key_type = :struct
|
|
626
|
-
end
|
|
627
|
-
|
|
628
|
-
post = PostRequest.new(
|
|
629
|
-
user_id: 1,
|
|
630
|
-
title: ' My First Post ',
|
|
631
|
-
content: ' This is the content of my first post ',
|
|
632
|
-
genre: { title: ' Science Fiction ', description: ' A genre of speculative fiction ' },
|
|
633
|
-
authors: [
|
|
634
|
-
{
|
|
635
|
-
name: ' John Doe ',
|
|
636
|
-
email: 'j@j.com',
|
|
637
|
-
age: ' 25 ',
|
|
638
|
-
location: {
|
|
639
|
-
city: ' New York ',
|
|
640
|
-
country_code: ' us ',
|
|
641
|
-
state_code: ' ny ',
|
|
642
|
-
postal_code: ' 10001-5432 ',
|
|
643
|
-
address: ' 123 Main St. ',
|
|
644
|
-
},
|
|
645
|
-
},
|
|
646
|
-
],
|
|
647
|
-
)
|
|
648
|
-
|
|
649
|
-
context = post
|
|
650
|
-
expect(context).to(be_a(Struct))
|
|
651
|
-
expect(context.user_id).to(eq(1))
|
|
652
|
-
expect(context.title).to(eq('My First Post'))
|
|
653
|
-
expect(context.content).to(eq('This is the content of my first post'))
|
|
654
|
-
expect(context.genre).to(be_a(Struct))
|
|
655
|
-
expect(context.genre.title).to(eq('Science Fiction'))
|
|
656
|
-
expect(context.genre.description).to(eq('A genre of speculative fiction'))
|
|
657
|
-
expect(context.authors).to(be_an(Array))
|
|
658
|
-
expect(context.authors.size).to(eq(1))
|
|
659
|
-
author = context.authors.first
|
|
660
|
-
expect(author).to(be_a(Struct))
|
|
661
|
-
expect(author.name).to(eq('John Doe'))
|
|
662
|
-
expect(author.email).to(eq('j@j.com'))
|
|
663
|
-
expect(author.age).to(eq(25))
|
|
664
|
-
expect(author.location).to(be_a(Struct))
|
|
665
|
-
expect(author.location.city).to(eq('new york'))
|
|
666
|
-
expect(author.location.country_code).to(eq('US'))
|
|
667
|
-
end
|
|
668
|
-
end
|
|
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
|
+
...
|
|
669
487
|
end
|
|
670
488
|
```
|
|
671
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
|
+
|
|
672
530
|
## π€ **Contributing**
|
|
673
531
|
|
|
674
532
|
Pull requests are welcome on [GitHub](https://github.com/charliemitchell/interactor_support).
|
|
@@ -1,4 +1,35 @@
|
|
|
1
|
+
# lib/interactor_support/version.rb
|
|
1
2
|
module InteractorSupport
|
|
3
|
+
##
|
|
4
|
+
# A bundle of DSL-style concerns that enhance interactors with expressive,
|
|
5
|
+
# composable behavior.
|
|
6
|
+
#
|
|
7
|
+
# This module is intended to be included into an `Interactor` or `Organizer`,
|
|
8
|
+
# providing access to a suite of declarative action helpers:
|
|
9
|
+
#
|
|
10
|
+
# - {Skippable} β Conditionally skip execution
|
|
11
|
+
# - {Transactionable} β Wrap logic in an ActiveRecord transaction
|
|
12
|
+
# - {Updatable} β Update records using context-driven attributes
|
|
13
|
+
# - {Findable} β Find one or many records into context
|
|
14
|
+
# - {Transformable} β Normalize or modify context values before execution
|
|
15
|
+
#
|
|
16
|
+
# @example Use in an interactor
|
|
17
|
+
# class UpdateUser
|
|
18
|
+
# include Interactor
|
|
19
|
+
# include InteractorSupport::Actions
|
|
20
|
+
#
|
|
21
|
+
# find_by :user
|
|
22
|
+
#
|
|
23
|
+
# transform :email, with: [:strip, :downcase]
|
|
24
|
+
#
|
|
25
|
+
# update :user, attributes: { email: :email }
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @see InteractorSupport::Concerns::Skippable
|
|
29
|
+
# @see InteractorSupport::Concerns::Transactionable
|
|
30
|
+
# @see InteractorSupport::Concerns::Updatable
|
|
31
|
+
# @see InteractorSupport::Concerns::Findable
|
|
32
|
+
# @see InteractorSupport::Concerns::Transformable
|
|
2
33
|
module Actions
|
|
3
34
|
extend ActiveSupport::Concern
|
|
4
35
|
included do
|