Skip to content

Commit 71ab123

Browse files
authored
implement Interactify.with job configuration DSL (#37)
1 parent 0d0a054 commit 71ab123

19 files changed

+419
-85
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
## [Unreleased]
2+
- Add support for `Interactify.with(queue: 'within_30_seconds', retry: 3)`
23

34
## [0.5.0] - 2024-01-01
45
- Add support for `SetA = Interactify { _1.a = 'a' }`, lambda and block class creation syntax

README.md

+53
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,26 @@ class LoadOrder
108108
end
109109
```
110110

111+
#### filled: false
112+
Both expect and promise can take the optional parameter `filled: false`
113+
This means that whilst the key is expected to be passed, it doesn't have to have a truthy or present value.
114+
Use this where valid values include, `[]`, `""`, `nil` or `false` etc.
115+
116+
117+
#### optional
118+
119+
```ruby
120+
class LoadOrder
121+
include Interactify
122+
123+
optional :some_key, :another_key
124+
end
125+
```
126+
127+
Optional can be used to denote that the key is not required to be passed.
128+
This is effectively equivalent to `delegate :key, to: :context`, but does not require the key to be present in the context.
129+
This is not recommended as the keys will not be validated by the contract or the interactor wiring specs.
130+
111131

112132
### Lambdas
113133

@@ -467,6 +487,39 @@ By using it's internal Async class.
467487
> [!CAUTION]
468488
> As your class is now executing asynchronously you can no longer rely on its promises later on in the chain.
469489
490+
491+
### Sidekiq options
492+
```ruby
493+
class SomeInteractor
494+
include Interactify.with(queue: 'within_30_seconds')
495+
end
496+
```
497+
498+
This allows you to set the sidekiq options for the asyncified interactor.
499+
It will autogenerate a class name that has the options set.
500+
501+
`SomeInteractor::Job__Queue_Within30Seconds` or with a random number suffix
502+
if there is a naming clash.
503+
504+
`SomeInteractor::Job__Queue_Within30Seconds_5342`
505+
506+
This is also aliased as `SomeInteractor::Job` for convenience.
507+
508+
An almost equivalent to the above without the `.with` method is:
509+
510+
```ruby
511+
class SomeInteractor
512+
include Interactify
513+
514+
class JobWithin30Seconds < Job
515+
sidekiq_options queue: 'within_30_seconds'
516+
end
517+
end
518+
```
519+
520+
Here the JobWithin30Seconds class is manually set up and subclasses the one
521+
automatically created by `include Interactify`.
522+
470523
## FAQs
471524
- This is ugly isn't it?
472525

lib/interactify.rb

+13-33
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
require "interactify/dependency_inference"
1515
require "interactify/hooks"
1616
require "interactify/configure"
17+
require "interactify/with_options"
1718

1819
module Interactify
1920
extend ActiveSupport::Concern
@@ -22,39 +23,18 @@ module Interactify
2223

2324
class << self
2425
delegate :root, to: :configuration
25-
end
26-
27-
included do |base|
28-
base.extend Interactify::Dsl
29-
30-
base.include Interactor::Organizer
31-
base.include Interactor::Contracts
32-
base.include Interactify::Contracts::Helpers
33-
34-
# defines two classes on the receiver class
35-
# the first is the job class
36-
# the second is the async class
37-
# the async class is a wrapper around the job class
38-
# that allows it to be used in an interactor chain
39-
#
40-
# E.g.
41-
#
42-
# class ExampleInteractor
43-
# include Interactify
44-
# expect :foo
45-
# end
46-
#
47-
# ExampleInteractor::Job is a class availabe to be used in a sidekiq yaml file
48-
#
49-
# doing the following will immediately enqueue a job
50-
# that calls the interactor ExampleInteractor with (foo: 'bar')
51-
#
52-
# ExampleInteractor::Async.call(foo: 'bar')
53-
include Interactify::Async::Jobable
54-
interactor_job
55-
end
5626

57-
def called_klass_list
58-
context._called.map(&:class)
27+
def included(base)
28+
# call `with` without arguments to get default Job and Async classes
29+
base.include(with)
30+
end
31+
32+
def with(sidekiq_opts = {})
33+
Module.new do
34+
define_singleton_method :included do |receiver|
35+
WithOptions.new(receiver, sidekiq_opts).setup
36+
end
37+
end
38+
end
5939
end
6040
end

lib/interactify/async/job_klass.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def args(context)
5151
end
5252

5353
def restrict_to_optional_or_keys_from_contract(args)
54-
keys = container_klass.expected_keys.map(&:to_s)
54+
keys = Array(container_klass.expected_keys).map(&:to_s)
5555

5656
optional = Array(container_klass.optional_attrs).map(&:to_s)
5757
keys += optional

lib/interactify/async/job_maker.rb

+5-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
module Interactify
66
module Async
77
class JobMaker
8+
VALID_KEYS = %i[queue retry dead backtrace pool tags].freeze
89
attr_reader :opts, :method_name, :container_klass, :klass_suffix
910

1011
def initialize(container_klass:, opts:, klass_suffix:, method_name: :call!)
@@ -26,7 +27,7 @@ def define_job_klass
2627

2728
this = self
2829

29-
invalid_keys = this.opts.symbolize_keys.keys - %i[queue retry dead backtrace pool tags]
30+
invalid_keys = this.opts.symbolize_keys.keys - VALID_KEYS
3031

3132
raise ArgumentError, "Invalid keys: #{invalid_keys}" if invalid_keys.any?
3233

@@ -43,7 +44,9 @@ def build_job_klass(opts)
4344
sidekiq_options(opts)
4445

4546
def perform(...)
46-
self.class.module_parent.send(self.class::JOBABLE_METHOD_NAME, ...)
47+
self.class.module_parent.send(
48+
self.class::JOBABLE_METHOD_NAME, ...
49+
)
4750
end
4851
end
4952
end

lib/interactify/core.rb

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
module Interactify
4+
module Core
5+
extend ActiveSupport::Concern
6+
7+
included do |base|
8+
base.extend Interactify::Dsl
9+
10+
base.include Interactor::Organizer
11+
base.include Interactor::Contracts
12+
base.include Interactify::Contracts::Helpers
13+
end
14+
15+
def called_klass_list
16+
context._called.map(&:class)
17+
end
18+
end
19+
end

lib/interactify/dsl/unique_klass_name.rb

+24-8
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,35 @@
33
module Interactify
44
module Dsl
55
module UniqueKlassName
6-
def self.for(namespace, prefix)
7-
id = generate_unique_id
8-
klass_name = :"#{prefix.to_s.camelize.gsub("::", "__")}#{id}"
6+
module_function
97

10-
while namespace.const_defined?(klass_name)
11-
id = generate_unique_id
12-
klass_name = :"#{prefix}#{id}"
8+
def for(namespace, prefix, camelize: true)
9+
prefix = normalize_prefix(prefix:, camelize:)
10+
klass_name = name_with_suffix(namespace, prefix, nil)
11+
12+
loop do
13+
return klass_name.to_sym if klass_name
14+
15+
klass_name = name_with_suffix(namespace, prefix, generate_unique_id)
1316
end
17+
end
18+
19+
def name_with_suffix(namespace, prefix, suffix)
20+
name = [prefix, suffix].compact.join("_")
21+
22+
return nil if namespace.const_defined?(name.to_sym)
23+
24+
name
25+
end
26+
27+
def normalize_prefix(prefix:, camelize:)
28+
normalized = prefix.to_s.gsub(/::/, "__")
29+
return normalized unless camelize
1430

15-
klass_name.to_sym
31+
normalized.camelize
1632
end
1733

18-
def self.generate_unique_id
34+
def generate_unique_id
1935
rand(10_000)
2036
end
2137
end

lib/interactify/with_options.rb

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
require "interactify/core"
4+
require "interactify/async/jobable"
5+
6+
module Interactify
7+
class WithOptions
8+
def initialize(receiver, sidekiq_opts = {})
9+
@receiver = receiver
10+
@options = sidekiq_opts.transform_keys(&:to_sym)
11+
end
12+
13+
def setup
14+
validate_options
15+
16+
this = self
17+
18+
@receiver.instance_eval do
19+
include Interactify::Core
20+
include Interactify::Async::Jobable
21+
interactor_job(opts: this.options, klass_suffix: this.klass_suffix)
22+
23+
# define aliases when the generate class name differs.
24+
# i.e. when options are passed
25+
if this.klass_suffix.present?
26+
const_set("Job", const_get(:"Job#{this.klass_suffix}"))
27+
const_set("Async", const_get(:"Async#{this.klass_suffix}"))
28+
end
29+
end
30+
end
31+
32+
attr_reader :options
33+
34+
def klass_suffix
35+
@klass_suffix ||= options.keys.sort.map do |key|
36+
"__#{key.to_s.camelize}_#{options[key].to_s.camelize}"
37+
end.join
38+
end
39+
40+
private
41+
42+
def validate_options
43+
return if invalid_keys.none?
44+
45+
raise ArgumentError, "Invalid keys: #{invalid_keys}"
46+
end
47+
48+
def invalid_keys
49+
options.keys - Interactify::Async::JobMaker::VALID_KEYS
50+
end
51+
end
52+
end

spec/lib/interactify.each_spec.rb

+4-4
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,15 @@ def k(klass)
3636
end
3737

3838
it "creates an interactor class that iterates over the given collection" do
39-
allow(SpecSupport).to receive(:const_set).and_wrap_original do |meth, name, klass|
40-
expect(name).to match(/EachThing\d+\z/)
39+
allow(SpecSupport::EachInteractor).to receive(:const_set).and_wrap_original do |meth, name, klass|
40+
expect(name).to match(/EachThing(_\d+)?\z/)
4141
expect(klass).to be_a(Class)
4242
expect(klass.ancestors).to include(Interactor)
4343
meth.call(name, klass)
4444
end
4545

46-
klass = SpecSupport.each(:things, k(:A), k(:B), k(:C))
47-
expect(klass.name).to match(/SpecSupport::EachThing\d+\z/)
46+
klass = SpecSupport::EachInteractor.each(:things, k(:A), k(:B), k(:C))
47+
expect(klass.name).to match(/SpecSupport::EachInteractor::EachThing(_\d+)?\z/)
4848

4949
file, line = klass.source_location
5050
expect(file).to match __FILE__

spec/lib/interactify.expect_spec.rb

+9-7
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
RSpec.describe Interactify do
44
describe ".expect" do
5-
class DummyInteractorClass
5+
self::DummyInteractorClass = Class.new do
66
include Interactify
77
expect :thing
88
expect :this, filled: false
@@ -18,10 +18,12 @@ def call; end
1818
end
1919
NOISY_CONTEXT = noisy_context
2020

21-
class AnotherDummyInteractorOrganizerClass
21+
this = self
22+
23+
self::AnotherDummyInteractorOrganizerClass = Class.new do
2224
include Interactify
2325

24-
organize DummyInteractorClass
26+
organize this::DummyInteractorClass
2527

2628
def call
2729
NOISY_CONTEXT.each do |k, v|
@@ -45,7 +47,7 @@ def call
4547
end
4648

4749
context "when using call" do
48-
let(:result) { AnotherDummyInteractorOrganizerClass.call }
50+
let(:result) { this::AnotherDummyInteractorOrganizerClass.call }
4951

5052
it "does not raise" do
5153
expect { result }.not_to raise_error
@@ -69,8 +71,8 @@ def self.log_error(exception); end
6971
end
7072

7173
it "raises a useful error", :aggregate_failures do
72-
expect { AnotherDummyInteractorOrganizerClass.call! }.to raise_error do |e|
73-
expect(e.class).to eq DummyInteractorClass::InteractorContractFailure
74+
expect { this::AnotherDummyInteractorOrganizerClass.call! }.to raise_error do |e|
75+
expect(e.class).to eq this::DummyInteractorClass::InteractorContractFailure
7476

7577
outputted_failures = JSON.parse(e.message)
7678

@@ -79,7 +81,7 @@ def self.log_error(exception); end
7981

8082
expect(@some_context).to eq NOISY_CONTEXT.symbolize_keys
8183
expect(@contract_failures).to eq contract_failures.symbolize_keys
82-
expect(@logged_exception).to be_a DummyInteractorClass::InteractorContractFailure
84+
expect(@logged_exception).to be_a this::DummyInteractorClass::InteractorContractFailure
8385
end
8486
end
8587
end

0 commit comments

Comments
 (0)