interactor_support 1.0.1 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99b9bd6793db476faba16087e24a939982b13974853c2a022e865e011509b143
4
- data.tar.gz: 6980cdf3351816cbd8209825c03b58b82c195ac21c337d0638223a97c68814f8
3
+ metadata.gz: 9389f81b3832c245eac6c1f850a1b55c6baaebbc01be44eccd8d4f31c45bc731
4
+ data.tar.gz: c3ee7cd401d9d744e51208bc9763cb417e25410e4e2797bb51676aece17ffeec
5
5
  SHA512:
6
- metadata.gz: ce6d5d43e59d7a70b8fa5e2183da93fc52644de5b12c796cf9b617e5a5eb5d78b32439e884cc40860e1bf2e7da6d1a926b58f9ed3257d2ced5ceb77a00c75715
7
- data.tar.gz: 940bafa5fbf6d6178b0c8f5f603ff37995c178511aab9cb50d12acc15492a9ace497dbd1e66b76c12fbc3e6943d41d97a9ec557d5451bd3bb6e741e590316194
6
+ metadata.gz: 67218c44b1b89e1173abba43d298742eaa2bf59d7a884c5e65017c855f80a4ace05a2423232325395085f8e70c9d7f981e6ef39d267368e9eaf7c2c32fb7e57e
7
+ data.tar.gz: 0c8ff1bac440c0cd7f07d4e3f9463109986ce92240aaa5725e5d405f21c62040d6ea7509e1de2b951184db39cef977c181f01c73ed69895e599b67dda093628c
data/CHANGELOG.md CHANGED
@@ -7,3 +7,7 @@
7
7
  ## [1.0.1] - 2025-03-26
8
8
 
9
9
  - Removed runtime requirements for rails and interactor.
10
+
11
+ ## [1.0.2] - 2025-03-28
12
+
13
+ - Added support for mixing symbols and procs in the transformable concern
data/README.md CHANGED
@@ -28,7 +28,7 @@ Make your **Rails interactors** clean, powerful, and less error-prone!
28
28
  Add to your Gemfile:
29
29
 
30
30
  ```sh
31
- bundle add interactor_support
31
+ gem 'interactor_support', '~> 1.0', '>= 1.0.1'
32
32
  ```
33
33
 
34
34
  Or install manually:
@@ -84,7 +84,7 @@ class UpdateTodoTitle
84
84
  include Interactor
85
85
  include InteractorSupport
86
86
 
87
- requires :todo_id, :title
87
+ required :todo_id, :title
88
88
  transform :title, with: :strip
89
89
  find_by :todo, query: { id: :todo_id }, required: true
90
90
  update :todo, attributes: { title: :title }
@@ -110,7 +110,7 @@ class CompleteTodo
110
110
  include InteractorSupport
111
111
 
112
112
  transaction
113
- requires :todo_id
113
+ required :todo_id
114
114
  find_by :todo, query: { id: :todo_id }, required: true
115
115
 
