From 71ab123b58f6f82770820f3cb33cb629440c09d2 Mon Sep 17 00:00:00 2001 From: Mark Burns Date: Wed, 3 Jul 2024 18:31:49 +0100 Subject: [PATCH] implement Interactify.with job configuration DSL (#37) --- CHANGELOG.md | 1 + README.md | 53 +++++++++++++++ lib/interactify.rb | 46 ++++--------- lib/interactify/async/job_klass.rb | 2 +- lib/interactify/async/job_maker.rb | 7 +- lib/interactify/core.rb | 19 ++++++ lib/interactify/dsl/unique_klass_name.rb | 32 ++++++--- lib/interactify/with_options.rb | 52 ++++++++++++++ spec/lib/interactify.each_spec.rb | 8 +-- spec/lib/interactify.expect_spec.rb | 16 +++-- spec/lib/interactify.with_spec.rb | 52 ++++++++++++++ spec/lib/interactify/async/jobable_spec.rb | 68 +++++++++++++++++++ .../contracts/helpers.expect_spec.rb | 40 +++++------ spec/lib/interactify/core_spec.rb | 25 +++++++ spec/lib/interactify/dsl/each_chain_spec.rb | 2 +- .../interactify/dsl/unique_klass_name_spec.rb | 31 +++++++-- spec/lib/interactify/dsl/wrapper_spec.rb | 4 +- spec/lib/interactify/with_options_spec.rb | 42 ++++++++++++ spec/support/spec_support.rb | 4 +- 19 files changed, 419 insertions(+), 85 deletions(-) create mode 100644 lib/interactify/core.rb create mode 100644 lib/interactify/with_options.rb create mode 100644 spec/lib/interactify.with_spec.rb create mode 100644 spec/lib/interactify/core_spec.rb create mode 100644 spec/lib/interactify/with_options_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ad248..fcea518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 3edf389..139f90b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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? diff --git a/lib/interactify.rb b/lib/interactify.rb index b3d2857..9cfe844 100644 --- a/lib/interactify.rb +++ b/lib/interactify.rb @@ -14,6 +14,7 @@ require "interactify/dependency_inference" require "interactify/hooks" require "interactify/configure" +require "interactify/with_options" module Interactify extend ActiveSupport::Concern @@ -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 diff --git a/lib/interactify/async/job_klass.rb b/lib/interactify/async/job_klass.rb index a7a72a8..10a273e 100644 --- a/lib/interactify/async/job_klass.rb +++ b/lib/interactify/async/job_klass.rb @@ -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 diff --git a/lib/interactify/async/job_maker.rb b/lib/interactify/async/job_maker.rb index f3e679c..178844a 100644 --- a/lib/interactify/async/job_maker.rb +++ b/lib/interactify/async/job_maker.rb @@ -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!) @@ -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? @@ -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 diff --git a/lib/interactify/core.rb b/lib/interactify/core.rb new file mode 100644 index 0000000..239384f --- /dev/null +++ b/lib/interactify/core.rb @@ -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 diff --git a/lib/interactify/dsl/unique_klass_name.rb b/lib/interactify/dsl/unique_klass_name.rb index a929c2d..34e5ba9 100644 --- a/lib/interactify/dsl/unique_klass_name.rb +++ b/lib/interactify/dsl/unique_klass_name.rb @@ -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 diff --git a/lib/interactify/with_options.rb b/lib/interactify/with_options.rb new file mode 100644 index 0000000..0a66026 --- /dev/null +++ b/lib/interactify/with_options.rb @@ -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 diff --git a/spec/lib/interactify.each_spec.rb b/spec/lib/interactify.each_spec.rb index a5a8e50..0a061e7 100644 --- a/spec/lib/interactify.each_spec.rb +++ b/spec/lib/interactify.each_spec.rb @@ -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__ diff --git a/spec/lib/interactify.expect_spec.rb b/spec/lib/interactify.expect_spec.rb index 061459c..7d545fa 100644 --- a/spec/lib/interactify.expect_spec.rb +++ b/spec/lib/interactify.expect_spec.rb @@ -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 @@ -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| @@ -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 @@ -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) @@ -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 diff --git a/spec/lib/interactify.with_spec.rb b/spec/lib/interactify.with_spec.rb new file mode 100644 index 0000000..eae5bb3 --- /dev/null +++ b/spec/lib/interactify.with_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +if Interactify.sidekiq? + RSpec.describe Interactify do + describe ".with" do + let(:klass_with_options) { k(:Optionified) } + let(:result) { organizer.call!(choose_life:) } + + context "when setting options" do + let(:constants) { klass_with_options.constants } + let(:async_klass_name) { constants.detect { _1 =~ /Async/ } } + let(:async_klass) { klass_with_options.const_get(async_klass_name) } + let(:job_klass_name) { constants.detect { _1 =~ /Job/ } } + + let(:job_klass) do + klass_with_options.const_get( + job_klass_name + ) + end + + it "calls the underlying job class" do + expect(job_klass.name).to match(/Optionified::Job__Queue_Within30Seconds__Retry_3(_\d+)?/) + expect(job_klass).to receive(:perform_async).with( + hash_including("choose_life" => true) + ) + + expect(async_klass.name).to match(/Optionified::Async__Queue_Within30Seconds__Retry_3(_\d+)?/) + async_klass.call!("choose_life" => true) + Sidekiq::Worker.drain_all + end + end + + module self::SomeNamespace + class Optionified + include Interactify.with( + queue: "within_30_seconds", + retry: 3 + ) + expect :choose_life, filled: false + + def call + context.life = true + end + end + end + + def k(klass) + self.class::SomeNamespace.const_get(klass) + end + end + end +end diff --git a/spec/lib/interactify/async/jobable_spec.rb b/spec/lib/interactify/async/jobable_spec.rb index 9607c8e..ee3472d 100644 --- a/spec/lib/interactify/async/jobable_spec.rb +++ b/spec/lib/interactify/async/jobable_spec.rb @@ -151,6 +151,74 @@ def self.reset side_effects.reset end end + + describe "#job_calling" do + context "with basic setup" do + class self::TestJobCalling + include Interactify::Async::Jobable + job_calling method_name: :custom_method + + def self.custom_method + "method called" + end + end + + it "creates a job class that can call the specified method" do + job_instance = self.class::TestJobCalling::Job.new + expect(job_instance.perform).to eq("method called") + end + end + + context "with custom options and class suffix" do + class self::TestJobWithOptions + include Interactify::Async::Jobable + job_calling method_name: :parameter_method, opts: { queue: "custom_queue" }, klass_suffix: "Custom" + + def self.parameter_method(param) + param + end + end + + it "applies custom options to the job class" do + expect(self.class::TestJobWithOptions::JobCustom::JOBABLE_OPTS[:queue]).to eq("custom_queue") + end + + it "creates a job class with the specified suffix" do + job_instance = self.class::TestJobWithOptions::JobCustom.new + expect(job_instance.perform("test")).to eq("test") + end + end + + context "integration with Sidekiq" do + before do + allow(Sidekiq::Worker).to receive(:perform_async) + end + + class self::TestJobWithSidekiq + include Interactify::Async::Jobable + + job_calling method_name: :sidekiq_method + + def self.sidekiq_method + "sidekiq method" + end + end + + it "enqueues job to Sidekiq" do + self.class::TestJobWithSidekiq::Job.perform_async + + enqueued = Sidekiq::Job.jobs.last + + expect(enqueued).to match( + a_hash_including( + "retry" => true, + "queue" => "default", + "class" => self.class::TestJobWithSidekiq::Job.to_s + ) + ) + end + end + end end end # rubocop:enable Naming/MethodParameterName diff --git a/spec/lib/interactify/contracts/helpers.expect_spec.rb b/spec/lib/interactify/contracts/helpers.expect_spec.rb index 04a8a5f..8854246 100644 --- a/spec/lib/interactify/contracts/helpers.expect_spec.rb +++ b/spec/lib/interactify/contracts/helpers.expect_spec.rb @@ -1,32 +1,34 @@ # frozen_string_literal: true RSpec.describe Interactify do - describe ".expect" do - class DummyInteractorClass - include Interactify - expect :thing - expect :this, filled: false + self::DummyInteractorClass = Class.new do + include Interactify + expect :thing + expect :this, filled: false - promise :another + promise :another - def call - context.another = thing - end + def call + context.another = thing end + end - class DummyOrganizerClass - include Interactify - expect :thing - promise :another + this = self - organize \ - DummyInteractorClass, - DummyInteractorClass - end + self::DummyOrganizerClass = Class.new do + include Interactify + expect :thing + promise :another + organize \ + this::DummyInteractorClass, + this::DummyInteractorClass + end + + describe ".expect" do it "is simplified syntax for an expects block" do - expect { DummyOrganizerClass.call! }.to raise_error DummyOrganizerClass::InteractorContractFailure - result = DummyOrganizerClass.call!(thing: "thing", this: nil) + expect { this::DummyOrganizerClass.call! }.to raise_error this::DummyOrganizerClass::InteractorContractFailure + result = this::DummyOrganizerClass.call!(thing: "thing", this: nil) expect(result.another).to eq "thing" end end diff --git a/spec/lib/interactify/core_spec.rb b/spec/lib/interactify/core_spec.rb new file mode 100644 index 0000000..d1749bb --- /dev/null +++ b/spec/lib/interactify/core_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe Interactify::Core do + self::DummyClass = Class.new do + include Interactify::Core + end + + this = self + + describe "#called_klass_list" do + let(:dummy_context) do + ctx = Interactor::Context.new + allow(ctx).to receive(:_called) { [1, 2.3, "some string"] } + ctx + end + + subject do + this::DummyClass.new(dummy_context) + end + + it "returns the list of called classes" do + expect(subject.called_klass_list).to eq([Integer, Float, String]) + end + end +end diff --git a/spec/lib/interactify/dsl/each_chain_spec.rb b/spec/lib/interactify/dsl/each_chain_spec.rb index f3dc86c..342b318 100644 --- a/spec/lib/interactify/dsl/each_chain_spec.rb +++ b/spec/lib/interactify/dsl/each_chain_spec.rb @@ -8,7 +8,7 @@ let(:caller_info) { "/some/path/to/file.rb:123" } it "attaches a new class to the passed in context" do - expect(chain.name).to match(/SpecSupport::EachThing\d+/) + expect(chain.name).to match(/SpecSupport::EachThing(_\d+)?/) result = chain.call!(things: [1, 2, 3]) expect(result.things).to eq([1, 2, 3]) diff --git a/spec/lib/interactify/dsl/unique_klass_name_spec.rb b/spec/lib/interactify/dsl/unique_klass_name_spec.rb index ff0dc50..751b9be 100644 --- a/spec/lib/interactify/dsl/unique_klass_name_spec.rb +++ b/spec/lib/interactify/dsl/unique_klass_name_spec.rb @@ -4,30 +4,47 @@ describe ".for" do it "generates a unique class name" do first_name = described_class.for(SpecSupport, "Whatever") - expect(first_name).to match(/Whatever\d+/) + expect(first_name).to match(/Whatever/) SpecSupport.const_set(first_name, Class.new) second_name = described_class.for(SpecSupport, "Whatever") - expect(second_name).to match(/Whatever\d+/) + expect(second_name).to match(/Whatever_\d+/) expect(first_name).not_to eq(second_name) end context "when passed a qualified klass name as a prefix" do + let(:first_name) { described_class.for(SpecSupport, "Whatever::Something", camelize:) } + let(:camelize) { true } + + context "when camelizing" do + let(:camelize) { true } + + it "generates a class name" do + expect(first_name).to match(/WhateverSomething(_\d+)?/) + end + end + + context "when not camelizing" do + let(:camelize) { false } + + it "generates a class name without spacing" do + expect(first_name).to match(/Whatever__Something(_\d+)?/) + end + end + it "generates a unique class name" do - first_name = described_class.for(SpecSupport, "Whatever::Something") - expect(first_name).to match(/Whatever__Something\d+/) SpecSupport.const_set(first_name, Class.new) second_name = described_class.for(SpecSupport, "Whatever::Something") - expect(second_name).to match(/Whatever__Something\d+/) + expect(second_name).to match(/WhateverSomething_\d+/) expect(first_name).not_to eq(second_name) end end end - describe ".generate_unique_id" do + describe ".generate_unique_id (private)" do it "generates a random number within the specified range" do - unique_id = described_class.generate_unique_id + unique_id = described_class.send(:generate_unique_id) expect(unique_id).to be >= 0 expect(unique_id).to be < 10_000 diff --git a/spec/lib/interactify/dsl/wrapper_spec.rb b/spec/lib/interactify/dsl/wrapper_spec.rb index 2bbab39..664bf19 100644 --- a/spec/lib/interactify/dsl/wrapper_spec.rb +++ b/spec/lib/interactify/dsl/wrapper_spec.rb @@ -49,7 +49,7 @@ it "chains the interactors within the organizer" do result = interactor_wrapper.wrap_chain - expect(result.name).to match(/#{Regexp.quote organizer.name}::Chained\d+\z/) + expect(result.name).to match(/#{Regexp.quote organizer.name}::Chained(_\d*)?\z/) end end @@ -61,7 +61,7 @@ it "wraps the conditional interactor correctly" do expect(interactor_wrapper.wrap_conditional.name) - .to match(/#{Regexp.quote organizer.name}::IfProc\d+\z/) + .to match(/#{Regexp.quote organizer.name}::IfProc(_\d+)?\z/) end end diff --git a/spec/lib/interactify/with_options_spec.rb b/spec/lib/interactify/with_options_spec.rb new file mode 100644 index 0000000..0998c95 --- /dev/null +++ b/spec/lib/interactify/with_options_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +RSpec.describe Interactify::WithOptions do + let(:dummy_class) { Class.new } + + let(:sidekiq_opts) { { queue: "critical", retry: 5 } } + let(:instance) { described_class.new(dummy_class, sidekiq_opts) } + + describe "#setup" do + before do + allow(dummy_class).to receive(:include).and_call_original + allow(dummy_class).to receive(:const_set).and_call_original + end + + it "includes core and async jobable modules in the receiver" do + expect(dummy_class).to receive(:include).with(Interactify::Core) + expect(dummy_class).to receive(:include).with(Interactify::Async::Jobable) + instance.setup + end + + it "defines job and async classes with correct suffixes based on options" do + suffix = instance.klass_suffix + expect(dummy_class).to receive(:const_set).with("Job#{suffix}", anything) + expect(dummy_class).to receive(:const_set).with("Async#{suffix}", anything) + instance.setup + end + + context "when options include valid keys" do + it "does not raise an error" do + expect { instance.setup }.not_to raise_error + end + end + + context "when options include invalid keys" do + let(:sidekiq_opts) { { invalid_key: "value" } } + + it "raises an ArgumentError" do + expect { instance.setup }.to raise_error(ArgumentError, /Invalid keys: \[:invalid_key\]/) + end + end + end +end diff --git a/spec/support/spec_support.rb b/spec/support/spec_support.rb index 2461788..3458c40 100644 --- a/spec/support/spec_support.rb +++ b/spec/support/spec_support.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true module SpecSupport - include Interactify + module EachInteractor + include Interactify + end module LoadInteractifyFixtures def load_interactify_fixtures(sub_directory)