dry-effects 0.1.0 → 0.1.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 +4 -4
- data/.codeclimate.yml +2 -5
- data/.github/ISSUE_TEMPLATE/----please-don-t-ask-for-support-via-issues.md +10 -0
- data/.github/ISSUE_TEMPLATE/---bug-report.md +34 -0
- data/.github/ISSUE_TEMPLATE/---feature-request.md +18 -0
- data/.github/workflows/ci.yml +74 -0
- data/.github/workflows/docsite.yml +34 -0
- data/.github/workflows/sync_configs.yml +34 -0
- data/.rspec +2 -1
- data/.rubocop.yml +22 -6
- data/CHANGELOG.md +17 -2
- data/CODE_OF_CONDUCT.md +13 -0
- data/CONTRIBUTING.md +2 -2
- data/Gemfile +1 -0
- data/LICENSE +14 -15
- data/README.md +4 -2
- data/docsite/source/effects.html.md +29 -0
- data/docsite/source/effects/cache.html.md +84 -0
- data/docsite/source/effects/current_time.html.md +186 -0
- data/docsite/source/effects/defer.html.md +130 -0
- data/docsite/source/effects/env.html.md +144 -0
- data/docsite/source/effects/interrupt.html.md +109 -0
- data/docsite/source/effects/parallel.html.md +25 -0
- data/docsite/source/effects/reader.html.md +126 -0
- data/docsite/source/effects/resolve.html.md +188 -0
- data/docsite/source/effects/state.html.md +178 -0
- data/docsite/source/effects/timeout.html.md +42 -0
- data/docsite/source/index.html.md +212 -0
- data/lib/dry/effects/extensions.rb +4 -0
- data/lib/dry/effects/extensions/rspec.rb +21 -0
- data/lib/dry/effects/version.rb +1 -1
- metadata +22 -3
- data/.travis.yml +0 -32
@@ -0,0 +1,109 @@
|
|
1
|
+
---
|
2
|
+
title: Interrupt
|
3
|
+
layout: gem-single
|
4
|
+
name: dry-effects
|
5
|
+
---
|
6
|
+
|
7
|
+
Interrupt is an effect with the semantics of `raise`/`rescue` or `throw`/`catch`. It's added for consistency and compatibility with other effects. Underneath, it uses `raise` + `rescue` so that application code can detect the bubbling.
|
8
|
+
|
9
|
+
### Basic usage
|
10
|
+
|
11
|
+
If you know what exceptions are, this should look familiar:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
require 'dry/effects'
|
15
|
+
|
16
|
+
class RunDivision
|
17
|
+
include Dry::Effects::Handler.Interrupt(:division_by_zero, as: :catch_zero_division)
|
18
|
+
|
19
|
+
def call
|
20
|
+
success, answer = catch_zero_division do
|
21
|
+
yield
|
22
|
+
end
|
23
|
+
|
24
|
+
if success
|
25
|
+
answer
|
26
|
+
else
|
27
|
+
:error
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
class Divide
|
33
|
+
include Dry::Effects.Interrupt(:division_by_zero)
|
34
|
+
|
35
|
+
def call(dividend, divisor)
|
36
|
+
if divisor.zero?
|
37
|
+
division_by_zero
|
38
|
+
else
|
39
|
+
dividend / divisor
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
run = RunDivision.new
|
45
|
+
divide = Divide.new
|
46
|
+
|
47
|
+
app = -> a, b { run.() { divide.(a, b) } }
|
48
|
+
|
49
|
+
app.(10, 2) # => 5
|
50
|
+
app.(1, 0) # => :error
|
51
|
+
```
|
52
|
+
|
53
|
+
The handler returns a flag indicating whether there was an interruption. `false` means the block was run without interruption, `true` stands for the code was interrupted at some point.
|
54
|
+
|
55
|
+
### Payload
|
56
|
+
|
57
|
+
Interruption can have a payload:
|
58
|
+
|
59
|
+
```ruby
|
60
|
+
class Callee
|
61
|
+
include Dry::Effects.Interrupt(:halt)
|
62
|
+
|
63
|
+
def call
|
64
|
+
halt :foo
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
class Caller
|
69
|
+
include Dry::Effects::Handler.Interrupt(:halt, as: :catch_halt)
|
70
|
+
|
71
|
+
def call
|
72
|
+
_, result = catch_halt do
|
73
|
+
yield
|
74
|
+
:bar
|
75
|
+
end
|
76
|
+
|
77
|
+
result
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
caller = Caller.new
|
82
|
+
callee = Callee.new
|
83
|
+
|
84
|
+
caller.() { callee.() } # => :foo
|
85
|
+
caller.() { } # => :bar
|
86
|
+
```
|
87
|
+
|
88
|
+
### Composition
|
89
|
+
|
90
|
+
Every Interrupt effect has to have an identifier so that they don't overlap. It's an equivalent of exception types. You can nest handlers with different identifiers; they will work just as you would expect:
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
class Catcher
|
94
|
+
include Dry::Effects::Handler(:div_error, as: :catch_div)
|
95
|
+
include Dry::Effects::Handler(:sqrt_error, as: :catch_sqrt)
|
96
|
+
|
97
|
+
def call
|
98
|
+
_, div_result = catch_div do
|
99
|
+
_, sqrt_result = catch_sqrt do
|
100
|
+
yield
|
101
|
+
end
|
102
|
+
|
103
|
+
sqrt_result
|
104
|
+
end
|
105
|
+
|
106
|
+
div_result
|
107
|
+
end
|
108
|
+
end
|
109
|
+
```
|
@@ -0,0 +1,25 @@
|
|
1
|
+
---
|
2
|
+
title: Parallel execution
|
3
|
+
layout: gem-single
|
4
|
+
name: dry-effects
|
5
|
+
---
|
6
|
+
|
7
|
+
There are two effects for using parallelism:
|
8
|
+
|
9
|
+
- `par` creates a unit of parallel execution;
|
10
|
+
- `join` combines an array of units to the array of their results.
|
11
|
+
|
12
|
+
`par`/`join` is almost identical to `defer`/`wait` from [`Defer`](/gems/dry-effects/0.1/effects/defer), the difference is in the semantics. `defer` is not supposed to be always awaited, it's usually fired-and-forgotten. On the contrary, `par` is meant to be `join`ed at some point.
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
class MakeRequests
|
16
|
+
include Dry::Effects.Resolve(:make_request)
|
17
|
+
|
18
|
+
def call(urls)
|
19
|
+
# Run every request in parallel and combine their results
|
20
|
+
urls.map { |url| par { make_request.(url) } }.then { |pars| join(pars) }
|
21
|
+
end
|
22
|
+
end
|
23
|
+
```
|
24
|
+
|
25
|
+
Just as [`Defer`](/gems/dry-effects/0.1/effects/defer), `Parallel` uses `concurrent-ruby` under the hood.
|
@@ -0,0 +1,126 @@
|
|
1
|
+
---
|
2
|
+
title: Reader
|
3
|
+
layout: gem-single
|
4
|
+
name: dry-effects
|
5
|
+
---
|
6
|
+
|
7
|
+
Reader is the simplest effect. It passes a value down to the stack.
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
require 'dry/effects'
|
11
|
+
|
12
|
+
class SetLocaleMiddleware
|
13
|
+
include Dry::Effects::Handler.Reader(:locale)
|
14
|
+
|
15
|
+
def initialize(app)
|
16
|
+
@app = app
|
17
|
+
end
|
18
|
+
|
19
|
+
def call(env)
|
20
|
+
with_locale(detect_locale(env)) do
|
21
|
+
@app.(env)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def detect_locale(env)
|
26
|
+
# arbitrary detection logic
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
### Anywhere in the app
|
31
|
+
|
32
|
+
class GreetUser
|
33
|
+
include Dry::Effects.Reader(:locale)
|
34
|
+
|
35
|
+
def call(user)
|
36
|
+
case locale
|
37
|
+
when :en then "Hello #{user.name}"
|
38
|
+
when :de then "Hallo #{user.name}"
|
39
|
+
when :ru then "Привет, #{user.name}"
|
40
|
+
when :it then "Ciao #{user.name}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
```
|
45
|
+
|
46
|
+
### Testing with Reader
|
47
|
+
|
48
|
+
If you run `GreetUser#call` without a Reader handler, it will raise an error. For unit tests you'll need some wrapping code:
|
49
|
+
|
50
|
+
```ruby
|
51
|
+
RSpec.describe GreetUser do
|
52
|
+
include Dry::Effects::Handler.Reader(:locale)
|
53
|
+
|
54
|
+
subject(:greet) { described_class.new }
|
55
|
+
|
56
|
+
let(:user) { double(:user, name: 'John') }
|
57
|
+
|
58
|
+
it 'uses the current locale to greet the user' do
|
59
|
+
examples = {
|
60
|
+
en: 'Hello John',
|
61
|
+
de: 'Hallo John',
|
62
|
+
ru: 'Привет, John',
|
63
|
+
it: 'Ciao John'
|
64
|
+
}
|
65
|
+
|
66
|
+
examples.each do |locale, expected_greeting|
|
67
|
+
with_locale(locale) do
|
68
|
+
expect(greet.(user)).to eql(expected_greeting)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
```
|
74
|
+
|
75
|
+
You can provide locale in an `around(:each)` hook:
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
require 'dry/effects'
|
79
|
+
|
80
|
+
# Build a provider object with .call interface
|
81
|
+
locale_provider = Object.new.extend(Dry::Effects::Handler.Reader(:locale, as: :call))
|
82
|
+
|
83
|
+
RSpec.configure do |config|
|
84
|
+
config.around(:each) do |ex|
|
85
|
+
locale_provider.(:en, &ex)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
```
|
89
|
+
|
90
|
+
### Nesting readers
|
91
|
+
|
92
|
+
As a general rule, if there are two handlers in the stack, the nested takes precedence:
|
93
|
+
|
94
|
+
```ruby
|
95
|
+
require 'dry/effects'
|
96
|
+
|
97
|
+
extend Dry::Effects::Handler.Reader(:locale)
|
98
|
+
extend Dry::Effects.Reader(:locale)
|
99
|
+
|
100
|
+
with_locale(:en) { with_locale(:de) { locale } } # => :de
|
101
|
+
```
|
102
|
+
|
103
|
+
### Mixing readers
|
104
|
+
|
105
|
+
Every Reader has an identifier. Handlers with different identifiers won't interfere:
|
106
|
+
|
107
|
+
```ruby
|
108
|
+
require 'dry/effects'
|
109
|
+
|
110
|
+
extend Dry::Effects::Handler.Reader(:locale)
|
111
|
+
extend Dry::Effects::Handler.Reader(:context)
|
112
|
+
extend Dry::Effects.Reader(:locale)
|
113
|
+
extend Dry::Effects.Reader(:context)
|
114
|
+
|
115
|
+
with_locale(:en) { with_context(:background) { [locale, context] } } # => [:en, :background]
|
116
|
+
# Order doesn't matter:
|
117
|
+
with_context(:background) { with_locale(:en) { [locale, context] } } # => [:en, :background]
|
118
|
+
```
|
119
|
+
|
120
|
+
### Relation to State
|
121
|
+
|
122
|
+
Reader is part of the [State](/gems/dry-effects/0.1/effects/state) effect.
|
123
|
+
|
124
|
+
### Tradeoffs of implicit passing
|
125
|
+
|
126
|
+
Passing values implicitly is not good or bad by itself; you should consider how it affects your code in every case. Providing the current locale is a good example where reader effect can be justified. On the other hand, passing optional values such as the IP-address of the current user should be done explicitly because they are not always present (consider background jobs, rake tasks, etc.).
|
@@ -0,0 +1,188 @@
|
|
1
|
+
---
|
2
|
+
title: Resolve (Dependency Injection)
|
3
|
+
layout: gem-single
|
4
|
+
name: dry-effects
|
5
|
+
---
|
6
|
+
|
7
|
+
Resolve is an effect for injecting dependencies. A simple usage example:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
require 'dry/effects'
|
11
|
+
|
12
|
+
class CreateUser
|
13
|
+
include Dry::Effects.Resolve(:user_repo)
|
14
|
+
|
15
|
+
def call(values)
|
16
|
+
name = values.values_at(:first_name, :last_name).join(' ')
|
17
|
+
user_repo.create(values.merge(name: name))
|
18
|
+
end
|
19
|
+
end
|
20
|
+
```
|
21
|
+
|
22
|
+
Providing `user_repo` in tests:
|
23
|
+
|
24
|
+
```ruby
|
25
|
+
RSpec.describe CreateUser do
|
26
|
+
# adds #provide
|
27
|
+
include Dry::Effects::Handler.Resolve
|
28
|
+
|
29
|
+
subject(:create_user) { described_class.new }
|
30
|
+
|
31
|
+
let(:user_repo) { double(:user_repo) }
|
32
|
+
|
33
|
+
it 'creates a user' do
|
34
|
+
expect(user_repo).to receive(:create).with(
|
35
|
+
first_name: 'John',
|
36
|
+
last_name: 'Doe',
|
37
|
+
name: 'John Doe'
|
38
|
+
)
|
39
|
+
|
40
|
+
provide(user_repo: user_repo) { create_user.(first_name: 'John', last_name: 'Doe') }
|
41
|
+
end
|
42
|
+
end
|
43
|
+
```
|
44
|
+
|
45
|
+
Providing dependencies with middleware:
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
class ProviderMiddleware
|
49
|
+
include Dry::Effects::Handler.Resolve
|
50
|
+
|
51
|
+
def initialize(app, dependencies)
|
52
|
+
@app = app
|
53
|
+
@dependencies = dependencies
|
54
|
+
end
|
55
|
+
|
56
|
+
def call(env)
|
57
|
+
provide(@dependencies) { @app.(env) }
|
58
|
+
end
|
59
|
+
end
|
60
|
+
```
|
61
|
+
|
62
|
+
Then in `config.ru`:
|
63
|
+
|
64
|
+
```ruby
|
65
|
+
# ...some bootstrapping code ...
|
66
|
+
|
67
|
+
use ProviderMiddleware, user_repo: UserRepo.new
|
68
|
+
run Application.new
|
69
|
+
```
|
70
|
+
|
71
|
+
### Compatibility with `dry-container` and `dry-system`
|
72
|
+
|
73
|
+
Any object that responds to `.key?` and `.[]` can be used for providing dependencies. Thus, the default Resolve provider is compatible with `dry-container` and `dry-system` out of the box.
|
74
|
+
|
75
|
+
```ruby
|
76
|
+
def call(env)
|
77
|
+
# Assuming App is a subclass of Dry::System::Container
|
78
|
+
provide(App) { @app.(env) }
|
79
|
+
end
|
80
|
+
```
|
81
|
+
|
82
|
+
### Providing static values
|
83
|
+
|
84
|
+
One can pass a container to the module builder:
|
85
|
+
|
86
|
+
```ruby
|
87
|
+
class ProviderMiddleware
|
88
|
+
include Dry::Effects::Handler.Resolve(Application)
|
89
|
+
|
90
|
+
def initialize(app)
|
91
|
+
@app = app
|
92
|
+
end
|
93
|
+
|
94
|
+
def call(env)
|
95
|
+
# Here Application will be used for resolving dependencies
|
96
|
+
provide { @app.(env) }
|
97
|
+
end
|
98
|
+
end
|
99
|
+
```
|
100
|
+
|
101
|
+
### Injecting many keys and using aliases
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
require 'dry/effects'
|
105
|
+
|
106
|
+
class CreateUser
|
107
|
+
include Dry::Effects.Resolve(
|
108
|
+
# Injected as .schema
|
109
|
+
# but resolved with 'operations.create_user.schema'
|
110
|
+
'operations.create_user.schema',
|
111
|
+
# Injected as .repo
|
112
|
+
# but resolved with 'repos.user_repo'
|
113
|
+
repo: 'repos.user_repo'
|
114
|
+
)
|
115
|
+
|
116
|
+
def call(values)
|
117
|
+
result = schema.(values)
|
118
|
+
|
119
|
+
if result.success?
|
120
|
+
user = repo.create(result.to_h)
|
121
|
+
[:ok, user]
|
122
|
+
else
|
123
|
+
[:err, result]
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
```
|
128
|
+
|
129
|
+
### Overriding dependencies in test environment
|
130
|
+
|
131
|
+
Sometimes you may want to push dependencies through an existing handler. This is normally needed for testing when you want to replace some dependencies in a test environment for an assembled app, like a Rack application. Passing `overridable: true` enables it:
|
132
|
+
|
133
|
+
```ruby
|
134
|
+
require 'dry/effects'
|
135
|
+
|
136
|
+
class ProviderMiddleware
|
137
|
+
include Dry::Effects::Handler.Resolve
|
138
|
+
|
139
|
+
def initialize(app)
|
140
|
+
@app = app
|
141
|
+
end
|
142
|
+
|
143
|
+
def call(env)
|
144
|
+
provide(Application, overridable: overridable?) { @app.(env) }
|
145
|
+
end
|
146
|
+
|
147
|
+
def overridable?
|
148
|
+
ENV['RACK_ENV'].eql?('test')
|
149
|
+
end
|
150
|
+
end
|
151
|
+
```
|
152
|
+
|
153
|
+
Now in tests, you can override some dependencies at will:
|
154
|
+
|
155
|
+
```ruby
|
156
|
+
require 'dry/effects'
|
157
|
+
require 'rack/test'
|
158
|
+
|
159
|
+
RSpec.describe do
|
160
|
+
include Rack::Test::Methods
|
161
|
+
include Dry::Effects::Handler.Provider
|
162
|
+
|
163
|
+
let(:app) do
|
164
|
+
# building an assembled rack app
|
165
|
+
end
|
166
|
+
|
167
|
+
describe 'POST /users' do
|
168
|
+
let(:user_repo) { double(:user_repo) }
|
169
|
+
|
170
|
+
it 'creates a user' do
|
171
|
+
expect(user_repo).to receive(:create).with(
|
172
|
+
first_name: 'John', last_name: 'Doe'
|
173
|
+
).and_return(1)
|
174
|
+
|
175
|
+
# Overriding one dependency
|
176
|
+
# It will only work if `overridable: true` is passed
|
177
|
+
# in the middleware
|
178
|
+
provide('repos.user_repo' => user_repo) do
|
179
|
+
post(
|
180
|
+
'/users',
|
181
|
+
JSON.dump(first_name: 'John', last_name: 'Doe'),
|
182
|
+
'CONTENT_TYPE' => 'application/json'
|
183
|
+
)
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
```
|