Skip to content

Commit

Permalink
implement Interactify.with job configuration DSL (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
markburns authored Jul 3, 2024
1 parent 0d0a054 commit 71ab123
Show file tree
Hide file tree
Showing 19 changed files with 419 additions and 85 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## [Unreleased]
- Add support for `Interactify.with(queue: 'within_30_seconds', retry: 3)`

## [0.5.0] - 2024-01-01
- Add support for `SetA = Interactify { _1.a = 'a' }`, lambda and block class creation syntax
Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,26 @@ class LoadOrder
end
```

#### filled: false
Both expect and promise can take the optional parameter `filled: false`
This means that whilst the key is expected to be passed, it doesn't have to have a truthy or present value.
Use this where valid values include, `[]`, `""`, `nil` or `false` etc.


#### optional

```ruby
class LoadOrder
include Interactify

optional :some_key, :another_key
end
```

Optional can be used to denote that the key is not required to be passed.
This is effectively equivalent to `delegate :key, to: :context`, but does not require the key to be present in the context.
This is not recommended as the keys will not be validated by the contract or the interactor wiring specs.


### Lambdas

Expand Down Expand Up @@ -467,6 +487,39 @@ By using it's internal Async class.
> [!CAUTION]
> As your class is now executing asynchronously you can no longer rely on its promises later on in the chain.

### Sidekiq options
```ruby
class SomeInteractor
include Interactify.with(queue: 'within_30_seconds')
end
```

This allows you to set the sidekiq options for the asyncified interactor.
It will autogenerate a class name that has the options set.

`SomeInteractor::Job__Queue_Within30Seconds` or with a random number suffix
if there is a naming clash.

`SomeInteractor::Job__Queue_Within30Seconds_5342`

This is also aliased as `SomeInteractor::Job` for convenience.

An almost equivalent to the above without the `.with` method is:

```ruby
class SomeInteractor
include Interactify

class JobWithin30Seconds < Job
sidekiq_options queue: 'within_30_seconds'
end
end
```

Here the JobWithin30Seconds class is manually set up and subclasses the one
automatically created by `include Interactify`.

## FAQs
- This is ugly isn't it?

Expand Down
46 changes: 13 additions & 33 deletions lib/interactify.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
require "interactify/dependency_inference"
require "interactify/hooks"
require "interactify/configure"
require "interactify/with_options"

module Interactify
extend ActiveSupport::Concern
Expand All @@ -22,39 +23,18 @@ module Interactify

class << self
delegate :root, to: :configuration
end

included do |base|
base.extend Interactify::Dsl

base.include Interactor::Organizer
base.include Interactor::Contracts
base.include Interactify::Contracts::Helpers

# defines two classes on the receiver class
# the first is the job class
# the second is the async class
# the async class is a wrapper around the job class
# that allows it to be used in an interactor chain
#
# E.g.
#
# class ExampleInteractor
# include Interactify
# expect :foo
# end
#
# ExampleInteractor::Job is a class availabe to be used in a sidekiq yaml file
#
# doing the following will immediately enqueue a job
# that calls the interactor ExampleInteractor with (foo: 'bar')
#
# ExampleInteractor::Async.call(foo: 'bar')
include Interactify::Async::Jobable
interactor_job
end

def called_klass_list
context._called.map(&:class)
def included(base)
# call `with` without arguments to get default Job and Async classes
base.include(with)
end

def with(sidekiq_opts = {})
Module.new do
define_singleton_method :included do |receiver|
WithOptions.new(receiver, sidekiq_opts).setup
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/interactify/async/job_klass.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def args(context)
end

def restrict_to_optional_or_keys_from_contract(args)
keys = container_klass.expected_keys.map(&:to_s)
keys = Array(container_klass.expected_keys).map(&:to_s)

optional = Array(container_klass.optional_attrs).map(&:to_s)
keys += optional
Expand Down
7 changes: 5 additions & 2 deletions lib/interactify/async/job_maker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
module Interactify
module Async
class JobMaker
VALID_KEYS = %i[queue retry dead backtrace pool tags].freeze
attr_reader :opts, :method_name, :container_klass, :klass_suffix

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

this = self

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

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

Expand All @@ -43,7 +44,9 @@ def build_job_klass(opts)
sidekiq_options(opts)

def perform(...)
self.class.module_parent.send(self.class::JOBABLE_METHOD_NAME, ...)
self.class.module_parent.send(
self.class::JOBABLE_METHOD_NAME, ...
)
end
end
end
Expand Down
19 changes: 19 additions & 0 deletions lib/interactify/core.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

module Interactify
module Core
extend ActiveSupport::Concern

included do |base|
base.extend Interactify::Dsl

base.include Interactor::Organizer
base.include Interactor::Contracts
base.include Interactify::Contracts::Helpers
end

def called_klass_list
context._called.map(&:class)
end
end
end
32 changes: 24 additions & 8 deletions lib/interactify/dsl/unique_klass_name.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,35 @@
module Interactify
module Dsl
module UniqueKlassName
def self.for(namespace, prefix)
id = generate_unique_id
klass_name = :"#{prefix.to_s.camelize.gsub("::", "__")}#{id}"
module_function

while namespace.const_defined?(klass_name)
id = generate_unique_id
klass_name = :"#{prefix}#{id}"
def for(namespace, prefix, camelize: true)
prefix = normalize_prefix(prefix:, camelize:)
klass_name = name_with_suffix(namespace, prefix, nil)

loop do
return klass_name.to_sym if klass_name

klass_name = name_with_suffix(namespace, prefix, generate_unique_id)
end
end

def name_with_suffix(namespace, prefix, suffix)
name = [prefix, suffix].compact.join("_")

return nil if namespace.const_defined?(name.to_sym)

name
end

def normalize_prefix(prefix:, camelize:)
normalized = prefix.to_s.gsub(/::/, "__")
return normalized unless camelize

klass_name.to_sym
normalized.camelize
end

def self.generate_unique_id
def generate_unique_id
rand(10_000)
end
end
Expand Down
52 changes: 52 additions & 0 deletions lib/interactify/with_options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

require "interactify/core"
require "interactify/async/jobable"

module Interactify
class WithOptions
def initialize(receiver, sidekiq_opts = {})
@receiver = receiver
@options = sidekiq_opts.transform_keys(&:to_sym)
end

def setup
validate_options

this = self

@receiver.instance_eval do
include Interactify::Core
include Interactify::Async::Jobable
interactor_job(opts: this.options, klass_suffix: this.klass_suffix)

# define aliases when the generate class name differs.
# i.e. when options are passed
if this.klass_suffix.present?
const_set("Job", const_get(:"Job#{this.klass_suffix}"))
const_set("Async", const_get(:"Async#{this.klass_suffix}"))
end
end
end

attr_reader :options

def klass_suffix
@klass_suffix ||= options.keys.sort.map do |key|
"__#{key.to_s.camelize}_#{options[key].to_s.camelize}"
end.join
end

private

def validate_options
return if invalid_keys.none?

raise ArgumentError, "Invalid keys: #{invalid_keys}"
end

def invalid_keys
options.keys - Interactify::Async::JobMaker::VALID_KEYS
end
end
end
8 changes: 4 additions & 4 deletions spec/lib/interactify.each_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ def k(klass)
end

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

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

file, line = klass.source_location
expect(file).to match __FILE__
Expand Down
16 changes: 9 additions & 7 deletions spec/lib/interactify.expect_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

RSpec.describe Interactify do
describe ".expect" do
class DummyInteractorClass
self::DummyInteractorClass = Class.new do
include Interactify
expect :thing
expect :this, filled: false
Expand All @@ -18,10 +18,12 @@ def call; end
end
NOISY_CONTEXT = noisy_context

class AnotherDummyInteractorOrganizerClass
this = self

self::AnotherDummyInteractorOrganizerClass = Class.new do
include Interactify

organize DummyInteractorClass
organize this::DummyInteractorClass

def call
NOISY_CONTEXT.each do |k, v|
Expand All @@ -45,7 +47,7 @@ def call
end

context "when using call" do
let(:result) { AnotherDummyInteractorOrganizerClass.call }
let(:result) { this::AnotherDummyInteractorOrganizerClass.call }

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

it "raises a useful error", :aggregate_failures do
expect { AnotherDummyInteractorOrganizerClass.call! }.to raise_error do |e|
expect(e.class).to eq DummyInteractorClass::InteractorContractFailure
expect { this::AnotherDummyInteractorOrganizerClass.call! }.to raise_error do |e|
expect(e.class).to eq this::DummyInteractorClass::InteractorContractFailure

outputted_failures = JSON.parse(e.message)

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

expect(@some_context).to eq NOISY_CONTEXT.symbolize_keys
expect(@contract_failures).to eq contract_failures.symbolize_keys
expect(@logged_exception).to be_a DummyInteractorClass::InteractorContractFailure
expect(@logged_exception).to be_a this::DummyInteractorClass::InteractorContractFailure
end
end
end
Expand Down
Loading

0 comments on commit 71ab123

Please sign in to comment.