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,186 @@
|
|
1
|
+
---
|
2
|
+
title: Current Time
|
3
|
+
layout: gem-single
|
4
|
+
name: dry-effects
|
5
|
+
---
|
6
|
+
|
7
|
+
Obtaining the current time with `Time.now` is a classic example of a side effect. Code relying on accessing system time is harder to test. One possible solution is passing time around explicitly, but using effects can save you some typing depending on the case.
|
8
|
+
|
9
|
+
Providing and obtaining the current time is straightforward:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
require 'dry/effects'
|
13
|
+
|
14
|
+
class CurrentTimeMiddleware
|
15
|
+
include Dry::Effects::Handler.CurrentTime
|
16
|
+
|
17
|
+
def initialize(app)
|
18
|
+
@app = app
|
19
|
+
end
|
20
|
+
|
21
|
+
def call(env)
|
22
|
+
# It will use Time.now internally once and set it fixed
|
23
|
+
with_current_time do
|
24
|
+
@app.(env)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
###
|
30
|
+
|
31
|
+
class CreateSubscription
|
32
|
+
include Dry::Efefcts.Resolve(:subscription_repo)
|
33
|
+
include Dry::Effects.CurrentTime
|
34
|
+
|
35
|
+
def call(values)
|
36
|
+
subscription_repo.create(
|
37
|
+
values.merge(start_at: current_time)
|
38
|
+
)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
```
|
42
|
+
|
43
|
+
### Providing time in tests
|
44
|
+
|
45
|
+
A typical usage would be:
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
require 'dry/effects'
|
49
|
+
|
50
|
+
RSpec.configure do |config|
|
51
|
+
config.include Dry::Effects::Handler.CurrentTime
|
52
|
+
config.include Dry::Effects.CurrentTime
|
53
|
+
|
54
|
+
config.around { |ex| with_current_time(&ex) }
|
55
|
+
end
|
56
|
+
```
|
57
|
+
|
58
|
+
Then anywhere in tests, you can use it:
|
59
|
+
|
60
|
+
```ruby
|
61
|
+
it 'uses current time as a start' do
|
62
|
+
subscription = create_subscription(...)
|
63
|
+
expect(subscription.start_at).to eql(current_time)
|
64
|
+
end
|
65
|
+
```
|
66
|
+
|
67
|
+
To change the time, call `with_current_time` with a proc:
|
68
|
+
|
69
|
+
```ruby
|
70
|
+
it 'closes a subscription with current time' do
|
71
|
+
future = current_time + 86_400
|
72
|
+
closed_subscription = with_current_time(proc { future }) { close_subscription(subscription) }
|
73
|
+
expect(closed_subscription.closed_at).to eql(future)
|
74
|
+
end
|
75
|
+
```
|
76
|
+
|
77
|
+
Wrapping time with a proc is required, read about generators below.
|
78
|
+
|
79
|
+
### Time rounding
|
80
|
+
|
81
|
+
`current_time` accepts an argument for rounding time values. It can be passed statically to the module builder or dynamically to the effect constructor:
|
82
|
+
|
83
|
+
```ruby
|
84
|
+
class CreateSubscription
|
85
|
+
include Dry::Effects.CurrentTime(round: 3)
|
86
|
+
|
87
|
+
def call(...)
|
88
|
+
# value will be rounded to milliseconds
|
89
|
+
current_time
|
90
|
+
# value will be rounded to microseconds
|
91
|
+
current_time(round: 6)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
```
|
95
|
+
|
96
|
+
### Time is fixed
|
97
|
+
|
98
|
+
By default, calling `with_current_time` even without arguments will freeze the current time. This means `current_time` will return the same value during request processing etc.
|
99
|
+
|
100
|
+
You can "unfix" time with passing `fixed: false` to the handler builder:
|
101
|
+
|
102
|
+
```ruby
|
103
|
+
include Dry::Effects::Handler.CurrentTime(fixed: false)
|
104
|
+
```
|
105
|
+
|
106
|
+
However, this is not recommended because it will make the behavior of `current_time` different in tests (where you pass a fixed value) and in a production environment.
|
107
|
+
|
108
|
+
### Using a custom generator
|
109
|
+
|
110
|
+
The default time provider accepts a custom generator which is a simple callable object. This way you can pass a proc with fixed time:
|
111
|
+
|
112
|
+
```ruby
|
113
|
+
frozen = Time.now
|
114
|
+
with_fixed_time(proc { frozen }) do
|
115
|
+
# ...
|
116
|
+
end
|
117
|
+
```
|
118
|
+
|
119
|
+
Or you can change time on every call:
|
120
|
+
|
121
|
+
```ruby
|
122
|
+
start = Time.now
|
123
|
+
with_fixed_time(proc { start += 0.1 }) do
|
124
|
+
# ...
|
125
|
+
end
|
126
|
+
```
|
127
|
+
|
128
|
+
### Discrete time shifts
|
129
|
+
|
130
|
+
If you pass `step: x` to the handler, it will shift the current time on every access by `x`:
|
131
|
+
|
132
|
+
```ruby
|
133
|
+
with_fixed_time(step: 0.1) do
|
134
|
+
current_time # => ... 18:00:00.000
|
135
|
+
current_time # => ... 18:00:00.100
|
136
|
+
current_time # => ... 18:00:00.200
|
137
|
+
end
|
138
|
+
```
|
139
|
+
|
140
|
+
You can also pass initial time:
|
141
|
+
|
142
|
+
```ruby
|
143
|
+
initial = Time.new(1970)
|
144
|
+
with_fixed_time(initial: initial, step: 60) do
|
145
|
+
current_time # => 1970-01-01 00:00:00 +0000
|
146
|
+
current_time # => 1970-01-01 00:01:00 +0000
|
147
|
+
current_time # => 1970-01-01 00:02:00 +0000
|
148
|
+
end
|
149
|
+
```
|
150
|
+
|
151
|
+
### Overriding handlers
|
152
|
+
|
153
|
+
Handlers of current time can be overridden by an outer handler if you pass `overridable: true`:
|
154
|
+
|
155
|
+
```ruby
|
156
|
+
require 'dry/effects'
|
157
|
+
|
158
|
+
class CurrentTimeMiddleware
|
159
|
+
include Dry::Effects::Handler.CurrentTime
|
160
|
+
|
161
|
+
def initialize(app)
|
162
|
+
@app = app
|
163
|
+
end
|
164
|
+
|
165
|
+
def call(env)
|
166
|
+
with_current_time(overridable: ENV['RACK_ENV'].eql?('test')) do
|
167
|
+
@app.(env)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
```
|
172
|
+
|
173
|
+
It's usually done in tests:
|
174
|
+
|
175
|
+
```ruby
|
176
|
+
# Using global time
|
177
|
+
frozen_time = Time.now
|
178
|
+
|
179
|
+
puts "Running with time #{frozen_time.iso8601}" if ENV['CI']
|
180
|
+
|
181
|
+
RSpec.configure do |config|
|
182
|
+
config.include Dry::Effects::Handler.CurrentTime
|
183
|
+
config.include(Module.new { define_method(:current_time) { frozen_time } })
|
184
|
+
config.around { |ex| with_current_time(proc { frozen_time }, &ex) }
|
185
|
+
end
|
186
|
+
```
|
@@ -0,0 +1,130 @@
|
|
1
|
+
---
|
2
|
+
title: Deferred execution
|
3
|
+
layout: gem-single
|
4
|
+
name: dry-effects
|
5
|
+
---
|
6
|
+
|
7
|
+
`Defer` adds three methods for working with deferred code execution:
|
8
|
+
|
9
|
+
- `defer` accepts a block and executes it (potentially) on a thread pool. It returns an object that can be awaited with `wait`. These objects are `Promise`s made by `concurrent-ruby`. You can use their API, but it's not fully supported and tested in conjunction with effects.
|
10
|
+
- `wait` accepts a promise or an array of promises returned by `defer` and returns their values. The method blocks the current thread until all values are available.
|
11
|
+
- `later` postpones block execution until the handler is finished (see examples below).
|
12
|
+
|
13
|
+
### Defer
|
14
|
+
|
15
|
+
A simple example:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
class CreateUser
|
19
|
+
include Dry::Effects.Resolve(:user_repo, :send_invitation)
|
20
|
+
include Dry::Effects.Defer
|
21
|
+
|
22
|
+
def call(values)
|
23
|
+
user = user_repo.create(values)
|
24
|
+
defer { send_invitation.(user) }
|
25
|
+
user
|
26
|
+
end
|
27
|
+
end
|
28
|
+
```
|
29
|
+
|
30
|
+
In the code above, `send_invitation` is run on a thread pool. It's the simplest way to run code concurrently.
|
31
|
+
|
32
|
+
Code within the `defer` block can use some effects but not all of them. For instance, `Interrupt` is not supported because you cannot return from one thread to another. This is a limitation of Ruby and threads in general.
|
33
|
+
|
34
|
+
### Handling
|
35
|
+
|
36
|
+
The default handler uses `concurrent-ruby` to do the heavy lifting. As an option, it accepts the executor—usually a thread pool where the code will be run.
|
37
|
+
|
38
|
+
> Three special values are also supported: `:io` returns the global pool for long, blocking (IO) tasks, `:fast` returns the global pool for short, fast operations, and `:immediate` returns the global ImmediateExecutor object.
|
39
|
+
|
40
|
+
By default, `Dry::Effects::Handler.Defer` uses `:io`.
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
class HandleDefer
|
44
|
+
include Dry::Effects::Handler.Defer(executor: :immediate)
|
45
|
+
|
46
|
+
def initialize(app)
|
47
|
+
@app = app
|
48
|
+
end
|
49
|
+
|
50
|
+
def call(env)
|
51
|
+
# defer tasks in @app will be run on the same thread
|
52
|
+
with_defer { @app.(env) }
|
53
|
+
end
|
54
|
+
end
|
55
|
+
```
|
56
|
+
|
57
|
+
The executor can be passed directly to `with_defer`:
|
58
|
+
|
59
|
+
```ruby
|
60
|
+
def call(env)
|
61
|
+
with_defer(executor: :fast) { @app.(env) }
|
62
|
+
end
|
63
|
+
```
|
64
|
+
|
65
|
+
### Using null executor
|
66
|
+
|
67
|
+
For skipping deferred tasks, create a mocked executor
|
68
|
+
|
69
|
+
```ruby
|
70
|
+
require 'concurrent/executor/executor_service'
|
71
|
+
|
72
|
+
NullExecutor = Object.new.extend(Concurrent::ExecutorService).tap do |null|
|
73
|
+
def null.post
|
74
|
+
end
|
75
|
+
end
|
76
|
+
```
|
77
|
+
|
78
|
+
and provide it in middleware
|
79
|
+
|
80
|
+
```ruby
|
81
|
+
class HandleDefer
|
82
|
+
include Dry::Effects::Handler.Defer
|
83
|
+
include Dry::Effects::Handler.Env(:environment)
|
84
|
+
|
85
|
+
def initialize(app)
|
86
|
+
@app = app
|
87
|
+
end
|
88
|
+
|
89
|
+
def call(env)
|
90
|
+
with_defer(executor: executor) { @app.(env) }
|
91
|
+
end
|
92
|
+
|
93
|
+
def executor
|
94
|
+
environment.equal?(:test) ? NullExecutor : :io
|
95
|
+
end
|
96
|
+
end
|
97
|
+
```
|
98
|
+
|
99
|
+
### Later
|
100
|
+
|
101
|
+
`later` doesn't return a result that can be awaited. Instead, it starts deferred blocks on handler exit. Consider this example:
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
class CreateUser
|
105
|
+
def call(values)
|
106
|
+
user_repo.transaction do
|
107
|
+
user = user_repo.create(values)
|
108
|
+
defer { send_invitation.(user) }
|
109
|
+
user_account = account_repo.create(user)
|
110
|
+
user
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
```
|
115
|
+
|
116
|
+
There is no guarantee `send_invitation` will be run _after_ the transaction finishes. It may lead to race conditions or anomalies. If `account_repo.create` fails with an exception, the transaction will be rolled back yet the invitation will be sent!
|
117
|
+
|
118
|
+
`later` captures the block but doesn't run it:
|
119
|
+
|
120
|
+
```ruby
|
121
|
+
later { send_invitation.(user) }
|
122
|
+
```
|
123
|
+
|
124
|
+
The invitaition will be sent when `with_defer` exits:
|
125
|
+
|
126
|
+
```ruby
|
127
|
+
with_defer { @app.(env) }
|
128
|
+
```
|
129
|
+
|
130
|
+
It usually happens outside of any transaction so that anomalies don't occur.
|
@@ -0,0 +1,144 @@
|
|
1
|
+
---
|
2
|
+
title: Environment
|
3
|
+
layout: gem-single
|
4
|
+
name: dry-effects
|
5
|
+
---
|
6
|
+
|
7
|
+
Configuring code via `ENV` can be handy, but testing it by mutating a global constant is usually not. Env is similar to Reader but exists for passing simple key-value pairs, precisely what `ENV` does.
|
8
|
+
|
9
|
+
### Providing environment
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
require 'dry/effects'
|
13
|
+
|
14
|
+
class EnvironmentMiddleware
|
15
|
+
include Dry::Effects::Handler.Env(environment: ENV['RACK_ENV'])
|
16
|
+
|
17
|
+
def initialize(app)
|
18
|
+
@app = app
|
19
|
+
end
|
20
|
+
|
21
|
+
def call(env)
|
22
|
+
with_env { @app.(env) }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
```
|
26
|
+
|
27
|
+
### Using environment
|
28
|
+
|
29
|
+
By default, `Effects.Env` creates accessor to keys with the same name:
|
30
|
+
|
31
|
+
```ruby
|
32
|
+
class CreateUser
|
33
|
+
include Dry::Effects.Env(:environment)
|
34
|
+
|
35
|
+
def call(...)
|
36
|
+
#
|
37
|
+
log unless environemnt.eql?('test')
|
38
|
+
end
|
39
|
+
end
|
40
|
+
```
|
41
|
+
|
42
|
+
But you can pass a hash and use arbitrary method names:
|
43
|
+
|
44
|
+
```ruby
|
45
|
+
class CreateUser
|
46
|
+
include Dry::Effects.Env(env: :environment)
|
47
|
+
|
48
|
+
def call(...)
|
49
|
+
#
|
50
|
+
log unless env.eql?('test')
|
51
|
+
end
|
52
|
+
end
|
53
|
+
```
|
54
|
+
|
55
|
+
### Interaction with `ENV`
|
56
|
+
|
57
|
+
The Env handler will search for keys in `ENV` as a fallback:
|
58
|
+
|
59
|
+
```ruby
|
60
|
+
class SendRequest
|
61
|
+
include Dry::Effects.Env(endpoint: 'THIRD_PARTY')
|
62
|
+
|
63
|
+
def call(...)
|
64
|
+
# some code using `endpoint`
|
65
|
+
end
|
66
|
+
end
|
67
|
+
```
|
68
|
+
|
69
|
+
In a production environment, it would be enough to pass `THIRD_PARTY` an environment variable and call `with_env` at the top of the application:
|
70
|
+
|
71
|
+
```ruby
|
72
|
+
require 'dry/effects'
|
73
|
+
|
74
|
+
class SidekiqEnvMiddleware
|
75
|
+
include Dry::Effects::Handler.Env
|
76
|
+
|
77
|
+
def call(_worker, _job, _queue, &block)
|
78
|
+
with_env(&block)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
Sidekiq.configure_server do |config|
|
83
|
+
config.server_middleware do |chain|
|
84
|
+
chain.add SidekiqEnvMiddleware
|
85
|
+
end
|
86
|
+
end
|
87
|
+
```
|
88
|
+
|
89
|
+
In tests, you can pass `THIRD_PARTY` without modifying `ENV`:
|
90
|
+
|
91
|
+
```ruby
|
92
|
+
RSpec.describe SendRequest do
|
93
|
+
include Dry::Effects::Handler.Env
|
94
|
+
|
95
|
+
subject(:send_request) { described_class.new }
|
96
|
+
|
97
|
+
let(:endpoint) { "fake server" }
|
98
|
+
|
99
|
+
around { with_env('THIRD_PARTY' => endpoint, &ex) }
|
100
|
+
|
101
|
+
it 'sends a request to a fake server' do
|
102
|
+
send_request.(...)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
```
|
106
|
+
|
107
|
+
### Overriding handlers
|
108
|
+
|
109
|
+
By passing `overridable: true` you can override values provided by the nested handler:
|
110
|
+
|
111
|
+
```ruby
|
112
|
+
class Application
|
113
|
+
include Dry::Effects.Env(:foo)
|
114
|
+
|
115
|
+
def call
|
116
|
+
puts foo
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
class ProvidingContext
|
121
|
+
include Dry::Effects::Handler.Env
|
122
|
+
|
123
|
+
def call
|
124
|
+
with_env({ foo: 100 }, overridable: true) { yield }
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
class OverridingContext
|
129
|
+
include Dry::Effects::Handler.Env
|
130
|
+
|
131
|
+
def call
|
132
|
+
with_env(foo: 200) { yield }
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
overriding = OverridingContext.new
|
137
|
+
providing = ProvidingContext.new
|
138
|
+
app = Application.new
|
139
|
+
|
140
|
+
overriding.() { providing.() { app.() } }
|
141
|
+
# prints 200, coming from overriding context
|
142
|
+
```
|
143
|
+
|
144
|
+
Again, this is useful for testing, you pass `overridable: true` in the test environment and override environment values in specs.
|