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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Interactify
4
+ VERSION = '0.1.0-alpha.1'
5
+ end