service_actor 1.0.0
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +298 -0
- data/lib/actor/attributable.rb +59 -0
- data/lib/actor/conditionable.rb +34 -0
- data/lib/actor/context.rb +92 -0
- data/lib/actor/defaultable.rb +25 -0
- data/lib/actor/failure.rb +16 -0
- data/lib/actor/filtered_context.rb +49 -0
- data/lib/actor/playable.rb +74 -0
- data/lib/actor/requireable.rb +36 -0
- data/lib/actor/success.rb +6 -0
- data/lib/actor/type_checkable.rb +43 -0
- data/lib/actor/version.rb +5 -0
- data/lib/service_actor.rb +88 -0
- metadata +118 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a16f2ccd8bf0d2e83d5cb9ec163c81b9df3be06515cb716138049f9d33420759
|
4
|
+
data.tar.gz: bfb6c4dca34cb64fa3c8ce84b085faa85547c1a554f241ccf49d92a7a67d012b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 18df62d093865eefd2acd8a9d3567c1379be5c097c087b6c2d2eee1dc8cf0485e5b0b6f712b11a19492eca3465b48ba0281de7b88130e170681923ec3cc2086c
|
7
|
+
data.tar.gz: 80df1b4cc8269802cfe217663fa16ed907103a1b3e4b8ee42d1d01ac08c47a571f4993439f27d1065fbbb38738132be459b042aadfd04bdfe229bb6a2a4dbc27
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2020 Sunny Ripert
|
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 all
|
13
|
+
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 THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,298 @@
|
|
1
|
+
# Actor
|
2
|
+
|
3
|
+

