interactify 0.1.0.pre.alpha.1 → 0.2.0.pre.alpha.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +23 -0
- data/CHANGELOG.md +4 -0
- data/README.md +154 -42
- data/lib/interactify/async_job_klass.rb +61 -0
- data/lib/interactify/call_wrapper.rb +2 -0
- data/lib/interactify/contract_failure.rb +6 -0
- data/lib/interactify/contract_helpers.rb +9 -9
- data/lib/interactify/dsl.rb +4 -2
- data/lib/interactify/each_chain.rb +2 -0
- data/lib/interactify/if_interactor.rb +3 -1
- data/lib/interactify/interactor_wiring/callable_representation.rb +79 -0
- data/lib/interactify/interactor_wiring/constants.rb +125 -0
- data/lib/interactify/interactor_wiring/error_context.rb +41 -0
- data/lib/interactify/interactor_wiring/files.rb +51 -0
- data/lib/interactify/interactor_wiring.rb +57 -272
- data/lib/interactify/job_maker.rb +20 -69
- data/lib/interactify/jobable.rb +9 -7
- data/lib/interactify/mismatching_promise_error.rb +17 -0
- data/lib/interactify/organizer_call_monkey_patch.rb +7 -2
- data/lib/interactify/promising.rb +34 -0
- data/lib/interactify/rspec/matchers.rb +9 -11
- data/lib/interactify/version.rb +1 -1
- data/lib/interactify.rb +39 -9
- metadata +11 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4c2dffe1928c3b1653ea12057fd660eacbef1d8fbd946d1bdbddef00494a8c8b
|
4
|
+
data.tar.gz: 7078fe7a6b217febd47f4598eb2e88fdcfb8affcd434dba239ee86a39a072176
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9e3eb106e5b896965c6b1fbfea135c62f4eb7f95779041c121bcb2040fd8568c54e94b4f77a3e1bcca6d922d8a8bdfcacda354d3a6396e19a49fbfce78f39af1
|
7
|
+
data.tar.gz: 3f998d9f35105a47becc0555ed80dff264fad2e5fe58f93ec0c731b8cab4281e457da9e3fc00d0e4709e1b62b4e2416a8a75b66f56723b610531bca8fd458ea3
|
data/.rubocop.yml
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
AllCops:
|
2
|
+
NewCops: enable
|
3
|
+
Exclude:
|
4
|
+
- 'spec/fixtures/**/*'
|
5
|
+
- 'tmp/**/*'
|
6
|
+
- '.git/**/*'
|
7
|
+
- 'bin/*'
|
8
|
+
Style/ClassAndModuleChildren:
|
9
|
+
Exclude:
|
10
|
+
- 'spec/**/*'
|
11
|
+
Metrics/BlockLength:
|
12
|
+
Exclude:
|
13
|
+
- 'spec/**/*'
|
14
|
+
- '*.gemspec'
|
15
|
+
Metrics/MethodLength:
|
16
|
+
Enabled: false
|
17
|
+
Lint/ConstantDefinitionInBlock:
|
18
|
+
Exclude:
|
19
|
+
- 'spec/**/*'
|
20
|
+
Style/Documentation:
|
21
|
+
Enabled: false
|
22
|
+
Style/StringLiterals:
|
23
|
+
EnforcedStyle: double_quotes
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -1,11 +1,50 @@
|
|
1
1
|
# Interactify
|
2
2
|
|
3
|
-
Interactors are a great way to encapsulate business logic in a Rails application.
|
3
|
+
[Interactors](https://github.com/collectiveidea/interactor) are a great way to encapsulate business logic in a Rails application.
|
4
4
|
However, sometimes in complex interactor chains, the complex debugging happens at one level up from your easy to read and test interactors.
|
5
5
|
|
6
|
-
|
6
|
+
[interactor-contracts](https://github.com/michaelherold/interactor-contracts) does a fantastic job of making your interactor chains more reliable.
|
7
|
+
|
8
|
+
Interactify wraps the interactor and interactor-contracts gem and provides additional functionality making chaining and understanding interactor chains easier.
|
9
|
+
|
10
|
+
This is a bells and whistles gem and assumes you are working in a Rails project with Sidekiq.
|
11
|
+
However, I'm open to the idea of making it more focused and making these more pluggable.
|
12
|
+
|
13
|
+
## Installation
|
14
|
+
|
15
|
+
```Gemfile
|
16
|
+
gem 'interactify'
|
17
|
+
```
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
### Initializer
|
22
|
+
```ruby
|
23
|
+
# in config/initializers/interactify.rb
|
24
|
+
Interactify.configure do |config|
|
25
|
+
# default
|
26
|
+
# config.root = Rails.root / 'app'
|
27
|
+
end
|
28
|
+
|
29
|
+
Interactify.on_contract_breach do |context, attrs|
|
30
|
+
# maybe add context to Sentry or Honeybadger etc here
|
31
|
+
end
|
32
|
+
|
33
|
+
Interactify.before_raise do |exception|
|
34
|
+
# maybe add context to Sentry or Honeybadger etc here
|
35
|
+
end
|
36
|
+
```
|
37
|
+
|
38
|
+
### Using the RSpec matchers
|
39
|
+
```ruby
|
40
|
+
# e.g. in spec/supoort/interactify.rb
|
41
|
+
require 'interactify/rspec/matchers'
|
42
|
+
```
|
7
43
|
|
8
44
|
### Syntactic Sugar
|
45
|
+
- Everything is an Organizer/Interactor and supports interactor-contracts.
|
46
|
+
- Concise syntax for most common scenarios with `expects` and `promises`. Verifying the presence of the keys/values.
|
47
|
+
- Automatic delegation of expected and promised keys to the context.
|
9
48
|
|
10
49
|
```ruby
|
11
50
|
# before
|
@@ -16,6 +55,8 @@ class LoadOrder
|
|
16
55
|
|
17
56
|
expects do
|
18
57
|
required(:id).filled
|
58
|
+
required(:something_else).filled
|
59
|
+
required(:a_boolean_flag)
|
19
60
|
end
|
20
61
|
|
21
62
|
promises do
|
@@ -35,7 +76,8 @@ end
|
|
35
76
|
class LoadOrder
|
36
77
|
include Interactify
|
37
78
|
|
38
|
-
expect :id
|
79
|
+
expect :id, :something_else
|
80
|
+
expect :a_boolean_flag, filled: false
|
39
81
|
promise :order
|
40
82
|
|
41
83
|
def call
|
@@ -63,7 +105,7 @@ organize \
|
|
63
105
|
|
64
106
|
Sometimes we want an interactor for each item in a collection.
|
65
107
|
But it gets unwieldy.
|
66
|
-
It was complex procedural code and is now broken into neat SRP classes
|
108
|
+
It was complex procedural code and is now broken into neat [SRP classes](https://en.wikipedia.org/wiki/Single_responsibility_principle).
|
67
109
|
But there is still boilerplate and jumping around between files to follow the orchestration.
|
68
110
|
It's easy to get lost in the orchestration code that occurs across say 7 or 8 files.
|
69
111
|
|
@@ -208,6 +250,90 @@ class SomeOrganizer
|
|
208
250
|
EitherWayDoThis
|
209
251
|
end
|
210
252
|
|
253
|
+
```
|
254
|
+
### Contract validation failures
|
255
|
+
Sometimes contract validation fails at runtime as an exception. It's something unexpected and you'll have an `Interactor::Failure` sent to rollbar/sentry/honeybadger.
|
256
|
+
If the context is large it's often hard to spot what the actual problem is or where it occurred.
|
257
|
+
|
258
|
+
#### before
|
259
|
+
```
|
260
|
+
Interactor::Failure
|
261
|
+
|
262
|
+
#<Interactor::Context output_destination="DataExportSystem", output_format=:xml, region_code="XX", custom_flag=false, process_mode="sample", cache_identifier="GenericProcessorSample-XML-XX-0", data_key="GenericProcessorSample", data_version=0, last_process_time=2023-12-26 04:00:18.953000000 GMT +00:00, process_start_time=2023-12-26 06:45:17.915237484 UTC, updated_ids=[BSON::ObjectId('123f77a58444201ff1f0611a'), BSON::ObjectId('123f78148444201fd62a2e9b'), BSON::ObjectId('12375d8084442038712ba40e')], lock_info=#<Processing::Lock _id: 123a767d7b944674cc069064, created_at: 2023-12-26 06:45:17.992417809 UTC, updated_at: 2023-12-26 06:45:17.992417809 UTC, processor: "DataExportSystem", format: "xml", type: "sample">, expired_cache_ids=[], jobs=['jobs must be filled'] items=#<Mongoid::Criteria (Interactor::Failure)
|
263
|
+
, tasks=[]>
|
264
|
+
```
|
265
|
+
|
266
|
+
#### after with call
|
267
|
+
```
|
268
|
+
#<Interactor::Context output_destination="DataExportSystem", output_format=:xml, region_code="XX", custom_flag=false, process_mode="sample", cache_identifier="GenericProcessorSample-XML-XX-0", data_key="GenericProcessorSample", data_version=0, last_process_time=2023-12-26 04:00:18.953000000 GMT +00:00, process_start_time=2023-12-26 06:45:17.915237484 UTC, updated_ids=[BSON::ObjectId('123f77a58444201ff1f0611a'), BSON::ObjectId('123f78148444201fd62a2e9b'), BSON::ObjectId('12375d8084442038712ba40e')], lock_info=#<Processing::Lock _id: 123a767d7b944674cc069064, created_at: 2023-12-26 06:45:17.992417809 UTC, updated_at: 2023-12-26 06:45:17.992417809 UTC, processor: "DataExportSystem", format: "xml", type: "sample">, expired_cache_ids=[], tasks=['tasks must be filled'] items=#<Mongoid::Criteria (Interactor::Failure)
|
269
|
+
, tasks=[], contract_failures={:tasks=>["tasks must be filled"]}>
|
270
|
+
```
|
271
|
+
|
272
|
+
#### after with call!
|
273
|
+
```
|
274
|
+
#<SomeSpecificInteractor::ContractFailure output_destination="DataExportSystem", output_format=:xml, region_code="XX", custom_flag=false, process_mode="sample", cache_identifier="GenericProcessorSample-XML-XX-0", data_key="GenericProcessorSample", data_version=0, last_process_time=2023-12-26 04:00:18.953000000 GMT +00:00, process_start_time=2023-12-26 06:45:17.915237484 UTC, updated_ids=[BSON::ObjectId('123f77a58444201ff1f0611a'), BSON::ObjectId('123f78148444201fd62a2e9b'), BSON::ObjectId('12375d8084442038712ba40e')], lock_info=#<Processing::Lock _id: 123a767d7b944674cc069064, created_at: 2023-12-26 06:45:17.992417809 UTC, updated_at: 2023-12-26 06:45:17.992417809 UTC, processor: "DataExportSystem", format: "xml", type: "sample">, expired_cache_ids=[], tasks=['tasks must be filled'] items=#<Mongoid::Criteria (Interactor::Failure)
|
275
|
+
, tasks=[], contract_failures={:tasks=>["tasks must be filled"]}>
|
276
|
+
```
|
277
|
+
|
278
|
+
### Promising
|
279
|
+
You can annotate your interactors in the organize arguments with their promises.
|
280
|
+
This then acts as executable documentation that is validated at load time and enforced to stay in sync with the interactor.
|
281
|
+
|
282
|
+
A writer of an organizer may quite reasonably expect `LoadOrder` to promise `:order`, but for the reader, it's not always as immediately obvious
|
283
|
+
which interactor in the chain is responsible for provides which key.
|
284
|
+
|
285
|
+
```ruby
|
286
|
+
organize \
|
287
|
+
LoadOrder.promising(:order),
|
288
|
+
TakePayment.promising(:payment_transaction)
|
289
|
+
```
|
290
|
+
|
291
|
+
This will be validated at load time against the interactors promises.
|
292
|
+
An example of a failure would be:
|
293
|
+
|
294
|
+
```
|
295
|
+
SomeOrganizer::DoStep1 does not promise:
|
296
|
+
step_1
|
297
|
+
|
298
|
+
Actual promises are:
|
299
|
+
step1
|
300
|
+
```
|
301
|
+
|
302
|
+
|
303
|
+
### Interactor wiring specs
|
304
|
+
Sometimes you have an interactor chain that fails because something is expected deeper down the chain and not provided further up the chain.
|
305
|
+
The existing way to solve this is with enough integration specs to catch them, hunting and sticking a `byebug`, `debugger` or `binding.pry` in at suspected locations and inferring where in the chain the wiring went awry.
|
306
|
+
|
307
|
+
But we can do better than that if we always `promise` something that is later `expect`ed.
|
308
|
+
|
309
|
+
In order to detect these wiring issues, stick a spec in your test suite like this:
|
310
|
+
|
311
|
+
```ruby
|
312
|
+
RSpec.describe 'InteractorWiring' do
|
313
|
+
it 'validates the interactors in the whole app', :aggregate_failures do
|
314
|
+
errors = Interactify.validate_app(ignore: [/Priam/])
|
315
|
+
|
316
|
+
expect(errors).to eq ''
|
317
|
+
end
|
318
|
+
end
|
319
|
+
```
|
320
|
+
|
321
|
+
```
|
322
|
+
Missing keys: :order_id
|
323
|
+
in: AssignOrderToUser
|
324
|
+
for: PlaceOrder
|
325
|
+
```
|
326
|
+
|
327
|
+
This allows you to quickly see exactly where you missed assigning something to the context.
|
328
|
+
Combine with lambda debugging `->(ctx) { byebug if ctx.order_id.nil?},` in your chains to drop into the exact
|
329
|
+
location in the chain to find where to make the change.
|
330
|
+
|
331
|
+
### RSpec matchers
|
332
|
+
Easily add [low value, low cost](https://noelrappin.com/blog/2017/02/high-cost-tests-and-high-value-tests/) specs for your expects and promises.
|
333
|
+
|
334
|
+
```ruby
|
335
|
+
expect(described_class).to expect_inputs(:order_id)
|
336
|
+
expect(described_class).to promise_outputs(:order)
|
211
337
|
```
|
212
338
|
|
213
339
|
### Sidekiq Jobs
|
@@ -230,10 +356,11 @@ clsas SomeInteractorJob
|
|
230
356
|
SomeInteractor.call(*args)
|
231
357
|
end
|
232
358
|
end
|
359
|
+
```
|
233
360
|
|
234
|
-
|
235
|
-
|
236
|
-
SomeInteractorJob.perform_async(*args)
|
361
|
+
```diff
|
362
|
+
-SomeInteractor.call(*args)
|
363
|
+
+SomeInteractorJob.perform_async(*args)
|
237
364
|
```
|
238
365
|
|
239
366
|
```ruby
|
@@ -245,14 +372,22 @@ class SomeInteractor
|
|
245
372
|
# ...
|
246
373
|
end
|
247
374
|
end
|
375
|
+
```
|
248
376
|
|
249
|
-
|
250
|
-
SomeInteractor::Async.call(*args)
|
377
|
+
No need to manually create a job class or handle the perform/call impedance mismatch
|
251
378
|
|
252
|
-
|
253
|
-
|
379
|
+
```diff
|
380
|
+
-SomeInteractor.call!(*args)
|
381
|
+
+SomeInteractor::Async.call!(*args)
|
254
382
|
```
|
255
383
|
|
384
|
+
This also makes it easy to add cron jobs to run interactors. As any interactor can be asyncified.
|
385
|
+
By using it's internal Async class.
|
386
|
+
|
387
|
+
N.B. as your class is now executing asynchronously you can no longer rely on its promises later on in the chain.
|
388
|
+
|
389
|
+
|
390
|
+
|
256
391
|
## FAQs
|
257
392
|
- This is ugly isn't it?
|
258
393
|
|
@@ -305,7 +440,14 @@ When I've used service objects, I've found them to be more complex to test and c
|
|
305
440
|
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
441
|
|
307
442
|
### TODO
|
308
|
-
We want to add support for explicitly specifying promises in organizers.
|
443
|
+
We want to add support for explicitly specifying promises in organizers.
|
444
|
+
|
445
|
+
The benefit here is on clarifying the contract between organizers and interactors.
|
446
|
+
|
447
|
+
This is another variation of the "interactors themselves are great but their coordination and finding where things happen is hard in large applications".
|
448
|
+
|
449
|
+
By adding promise notation to organizers, we can signal to the reader that 'here in this part of the chain is the thing you are looking for'.
|
450
|
+
|
309
451
|
A writer of an organizer may expect LoadOrder to promise :order, but for the reader, it's not quite as explicit.
|
310
452
|
The expected syntax will be
|
311
453
|
|
@@ -317,36 +459,6 @@ organize \
|
|
317
459
|
|
318
460
|
This will be validated at test time against the interactors promises.
|
319
461
|
|
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
462
|
|
351
463
|
## Development
|
352
464
|
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Interactify
|
4
|
+
class AsyncJobKlass
|
5
|
+
attr_reader :container_klass, :klass_suffix
|
6
|
+
|
7
|
+
def initialize(container_klass:, klass_suffix:)
|
8
|
+
@container_klass = container_klass
|
9
|
+
@klass_suffix = klass_suffix
|
10
|
+
end
|
11
|
+
|
12
|
+
def async_job_klass
|
13
|
+
klass = Class.new do
|
14
|
+
include Interactor
|
15
|
+
include Interactor::Contracts
|
16
|
+
end
|
17
|
+
|
18
|
+
attach_call(klass)
|
19
|
+
attach_call!(klass)
|
20
|
+
|
21
|
+
klass
|
22
|
+
end
|
23
|
+
|
24
|
+
def attach_call(async_job_klass)
|
25
|
+
# e.g. SomeInteractor::AsyncWithSuffix.call(foo: 'bar')
|
26
|
+
async_job_klass.send(:define_singleton_method, :call) do |context|
|
27
|
+
call!(context)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def attach_call!(async_job_klass)
|
32
|
+
this = self
|
33
|
+
|
34
|
+
# e.g. SomeInteractor::AsyncWithSuffix.call!(foo: 'bar')
|
35
|
+
async_job_klass.send(:define_singleton_method, :call!) do |context|
|
36
|
+
# e.g. SomeInteractor::JobWithSuffix
|
37
|
+
job_klass = this.container_klass.const_get("Job#{this.klass_suffix}")
|
38
|
+
|
39
|
+
# e.g. SomeInteractor::JobWithSuffix.perform_async({foo: 'bar'})
|
40
|
+
job_klass.perform_async(this.args(context))
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def args(context)
|
45
|
+
args = context.to_h.stringify_keys
|
46
|
+
|
47
|
+
return args unless container_klass.respond_to?(:expected_keys)
|
48
|
+
|
49
|
+
restrict_to_optional_or_keys_from_contract(args)
|
50
|
+
end
|
51
|
+
|
52
|
+
def restrict_to_optional_or_keys_from_contract(args)
|
53
|
+
keys = container_klass.expected_keys.map(&:to_s)
|
54
|
+
|
55
|
+
optional = Array(container_klass.optional_attrs).map(&:to_s)
|
56
|
+
keys += optional
|
57
|
+
|
58
|
+
args.slice(*keys)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -1,6 +1,9 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
require
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "interactify/jobable"
|
4
|
+
require "interactify/call_wrapper"
|
5
|
+
require "interactify/organizer_call_monkey_patch"
|
6
|
+
require "interactify/contract_failure"
|
4
7
|
|
5
8
|
module Interactify
|
6
9
|
module ContractHelpers
|
@@ -39,14 +42,11 @@ module Interactify
|
|
39
42
|
end
|
40
43
|
end
|
41
44
|
|
42
|
-
class ContractFailure < ::Interactor::Failure
|
43
|
-
end
|
44
|
-
|
45
45
|
included do
|
46
46
|
c = Class.new(ContractFailure)
|
47
|
-
# example self is
|
48
|
-
# failure class:
|
49
|
-
const_set
|
47
|
+
# example self is Whatever::SomeInteractor
|
48
|
+
# failure class: Whatever::SomeInteractor::InteractorContractFailure
|
49
|
+
const_set "InteractorContractFailure", c
|
50
50
|
prepend CallWrapper
|
51
51
|
include OrganizerCallMonkeyPatch if ancestors.include? Interactor::Organizer
|
52
52
|
|
data/lib/interactify/dsl.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Interactify
|
2
4
|
class IfInteractor
|
3
5
|
attr_reader :condition, :success_interactor, :failure_interactor, :evaluating_receiver
|
@@ -56,7 +58,7 @@ module Interactify
|
|
56
58
|
end
|
57
59
|
|
58
60
|
def if_klass_name
|
59
|
-
name = condition.is_a?(Proc) ?
|
61
|
+
name = condition.is_a?(Proc) ? "Proc" : condition
|
60
62
|
|
61
63
|
"If#{name.to_s.camelize}".to_sym
|
62
64
|
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "interactify/interactor_wiring/error_context"
|
4
|
+
|
5
|
+
module Interactify
|
6
|
+
class InteractorWiring
|
7
|
+
class CallableRepresentation
|
8
|
+
attr_reader :filename, :klass, :wiring
|
9
|
+
|
10
|
+
delegate :interactor_lookup, to: :wiring
|
11
|
+
|
12
|
+
def initialize(filename:, klass:, wiring:)
|
13
|
+
@filename = filename
|
14
|
+
@klass = klass
|
15
|
+
@wiring = wiring
|
16
|
+
end
|
17
|
+
|
18
|
+
def validate_callable(error_context: ErrorContext.new)
|
19
|
+
if organizer?
|
20
|
+
assign_previously_defined(error_context:)
|
21
|
+
validate_children(error_context:)
|
22
|
+
end
|
23
|
+
|
24
|
+
validate_self(error_context:)
|
25
|
+
end
|
26
|
+
|
27
|
+
def expected_keys
|
28
|
+
klass.respond_to?(:expected_keys) ? Array(klass.expected_keys) : []
|
29
|
+
end
|
30
|
+
|
31
|
+
def promised_keys
|
32
|
+
klass.respond_to?(:promised_keys) ? Array(klass.promised_keys) : []
|
33
|
+
end
|
34
|
+
|
35
|
+
def all_keys
|
36
|
+
expected_keys.concat(promised_keys)
|
37
|
+
end
|
38
|
+
|
39
|
+
def inspect
|
40
|
+
"#<#{self.class.name}#{object_id} @filename=#{filename}, @klass=#{klass.name}>"
|
41
|
+
end
|
42
|
+
|
43
|
+
def organizer?
|
44
|
+
klass.respond_to?(:organized) && klass.organized.any?
|
45
|
+
end
|
46
|
+
|
47
|
+
def assign_previously_defined(error_context:)
|
48
|
+
return unless contract?
|
49
|
+
|
50
|
+
error_context.append_previously_defined_keys(all_keys)
|
51
|
+
end
|
52
|
+
|
53
|
+
def validate_children(error_context:)
|
54
|
+
klass.organized.each do |interactor|
|
55
|
+
interactor_as_callable = interactor_lookup[interactor]
|
56
|
+
next if interactor_as_callable.nil?
|
57
|
+
|
58
|
+
error_context = interactor_as_callable.validate_callable(error_context:)
|
59
|
+
end
|
60
|
+
|
61
|
+
error_context
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def contract?
|
67
|
+
klass.ancestors.include? Interactor::Contracts
|
68
|
+
end
|
69
|
+
|
70
|
+
def validate_self(error_context:)
|
71
|
+
return error_context unless contract?
|
72
|
+
|
73
|
+
error_context.infer_missing_keys(self)
|
74
|
+
error_context.add_promised_keys(promised_keys)
|
75
|
+
error_context
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Interactify
|
4
|
+
class InteractorWiring
|
5
|
+
class Constants
|
6
|
+
attr_reader :root, :organizer_files, :interactor_files
|
7
|
+
|
8
|
+
def initialize(root:, organizer_files:, interactor_files:)
|
9
|
+
@root = root.is_a?(Pathname) ? root : Pathname.new(root)
|
10
|
+
@organizer_files = organizer_files
|
11
|
+
@interactor_files = interactor_files
|
12
|
+
end
|
13
|
+
|
14
|
+
def organizers
|
15
|
+
@organizers ||= organizer_files.flat_map do |f|
|
16
|
+
callables_in_file(f)
|
17
|
+
end.compact.select(&:organizer?)
|
18
|
+
end
|
19
|
+
|
20
|
+
def interactors
|
21
|
+
@interactors ||= interactor_files.flat_map do |f|
|
22
|
+
callables_in_file(f)
|
23
|
+
end.compact.reject(&:organizer?)
|
24
|
+
end
|
25
|
+
|
26
|
+
def interactor_lookup
|
27
|
+
@interactor_lookup ||= (interactors + organizers).index_by(&:klass)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def callables_in_file(filename)
|
33
|
+
@callables_in_file ||= {}
|
34
|
+
|
35
|
+
@callables_in_file[filename] ||= _callables_in_file(filename)
|
36
|
+
end
|
37
|
+
|
38
|
+
def _callables_in_file(filename)
|
39
|
+
constant = constant_for(filename)
|
40
|
+
return if constant == Interactify
|
41
|
+
|
42
|
+
internal_klasses = internal_constants_for(constant)
|
43
|
+
|
44
|
+
([constant] + internal_klasses).map do |k|
|
45
|
+
new_callable(filename, k, self)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def internal_constants_for(constant)
|
50
|
+
constant
|
51
|
+
.constants
|
52
|
+
.map { |sym| constant_from_symbol(constant, sym) }
|
53
|
+
.select { |pk| interactor_klass?(pk) }
|
54
|
+
end
|
55
|
+
|
56
|
+
def constant_from_symbol(constant, symbol)
|
57
|
+
constant.module_eval do
|
58
|
+
symbol.to_s.constantize
|
59
|
+
rescue StandardError
|
60
|
+
begin
|
61
|
+
"#{constant.name}::#{symbol}".constantize
|
62
|
+
rescue StandardError
|
63
|
+
nil
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def interactor_klass?(object)
|
69
|
+
return unless object.is_a?(Class) && object.ancestors.include?(Interactor)
|
70
|
+
return if object.is_a?(Sidekiq::Job)
|
71
|
+
|
72
|
+
true
|
73
|
+
end
|
74
|
+
|
75
|
+
def new_callable(filename, klass, wiring)
|
76
|
+
CallableRepresentation.new(filename:, klass:, wiring:)
|
77
|
+
end
|
78
|
+
|
79
|
+
def constant_for(filename)
|
80
|
+
require filename
|
81
|
+
|
82
|
+
underscored_klass_name = underscored_klass_name(filename)
|
83
|
+
underscored_klass_name = trim_rails_design_pattern_folder underscored_klass_name
|
84
|
+
|
85
|
+
klass_name = underscored_klass_name.classify
|
86
|
+
|
87
|
+
should_pluralize = filename[underscored_klass_name.pluralize]
|
88
|
+
klass_name = klass_name.pluralize if should_pluralize
|
89
|
+
|
90
|
+
Object.const_get(klass_name)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Example:
|
94
|
+
# trim_rails_folder("app/interactors/namespace/sub_namespace/class_name.rb")
|
95
|
+
# => "namespace/sub_namespace/class_name.rb"
|
96
|
+
def trim_rails_design_pattern_folder(filename)
|
97
|
+
rails_folders.each do |folder|
|
98
|
+
regexable_folder = Regexp.quote("#{folder}/")
|
99
|
+
regex = /^#{regexable_folder}/
|
100
|
+
|
101
|
+
return filename.gsub(regex, "") if filename.match?(regex)
|
102
|
+
end
|
103
|
+
|
104
|
+
filename
|
105
|
+
end
|
106
|
+
|
107
|
+
def rails_folders = Dir.glob(root / "*").map { Pathname.new _1 }.select(&:directory?).map { |f| File.basename(f) }
|
108
|
+
|
109
|
+
# Example:
|
110
|
+
# "/home/code/something/app/interactors/namespace/sub_namespace/class_name.rb"
|
111
|
+
# "/namespace/sub_namespace/class_name.rb"
|
112
|
+
# ['', 'namespace', 'sub_namespace', 'class_name.rb']
|
113
|
+
# ['namespace', 'sub_namespace', 'class_name.rb']
|
114
|
+
def underscored_klass_name(filename)
|
115
|
+
filename.to_s # "/home/code/something/app/interactors/namespace/sub_namespace/class_name.rb"
|
116
|
+
.gsub(root.to_s, "") # "/namespace/sub_namespace/class_name.rb"
|
117
|
+
.gsub("/concerns", "") # concerns directory is ignored by Zeitwerk
|
118
|
+
.split("/") # "['', 'namespace', 'sub_namespace', 'class_name.rb']
|
119
|
+
.compact_blank # "['namespace', 'sub_namespace', 'class_name.rb']
|
120
|
+
.join("/") # 'namespace/sub_namespace/class_name.rb'
|
121
|
+
.gsub(/\.rb\z/, "") # 'namespace/sub_namespace/class_name'
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|