dry-effects 0.1.0 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
```
|