116
116
  update :todo, attributes: {
@@ -133,7 +133,7 @@ class CompleteTodo
133
133
  include InteractorSupport
134
134
 
135
135
  transaction
136
- requires :todo
136
+ required :todo
137
137
  skip if: -> { todo.completed? }
138
138
  update :todo, attributes: { completed: true, completed_at: -> { Time.current } }
139
139
  end
@@ -235,10 +235,53 @@ find_where :post, where: { user_id: :user_id }, context_key: :user_posts
235
235
 
236
236
  Provides `transform` to **sanitize and normalize inputs**.
237
237
 
238
+ ```rb
239
+ # any method that the attribute responds to will work
240
+ transform :title, with: :strip
241
+
242
+ # You can chain transformers on an attribute
243
+ # show_the_thing == "1" => "1".to_i.positive? => true
244
+ transform :show_the_thing, with: [:to_i, :positive?]
245
+
246
+ # transforming a string to a boolean using a lambda, eg: "true" => true
247
+ transform :my_param, with: -> (val) { ActiveModel::Type::Boolean.new.cast(val) }
248
+
249
+ # added in 1.0.2
250
+ # mixing symbols and keys. eg: " True " => true
251
+ transform :my_param, with: [
252
+ :strip,
253
+ :downcase,
254
+ -> (val) { ActiveModel::Type::Boolean.new.cast(val) }
255
+ ]
256
+
257
+ ```
258
+
238
259
  #### 🔹 **`InteractorSupport::Concerns::Skippable`**
239
260
 
240
261
  Allows an interactor to **skip execution** if a condition is met.
241
262
 
263
+ ```rb
264
+ # skips execution
265
+ skip if: true
266
+ # skips execution when a lambda is passed
267
+ skip if: -> { true }
268
+ # using a method
269
+ skip if: :some_method?
270
+ # using a context variable
271
+ skip if: :condition
272
+
273
+ # Using `unless`
274
+
275
+ # skips execution
276
+ skip unless: false
277
+ # skips execution when a lambda is passed
278
+ skip unless: -> { false }
279
+ # using a method
280
+ skip unless: :some_method?
281
+ # using a context variable
282
+ skip unless: :condition
283
+ ```
284
+
242
285
  #### 🔹 **`InteractorSupport::Validations`**
243
286
 
244
287
  Provides **automatic input validation** before execution. This includes `ActiveModel::Validations` and
@@ -274,6 +317,358 @@ If any validation fails, context.fail!(errors: errors.full_messages) will automa
274
317
 
275
318
  Provides structured, validated request objects based on **ActiveModel**.
276
319
 
320
+ For now, here's the specs:
321
+
322
+ ```rb
323
+ class GenreRequest
324
+ include InteractorSupport::RequestObject
325
+
326
+ attribute :title, transform: :strip
327
+ attribute :description, transform: :strip
328
+
329
+ validates :title, :description, presence: true
330
+ end
331
+
332
+ class LocationRequest
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
340
+
341
+ validates :city, :postal_code, :address, presence: true
342
+ validates :country_code, :state_code, presence: true, length: { is: 2 }
343
+
344
+ def clean_postal_code(value)
345
+ value.to_s.gsub(/\D/, '')
346
+ value.first(5)
347
+ end
348
+ end
349
+
350
+ class AuthorRequest
351
+ include InteractorSupport::RequestObject
352
+
353
+ attribute :name, transform: :strip
354
+ attribute :email, transform: [:strip, :downcase]
355
+ attribute :age, transform: :to_i
356
+ attribute :location, type: LocationRequest
357
+ validates_format_of :email, with: URI::MailTo::EMAIL_REGEXP
358
+ end
359
+
360
+ class PostRequest
361
+ include InteractorSupport::RequestObject
362
+
363
+ attribute :user_id
364
+ attribute :title, transform: :strip
365
+ attribute :content, transform: :strip
366
+ attribute :genre, type: GenreRequest
367
+ attribute :authors, type: AuthorRequest, array: true
368
+ end
369
+
370
+ RSpec.describe(InteractorSupport::RequestObject) do
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
414
+
415
+ describe 'nesting request objects and array support' do
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
479
+
480
+ describe 'to_context' do
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
527
+
528
+ describe 'configured request object behavior' do
529
+ it 'returns a context hash with symbol keys when configured to do so' do
530
+ InteractorSupport.configure do |config|
531
+ config.request_object_behavior = :returns_context
532
+ config.request_object_key_type = :symbol
533
+ end
534
+
535
+ context = PostRequest.new(
536
+ user_id: 1,
537
+ title: ' My First Post ',
538
+ content: ' This is the content of my first post ',
539
+ genre: { title: ' Science Fiction ', description: ' A genre of speculative fiction ' },
540
+ authors: [
541
+ {
542
+ name: ' John Doe ',
543
+ email: 'j@j.com',
544
+ age: ' 25 ',
545
+ location: {
546
+ city: ' New York ',
547
+ country_code: ' us ',
548
+ state_code: ' ny ',
549
+ postal_code: ' 10001-5432 ',
550
+ address: ' 123 Main St. ',
551
+ },
552
+ },
553
+ ],
554
+ )
555
+
556
+ expect(context).to(be_a(Hash))
557
+ expect(context[:user_id]).to(eq(1))
558
+ expect(context[:title]).to(eq('My First Post'))
559
+ expect(context[:content]).to(eq('This is the content of my first post'))
560
+ expect(context[:genre]).to(be_a(Hash))
561
+ expect(context.dig(:genre, :title)).to(eq('Science Fiction'))
562
+ expect(context.dig(:genre, :description)).to(eq('A genre of speculative fiction'))
563
+ expect(context[:authors]).to(be_an(Array))
564
+ expect(context[:authors].size).to(eq(1))
565
+ author = context[:authors].first
566
+ expect(author).to(be_a(Hash))
567
+ expect(author[:name]).to(eq('John Doe'))
568
+ expect(author[:email]).to(eq('j@j.com'))
569
+ expect(author[:age]).to(eq(25))
570
+ expect(author[:location]).to(be_a(Hash))
571
+ expect(author[:location][:city]).to(eq('new york'))
572
+ expect(author[:location][:country_code]).to(eq('US'))
573
+ end
574
+
575
+ it 'returns a context hash with string keys when configured to do so' do
576
+ InteractorSupport.configure do |config|
577
+ config.request_object_behavior = :returns_context
578
+ config.request_object_key_type = :string
579
+ end
580
+
581
+ post = PostRequest.new(
582
+ user_id: 1,
583
+ title: ' My First Post ',
584
+ content: ' This is the content of my first post ',
585
+ genre: { title: ' Science Fiction ', description: ' A genre of speculative fiction ' },
586
+ authors: [
587
+ {
588
+ name: ' John Doe ',
589
+ email: 'j@j.com',
590
+ age: ' 25 ',
591
+ location: {
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
669
+ end
670
+ ```
671
+
277
672
  ## 🤝 **Contributing**
278
673
 
279
674
  Pull requests are welcome on [GitHub](https://github.com/charliemitchell/interactor_support).
@@ -42,11 +42,20 @@ module InteractorSupport
42
42
  context.fail!(errors: ["#{key} failed to transform: #{e.message}"])
43
43
  end
44
44
  elsif with.is_a?(Array)
45
- context.fail!(errors: ["#{key} does not respond to all transforms"]) unless with.all? do |t|
46
- t.is_a?(Symbol) && context[key].respond_to?(t)
47
- end
48
- context[key] = with.inject(context[key]) do |value, method|
49
- value.send(method)
45
+ with.each do |method|
46
+ if method.is_a?(Proc)
47
+ begin
48
+ context[key] = context.instance_exec(&method)
49
+ rescue => e
50
+ context.fail!(errors: ["#{key} failed to transform: #{e.message}"])
51
+ end
52
+ else
53
+ context.fail!(
54
+ errors: ["#{key} does not respond to all transforms"],
55
+ ) unless context[key].respond_to?(method)
56
+
57
+ context[key] = context[key].send(method)
58
+ end
50
59
  end
51
60
  elsif with.is_a?(Symbol) && context[key].respond_to?(with)
52
61
  context[key] = context[key].send(with)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module InteractorSupport
4
- VERSION = '1.0.1'
4
+ VERSION = '1.0.2'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: interactor_support
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Charlie Mitchell
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-03-26 00:00:00.000000000 Z
11
+ date: 2025-03-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email: