interactify 0.1.0.pre.alpha.1 → 0.3.0.pre.alpha.1

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.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "interactify/unique_klass_name"
4
+
5
+ module Interactify
6
+ class InteractorWrapper
7
+ attr_reader :organizer, :interactor
8
+
9
+ def self.wrap_many(organizer, interactors)
10
+ Array(interactors).map do |interactor|
11
+ wrap(organizer, interactor)
12
+ end
13
+ end
14
+
15
+ def self.wrap(organizer, interactor)
16
+ new(organizer, interactor).wrap
17
+ end
18
+
19
+ def initialize(organizer, interactor)
20
+ @organizer = organizer
21
+ @interactor = interactor
22
+ end
23
+
24
+ def wrap
25
+ case interactor
26
+ when Hash
27
+ wrap_conditional
28
+ when Array
29
+ wrap_chain
30
+ when Proc
31
+ wrap_proc
32
+ else
33
+ interactor
34
+ end
35
+ end
36
+
37
+ def wrap_chain
38
+ return self.class.wrap(organizer, interactor.first) if interactor.length == 1
39
+
40
+ klass_name = UniqueKlassName.for(organizer, "Chained")
41
+ organizer.chain(klass_name, *interactor.map { self.class.wrap(organizer, _1) })
42
+ end
43
+
44
+ def wrap_conditional
45
+ raise ArgumentError, "Hash must have at least :if, and :then key" unless condition && then_do
46
+
47
+ return organizer.if(condition, then_do, else_do) if else_do
48
+
49
+ organizer.if(condition, then_do)
50
+ end
51
+
52
+ def condition = interactor[:if]
53
+ def then_do = interactor[:then]
54
+ def else_do = interactor[:else]
55
+
56
+ def wrap_proc
57
+ this = self
58
+
59
+ Class.new do
60
+ include Interactify
61
+
62
+ define_singleton_method :wrapped do
63
+ this.interactor
64
+ end
65
+
66
+ define_method(:call) do
67
+ this.interactor.call(context)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,5 +1,9 @@
1
- require 'sidekiq'
2
- require 'sidekiq/job'
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq"
4
+ require "sidekiq/job"
5
+
6
+ require "interactify/async_job_klass"
3
7
 
4
8
  module Interactify
5
9
  class JobMaker
@@ -13,93 +17,40 @@ module Interactify
13
17
  end
14
18
 
15
19
  concerning :JobClass do
16
- def job_class
17
- @job_class ||= define_job_class
20
+ def job_klass
21
+ @job_klass ||= define_job_klass
18
22
  end
19
23
 
20
24
  private
21
25
 
22
- def define_job_class
26
+ def define_job_klass
23
27
  this = self
24
28
 
25
29
  invalid_keys = this.opts.symbolize_keys.keys - %i[queue retry dead backtrace pool tags]
26
30
 
27
31
  raise ArgumentError, "Invalid keys: #{invalid_keys}" if invalid_keys.any?
28
32
 
29
- job_class = Class.new do
33
+ build_job_klass(opts).tap do |klass|
34
+ klass.const_set(:JOBABLE_OPTS, opts)
35
+ klass.const_set(:JOBABLE_METHOD_NAME, method_name)
36
+ end
37
+ end
38
+
39
+ def build_job_klass(opts)
40
+ Class.new do
30
41
  include Sidekiq::Job
31
42
 
32
- sidekiq_options(this.opts)
43
+ sidekiq_options(opts)
33
44
 
34
45
  def perform(...)
35
46
  self.class.module_parent.send(self.class::JOBABLE_METHOD_NAME, ...)
36
47
  end
37
48
  end
38
-
39
- job_class.const_set(:JOBABLE_OPTS, opts)
40
- job_class.const_set(:JOBABLE_METHOD_NAME, method_name)
41
- job_class
42
49
  end
43
50
  end
44
51
 
