interactify 0.1.0.pre.alpha.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7bbf811a8d9e44f85d4e0c5b36f4ad3291cbc8f5aa52e5d2dd31ecbdd753a256
4
+ data.tar.gz: 92ed7413e2ea370d388c43d9993928652d9bc23a3139d3fa083050243bc30695
5
+ SHA512:
6
+ metadata.gz: 1d9e01fc12b9b02bdc9d897b81866c7d668f94cf0fafd51e018cded3ec2892d5452848000672a9c4ac139f1c1dc3d30aa23e1c16b0dcaf66afd901c4586d8eae
7
+ data.tar.gz: b33dc94b10b26cf1b4baeace46168641d88121bf05fe2a4c8c37098d3b5bc678667e0ff253f620def1798846b9b95533f943ef4c80726abe08a1a41662c338c0
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2023-12-16
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Mark Burns
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,363 @@
1
+ # Interactify
2
+
3
+ Interactors are a great way to encapsulate business logic in a Rails application.
4
+ However, sometimes in complex interactor chains, the complex debugging happens at one level up from your easy to read and test interactors.
5
+
6
+ Interactify wraps the interactor and interactor-contract gem and provides additional functionality making chaining and understanding interactor chains easier.
7
+
8
+ ### Syntactic Sugar
9
+
10
+ ```ruby
11
+ # before
12
+
13
+ class LoadOrder
14
+ include Interactor
15
+ include Interactor::Contracts
16
+
17
+ expects do
18
+ required(:id).filled
19
+ end
20
+
21
+ promises do
22
+ required(:order).filled
23
+ end
24
+
25
+
26
+ def call
27
+ context.order = Order.find(context.id)
28
+ end
29
+ end
30
+ ```
31
+
32
+
33
+ ```ruby
34
+ # after
35
+ class LoadOrder
36
+ include Interactify
37
+
38
+ expect :id
39
+ promise :order
40
+
41
+ def call
42
+ context.order = Order.find(id)
43
+ end
44
+ end
45
+ ```
46
+
47
+
48
+ ### Lambdas
49
+
50
+ With vanilla interactors, it's not possible to use lambdas in organizers, and sometimes we only want a lambda.
51
+ So we added support.
52
+
53
+ ```ruby
54
+ organize LoadOrder, ->(context) { context.order = context.order.decorate }
55
+
56
+ organize \
57
+ Thing1,
58
+ ->(c){ byebug if c.order.nil? },
59
+ Thing2
60
+ ```
61
+
62
+ ### Each/Iteration
63
+
64
+ Sometimes we want an interactor for each item in a collection.
65
+ But it gets unwieldy.
66
+ It was complex procedural code and is now broken into neat SRP classes (Single Responsibility Principle).
67
+ But there is still boilerplate and jumping around between files to follow the orchestration.
68
+ It's easy to get lost in the orchestration code that occurs across say 7 or 8 files.
69
+
70
+ So the complexity problem is just moved to the gaps between the classes and files.
71
+ We gain things like `EachOrder`, or `EachProduct` interactors.
72
+
73
+ Less obvious, still there.
74
+
75
+ By using `Interactify.each` we can keep the orchestration code in one place.
76
+ We get slightly more complex organizers, but a simpler mental model of organizer as orchestrator and SRP interactors.
77
+
78
+ ```ruby
79
+ # before
80
+ class OuterOrganizer
81
+ # ... boilerplate ...
82
+ organize SetupStep, LoadOrders, DoSomethingWithOrders
83
+ end
84
+
85
+ class LoadOrders
86
+ # ... boilerplate ...
87
+ def call
88
+ context.orders = context.ids.map do |id|
89
+ LoadOrder.call(id: id).order
90
+ end
91
+ end
92
+ end
93
+
94
+ class LoadOrder
95
+ # ... boilerplate ...
96
+ def call
97
+ # ...
98
+ end
99
+ end
100
+
101
+ class DoSomethingWithOrders
102
+ # ... boilerplate ...
103
+ def call
104
+ context.orders.each do |order|
105
+ DoSomethingWithOrder.call(order: order)
106
+ end
107
+ end
108
+ end
109
+
110
+ class DoSomethingWithOrder
111
+ # ... boilerplate ...
112
+ def call
113
+ # ...
114
+ end
115
+ end
116
+ ```
117
+
118
+
119
+ ```ruby
120
+ # after
121
+ class OuterOrganizer
122
+ # ... boilerplate ...
123
+ organize \
124
+ SetupStep,
125
+ self.each(:ids,
126
+ LoadOrder,
127
+ ->(c){ byebug if c.order.nil? },
128
+ DoSomethingWithOrder
129
+ )
130
+ end
131
+
132
+ class LoadOrder
133
+ # ... boilerplate ...
134
+ def call
135
+ # ...
136
+ end
137
+ end
138
+
139
+
140
+ class DoSomethingWithOrder
141
+ # ... boilerplate ...
142
+ def call
143
+ # ...
144
+ end
145
+ end
146
+ ```
147
+
148
+ ### Conditionals (if/else)
149
+
150
+ Along the same lines of each/iteration. We sometimes have to 'break the chain' with interactors just to conditionally call one interactor chain path or another.
151
+
152
+ The same mental model problem applies. We have to jump around between files to follow the orchestration.
153
+
154
+ ```ruby
155
+ # before
156
+ class OuterThing
157
+ # ... boilerplate ...
158
+ organize SetupStep, InnerThing
159
+ end
160
+
161
+ class InnerThing
162
+ # ... boilerplate ...
163
+ def call
164
+ if context.thing == 'a'
165
+ DoThingA.call(context)
166
+ else
167
+ DoThingB.call(context)
168
+ end
169
+ end
170
+ end
171
+ ```
172
+
173
+
174
+ ```ruby
175
+ # after
176
+ class OuterThing
177
+ # ... boilerplate ...
178
+ organize \
179
+ SetupStep,
180
+ self.if(->(c){ c.thing == 'a' }, DoThingA, DoThingB),
181
+ end
182
+
183
+ ```
184
+
185
+ ### More Conditionals
186
+
187
+ ```ruby
188
+ class OuterThing
189
+ # ... boilerplate ...
190
+ organize \
191
+ self.if(:key_set_on_context, DoThingA, DoThingB),
192
+ AfterBothCases
193
+ end
194
+ ```
195
+
196
+ ### Simple chains
197
+ Sometimes you want an organizer that just calls a few interactors in a row.
198
+ You may want to create these dynamically at load time, or you may just want to keep the orchestration in one place.
199
+
200
+ `self.chain` is a simple way to do this.
201
+
202
+ ```ruby
203
+ class SomeOrganizer
204
+ include Interactify
205
+
206
+ organize \
207
+ self.if(:key_set_on_context, self.chain(DoThingA, ThenB, ThenC), DoDifferentThingB),
208
+ EitherWayDoThis
209
+ end
210
+
211
+ ```
212
+
213
+ ### Sidekiq Jobs
214
+ Sometimes you want to asyncify an interactor.
215
+
216
+ ```ruby
217
+ # before
218
+ class SomeInteractor
219
+ include Interactify
220
+
221
+ def call
222
+ # ...
223
+ end
224
+ end
225
+
226
+ clsas SomeInteractorJob
227
+ include Sidekiq::Job
228
+
229
+ def perform(*args)
230
+ SomeInteractor.call(*args)
231
+ end
232
+ end
233
+
234
+ SomeInteractor.call(*args)
235
+ code is changed to
236
+ SomeInteractorJob.perform_async(*args)
237
+ ```
238
+
239
+ ```ruby
240
+ # after
241
+ class SomeInteractor
242
+ include Interactify
243
+
244
+ def call
245
+ # ...
246
+ end
247
+ end
248
+
249
+ # no need to manually create a job class or handle the perform/call impedance mismatch
250
+ SomeInteractor::Async.call(*args)
251
+
252
+ # This also makes it easy to add cron jobs to run interactors. As any interactor can be asyncified.
253
+ # By using it's internal Async class.
254
+ ```
255
+
256
+ ## FAQs
257
+ - This is ugly isn't it?
258
+
259
+ ```ruby
260
+ class OuterOrganizer
261
+ # ... boilerplate ...
262
+ organize \
263
+ SetupStep,
264
+ self.each(:ids,
265
+ LoadOrder,
266
+ ->(c){ byebug if c.order.nil? },
267
+ DoSomethingWithOrder
268
+ )
269
+ end
270
+ ```
271
+
272
+ Yes I agree. It's early days and I'm open to syntax improvement ideas. This is really about it being conceptually less ugly than the alternative, which is to jump around between lots of files. In the existing alternative to using this gem the ugliness is not within each individual file, but within the overall hidden architecture and the hunting process of jumping around in complex interactor chains. We can't see that ugliness but we probably experience it. If you don't feel or experience that ugliness then this gem may not be the right fit for you.
273
+
274
+
275
+ - Is this interactor/interactor-contracts compatible?
276
+ Yes and we use them as dependencies. It's possible we'd drop those dependencies in the future but unlikely. I think it's highly likely we'd retain compatibility.
277
+
278
+
279
+ - Why not propose changes to the interactor or interactor-contracts gem?
280
+ Honestly, I think both are great and why we've built on top of them.
281
+ I presume they'd object to such an extensive opinionated change, and I think that would be the right decision too.
282
+ If this becomes more stable, less coupled to Rails, there's interest, and things we can provide upstream I'd be happy to propose changes to those gems.
283
+
284
+ - Isn't this all just syntactic sugar?
285
+ Yes, but it's sugar that makes the code easier to read and understand.
286
+
287
+ - Is it really easier to parse this new DSL/syntax than POROs?
288
+ That's subjective, but I think so. The benefit is you have fewer extraneous files patching over a common problem in interactors.
289
+
290
+ - But it gets really verbose and complex!
291
+ Again this is subjective, but if you've worked with apps with hundred or thousands of interactors, you'll have encountered these problems.
292
+ I think when we work with interactors we're in one of two modes.
293
+ Hunting to find the interactor we need to change, or working on the interactor we need to change.
294
+ This makes the first step much easier.
295
+ The second step has always been a great experience with interactors.
296
+
297
+ - I prefer Service Objects
298
+ If you're not heavily invested into interactors this may not be for you.
299
+ I love the chaining interactors provide.
300
+ I love the contracts.
301
+ I love the simplicity of the interface.
302
+ I love the way they can be composed.
303
+ I love the way they can be tested.
304
+ When I've used service objects, I've found them to be more complex to test and compose.
305
+ I can't see a clean way that using service objects to compose interactors could work well without losing some of the aforementioned benefits.
306
+
307
+ ### TODO
308
+ We want to add support for explicitly specifying promises in organizers. The benefit here is on clarifying the contract between organizers and interactors.
309
+ A writer of an organizer may expect LoadOrder to promise :order, but for the reader, it's not quite as explicit.
310
+ The expected syntax will be
311
+
312
+ ```ruby
313
+ organize \
314
+ LoadOrder.promising(:order),
315
+ TakePayment.promising(:payment_transaction)
316
+ ```
317
+
318
+ This will be validated at test time against the interactors promises.
319
+
320
+ ## Installation
321
+
322
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
323
+
324
+ Install the gem and add to the application's Gemfile by executing:
325
+
326
+ $ bundle add UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
327
+
328
+ If bundler is not being used to manage dependencies, install the gem by executing:
329
+
330
+ $ gem install UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
331
+
332
+ ## Usage
333
+
334
+ ```ruby
335
+ # e.g. in spec/supoort/interactify.rb
336
+ require 'interactify/rspec/matchers'
337
+
338
+ Interactify.configure do |config|
339
+ config.root = Rails.root '/app'
340
+ end
341
+
342
+ Interactify.on_contract_breach do |context, attrs|
343
+ # maybe add context to Sentry or Honeybadger etc here
344
+ end
345
+
346
+ Interactify.before_raise do |exception|
347
+ # maybe add context to Sentry or Honeybadger etc here
348
+ end
349
+ ```
350
+
351
+ ## Development
352
+
353
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
354
+
355
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
356
+
357
+ ## Contributing
358
+
359
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/interactify.
360
+
361
+ ## License
362
+
363
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,15 @@
1
+ module Interactify
2
+ module CallWrapper
3
+ # https://github.com/collectiveidea/interactor/blob/57b2af9a5a5afeb2c01059c40b792485cc21b052/lib/interactor.rb#L114
4
+ # Interactor#run calls Interactor#run!
5
+ # https://github.com/collectiveidea/interactor/blob/57b2af9a5a5afeb2c01059c40b792485cc21b052/lib/interactor.rb#L49
6
+ # Interactor.call calls Interactor.run
7
+ #
8
+ # The non bang methods call the bang methods and rescue
9
+ def run
10
+ @_interactor_called_by_non_bang_method = true
11
+
12
+ super
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,71 @@
1
+ require 'interactify/jobable'
2
+ require 'interactify/call_wrapper'
3
+ require 'interactify/organizer_call_monkey_patch'
4
+
5
+ module Interactify
6
+ module ContractHelpers
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ def expect(*attrs, filled: true)
11
+ expects do
12
+ attrs.each do |attr|
13
+ field = required(attr)
14
+ field.filled if filled
15
+ end
16
+ end
17
+
18
+ delegate(*attrs, to: :context)
19
+ end
20
+
21
+ def optional(*attrs)
22
+ @optional_attrs ||= []
23
+ @optional_attrs += attrs
24
+
25
+ delegate(*attrs, to: :context)
26
+ end
27
+
28
+ attr_reader :optional_attrs
29
+
30
+ def promise(*attrs, filled: true, should_delegate: true)
31
+ promises do
32
+ attrs.each do |attr|
33
+ field = required(attr)
34
+ field.filled if filled
35
+ end
36
+ end
37
+
38
+ delegate(*attrs, to: :context) if should_delegate
39
+ end
40
+ end
41
+
42
+ class ContractFailure < ::Interactor::Failure
43
+ end
44
+
45
+ included do
46
+ c = Class.new(ContractFailure)
47
+ # example self is Shopkeeper::Fetch
48
+ # failure class: Shopkeeper::Fetch::InteractorContractFailure
49
+ const_set 'InteractorContractFailure', c
50
+ prepend CallWrapper
51
+ include OrganizerCallMonkeyPatch if ancestors.include? Interactor::Organizer
52
+
53
+ on_breach do |breaches|
54
+ breaches = breaches.map { |b| { b.property => b.messages } }.inject(&:merge)
55
+
56
+ Interactify.trigger_contract_breach_hook(context, breaches)
57
+
58
+ if @_interactor_called_by_non_bang_method == true
59
+ context.fail! contract_failures: breaches
60
+ else
61
+ # e.g. raises
62
+ # SomeNamespace::SomeClass::ContractFailure, {whatever: 'is missing'}
63
+ # but also sending the context into Sentry
64
+ exception = c.new(breaches.to_json)
65
+ Interactify.trigger_before_raise_hook(exception)
66
+ raise exception
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,67 @@
1
+ require 'interactify/each_chain'
2
+ require 'interactify/if_interactor'
3
+
4
+ module Interactify
5
+ module Dsl
6
+ # creates a class in the attach_klass_to's namespace
7
+ # e.g.
8
+ #
9
+ # in Orders
10
+ # Interactify.each(self, :packages, A, B, C)
11
+ #
12
+ # will create a class called Orders::EachPackage, that
13
+ # will call the interactor chain A, B, C for each package in the context
14
+ def each(plural_resource_name, *each_loop_klasses)
15
+ EachChain.attach_klass(
16
+ self,
17
+ plural_resource_name,
18
+ *each_loop_klasses
19
+ )
20
+ end
21
+
22
+ def if(condition, succcess_interactor, failure_interactor = nil)
23
+ IfInteractor.attach_klass(
24
+ self,
25
+ condition,
26
+ succcess_interactor,
27
+ failure_interactor
28
+ )
29
+ end
30
+
31
+ # this method allows us to dynamically create
32
+ # an organizer from a name, and a chain of interactors
33
+ #
34
+ # e.g.
35
+ #
36
+ # Interactify.chain(:SomeClass, A, B, C, expect: [:foo, :bar])
37
+ #
38
+ # is the programmable equivalent to
39
+ #
40
+ # class SomeClass
41
+ # include Interactify
42
+ # organize(A, B, C)
43
+ # end
44
+ #
45
+ # it will attach the generate class to the currenct class and
46
+ # use the class name passed in
47
+ # rubocop:disable all
48
+ def chain(klass_name, *chained_klasses, expect: [])
49
+ expectations = expect
50
+
51
+ klass = Class.new do # class EvaluatingNamespace::SomeClass
52
+ include Interactify # include Interactify
53
+ expect(*expectations) if expectations.any? # expect :foo, :bar
54
+
55
+ define_singleton_method(:source_location) do # def self.source_location
56
+ source_location # [file, line]
57
+ end # end
58
+
59
+ organize(*chained_klasses) # organize(A, B, C)
60
+ end # end
61
+
62
+ # attach the class to the calling namespace
63
+ where_to_attach = self.binding.receiver
64
+ where_to_attach.const_set(klass_name, klass)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,80 @@
1
+ module Interactify
2
+ class EachChain
3
+ attr_reader :each_loop_klasses, :plural_resource_name, :evaluating_receiver
4
+
5
+ def self.attach_klass(evaluating_receiver, plural_resource_name, *each_loop_klasses)
6
+ iteratable = new(each_loop_klasses, plural_resource_name, evaluating_receiver)
7
+ iteratable.attach_klass
8
+ end
9
+
10
+ def initialize(each_loop_klasses, plural_resource_name, evaluating_receiver)
11
+ @each_loop_klasses = each_loop_klasses
12
+ @plural_resource_name = plural_resource_name
13
+ @evaluating_receiver = evaluating_receiver
14
+ end
15
+
16
+ # allows us to dynamically create an interactor chain
17
+ # that iterates over the packages and
18
+ # uses the passed in each_loop_klasses
19
+ # rubocop:disable all
20
+ def klass
21
+ this = self
22
+
23
+ Class.new do # class SomeNamespace::EachPackage
24
+ include Interactify # include Interactify
25
+
26
+ expects do # expects do
27
+ required(this.plural_resource_name) # required(:packages)
28
+ end # end
29
+
30
+ define_singleton_method(:source_location) do # def self.source_location
31
+ const_source_location this.evaluating_receiver.to_s # [file, line]
32
+ end # end
33
+
34
+ define_method(:run!) do # def run!
35
+ context.send(this.plural_resource_name).each_with_index do |resource, index|# context.packages.each_with_index do |package, index|
36
+ context[this.singular_resource_name] = resource # context.package = package
37
+ context[this.singular_resource_index_name] = index # context.package_index = index
38
+
39
+ klasses = self.class.wrap_lambdas_in_interactors(this.each_loop_klasses)
40
+
41
+ klasses.each do |interactor| # [A, B, C].each do |interactor|
42
+ interactor.call!(context) # interactor.call!(context)
43
+ end # end
44
+ end # end
45
+
46
+ context[this.singular_resource_name] = nil # context.package = nil
47
+ context[this.singular_resource_index_name] = nil # context.package_index = nil
48
+
49
+ context # context
50
+ end # end
51
+
52
+ define_method(:inspect) do
53
+ "<#{this.namespace}::#{this.iterator_klass_name} iterates_over: #{this.each_loop_klasses.inspect}>"
54
+ end
55
+ end
56
+ end
57
+ # rubocop:enable all
58
+
59
+ def attach_klass
60
+ namespace.const_set(iterator_klass_name, klass)
61
+ namespace.const_get(iterator_klass_name)
62
+ end
63
+
64
+ def namespace
65
+ evaluating_receiver
66
+ end
67
+
68
+ def iterator_klass_name
69
+ :"Each#{singular_resource_name.to_s.camelize}".to_sym
70
+ end
71
+
72
+ def singular_resource_name
73
+ plural_resource_name.to_s.singularize.to_sym
74
+ end
75
+
76
+ def singular_resource_index_name
77
+ "#{singular_resource_name}_index".to_sym
78
+ end
79
+ end
80
+ end