interactify 0.1.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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +363 -0
- data/Rakefile +8 -0
- data/lib/interactify/call_wrapper.rb +15 -0
- data/lib/interactify/contract_helpers.rb +71 -0
- data/lib/interactify/dsl.rb +67 -0
- data/lib/interactify/each_chain.rb +80 -0
- data/lib/interactify/if_interactor.rb +64 -0
- data/lib/interactify/interactor_wiring.rb +305 -0
- data/lib/interactify/job_maker.rb +105 -0
- data/lib/interactify/jobable.rb +90 -0
- data/lib/interactify/organizer_call_monkey_patch.rb +40 -0
- data/lib/interactify/rspec/matchers.rb +69 -0
- data/lib/interactify/version.rb +5 -0
- data/lib/interactify.rb +89 -0
- data/sig/interactify.rbs +4 -0
- metadata +157 -0
@@ -0,0 +1,64 @@
|
|
1
|
+
module Interactify
|
2
|
+
class IfInteractor
|
3
|
+
attr_reader :condition, :success_interactor, :failure_interactor, :evaluating_receiver
|
4
|
+
|
5
|
+
def self.attach_klass(evaluating_receiver, condition, succcess_interactor, failure_interactor)
|
6
|
+
ifable = new(evaluating_receiver, condition, succcess_interactor, failure_interactor)
|
7
|
+
ifable.attach_klass
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize(evaluating_receiver, condition, succcess_interactor, failure_interactor)
|
11
|
+
@evaluating_receiver = evaluating_receiver
|
12
|
+
@condition = condition
|
13
|
+
@success_interactor = succcess_interactor
|
14
|
+
@failure_interactor = failure_interactor
|
15
|
+
end
|
16
|
+
|
17
|
+
# allows us to dynamically create an interactor chain
|
18
|
+
# that iterates over the packages and
|
19
|
+
# uses the passed in each_loop_klasses
|
20
|
+
# rubocop:disable all
|
21
|
+
def klass
|
22
|
+
this = self
|
23
|
+
|
24
|
+
Class.new do
|
25
|
+
include Interactor
|
26
|
+
include Interactor::Contracts
|
27
|
+
|
28
|
+
expects do
|
29
|
+
required(this.condition) unless this.condition.is_a?(Proc)
|
30
|
+
end
|
31
|
+
|
32
|
+
define_singleton_method(:source_location) do
|
33
|
+
const_source_location this.evaluating_receiver.to_s # [file, line]
|
34
|
+
end
|
35
|
+
|
36
|
+
define_method(:run!) do
|
37
|
+
result = this.condition.is_a?(Proc) ? this.condition.call(context) : context.send(this.condition)
|
38
|
+
interactor = result ? this.success_interactor : this.failure_interactor
|
39
|
+
interactor&.respond_to?(:call!) ? interactor.call!(context) : interactor&.call(context)
|
40
|
+
end
|
41
|
+
|
42
|
+
define_method(:inspect) do
|
43
|
+
"<#{this.namespace}::#{this.if_klass_name} #{this.condition} ? #{this.success_interactor} : #{this.failure_interactor}>"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
# rubocop:enable all
|
48
|
+
|
49
|
+
def attach_klass
|
50
|
+
namespace.const_set(if_klass_name, klass)
|
51
|
+
namespace.const_get(if_klass_name)
|
52
|
+
end
|
53
|
+
|
54
|
+
def namespace
|
55
|
+
evaluating_receiver
|
56
|
+
end
|
57
|
+
|
58
|
+
def if_klass_name
|
59
|
+
name = condition.is_a?(Proc) ? 'Proc' : condition
|
60
|
+
|
61
|
+
"If#{name.to_s.camelize}".to_sym
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,305 @@
|
|
1
|
+
require 'active_support/all'
|
2
|
+
|
3
|
+
module Interactify
|
4
|
+
class InteractorWiring
|
5
|
+
attr_reader :root, :namespace, :ignore
|
6
|
+
|
7
|
+
def initialize(root: Rails.root, namespace: 'Object', ignore: [])
|
8
|
+
@root = root.to_s.gsub(%r{/$}, '')
|
9
|
+
@namespace = namespace
|
10
|
+
@ignore = ignore
|
11
|
+
end
|
12
|
+
|
13
|
+
concerning :Validation do
|
14
|
+
def validate_app
|
15
|
+
errors = organizers.each_with_object({}) do |organizer, all_errors|
|
16
|
+
next if ignore_klass?(ignore, organizer.klass)
|
17
|
+
|
18
|
+
errors = organizer.validate_callable
|
19
|
+
all_errors[organizer] = errors
|
20
|
+
end
|
21
|
+
|
22
|
+
format_errors(errors)
|
23
|
+
end
|
24
|
+
|
25
|
+
def format_errors(all_errors)
|
26
|
+
formatted_errors = []
|
27
|
+
|
28
|
+
all_errors.each do |organizer, error_context|
|
29
|
+
next if ignore_klass?(ignore, organizer.klass)
|
30
|
+
|
31
|
+
error_context.missing_keys.each do |interactor, missing|
|
32
|
+
next if ignore_klass?(ignore, interactor.klass)
|
33
|
+
|
34
|
+
formatted_errors << <<~ERROR
|
35
|
+
Missing keys: #{missing.to_a.map(&:to_sym).map(&:inspect).join(', ')}
|
36
|
+
in: #{interactor.klass}
|
37
|
+
for: #{organizer.klass}
|
38
|
+
ERROR
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
formatted_errors.join("\n\n")
|
43
|
+
end
|
44
|
+
|
45
|
+
def ignore_klass?(ignore, klass)
|
46
|
+
case ignore
|
47
|
+
when Array
|
48
|
+
ignore.any? { ignore_klass?(_1, klass) }
|
49
|
+
when Regexp
|
50
|
+
klass.to_s =~ ignore
|
51
|
+
when String
|
52
|
+
klass.to_s[ignore]
|
53
|
+
when Proc
|
54
|
+
ignore.call(klass)
|
55
|
+
when Class
|
56
|
+
klass <= ignore
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class CallableRepresentation
|
62
|
+
attr_reader :filename, :klass, :wiring
|
63
|
+
|
64
|
+
delegate :interactor_lookup, to: :wiring
|
65
|
+
|
66
|
+
def initialize(filename:, klass:, wiring:, organizer: nil)
|
67
|
+
@filename = filename
|
68
|
+
@klass = klass
|
69
|
+
@wiring = wiring
|
70
|
+
end
|
71
|
+
|
72
|
+
def validate_callable(error_context: ErrorContext.new)
|
73
|
+
if organizer?
|
74
|
+
assign_previously_defined(error_context: error_context)
|
75
|
+
validate_children(error_context: error_context)
|
76
|
+
end
|
77
|
+
|
78
|
+
validate_self(error_context: error_context)
|
79
|
+
end
|
80
|
+
|
81
|
+
def promised_keys
|
82
|
+
Array(klass.contract.promises.instance_eval { @terms }.json&.rules&.keys)
|
83
|
+
end
|
84
|
+
|
85
|
+
def expected_keys
|
86
|
+
Array(klass.contract.expectations.instance_eval { @terms }.json&.rules&.keys)
|
87
|
+
end
|
88
|
+
|
89
|
+
def all_keys
|
90
|
+
expected_keys.concat(promised_keys)
|
91
|
+
end
|
92
|
+
|
93
|
+
def inspect
|
94
|
+
"#<#{self.class.name}#{object_id} @filename=#{filename}, @klass=#{klass.name}>"
|
95
|
+
end
|
96
|
+
|
97
|
+
def organizer?
|
98
|
+
klass.respond_to?(:organized)
|
99
|
+
end
|
100
|
+
|
101
|
+
def assign_previously_defined(error_context:)
|
102
|
+
return unless contract?
|
103
|
+
|
104
|
+
error_context.append_previously_defined_keys(all_keys)
|
105
|
+
end
|
106
|
+
|
107
|
+
def validate_children(error_context:)
|
108
|
+
klass.organized.each do |interactor|
|
109
|
+
nested_callable = interactor_lookup[interactor]
|
110
|
+
next if nested_callable.nil?
|
111
|
+
|
112
|
+
error_context = nested_callable.validate_callable(error_context: error_context)
|
113
|
+
end
|
114
|
+
|
115
|
+
error_context
|
116
|
+
end
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
def contract?
|
121
|
+
klass.ancestors.include? Interactor::Contracts
|
122
|
+
end
|
123
|
+
|
124
|
+
def validate_self(error_context:)
|
125
|
+
return error_context unless contract?
|
126
|
+
|
127
|
+
error_context.infer_missing_keys(self)
|
128
|
+
error_context.add_promised_keys(promised_keys)
|
129
|
+
error_context
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
class ErrorContext
|
134
|
+
def previously_defined_keys
|
135
|
+
@previously_defined_keys ||= Set.new
|
136
|
+
end
|
137
|
+
|
138
|
+
def append_previously_defined_keys(keys)
|
139
|
+
keys.each do |key|
|
140
|
+
previously_defined_keys << key
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def missing_keys
|
145
|
+
@missing_keys ||= {}
|
146
|
+
end
|
147
|
+
|
148
|
+
def add_promised_keys(promised_keys)
|
149
|
+
promised_keys.each do |key|
|
150
|
+
previously_defined_keys << key
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def infer_missing_keys(callable)
|
155
|
+
new_keys = callable.expected_keys
|
156
|
+
not_in_previous_keys = new_keys.reject { |key| previously_defined_keys.include?(key) }
|
157
|
+
|
158
|
+
add_missing_keys(callable, not_in_previous_keys)
|
159
|
+
end
|
160
|
+
|
161
|
+
def add_missing_keys(callable, not_in_previous_keys)
|
162
|
+
return if not_in_previous_keys.empty?
|
163
|
+
|
164
|
+
missing_keys[callable] ||= Set.new
|
165
|
+
missing_keys[callable] += not_in_previous_keys
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
concerning :Constants do
|
170
|
+
def organizers
|
171
|
+
@organizers ||= organizer_files.flat_map do |f|
|
172
|
+
next if f[/interactor_organizer_contracts/] || f[/interactor_contracts/]
|
173
|
+
|
174
|
+
callables_in_file(f)
|
175
|
+
end.compact.select(&:organizer?)
|
176
|
+
end
|
177
|
+
|
178
|
+
def interactors
|
179
|
+
@interactors ||= interactor_files.flat_map do |f|
|
180
|
+
callables_in_file(f)
|
181
|
+
end.compact.reject(&:organizer?)
|
182
|
+
end
|
183
|
+
|
184
|
+
def callables_in_file(f)
|
185
|
+
@callables_in_file ||= {}
|
186
|
+
|
187
|
+
@callables_in_file[f] ||= _callables_in_file(f)
|
188
|
+
end
|
189
|
+
|
190
|
+
def _callables_in_file(f)
|
191
|
+
constant = constant_for(f)
|
192
|
+
return if constant == Interactify
|
193
|
+
|
194
|
+
internal_klasses = internal_constants_for(constant)
|
195
|
+
|
196
|
+
([constant] + internal_klasses).map { |k| new_callable(f, k, self) }
|
197
|
+
end
|
198
|
+
|
199
|
+
def internal_constants_for(constant)
|
200
|
+
constant
|
201
|
+
.constants
|
202
|
+
.map { |sym| constant_from_symbol(constant, sym) }
|
203
|
+
.select { |pk| interactor_klass?(pk) }
|
204
|
+
end
|
205
|
+
|
206
|
+
def constant_from_symbol(constant, symbol)
|
207
|
+
constant.module_eval do
|
208
|
+
symbol.to_s.constantize
|
209
|
+
rescue StandardError
|
210
|
+
"#{constant.name}::#{symbol}".constantize rescue nil
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
def interactor_klass?(object)
|
215
|
+
return unless object.is_a? Class
|
216
|
+
|
217
|
+
object.ancestors.include?(Interactor) || object.ancestors.include?(Interactor::Organizer)
|
218
|
+
end
|
219
|
+
|
220
|
+
def new_callable(filename, klass, wiring)
|
221
|
+
CallableRepresentation.new(filename: filename, klass: klass, wiring: wiring)
|
222
|
+
end
|
223
|
+
|
224
|
+
def interactor_lookup
|
225
|
+
@interactor_lookup ||= (interactors + organizers).index_by(&:klass)
|
226
|
+
end
|
227
|
+
|
228
|
+
private
|
229
|
+
|
230
|
+
def constant_for(filename)
|
231
|
+
require filename
|
232
|
+
|
233
|
+
underscored_klass_name = underscored_klass_name_without_outer_namespace(filename)
|
234
|
+
underscored_klass_name = trim_rails_folder underscored_klass_name
|
235
|
+
|
236
|
+
klass_name = underscored_klass_name.classify
|
237
|
+
|
238
|
+
should_pluralize = filename[underscored_klass_name.pluralize]
|
239
|
+
klass_name = klass_name.pluralize if should_pluralize
|
240
|
+
|
241
|
+
outer_namespace_constant.const_get(klass_name)
|
242
|
+
end
|
243
|
+
|
244
|
+
# Example:
|
245
|
+
# trim_rails_folder("interactors/namespace/sub_namespace/class_name.rb")
|
246
|
+
# => "namespace/sub_namespace/class_name.rb"
|
247
|
+
def trim_rails_folder(filename)
|
248
|
+
rails_folders = Dir.glob(Interactify.root / '*').map { |f| File.basename(f) }
|
249
|
+
|
250
|
+
rails_folders.each do |folder|
|
251
|
+
regexable_folder = Regexp.quote("#{folder}/")
|
252
|
+
regex = /^#{regexable_folder}/
|
253
|
+
|
254
|
+
return filename.gsub(regex, '') if filename.match?(regex)
|
255
|
+
end
|
256
|
+
|
257
|
+
filename
|
258
|
+
end
|
259
|
+
|
260
|
+
# Example:
|
261
|
+
# "/home/code/something/app/interactors/namespace/sub_namespace/class_name.rb"
|
262
|
+
# "/namespace/sub_namespace/class_name.rb"
|
263
|
+
# ['', 'namespace', 'sub_namespace', 'class_name.rb']
|
264
|
+
# ['namespace', 'sub_namespace', 'class_name.rb']
|
265
|
+
# remove outernamespace (SpecSupport)
|
266
|
+
def underscored_klass_name_without_outer_namespace(filename)
|
267
|
+
filename.to_s # "/home/code/something/app/interactors/namespace/sub_namespace/class_name.rb"
|
268
|
+
.gsub(root.to_s, '') # "/namespace/sub_namespace/class_name.rb"
|
269
|
+
.gsub('/concerns', '') # concerns directory is ignored by Zeitwerk
|
270
|
+
.split('/') # "['', 'namespace', 'sub_namespace', 'class_name.rb']
|
271
|
+
.compact_blank # "['namespace', 'sub_namespace', 'class_name.rb']
|
272
|
+
.reject.with_index { |segment, i| i.zero? && segment == namespace }
|
273
|
+
.join('/') # 'namespace/sub_namespace/class_name.rb'
|
274
|
+
.gsub(/\.rb\z/, '') # 'namespace/sub_namespace/class_name'
|
275
|
+
end
|
276
|
+
|
277
|
+
def outer_namespace_constant
|
278
|
+
@outer_namespace_constant ||= Object.const_get(namespace)
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
concerning :Files do
|
283
|
+
def organizer_files
|
284
|
+
possible_files.select { |_, contents| organizer_file?(contents) }.map(&:first).sort
|
285
|
+
end
|
286
|
+
|
287
|
+
def interactor_files
|
288
|
+
possible_files.select { |_, contents| interactor_file?(contents) }.map(&:first).sort
|
289
|
+
end
|
290
|
+
|
291
|
+
def organizer_file?(file_contents)
|
292
|
+
(file_contents['include Interactify'] || file_contents[/include Interactor::Organizer/]) &&
|
293
|
+
file_contents[/^\s+organize/]
|
294
|
+
end
|
295
|
+
|
296
|
+
def interactor_file?(file_contents)
|
297
|
+
file_contents['include Interactify'] || file_contents[/include Interactor$/]
|
298
|
+
end
|
299
|
+
|
300
|
+
def possible_files
|
301
|
+
@possible_files ||= Dir.glob("#{root}/**/*.rb").map { |f| [f, File.read(f)] }
|
302
|
+
end
|
303
|
+
end
|
304
|
+
end
|
305
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
require 'sidekiq'
|
2
|
+
require 'sidekiq/job'
|
3
|
+
|
4
|
+
module Interactify
|
5
|
+
class JobMaker
|
6
|
+
attr_reader :opts, :method_name, :container_klass, :klass_suffix
|
7
|
+
|
8
|
+
def initialize(container_klass:, opts:, klass_suffix:, method_name: :call!)
|
9
|
+
@container_klass = container_klass
|
10
|
+
@opts = opts
|
11
|
+
@method_name = method_name
|
12
|
+
@klass_suffix = klass_suffix
|
13
|
+
end
|
14
|
+
|
15
|
+
concerning :JobClass do
|
16
|
+
def job_class
|
17
|
+
@job_class ||= define_job_class
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def define_job_class
|
23
|
+
this = self
|
24
|
+
|
25
|
+
invalid_keys = this.opts.symbolize_keys.keys - %i[queue retry dead backtrace pool tags]
|
26
|
+
|
27
|
+
raise ArgumentError, "Invalid keys: #{invalid_keys}" if invalid_keys.any?
|
28
|
+
|
29
|
+
job_class = Class.new do
|
30
|
+
include Sidekiq::Job
|
31
|
+
|
32
|
+
sidekiq_options(this.opts)
|
33
|
+
|
34
|
+
def perform(...)
|
35
|
+
self.class.module_parent.send(self.class::JOBABLE_METHOD_NAME, ...)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
job_class.const_set(:JOBABLE_OPTS, opts)
|
40
|
+
job_class.const_set(:JOBABLE_METHOD_NAME, method_name)
|
41
|
+
job_class
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
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
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
require 'interactify/job_maker'
|
2
|
+
|
3
|
+
module Interactify
|
4
|
+
module Jobable
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
# e.g. if Klass < Base
|
8
|
+
# and Base has a Base::Job class
|
9
|
+
#
|
10
|
+
# then let's make sure to define Klass::Job separately
|
11
|
+
included do |base|
|
12
|
+
def base.inherited(klass)
|
13
|
+
super_klass = klass.superclass
|
14
|
+
super_job = super_klass::Job # really spiffing
|
15
|
+
|
16
|
+
opts = super_job::JOBABLE_OPTS
|
17
|
+
jobable_method_name = super_job::JOBABLE_METHOD_NAME
|
18
|
+
|
19
|
+
to_call = defined?(super_klass::Async) ? :interactor_job : :job_calling
|
20
|
+
|
21
|
+
klass.send(to_call, opts: opts, method_name: jobable_method_name)
|
22
|
+
super(klass)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
class_methods do
|
27
|
+
# create a Job class and an Async class
|
28
|
+
# see job_calling for details on the Job class
|
29
|
+
#
|
30
|
+
# the Async class is a wrapper around the Job class
|
31
|
+
# that allows it to be used in an interactor chain
|
32
|
+
#
|
33
|
+
# E.g.
|
34
|
+
#
|
35
|
+
# class ExampleInteractor
|
36
|
+
# include Interactify
|
37
|
+
# expect :foo
|
38
|
+
#
|
39
|
+
# include Jobable
|
40
|
+
# interactor_job
|
41
|
+
# end
|
42
|
+
#
|
43
|
+
# doing the following will immediately enqueue a job
|
44
|
+
# that calls the interactor ExampleInteractor with (foo: 'bar')
|
45
|
+
# ExampleInteractor::Async.call(foo: 'bar')
|
46
|
+
#
|
47
|
+
# it will also ensure to pluck only the expects from the context
|
48
|
+
# so that you can have other non primitive values in the context
|
49
|
+
# but the job will only have the expects passed to it
|
50
|
+
#
|
51
|
+
# obviously you will need to be aware that later interactors
|
52
|
+
# in an interactor chain cannot depend on the result of the async
|
53
|
+
# interactor
|
54
|
+
def interactor_job(method_name: :call!, opts: {}, klass_suffix: '')
|
55
|
+
job_maker = JobMaker.new(container_klass: self, opts:, method_name:, klass_suffix:)
|
56
|
+
# with WhateverInteractor::Job you can perform the interactor as a job
|
57
|
+
# from sidekiq
|
58
|
+
# e.g. WhateverInteractor::Job.perform_async(...)
|
59
|
+
const_set("Job#{klass_suffix}", job_maker.job_class)
|
60
|
+
|
61
|
+
# with WhateverInteractor::Async you can call WhateverInteractor::Job
|
62
|
+
# in an organizer oro on its oen using normal interactor call call! semantics
|
63
|
+
# e.g. WhateverInteractor::Async.call(...)
|
64
|
+
# WhateverInteractor::Async.call!(...)
|
65
|
+
const_set("Async#{klass_suffix}", job_maker.async_job_class)
|
66
|
+
end
|
67
|
+
|
68
|
+
# if this was defined in ExampleClass this creates the following class
|
69
|
+
# ExampleClass::Job
|
70
|
+
# this class ia added as a convenience so you can easily turn a
|
71
|
+
# class method into a job
|
72
|
+
#
|
73
|
+
# Example:
|
74
|
+
#
|
75
|
+
# class ExampleClass
|
76
|
+
# include Jobable
|
77
|
+
# job_calling method_name: :some_method
|
78
|
+
# end
|
79
|
+
#
|
80
|
+
# # the following class is created that you can use to enqueue a job
|
81
|
+
# in the sidekiq yaml file
|
82
|
+
# ExampleClass::Job.some_method
|
83
|
+
def job_calling(method_name:, opts: {}, klass_suffix: '')
|
84
|
+
job_maker = JobMaker.new(container_klass: self, opts:, method_name:, klass_suffix:)
|
85
|
+
|
86
|
+
const_set("Job#{klass_suffix}", job_maker.job_class)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,40 @@
|
|
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
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'interactify/interactor_wiring'
|
2
|
+
|
3
|
+
# Custom matcher that implements expect_inputs
|
4
|
+
# e.g.
|
5
|
+
# expect(described_class).to expect_inputs(:connection, :order)
|
6
|
+
|
7
|
+
RSpec::Matchers.define :expect_inputs do |*expected_inputs|
|
8
|
+
match do |actual|
|
9
|
+
actual_inputs = expected_keys(actual)
|
10
|
+
@missing_inputs = expected_inputs - actual_inputs
|
11
|
+
@extra_inputs = actual_inputs - expected_inputs
|
12
|
+
|
13
|
+
@missing_inputs.empty? && @extra_inputs.empty?
|
14
|
+
end
|
15
|
+
|
16
|
+
failure_message do |actual|
|
17
|
+
message = "expected #{actual} to expect inputs #{expected_inputs.inspect}"
|
18
|
+
message += "\n\tmissing inputs: #{@missing_inputs}" if @missing_inputs
|
19
|
+
message += "\n\textra inputs: #{@extra_inputs}" if @extra_inputs
|
20
|
+
message
|
21
|
+
end
|
22
|
+
|
23
|
+
def expected_keys(klass)
|
24
|
+
Array(klass.contract.expectations.instance_eval { @terms }.json&.rules&.keys)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Custom matcher that implements promise_outputs
|
29
|
+
# e.g. expect(described_class).to promise_outputs(:request_logger)
|
30
|
+
RSpec::Matchers.define :promise_outputs do |*expected_outputs|
|
31
|
+
match do |actual|
|
32
|
+
actual_outputs = promised_keys(actual)
|
33
|
+
@missing_outputs = expected_outputs - actual_outputs
|
34
|
+
@extra_outputs = actual_outputs - expected_outputs
|
35
|
+
|
36
|
+
@missing_outputs.empty? && @extra_outputs.empty?
|
37
|
+
end
|
38
|
+
|
39
|
+
failure_message do |actual|
|
40
|
+
message = "expected #{actual} to promise outputs #{expected_outputs.inspect}"
|
41
|
+
message += "\n\tmissing outputs: #{@missing_outputs}" if @missing_outputs
|
42
|
+
message += "\n\textra outputs: #{@extra_outputs}" if @extra_outputs
|
43
|
+
message
|
44
|
+
end
|
45
|
+
|
46
|
+
def promised_keys(klass)
|
47
|
+
Array(klass.contract.promises.instance_eval { @terms }.json&.rules&.keys)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Custom matcher that implements organize_interactors
|
52
|
+
#
|
53
|
+
# e.g. expect(described_class).to organize_interactors(SeparateIntoPackages, SendPackagesToSeko)
|
54
|
+
RSpec::Matchers.define :organize_interactors do |*expected_interactors|
|
55
|
+
match do |actual|
|
56
|
+
actual_interactors = actual.organized
|
57
|
+
@missing_interactors = expected_interactors - actual_interactors
|
58
|
+
@extra_interactors = actual_interactors - expected_interactors
|
59
|
+
|
60
|
+
@missing_interactors.empty? && @extra_interactors.empty?
|
61
|
+
end
|
62
|
+
|
63
|
+
failure_message do |actual|
|
64
|
+
message = "expected #{actual} to organize interactors #{expected_interactors.inspect}"
|
65
|
+
message += "\n\tmissing interactors: #{@missing_interactors}" if @missing_interactors
|
66
|
+
message += "\n\textra interactors: #{@extra_interactors}" if @extra_interactors
|
67
|
+
message
|
68
|
+
end
|
69
|
+
end
|