45
- concerning :AsyncJobClass do
46
- def async_job_class
47
- klass = Class.new do
48
- include Interactor
49
- include Interactor::Contracts
50
- end
51
-
52
- attach_call(klass)
53
- attach_call!(klass)
54
-
55
- klass
56
- end
57
-
58
- def args(context)
59
- args = context.to_h.stringify_keys
60
-
61
- return args unless container_klass.respond_to?(:contract)
62
-
63
- restrict_to_optional_or_keys_from_contract(args)
64
- end
65
-
66
- private
67
-
68
- def attach_call(async_job_class)
69
- # e.g. SomeInteractor::AsyncWithSuffix.call(foo: 'bar')
70
- async_job_class.send(:define_singleton_method, :call) do |context|
71
- call!(context)
72
- end
73
- end
74
-
75
- def attach_call!(async_job_class)
76
- this = self
77
-
78
- # e.g. SomeInteractor::AsyncWithSuffix.call!(foo: 'bar')
79
- async_job_class.send(:define_singleton_method, :call!) do |context|
80
- # e.g. SomeInteractor::JobWithSuffix
81
- job_klass = this.container_klass.const_get("Job#{this.klass_suffix}")
82
-
83
- # e.g. SomeInteractor::JobWithSuffix.perform_async({foo: 'bar'})
84
- job_klass.perform_async(this.args(context))
85
- end
86
- end
87
-
88
- def restrict_to_optional_or_keys_from_contract(args)
89
- keys = container_klass
90
- .contract
91
- .expectations
92
- .instance_eval { @terms }
93
- .schema
94
- .key_map
95
- .to_dot_notation
96
- .map(&:to_s)
97
-
98
- optional = Array(container_klass.optional_attrs).map(&:to_s)
99
- keys += optional
100
-
101
- args.slice(*keys)
102
- end
52
+ def async_job_klass
53
+ AsyncJobKlass.new(container_klass:, klass_suffix:).async_job_klass
103
54
  end
104
55
  end
105
56
  end
@@ -1,4 +1,6 @@
1
- require 'interactify/job_maker'
1
+ # frozen_string_literal: true
2
+
3
+ require "interactify/job_maker"
2
4
 
3
5
  module Interactify
4
6
  module Jobable
@@ -18,7 +20,7 @@ module Interactify
18
20
 
19
21
  to_call = defined?(super_klass::Async) ? :interactor_job : :job_calling
20
22
 
21
- klass.send(to_call, opts: opts, method_name: jobable_method_name)
23
+ klass.send(to_call, opts:, method_name: jobable_method_name)
22
24
  super(klass)
23
25
  end
24
26
  end
@@ -51,18 +53,18 @@ module Interactify
51
53
  # obviously you will need to be aware that later interactors
52
54
  # in an interactor chain cannot depend on the result of the async
53
55
  # interactor
54
- def interactor_job(method_name: :call!, opts: {}, klass_suffix: '')
56
+ def interactor_job(method_name: :call!, opts: {}, klass_suffix: "")
55
57
  job_maker = JobMaker.new(container_klass: self, opts:, method_name:, klass_suffix:)
56
58
  # with WhateverInteractor::Job you can perform the interactor as a job
57
59
  # from sidekiq
58
60
  # e.g. WhateverInteractor::Job.perform_async(...)
59
- const_set("Job#{klass_suffix}", job_maker.job_class)
61
+ const_set("Job#{klass_suffix}", job_maker.job_klass)
60
62
 
61
63
  # with WhateverInteractor::Async you can call WhateverInteractor::Job
62
64
  # in an organizer oro on its oen using normal interactor call call! semantics
63
65
  # e.g. WhateverInteractor::Async.call(...)
64
66
  # WhateverInteractor::Async.call!(...)
65
- const_set("Async#{klass_suffix}", job_maker.async_job_class)
67
+ const_set("Async#{klass_suffix}", job_maker.async_job_klass)
66
68
  end
67
69
 
68
70
  # if this was defined in ExampleClass this creates the following class
@@ -80,10 +82,10 @@ module Interactify
80
82
  # # the following class is created that you can use to enqueue a job
81
83
  # in the sidekiq yaml file
82
84
  # ExampleClass::Job.some_method
83
- def job_calling(method_name:, opts: {}, klass_suffix: '')
85
+ def job_calling(method_name:, opts: {}, klass_suffix: "")
84
86
  job_maker = JobMaker.new(container_klass: self, opts:, method_name:, klass_suffix:)
85
87
 
86
- const_set("Job#{klass_suffix}", job_maker.job_class)
88
+ const_set("Job#{klass_suffix}", job_maker.job_klass)
87
89
  end
88
90
  end