|
4
|
+
|
5
|
+
Ruby service objects. Lets you move your application logic into small
|
6
|
+
building blocs to keep your controllers and your models thin.
|
7
|
+
|
8
|
+
## Installation
|
9
|
+
|
10
|
+
Add these lines to your application's Gemfile:
|
11
|
+
|
12
|
+
```rb
|
13
|
+
# Service objects to keep the business logic
|
14
|
+
gem 'service_actor'
|
15
|
+
```
|
16
|
+
|
17
|
+
|
18
|
+
## Usage
|
19
|
+
|
20
|
+
Actors are single-purpose actions in your application that represent your
|
21
|
+
business logic. They start with a verb, inherit from `Actor` and implement a
|
22
|
+
`call` method.
|
23
|
+
|
24
|
+
```rb
|
25
|
+
# app/actors/send_notification.rb
|
26
|
+
class SendNotification < Actor
|
27
|
+
def call
|
28
|
+
# …
|
29
|
+
end
|
30
|
+
end
|
31
|
+
```
|
32
|
+
|
33
|
+
Use `.call` to use them in your application:
|
34
|
+
|
35
|
+
```rb
|
36
|
+
SendNotification.call
|
37
|
+
```
|
38
|
+
|
39
|
+
### Inputs
|
40
|
+
|
41
|
+
Actors can accept arguments with `input`:
|
42
|
+
|
43
|
+
```rb
|
44
|
+
class GreetUser < Actor
|
45
|
+
input :user
|
46
|
+
|
47
|
+
def call
|
48
|
+
puts "Hello #{user.name}!"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
```
|
52
|
+
|
53
|
+
And receive them as arguments to `call`:
|
54
|
+
|
55
|
+
```rb
|
56
|
+
GreetUser.call(user: User.first)
|
57
|
+
```
|
58
|
+
|
59
|
+
### Outputs
|
60
|
+
|
61
|
+
Use `output` to declare what your actor can return, then assign them to your
|
62
|
+
context.
|
63
|
+
|
64
|
+
```rb
|
65
|
+
class BuildGreeting < Actor
|
66
|
+
output :greeting
|
67
|
+
|
68
|
+
def call
|
69
|
+
context.greeting = "Have a wonderful day!"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
```
|
73
|
+
|
74
|
+
Calling an actor returns a context:
|
75
|
+
|
76
|
+
```rb
|
77
|
+
result = BuildGreeting.call
|
78
|
+
result.greeting # => "Have a wonderful day!"
|
79
|
+
```
|
80
|
+
|
81
|
+
### Defaults
|
82
|
+
|
83
|
+
Inputs can have defaults:
|
84
|
+
|
85
|
+
```rb
|
86
|
+
class PrintWelcome < Actor
|
87
|
+
input :user
|
88
|
+
input :adjective, default: "wonderful"
|
89
|
+
input :length_of_time, default: -> { ["day", "week", "month"].sample }
|
90
|
+
|
91
|
+
output :greeting
|
92
|
+
|
93
|
+
def call
|
94
|
+
context.greeting = "Hello #{name}! Have a #{adjective} #{length_of_time}!"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
```
|
98
|
+
|
99
|
+
### Types
|
100
|
+
|
101
|
+
Inputs can define a type, or an array of possible types it must match:
|
102
|
+
|
103
|
+
```rb
|
104
|
+
class UpdateUser < Actor
|
105
|
+
input :user, type: 'User'
|
106
|
+
input :age, type: %w[Integer Float]
|
107
|
+
|
108
|
+
# …
|
109
|
+
end
|
110
|
+
```
|
111
|
+
|
112
|
+
### Conditions
|
113
|
+
|
114
|
+
If types don't cut it, you can add small conditions with the name of your choice
|
115
|
+
under `must`:
|
116
|
+
|
117
|
+
```rb
|
118
|
+
class UpdateAdminUser < Actor
|
119
|
+
input :user,
|
120
|
+
must: {
|
121
|
+
be_an_admin: ->(user) { user.admin? }
|
122
|
+
}
|
123
|
+
end
|
124
|
+
```
|
125
|
+
|
126
|
+
### Result
|
127
|
+
|
128
|
+
All actors are successful by default. To stop its execution and mark is as
|
129
|
+
having failed, use `fail!`:
|
130
|
+
|
131
|
+
```rb
|
132
|
+
class UpdateUser
|
133
|
+
input :user
|
134
|
+
input :attributes
|
135
|
+
|
136
|
+
def call
|
137
|
+
user.attributes = attributes
|
138
|
+
|
139
|
+
fail!(error: "Invalid user") unless user.valid?
|
140
|
+
|
141
|
+
# …
|
142
|
+
end
|
143
|
+
end
|
144
|
+
```
|
145
|
+
|
146
|
+
You can then test for the success by calling your actor with `.result` instead
|
147
|
+
of `.call`. This will let you test for `success?` or `failure?` on the context
|
148
|
+
instead of raising an exception.
|
149
|
+
|
150
|
+
For example in a Rails controller:
|
151
|
+
|
152
|
+
```rb
|
153
|
+
# app/controllers/users_controller.rb
|
154
|
+
class UsersController < ApplicationController
|
155
|
+
def create
|
156
|
+
result = UpdateUser.result(user: user, attributes: user_attributes)
|
157
|
+
if result.success?
|
158
|
+
redirect_to result.user
|
159
|
+
else
|
160
|
+
render :new, notice: result.error
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
```
|
165
|
+
|
166
|
+
### Play
|
167
|
+
|
168
|
+
An actor can call actors in sequence by using `play`. Each actor will hand over
|
169
|
+
the context to the next actor.
|
170
|
+
|
171
|
+
```rb
|
172
|
+
class PlaceOrder < Actor
|
173
|
+
play CreateOrder,
|
174
|
+
Pay,
|
175
|
+
SendOrderConfirmation,
|
176
|
+
NotifyAdmins
|
177
|
+
end
|
178
|
+
```
|
179
|
+
|
180
|
+
### Rollback
|
181
|
+
|
182
|
+
When using `play`, if one of the actors calls `fail!`, the following actors will
|
183
|
+
not be called.
|
184
|
+
|
185
|
+
Also, any _previous_ actor that succeeded will call the `rollback` method, if
|
186
|
+
you defined one.
|
187
|
+
|
188
|
+
```rb
|
189
|
+
class CreateOrder < Actor
|
190
|
+
def call
|
191
|
+
context.order = Order.create!(…)
|
192
|
+
end
|
193
|
+
|
194
|
+
def rollback
|
195
|
+
context.order.destroy
|
196
|
+
end
|
197
|
+
end
|
198
|
+
```
|
199
|
+
|
200
|
+
### Early success
|
201
|
+
|
202
|
+
When using `play` you can use `succeed!` so that the following actors will not
|
203
|
+
be called, but still consider the actor to be successful.
|
204
|
+
|
205
|
+
### Lambdas
|
206
|
+
|
207
|
+
You can call inline actions using lambdas:
|
208
|
+
|
209
|
+
```rb
|
210
|
+
class Pay
|
211
|
+
play ->(ctx) { ctx.payment_provider = "stripe" },
|
212
|
+
CreatePayment,
|
213
|
+
->(ctx) { ctx.user_to_notify = ctx.payment.user },
|
214
|
+
SendNotification
|
215
|
+
end
|
216
|
+
```
|
217
|
+
|
218
|
+
### Before, after and around
|
219
|
+
|
220
|
+
To do actions before or after actors, use lambdas or simply override `call` and
|
221
|
+
use `super`. For example:
|
222
|
+
|
223
|
+
```rb
|
224
|
+
class Pay
|
225
|
+
# …
|
226
|
+
|
227
|
+
def call
|
228
|
+
Time.with_timezone('Paris') do
|
229
|
+
super
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
```
|
234
|
+
|
235
|
+
### Play conditions
|
236
|
+
|
237
|
+
Some actors in a play can be called conditionaly:
|
238
|
+
|
239
|
+
```rb
|
240
|
+
class PlaceOrder < Actor
|
241
|
+
play CreateOrder,
|
242
|
+
Pay
|
243
|
+
play NotifyAdmins, if: ->(ctx) { ctx.order.amount > 42 }
|
244
|
+
end
|
245
|
+
```
|
246
|
+
|
247
|
+
## Influences
|
248
|
+
|
249
|
+
This gem is heavily influenced by
|
250
|
+
[Interactor](https://github.com/collectiveidea/interactor) ♥.
|
251
|
+
However there a a few key differences which make `actor` unique:
|
252
|
+
|
253
|
+
- Defaults to raising errors on failures. Actor uses `call` and `result` instead of `call!` and `call`. This way, the default is to raise an error and failures are not hidden away.
|
254
|
+
- Does not [hide errors when an actor fails inside another actor](https://github.com/collectiveidea/interactor/issues/170).
|
255
|
+
- You can use lambdas inside organizers.
|
256
|
+
- Requires you to document the arguments with `input` and `output`.
|
257
|
+
- Type checking of inputs and outputs.
|
258
|
+
- Inputs and outputs can be required.
|
259
|
+
- Defaults for inputs.
|
260
|
+
- Conditions on inputs.
|
261
|
+
- Shorter fail syntax: `fail!` vs `context.fail!`.
|
262
|
+
- Trigger early success in organisers with `succeed!`.
|
263
|
+
- Shorter setup syntax: inherit from `< Actor` vs having to `include Interactor` or `include Interactor::Organizer`.
|
264
|
+
- Multiple organizers.
|
265
|
+
- Conditions inside organizers.
|
266
|
+
- No `before`, `after` and `around` hooks. Prefer simply overriding `call` with `super` which allows wrapping the whole method.
|
267
|
+
- [Does not rely on `OpenStruct`](https://github.com/collectiveidea/interactor/issues/183)
|
268
|
+
- Does not print warnings on Ruby 2.7.
|
269
|
+
|
270
|
+
|
271
|
+
## Development
|
272
|
+
|
273
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
274
|
+
`rake` to run the tests. You can also run `bin/console` for an interactive
|
275
|
+
prompt that will allow you to experiment.
|
276
|
+
|
277
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
278
|
+
To release a new version, update the version number in `version.rb`, and then
|
279
|
+
run `bundle exec rake release`, which will create a git tag for the version,
|
280
|
+
push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
281
|
+
|
282
|
+
## Contributing
|
283
|
+
|
284
|
+
Bug reports and pull requests are welcome on GitHub at
|
285
|
+
https://github.com/sunny/actor. This project is intended to be a safe,
|
286
|
+
welcoming space for collaboration, and contributors are expected to adhere to
|
287
|
+
the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
288
|
+
|
289
|
+
## License
|
290
|
+
|
291
|
+
The gem is available as open source under the terms of the
|
292
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
293
|
+
|
294
|
+
## Code of Conduct
|
295
|
+
|
296
|
+
Everyone interacting in the Test project’s codebases, issue trackers, chat
|
297
|
+
rooms and mailing lists is expected to follow the
|
298
|
+
[code of conduct](https://github.com/sunny/actor/blob/master/CODE_OF_CONDUCT.md).
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Actor
|
4
|
+
# DSL to document the accepted attributes.
|
5
|
+
#
|
6
|
+
# class CreateUser < Actor
|
7
|
+
# input :name
|
8
|
+
# output :name
|
9
|
+
# end
|
10
|
+
module Attributable
|
11
|
+
def self.included(base)
|
12
|
+
base.extend(ClassMethods)
|
13
|
+
base.prepend(PrependedMethods)
|
14
|
+
end
|
15
|
+
|
16
|
+
module ClassMethods
|
17
|
+
def inherited(child)
|
18
|
+
super
|
19
|
+
|
20
|
+
child.inputs.merge!(inputs)
|
21
|
+
child.outputs.merge!(outputs)
|
22
|
+
end
|
23
|
+
|
24
|
+
def input(name, **arguments)
|
25
|
+
inputs[name] = arguments
|
26
|
+
|
27
|
+
define_method(name) do
|
28
|
+
context.public_send(name)
|
29
|
+
end
|
30
|
+
|
31
|
+
private name
|
32
|
+
end
|
33
|
+
|
34
|
+
def inputs
|
35
|
+
@inputs ||= {}
|
36
|
+
end
|
37
|
+
|
38
|
+
def output(name, **arguments)
|
39
|
+
outputs[name] = arguments
|
40
|
+
end
|
41
|
+
|
42
|
+
def outputs
|
43
|
+
@outputs ||= { error: { type: 'String' } }
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
module PrependedMethods
|
48
|
+
# rubocop:disable Naming/MemoizedInstanceVariableName
|
49
|
+
def context
|
50
|
+
@filtered_context ||= Actor::FilteredContext.new(
|
51
|
+
super,
|
52
|
+
readers: self.class.inputs.keys,
|
53
|
+
setters: self.class.outputs.keys,
|
54
|
+
)
|
55
|
+
end
|
56
|
+
# rubocop:enable Naming/MemoizedInstanceVariableName
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Actor
|
4
|
+
# Add boolean checks to inputs, by calling lambdas starting with `must*`.
|
5
|
+
#
|
6
|
+
# Example:
|
7
|
+
#
|
8
|
+
# class Pay < Actor
|
9
|
+
# input :provider,
|
10
|
+
# must: {
|
11
|
+
# exist: ->(provider) { PROVIDERS.include?(provider) }
|
12
|
+
# }
|
13
|
+
#
|
14
|
+
# output :user, required: true
|
15
|
+
# end
|
16
|
+
module Conditionable
|
17
|
+
def before
|
18
|
+
super
|
19
|
+
|
20
|
+
self.class.inputs.each do |key, options|
|
21
|
+
next unless options[:must]
|
22
|
+
|
23
|
+
options[:must].each do |name, check|
|
24
|
+
value = @context[key]
|
25
|
+
next if check.call(value)
|
26
|
+
|
27
|
+
name = name.to_s.sub(/^must_/, '')
|
28
|
+
raise ArgumentError,
|
29
|
+
"Input #{key} must #{name} but was #{value.inspect}."
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Actor
|
4
|
+
# Represents the result of an action.
|
5
|
+
class Context
|
6
|
+
def self.to_context(data)
|
7
|
+
return data if data.is_a?(self)
|
8
|
+
|
9
|
+
new(data)
|
10
|
+
end
|
11
|
+
|
12
|
+
def initialize(data = {})
|
13
|
+
@data = data.dup
|
14
|
+
end
|
15
|
+
|
16
|
+
def ==(other)
|
17
|
+
other.class == self.class && data == other.data
|
18
|
+
end
|
19
|
+
|
20
|
+
def inspect
|
21
|
+
"<ActorContext #{data.inspect}>"
|
22
|
+
end
|
23
|
+
|
24
|
+
def fail!(context = {})
|
25
|
+
merge!(context)
|
26
|
+
data[:failure?] = true
|
27
|
+
raise Actor::Failure, self
|
28
|
+
end
|
29
|
+
|
30
|
+
def succeed!(context = {})
|
31
|
+
merge!(context)
|
32
|
+
data[:failure?] = false
|
33
|
+
raise Actor::Success, self
|
34
|
+
end
|
35
|
+
|
36
|
+
def success?
|
37
|
+
!failure?
|
38
|
+
end
|
39
|
+
|
40
|
+
def failure?
|
41
|
+
data.fetch(:failure?, false)
|
42
|
+
end
|
43
|
+
|
44
|
+
def merge!(context)
|
45
|
+
data.merge!(context)
|
46
|
+
|
47
|
+
self
|
48
|
+
end
|
49
|
+
|
50
|
+
def key?(name)
|
51
|
+
data.key?(name)
|
52
|
+
end
|
53
|
+
|
54
|
+
def [](name)
|
55
|
+
data[name]
|
56
|
+
end
|
57
|
+
|
58
|
+
# Redefined here to override the method on `Object`.
|
59
|
+
def display
|
60
|
+
data.fetch(:display)
|
61
|
+
end
|
62
|
+
|
63
|
+
protected
|
64
|
+
|
65
|
+
attr_reader :data
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
# rubocop:disable Style/MethodMissingSuper
|
70
|
+
def method_missing(name, *arguments, **)
|
71
|
+
if name =~ /=$/
|
72
|
+
key = name.to_s.sub('=', '').to_sym
|
73
|
+
data[key] = arguments.first
|
74
|
+
else
|
75
|
+
data[name]
|
76
|
+
end
|
77
|
+
end
|
78
|
+
# rubocop:enable Style/MethodMissingSuper
|
79
|
+
|
80
|
+
def respond_to_missing?(*_arguments)
|
81
|
+
true
|
82
|
+
end
|
83
|
+
|
84
|
+
def context_get(key)
|
85
|
+
data[key]
|
86
|
+
end
|
87
|
+
|
88
|
+
def context_set(key, value)
|
89
|
+
data[key] = value
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Actor
|
4
|
+
# Adds the `default:` option to inputs. Accepts regular values and lambdas.
|
5
|
+
#
|
6
|
+
# Example:
|
7
|
+
#
|
8
|
+
# class MultiplyThing < Actor
|
9
|
+
# input :counter, default: 1
|
10
|
+
# input :multiplier, default: -> { rand(1..10) }
|
11
|
+
# end
|
12
|
+
module Defaultable
|
13
|
+
def before
|
14
|
+
self.class.inputs.each do |name, input|
|
15
|
+
next if !input.key?(:default) || @context.key?(name)
|
16
|
+
|
17
|
+
default = input[:default]
|
18
|
+
default = default.call if default.respond_to?(:call)
|
19
|
+
@context.merge!(name => default)
|
20
|
+
end
|
21
|
+
|
22
|
+
super
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Actor
|
4
|
+
# Error raised when using `fail!` inside an actor.
|
5
|
+
class Failure < StandardError
|
6
|
+
def initialize(context)
|
7
|
+
@context = context
|
8
|
+
|
9
|
+
error = context.respond_to?(:error) ? context.error : nil
|
10
|
+
|
11
|
+
super(error)
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_reader :context
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Actor
|
4
|
+
# Represents the result of an action, tied to inputs and outputs.
|
5
|
+
class FilteredContext
|
6
|
+
def initialize(context, readers:, setters:)
|
7
|
+
@context = context
|
8
|
+
@readers = readers
|
9
|
+
@setters = setters
|
10
|
+
end
|
11
|
+
|
12
|
+
def inspect
|
13
|
+
"<#{self.class.name} #{context.inspect} " \
|
14
|
+
"readers: #{readers.inspect} " \
|
15
|
+
"setters: #{setters.inspect}>"
|
16
|
+
end
|
17
|
+
|
18
|
+
def fail!(**arguments)
|
19
|
+
context.fail!(**arguments)
|
20
|
+
end
|
21
|
+
|
22
|
+
def succeed!(**arguments)
|
23
|
+
context.fail!(**arguments)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
attr_reader :context, :readers, :setters
|
29
|
+
|
30
|
+
def method_missing(name, *arguments, **options)
|
31
|
+
return super unless context.respond_to?(name)
|
32
|
+
|
33
|
+
unless available_methods.include?(name)
|
34
|
+
raise ArgumentError, "Cannot call #{name} on #{inspect}"
|
35
|
+
end
|
36
|
+
|
37
|
+
context.public_send(name, *arguments)
|
38
|
+
end
|
39
|
+
|
40
|
+
def respond_to_missing?(name, *_arguments)
|
41
|
+
available_methods.include?(name)
|
42
|
+
end
|
43
|
+
|
44
|
+
def available_methods
|
45
|
+
@available_methods ||=
|
46
|
+
readers + setters.map { |key| "#{key}=".to_sym }
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Actor
|
4
|
+
# DSL to call a series of actors with the same context. On failure, calls
|
5
|
+
# rollback on any actor that succeeded.
|
6
|
+
#
|
7
|
+
# class CreateUser < Actor
|
8
|
+
# play SaveUser,
|
9
|
+
# CreateSettings,
|
10
|
+
# SendWelcomeEmail
|
11
|
+
# end
|
12
|
+
module Playable
|
13
|
+
def self.included(base)
|
14
|
+
base.extend(ClassMethods)
|
15
|
+
base.prepend(PrependedMethods)
|
16
|
+
end
|
17
|
+
|
18
|
+
module ClassMethods
|
19
|
+
def inherited(child)
|
20
|
+
super
|
21
|
+
|
22
|
+
play_actors.each do |actor|
|
23
|
+
child.play_actors << actor
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def play(*actors, **options)
|
28
|
+
actors.each do |actor|
|
29
|
+
play_actors.push({ actor: actor, **options })
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def play_actors
|
34
|
+
@play_actors ||= []
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
module PrependedMethods
|
39
|
+
def call
|
40
|
+
self.class.play_actors.each do |options|
|
41
|
+
next if options[:if] && !options[:if].call(@context)
|
42
|
+
|
43
|
+
play_actor(options[:actor])
|
44
|
+
end
|
45
|
+
rescue Actor::Failure
|
46
|
+
rollback
|
47
|
+
raise
|
48
|
+
end
|
49
|
+
|
50
|
+
def rollback
|
51
|
+
return unless @played
|
52
|
+
|
53
|
+
@played.each do |actor|
|
54
|
+
next unless actor.respond_to?(:rollback)
|
55
|
+
|
56
|
+
actor.rollback
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def play_actor(actor)
|
63
|
+
if actor.respond_to?(:new)
|
64
|
+
actor = actor.new(@context)
|
65
|
+
actor.run
|
66
|
+
else
|
67
|
+
actor.call(@context)
|
68
|
+
end
|
69
|
+
|
70
|
+
(@played ||= []).unshift(actor)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Actor
|
4
|
+
# Adds `required:` checking to inputs and outputs.
|
5
|
+
#
|
6
|
+
# Example:
|
7
|
+
#
|
8
|
+
# class CreateUser < Actor
|
9
|
+
# input :name, required: true
|
10
|
+
# output :user, required: true
|
11
|
+
# end
|
12
|
+
module Requireable
|
13
|
+
def before
|
14
|
+
super
|
15
|
+
|
16
|
+
check_required_definitions(self.class.inputs, kind: 'Input')
|
17
|
+
end
|
18
|
+
|
19
|
+
def after
|
20
|
+
super
|
21
|
+
|
22
|
+
check_required_definitions(self.class.outputs, kind: 'Output')
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def check_required_definitions(definitions, kind:)
|
28
|
+
definitions.each do |key, options|
|
29
|
+
next unless options[:required] && @context[key].nil?
|
30
|
+
|
31
|
+
raise ArgumentError,
|
32
|
+
"#{kind} #{key} on #{self.class} is required but was nil."
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Actor
|
4
|
+
# Adds `type:` checking to inputs and outputs. Accepts strings that should
|
5
|
+
# match an ancestor. Also accepts arrays.
|
6
|
+
#
|
7
|
+
# Example:
|
8
|
+
#
|
9
|
+
# class ReduceOrderAmount < Actor
|
10
|
+
# input :order, type: 'Order'
|
11
|
+
# input :amount, type: %w[Integer Float]
|
12
|
+
# input :bonus_applied, type: %w[TrueClass FalseClass]
|
13
|
+
# end
|
14
|
+
module TypeCheckable
|
15
|
+
def before
|
16
|
+
super
|
17
|
+
|
18
|
+
check_type_definitions(self.class.inputs, kind: 'Input')
|
19
|
+
end
|
20
|
+
|
21
|
+
def after
|
22
|
+
super
|
23
|
+
|
24
|
+
check_type_definitions(self.class.outputs, kind: 'Output')
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def check_type_definitions(definitions, kind:)
|
30
|
+
definitions.each do |key, options|
|
31
|
+
type_definition = options[:type] || next
|
32
|
+
value = @context[key] || next
|
33
|
+
|
34
|
+
types = Array(type_definition).map { |name| Object.const_get(name) }
|
35
|
+
next if types.any? { |type| value.is_a?(type) }
|
36
|
+
|
37
|
+
error = "#{kind} #{key} on #{self.class} must be of type " \
|
38
|
+
"#{types.join(', ')} but was #{value.class}"
|
39
|
+
raise ArgumentError, error
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'actor/failure'
|
4
|
+
require 'actor/success'
|
5
|
+
require 'actor/context'
|
6
|
+
require 'actor/filtered_context'
|
7
|
+
|
8
|
+
require 'actor/playable'
|
9
|
+
require 'actor/attributable'
|
10
|
+
require 'actor/defaultable'
|
11
|
+
require 'actor/type_checkable'
|
12
|
+
require 'actor/requireable'
|
13
|
+
require 'actor/conditionable'
|
14
|
+
|
15
|
+
# Actors should start with a verb, inherit from Actor and implement a `call`
|
16
|
+
# method.
|
17
|
+
class Actor
|
18
|
+
include Attributable
|
19
|
+
include Playable
|
20
|
+
prepend Defaultable
|
21
|
+
prepend TypeCheckable
|
22
|
+
prepend Requireable
|
23
|
+
prepend Conditionable
|
24
|
+
|
25
|
+
class << self
|
26
|
+
# Call an actor with a given context. Returns the context.
|
27
|
+
#
|
28
|
+
# CreateUser.call(name: 'Joe')
|
29
|
+
def call(context = {}, **arguments)
|
30
|
+
context = Actor::Context.to_context(context).merge!(arguments)
|
31
|
+
new(context).run
|
32
|
+
context
|
33
|
+
rescue Actor::Success
|
34
|
+
context
|
35
|
+
end
|
36
|
+
|
37
|
+
alias call! call
|
38
|
+
|
39
|
+
# Call an actor with a given context. Returns the context and does not raise
|
40
|
+
# on failure.
|
41
|
+
#
|
42
|
+
# CreateUser.result(name: 'Joe')
|
43
|
+
def result(context = {}, **arguments)
|
44
|
+
call(context, **arguments)
|
45
|
+
rescue Actor::Failure => e
|
46
|
+
e.context
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# :nodoc:
|
51
|
+
def initialize(context)
|
52
|
+
@context = context
|
53
|
+
end
|
54
|
+
|
55
|
+
# To implement in your actors.
|
56
|
+
def call; end
|
57
|
+
|
58
|
+
# To implement in your actors.
|
59
|
+
def rollback; end
|
60
|
+
|
61
|
+
# :nodoc:
|
62
|
+
def before; end
|
63
|
+
|
64
|
+
# :nodoc:
|
65
|
+
def after; end
|
66
|
+
|
67
|
+
# :nodoc:
|
68
|
+
def run
|
69
|
+
before
|
70
|
+
call
|
71
|
+
after
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
# Returns the current context from inside an actor.
|
77
|
+
attr_reader :context
|
78
|
+
|
79
|
+
# Can be called from inside an actor to stop execution and mark as failed.
|
80
|
+
def fail!(**arguments)
|
81
|
+
@context.fail!(**arguments)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Can be called from inside an actor to stop execution early.
|
85
|
+
def succeed!(**arguments)
|
86
|
+
@context.succeed!(**arguments)
|
87
|
+
end
|
88
|
+
end
|
metadata
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: service_actor
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Sunny Ripert
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-03-15 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rspec
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: pry
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rubocop
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
description: Service objects for your application logic
|
70
|
+
email:
|
71
|
+
- sunny@sunfox.org
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files:
|
75
|
+
- LICENSE.txt
|
76
|
+
- README.md
|
77
|
+
files:
|
78
|
+
- LICENSE.txt
|
79
|
+
- README.md
|
80
|
+
- lib/actor/attributable.rb
|
81
|
+
- lib/actor/conditionable.rb
|
82
|
+
- lib/actor/context.rb
|
83
|
+
- lib/actor/defaultable.rb
|
84
|
+
- lib/actor/failure.rb
|
85
|
+
- lib/actor/filtered_context.rb
|
86
|
+
- lib/actor/playable.rb
|
87
|
+
- lib/actor/requireable.rb
|
88
|
+
- lib/actor/success.rb
|
89
|
+
- lib/actor/type_checkable.rb
|
90
|
+
- lib/actor/version.rb
|
91
|
+
- lib/service_actor.rb
|
92
|
+
homepage: https://github.com/sunny/actor
|
93
|
+
licenses:
|
94
|
+
- MIT
|
95
|
+
metadata:
|
96
|
+
homepage_uri: https://github.com/sunny/actor
|
97
|
+
source_code_uri: https://github.com/sunny/actor
|
98
|
+
changelog_uri: https://github.com/sunny/actor/blob/master/CHANGELOG.md
|
99
|
+
post_install_message:
|
100
|
+
rdoc_options: []
|
101
|
+
require_paths:
|
102
|
+
- lib
|
103
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
104
|
+
requirements:
|
105
|
+
- - ">="
|
106
|
+
- !ruby/object:Gem::Version
|
107
|
+
version: '0'
|
108
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
109
|
+
requirements:
|
110
|
+
- - ">="
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: '0'
|
113
|
+
requirements: []
|
114
|
+
rubygems_version: 3.1.2
|
115
|
+
signing_key:
|
116
|
+
specification_version: 4
|
117
|
+
summary: Service objects for your application logic
|
118
|
+
test_files: []
|