89
91
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "interactify/contract_failure"
4
+
5
+ module Interactify
6
+ class MismatchingPromiseError < ContractFailure
7
+ def initialize(interactor, promising, promised_keys)
8
+ super <<~MESSAGE.chomp
9
+ #{interactor} does not promise:
10
+ #{promising.inspect}
11
+
12
+ Actual promises are:
13
+ #{promised_keys.inspect}
14
+ MESSAGE
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "interactify/interactor_wrapper"
4
+
5
+ module Interactify
6
+ module Organizer
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ def organize(*interactors)
11
+ wrapped = InteractorWrapper.wrap_many(self, interactors)
12
+
13
+ super(*wrapped)
14
+ end
15
+ end
16
+
17
+ def call
18
+ self.class.organized.each do |interactor|
19
+ instance = interactor.new(context)
20
+
21
+ instance.instance_variable_set(
22
+ :@_interactor_called_by_non_bang_method,
23
+ @_interactor_called_by_non_bang_method
24
+ )
25
+
26
+ instance.tap(&:run!)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "interactify/mismatching_promise_error"
4
+
5
+ module Interactify
6
+ class Promising
7
+ attr_reader :interactor, :promising
8
+
9
+ def self.validate(interactor, *promising)
10
+ new(interactor, *promising).validate
11
+
12
+ interactor
13
+ end
14
+
15
+ def initialize(interactor, *promising)
16
+ @interactor = interactor
17
+ @promising = format_keys promising
18
+ end
19
+
20
+ def validate
21
+ return if promising == promised_keys
22
+
23
+ raise MismatchingPromiseError.new(interactor, promising, promised_keys)
24
+ end
25
+
26
+ def promised_keys
27
+ format_keys interactor.promised_keys
28
+ end
29
+
30
+ def format_keys(keys)
31
+ Array(keys).compact.map(&:to_sym).sort
32
+ end
33
+ end
34
+ end
@@ -1,4 +1,6 @@
1
- require 'interactify/interactor_wiring'
1
+ # frozen_string_literal: true
2
+
3
+ require "interactify/interactor_wiring"
2
4
 
3
5
  # Custom matcher that implements expect_inputs
4
6
  # e.g.
@@ -6,7 +8,9 @@ require 'interactify/interactor_wiring'
6
8
 
7
9
  RSpec::Matchers.define :expect_inputs do |*expected_inputs|
8
10
  match do |actual|
9
- actual_inputs = expected_keys(actual)
11
+ next false unless actual.respond_to?(:expected_keys)
12
+
13
+ actual_inputs = Array(actual.expected_keys)
10
14
  @missing_inputs = expected_inputs - actual_inputs
11
15
  @extra_inputs = actual_inputs - expected_inputs
12
16
 
@@ -19,17 +23,15 @@ RSpec::Matchers.define :expect_inputs do |*expected_inputs|
19
23
  message += "\n\textra inputs: #{@extra_inputs}" if @extra_inputs
20
24
  message
21
25
  end
22
-
23
- def expected_keys(klass)
24
- Array(klass.contract.expectations.instance_eval { @terms }.json&.rules&.keys)
25
- end
26
26
  end
27
27
 
28
28
  # Custom matcher that implements promise_outputs
29
29
  # e.g. expect(described_class).to promise_outputs(:request_logger)
30
30
  RSpec::Matchers.define :promise_outputs do |*expected_outputs|
31
31
  match do |actual|
32
- actual_outputs = promised_keys(actual)
32
+ next false unless actual.respond_to?(:promised_keys)
33
+
34
+ actual_outputs = Array(actual.promised_keys)
33
35
  @missing_outputs = expected_outputs - actual_outputs
34
36
  @extra_outputs = actual_outputs - expected_outputs
35
37
 
@@ -42,10 +44,6 @@ RSpec::Matchers.define :promise_outputs do |*expected_outputs|
42
44
  message += "\n\textra outputs: #{@extra_outputs}" if @extra_outputs
43
45
  message
44
46
  end
45
-
46
- def promised_keys(klass)
47
- Array(klass.contract.promises.instance_eval { @terms }.json&.rules&.keys)
48
- end
49
47
  end
50
48
 
51
49
  # Custom matcher that implements organize_interactors
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Interactify
4
+ module UniqueKlassName
5
+ def self.for(namespace, prefix)
6
+ id = generate_unique_id
7
+ klass_name = :"#{prefix}#{id}"
8
+
9
+ while namespace.const_defined?(klass_name)
10
+ id = generate_unique_id
11
+ klass_name = :"#{prefix}#{id}"
12
+ end
13
+
14
+ klass_name.to_sym
15
+ end
16
+
17
+ def self.generate_unique_id
18
+ rand(10_000)
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Interactify
4
- VERSION = '0.1.0-alpha.1'
4
+ VERSION = "0.3.0-alpha.1"
5
5
  end
data/lib/interactify.rb CHANGED
@@ -1,14 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'interactor'
4
- require 'interactor-contracts'
5
- require 'rails'
6
- require 'active_support/all'
3
+ require "interactor"
4
+ require "interactor-contracts"
5
+ require "rails"
6
+ require "active_support/all"
7
7
 
8
- require 'interactify/version'
9
- require 'interactify/contract_helpers'
10
- require 'interactify/dsl'
11
- require 'interactify/interactor_wiring'
8
+ require "interactify/version"
9
+ require "interactify/contract_helpers"
10
+ require "interactify/dsl"
11
+ require "interactify/interactor_wiring"
12
+ require "interactify/promising"
12
13
 
13
14
  module Interactify
14
15
  extend ActiveSupport::Concern
@@ -18,6 +19,12 @@ module Interactify
18
19
  Interactify::InteractorWiring.new(root: Interactify.configuration.root, ignore:).validate_app
19
20
  end
20
21
 
22
+ def reset
23
+ @on_contract_breach = nil
24
+ @before_raise_hook = nil
25
+ @configuration = nil
26
+ end
27
+
21
28
  def trigger_contract_breach_hook(...)
22
29
  @on_contract_breach&.call(...)
23
30
  end
@@ -75,11 +82,34 @@ module Interactify
75
82
  interactor_job
76
83
  end
77
84
 
85
+ class_methods do
86
+ def promising(*args)
87
+ Promising.validate(self, *args)
88
+ end
89
+
90
+ def promised_keys
91
+ _interactify_extract_keys(contract.promises)
92
+ end
93
+
94
+ def expected_keys
95
+ _interactify_extract_keys(contract.expectations)
96
+ end
97
+
98
+ private
99
+
100
+ # this is the most brittle part of the code, relying on
101
+ # interactor-contracts internals
102
+ # so extracted it to here so change is isolated
103
+ def _interactify_extract_keys(clauses)
104
+ clauses.instance_eval { @terms }.json&.rules&.keys
105
+ end
106
+ end
107
+
78
108
  class Configuration
79
109
  attr_writer :root
80
110
 
81
111
  def root
82
- @root ||= Rails.root / 'app'
112
+ @root ||= Rails.root / "app"
83
113
  end
84
114
  end
85
115
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: interactify
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre.alpha.1
4
+ version: 0.3.0.pre.alpha.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mark Burns
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-12-26 00:00:00.000000000 Z
11
+ date: 2023-12-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: interactor
@@ -109,21 +109,32 @@ extensions: []
109
109
  extra_rdoc_files: []
110
110
  files:
111
111
  - ".rspec"
112
+ - ".rubocop.yml"
112
113
  - CHANGELOG.md
113
114
  - LICENSE.txt
114
115
  - README.md
115
116
  - Rakefile
116
117
  - lib/interactify.rb
118
+ - lib/interactify/async_job_klass.rb
117
119
  - lib/interactify/call_wrapper.rb
120
+ - lib/interactify/contract_failure.rb
118
121
  - lib/interactify/contract_helpers.rb
119
122
  - lib/interactify/dsl.rb
120
123
  - lib/interactify/each_chain.rb
121
124
  - lib/interactify/if_interactor.rb
122
125
  - lib/interactify/interactor_wiring.rb
126
+ - lib/interactify/interactor_wiring/callable_representation.rb
127
+ - lib/interactify/interactor_wiring/constants.rb
128
+ - lib/interactify/interactor_wiring/error_context.rb
129
+ - lib/interactify/interactor_wiring/files.rb
130
+ - lib/interactify/interactor_wrapper.rb
123
131
  - lib/interactify/job_maker.rb
124
132
  - lib/interactify/jobable.rb
125
- - lib/interactify/organizer_call_monkey_patch.rb
133
+ - lib/interactify/mismatching_promise_error.rb
134
+ - lib/interactify/organizer.rb
135
+ - lib/interactify/promising.rb
126
136
  - lib/interactify/rspec/matchers.rb
137
+ - lib/interactify/unique_klass_name.rb
127
138
  - lib/interactify/version.rb
128
139
  - sig/interactify.rbs
129
140
  homepage: https://github.com/markburns/interactify
@@ -1,40 +0,0 @@
1
- module Interactify
2
- module OrganizerCallMonkeyPatch
3
- extend ActiveSupport::Concern
4
-
5
- class_methods do
6
- def organize(*interactors)
7
- wrapped = wrap_lambdas_in_interactors(interactors)
8
-
9
- super(*wrapped)
10
- end
11
-
12
- def wrap_lambdas_in_interactors(interactors)
13
- Array(interactors).map do |interactor|
14
- case interactor
15
- when Proc
16
- Class.new do
17
- include Interactify
18
-
19
- define_method(:call) do
20
- interactor.call(context)
21
- end
22
- end
23
- else
24
- interactor
25
- end
26
- end
27
- end
28
- end
29
-
30
- def call
31
- self.class.organized.each do |interactor|
32
- instance = interactor.new(context)
33
- instance.instance_variable_set(:@_interactor_called_by_non_bang_method,
34
- @_interactor_called_by_non_bang_method)
35
-
36
- instance.tap(&:run!)
37
- end
38
- end
39
- end
40
- end