service_actor 3.6.1 → 3.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0597123f9fd025cb0fdd1d099c9a4c29865a086a107d67f7b336b3828e6743c5'
4
- data.tar.gz: fa2e8c72f82970ff779f1c870fa4074630b7e4d2d947f10f57e8a0670a9bae07
3
+ metadata.gz: a852443e88e3b6957cbaf093330d08691ba8e862826ac3b745fa5510d1b0273c
4
+ data.tar.gz: 21a7648a3e3302600b1946285a76b0e80c70ba1550776dfd07057718397d5f66
5
5
  SHA512:
6
- metadata.gz: 73dea56535940a843c9cf9bee977882293a0dd2ad4b6c0d3069fe28ea4b7d2569f3c3508cc68f8d378ebb526b00027b72a232373049bcf541e91861fe558d589
7
- data.tar.gz: 282acf3196702a03f15e8faba840abdf68075abcc3e132dce6efcb090c5349b426f8261bd13fafc68f4e4fbac2a25a1401da8ad9b2638eb1d2a907cb548c8887
6
+ metadata.gz: 692cc4e5240be00b9a184daf7e7ec921976649ec98c991e14b538e6be37e61462d06dadaa72704010f59c2f3c61ce5921a64871e5050e5fc0efb9a8a118c94d8
7
+ data.tar.gz: bc2a009e14baaa36d81179fd38f77ed22a972cc9486b0f5d511f4c49bef865d4e940fc990745a07922687340be4999f956f50bd4faae78c1567cb17d7902fd05
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ServiceActor
2
2
 
3
- This Ruby gem lets you move your application logic into into small composable
3
+ This Ruby gem lets you move your application logic into small composable
4
4
  service objects. It is a lightweight framework that helps you keep your models
5
5
  and controllers thin.
6
6
 
@@ -12,17 +12,18 @@ and controllers thin.
12
12
  - [Usage](#usage)
13
13
  - [Inputs](#inputs)
14
14
  - [Outputs](#outputs)
15
- - [Defaults](#defaults)
16
- - [Allow nil](#allow-nil)
17
- - [Conditions](#conditions)
18
- - [Types](#types)
19
15
  - [Fail](#fail)
20
- - [Custom input errors](#custom-input-errors)
21
16
  - [Play actors in a sequence](#play-actors-in-a-sequence)
22
17
  - [Rollback](#rollback)
23
18
  - [Inline actors](#inline-actors)
24
19
  - [Play conditions](#play-conditions)
25
20
  - [Input aliases](#input-aliases)
21
+ - [Input options](#input-options)
22
+ - [Defaults](#defaults)
23
+ - [Allow nil](#allow-nil)
24
+ - [Conditions](#conditions)
25
+ - [Types](#types)
26
+ - [Custom input errors](#custom-input-errors)
26
27
  - [Testing](#testing)
27
28
  - [FAQ](#faq)
28
29
  - [Thanks](#thanks)
@@ -77,7 +78,7 @@ SendNotification.call # => <ServiceActor::Result…>
77
78
  When called, an actor returns a result. Reading and writing to this result allows
78
79
  actors to accept and return multiple arguments. Let’s find out how to do that
79
80
  and then we’ll see how to
80
- [chain multiple actors togethor](#play-actors-in-a-sequence).
81
+ [chain multiple actors together](#play-actors-in-a-sequence).
81
82
 
82
83
  ### Inputs
83
84
 
@@ -122,25 +123,200 @@ actor.greeting # => "Have a wonderful day!"
122
123
  actor.greeting? # => true
123
124
  ```
124
125
 
126
+ ### Fail
127
+
128
+ To stop the execution and mark an actor as having failed, use `fail!`:
129
+
130
+ ```rb
131
+ class UpdateUser < Actor
132
+ input :user
133
+ input :attributes
134
+
135
+ def call
136
+ user.attributes = attributes
137
+
138
+ fail!(error: "Invalid user") unless user.valid?
139
+
140
+ # …
141
+ end
142
+ end
143
+ ```
144
+
145
+ This will raise an error in your application with the given data added to the
146
+ result.
147
+
148
+ To test for the success of your actor instead of raising an exception, use
149
+ `.result` instead of `.call`. You can then call `success?` or `failure?` on
150
+ the result.
151
+
152
+ For example in a Rails controller:
153
+
154
+ ```rb
155
+ # app/controllers/users_controller.rb
156
+ class UsersController < ApplicationController
157
+ def create
158
+ actor = UpdateUser.result(user: user, attributes: user_attributes)
159
+ if actor.success?
160
+ redirect_to actor.user
161
+ else
162
+ render :new, notice: actor.error
163
+ end
164
+ end
165
+ end
166
+ ```
167
+
168
+ ## Play actors in a sequence
169
+
170
+ To help you create actors that are small, single-responsibility actions, an
171
+ actor can use `play` to call other actors:
172
+
173
+ ```rb
174
+ class PlaceOrder < Actor
175
+ play CreateOrder,
176
+ PayOrder,
177
+ SendOrderConfirmation,
178
+ NotifyAdmins
179
+ end
180
+ ```
181
+
182
+ Calling this actor will now call every actor along the way. Inputs and outputs
183
+ will go from one actor to the next, all sharing the same result set until it is
184
+ finally returned.
185
+
186
+ ### Rollback
187
+
188
+ When using `play`, if an actor calls `fail!`, the following actors will not be
189
+ called.
190
+
191
+ Instead, all the actors that succeeded will have their `rollback` method called
192
+ in reverse order. This allows actors a chance to cleanup, for example:
193
+
194
+ ```rb
195
+ class CreateOrder < Actor
196
+ output :order
197
+
198
+ def call
199
+ self.order = Order.create!(…)
200
+ end
201
+
202
+ def rollback
203
+ order.destroy
204
+ end
205
+ end
206
+ ```
207
+
208
+ Rollback is only called on the _previous_ actors in `play` and is not called on
209
+ the failing actor itself. Actors should be kept to a single purpose and not have
210
+ anything to clean up if they call `fail!`.
211
+
212
+ ### Inline actors
213
+
214
+ For small work or preparing the result set for the next actors, you can create
215
+ inline actors by using lambdas. Each lambda has access to the shared result. For
216
+ example:
217
+
218
+ ```rb
219
+ class PayOrder < Actor
220
+ input :order
221
+
222
+ play -> actor { actor.order.currency ||= "EUR" },
223
+ CreatePayment,
224
+ UpdateOrderBalance,
225
+ -> actor { Logger.info("Order #{actor.order.id} paid") }
226
+ end
227
+ ```
228
+
229
+ You can also call instance methods. For example:
230
+
231
+ ```rb
232
+ class PayOrder < Actor
233
+ input :order
234
+
235
+ play :assign_default_currency,
236
+ CreatePayment,
237
+ UpdateOrderBalance,
238
+ :log_payment
239
+
240
+ private
241
+
242
+ def assign_default_currency
243
+ order.currency ||= "EUR"
244
+ end
245
+
246
+ def log_payment
247
+ Logger.info("Order #{order.id} paid")
248
+ end
249
+ end
250
+ ```
251
+
252
+ If you want to do work around the whole actor, you can also override the `call`
253
+ method. For example:
254
+
255
+ ```rb
256
+ class PayOrder < Actor
257
+ # …
258
+
259
+ def call
260
+ Time.with_timezone("Paris") do
261
+ super
262
+ end
263
+ end
264
+ end
265
+ ```
266
+
267
+ ### Play conditions
268
+
269
+ Actors in a play can be called conditionally:
270
+
271
+ ```rb
272
+ class PlaceOrder < Actor
273
+ play CreateOrder,
274
+ Pay
275
+ play NotifyAdmins, if: -> actor { actor.order.amount > 42 }
276
+ play CreatePayment, unless: -> actor { actor.order.currency == "USD" }
277
+ end
278
+ ```
279
+
280
+ ### Input aliases
281
+
282
+ You can use `alias_input` to transform the output of an actor into the input of
283
+ the next actors.
284
+
285
+ ```rb
286
+ class PlaceComment < Actor
287
+ play CreateComment,
288
+ NotifyCommentFollowers,
289
+ alias_input(commenter: :user),
290
+ UpdateUserStats
291
+ end
292
+ ```
293
+
294
+ ## Input options
295
+
125
296
  ### Defaults
126
297
 
127
- Inputs can be optional by providing a default:
298
+ Inputs can be optional by providing a `default` value or lambda.
128
299
 
129
300
  ```rb
130
301
  class BuildGreeting < Actor
131
302
  input :name
132
303
  input :adjective, default: "wonderful"
133
304
  input :length_of_time, default: -> { ["day", "week", "month"].sample }
305
+ input :article,
306
+ default: -> context { context.adjective.match?(/^aeiou/) ? "an" : "a" }
134
307
 
135
308
  output :greeting
136
309
 
137
310
  def call
138
- self.greeting = "Have a #{adjective} #{length_of_time} #{name}!"
311
+ self.greeting = "Have #{article} #{length_of_time}, #{name}!"
139
312
  end
140
313
  end
141
314
 
142
315
  actor = BuildGreeting.call(name: "Jim")
143
- actor.greeting # => "Have a wonderful week Jim!"
316
+ actor.greeting # => "Have a wonderful week, Jim!"
317
+
318
+ actor = BuildGreeting.call(name: "Siobhan", adjective: "elegant")
319
+ actor.greeting # => "Have an elegant week, Siobhan!"
144
320
  ```
145
321
 
146
322
  ### Allow nil
@@ -208,48 +384,6 @@ You may also use strings instead of constants, such as `type: "User"`.
208
384
 
209
385
  When using a type condition, `allow_nil` defaults to `false`.
210
386
 
211
- ### Fail
212
-
213
- To stop the execution and mark an actor as having failed, use `fail!`:
214
-
215
- ```rb
216
- class UpdateUser < Actor
217
- input :user
218
- input :attributes
219
-
220
- def call
221
- user.attributes = attributes
222
-
223
- fail!(error: "Invalid user") unless user.valid?
224
-
225
- # …
226
- end
227
- end
228
- ```
229
-
230
- This will raise an error in your application with the given data added to the
231
- result.
232
-
233
- To test for the success of your actor instead of raising an exception, use
234
- `.result` instead of `.call`. You can then call `success?` or `failure?` on
235
- the result.
236
-
237
- For example in a Rails controller:
238
-
239
- ```rb
240
- # app/controllers/users_controller.rb
241
- class UsersController < ApplicationController
242
- def create
243
- actor = UpdateUser.result(user: user, attributes: user_attributes)
244
- if actor.success?
245
- redirect_to actor.user
246
- else
247
- render :new, notice: actor.error
248
- end
249
- end
250
- end
251
- ```
252
-
253
387
  ### Custom input errors
254
388
 
255
389
  Use a `Hash` with `is:` and `message:` keys to prepare custom
@@ -341,7 +475,7 @@ end
341
475
  is: [TrueClass, FalseClass],
342
476
  message: (lambda do |input_key:, expected_type:, given_type:, **|
343
477
  "Wrong type \"#{given_type}\" for \"#{input_key}\". " \
344
- "Expected: \"#{expected_type}\""
478
+ "Expected: \"#{expected_type}\""
345
479
  end)
346
480
  }
347
481
  end
@@ -363,131 +497,6 @@ end
363
497
 
364
498
  </details>
365
499
 
366
- ## Play actors in a sequence
367
-
368
- To help you create actors that are small, single-responsibility actions, an
369
- actor can use `play` to call other actors:
370
-
371
- ```rb
372
- class PlaceOrder < Actor
373
- play CreateOrder,
374
- PayOrder,
375
- SendOrderConfirmation,
376
- NotifyAdmins
377
- end
378
- ```
379
-
380
- Calling this actor will now call every actor along the way. Inputs and outputs
381
- will go from one actor to the next, all sharing the same result set until it is
382
- finally returned.
383
-
384
- ### Rollback
385
-
386
- When using `play`, if an actor calls `fail!`, the following actors will not be
387
- called.
388
-
389
- Instead, all the actors that succeeded will have their `rollback` method called
390
- in reverse order. This allows actors a chance to cleanup, for example:
391
-
392
- ```rb
393
- class CreateOrder < Actor
394
- output :order
395
-
396
- def call
397
- self.order = Order.create!(…)
398
- end
399
-
400
- def rollback
401
- order.destroy
402
- end
403
- end
404
- ```
405
-
406
- Rollback is only called on the _previous_ actors in `play` and is not called on
407
- the failing actor itself. Actors should be kept to a single purpose and not have
408
- anything to clean up if they call `fail!`.
409
-
410
- ### Inline actors
411
-
412
- For small work or preparing the result set for the next actors, you can create
413
- inline actors by using lambdas. Each lambda has access to the shared result. For
414
- example:
415
-
416
- ```rb
417
- class PayOrder < Actor
418
- input :order
419
-
420
- play -> actor { actor.order.currency ||= "EUR" },
421
- CreatePayment,
422
- UpdateOrderBalance,
423
- -> actor { Logger.info("Order #{actor.order.id} paid") }
424
- end
425
- ```
426
-
427
- You can also call instance methods. For example:
428
-
429
- ```rb
430
- class PayOrder < Actor
431
- input :order
432
-
433
- play :assign_default_currency,
434
- CreatePayment,
435
- UpdateOrderBalance,
436
- :log_payment
437
-
438
- private
439
-
440
- def assign_default_currency
441
- order.currency ||= "EUR"
442
- end
443
-
444
- def log_payment
445
- Logger.info("Order #{order.id} paid")
446
- end
447
- end
448
- ```
449
-
450
- If you want to do work around the whole actor, you can also override the `call`
451
- method. For example:
452
-
453
- ```rb
454
- class PayOrder < Actor
455
- # …
456
-
457
- def call
458
- Time.with_timezone("Paris") do
459
- super
460
- end
461
- end
462
- end
463
- ```
464
-
465
- ### Play conditions
466
-
467
- Actors in a play can be called conditionally:
468
-
469
- ```rb
470
- class PlaceOrder < Actor
471
- play CreateOrder,
472
- Pay
473
- play NotifyAdmins, if: -> actor { actor.order.amount > 42 }
474
- end
475
- ```
476
-
477
- ### Input aliases
478
-
479
- You can use `alias_input` to transform the output of an actor into the input of
480
- the next actors.
481
-
482
- ```rb
483
- class PlaceComment < Actor
484
- play CreateComment,
485
- NotifyCommentFollowers,
486
- alias_input(commenter: :user),
487
- UpdateUserStats
488
- end
489
- ```
490
-
491
500
  ## Testing
492
501
 
493
502
  In your application, add automated testing to your actors as you would do to any
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ServiceActor::ArgumentsValidator
4
+ module_function
5
+
6
+ def validate_origin_name(name, origin:)
7
+ return unless ServiceActor::Result.instance_methods.include?(name.to_sym)
8
+
9
+ raise ArgumentError,
10
+ "#{origin} `#{name}` overrides `ServiceActor::Result` instance method"
11
+ end
12
+
13
+ def validate_error_class(value)
14
+ return if value.is_a?(Class) && value <= Exception
15
+
16
+ raise ArgumentError, "Expected #{value} to be a subclass of Exception"
17
+ end
18
+ end
@@ -7,8 +7,10 @@
7
7
  # output :name
8
8
  # end
9
9
  module ServiceActor::Attributable
10
- def self.included(base)
11
- base.extend(ClassMethods)
10
+ class << self
11
+ def included(base)
12
+ base.extend(ClassMethods)
13
+ end
12
14
  end
13
15
 
14
16
  module ClassMethods
@@ -20,14 +22,18 @@ module ServiceActor::Attributable
20
22
  end
21
23
 
22
24
  def input(name, **arguments)
25
+ ServiceActor::ArgumentsValidator.validate_origin_name(
26
+ name, origin: :input
27
+ )
28
+
23
29
  inputs[name] = arguments
24
30
 
25
31
  define_method(name) do
26
32
  result[name]
27
33
  end
28
34
 
29
- # For avoid method redefined warning messages.
30
- alias_method name, name if method_defined?(name)
35
+ # To avoid method redefined warning messages.
36
+ alias_method(name, name) if method_defined?(name)
31
37
 
32
38
  protected name
33
39
  end
@@ -37,17 +43,21 @@ module ServiceActor::Attributable
37
43
  end
38
44
 
39
45
  def output(name, **arguments)
46
+ ServiceActor::ArgumentsValidator.validate_origin_name(
47
+ name, origin: :output
48
+ )
49
+
40
50
  outputs[name] = arguments
41
51
 
42
52
  define_method(name) do
43
53
  result[name]
44
54
  end
55
+ protected name
45
56
 
46
- define_method("#{name}=") do |value|
57
+ define_method(:"#{name}=") do |value|
47
58
  result[name] = value
48
59
  end
49
-
50
- protected name, "#{name}="
60
+ protected :"#{name}="
51
61
  end
52
62
 
53
63
  def outputs
@@ -3,16 +3,18 @@
3
3
  require "service_actor/support/loader"
4
4
 
5
5
  module ServiceActor::Base
6
- def self.included(base)
7
- # Essential mechanics
8
- base.include(ServiceActor::Core)
9
- base.include(ServiceActor::Configurable)
10
- base.include(ServiceActor::Attributable)
11
- base.include(ServiceActor::Playable)
6
+ class << self
7
+ def included(base)
8
+ # Essential mechanics
9
+ base.include(ServiceActor::Core)
10
+ base.include(ServiceActor::Configurable)
11
+ base.include(ServiceActor::Attributable)
12
+ base.include(ServiceActor::Playable)
12
13
 
13
- # Extra concerns
14
- base.include(ServiceActor::Checkable)
15
- base.include(ServiceActor::Defaultable)
16
- base.include(ServiceActor::Failable)
14
+ # Extra concerns
15
+ base.include(ServiceActor::Checkable)
16
+ base.include(ServiceActor::Defaultable)
17
+ base.include(ServiceActor::Failable)
18
+ end
17
19
  end
18
20
  end
@@ -1,11 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ServiceActor::Checkable
4
- def self.included(base)
5
- base.prepend(PrependedMethods)
4
+ class << self
5
+ def included(base)
6
+ base.prepend(PrependedMethods)
7
+ end
6
8
  end
7
9
 
8
10
  module PrependedMethods
11
+ CHECK_CLASSES = [
12
+ ServiceActor::Checks::TypeCheck,
13
+ ServiceActor::Checks::MustCheck,
14
+ ServiceActor::Checks::InclusionCheck,
15
+ ServiceActor::Checks::NilCheck,
16
+ ].freeze
17
+ private_constant :CHECK_CLASSES
18
+
9
19
  def _call
10
20
  self.service_actor_argument_errors = []
11
21
 
@@ -20,7 +30,8 @@ module ServiceActor::Checkable
20
30
 
21
31
  # rubocop:disable Metrics/MethodLength
22
32
  def service_actor_checks_for(origin)
23
- self.class.public_send("#{origin}s").each do |input_key, input_options|
33
+ check_classes = CHECK_CLASSES.select { _1.applicable_to_origin?(origin) }
34
+ self.class.public_send(:"#{origin}s").each do |input_key, input_options|
24
35
  input_options.each do |check_name, check_conditions|
25
36
  check_classes.each do |check_class|
26
37
  argument_errors = check_class.check(
@@ -48,15 +59,5 @@ module ServiceActor::Checkable
48
59
  raise self.class.argument_error_class,
49
60
  service_actor_argument_errors.first
50
61
  end
51
-
52
- def check_classes
53
- [
54
- ServiceActor::Checks::TypeCheck,
55
- ServiceActor::Checks::MustCheck,
56
- ServiceActor::Checks::InclusionCheck,
57
- ServiceActor::Checks::NilCheck,
58
- ServiceActor::Checks::DefaultCheck
59
- ]
60
- end
61
62
  end
62
63
  end
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ServiceActor::Checks::Base
4
+ class << self
5
+ def applicable_to_origin?(_origin)
6
+ true
7
+ end
8
+ end
9
+
4
10
  def initialize
5
11
  @argument_errors = []
6
12
  end
@@ -22,21 +22,23 @@ class ServiceActor::Checks::InclusionCheck < ServiceActor::Checks::Base
22
22
  DEFAULT_MESSAGE = lambda do |input_key:, actor:, inclusion_in:, value:|
23
23
  "The \"#{input_key}\" input must be included " \
24
24
  "in #{inclusion_in.inspect} on \"#{actor}\" " \
25
- "instead of #{value.inspect}"
25
+ "instead of #{value.inspect}"
26
26
  end
27
27
 
28
28
  private_constant :DEFAULT_MESSAGE
29
29
 
30
- def self.check(check_name:, input_key:, actor:, conditions:, result:, **) # rubocop:disable Metrics/ParameterLists
31
- # DEPRECATED: `in` is deprecated in favor of `inclusion`.
32
- return unless %i[inclusion in].include?(check_name)
30
+ class << self
31
+ def check(check_name:, input_key:, actor:, conditions:, result:, **)
32
+ # DEPRECATED: `in` is deprecated in favor of `inclusion`.
33
+ return unless %i[inclusion in].include?(check_name)
33
34
 
34
- new(
35
- input_key: input_key,
36
- actor: actor,
37
- inclusion: conditions,
38
- value: result[input_key],
39
- ).check
35
+ new(
36
+ input_key: input_key,
37
+ actor: actor,
38
+ inclusion: conditions,
39
+ value: result[input_key],
40
+ ).check
41
+ end
40
42
  end
41
43
 
42
44
  def initialize(input_key:, actor:, inclusion:, value:)
@@ -67,6 +69,7 @@ class ServiceActor::Checks::InclusionCheck < ServiceActor::Checks::Base
67
69
 
68
70
  def define_inclusion_and_message
69
71
  if @inclusion.is_a?(Hash)
72
+ @inclusion[:message] ||= DEFAULT_MESSAGE
70
73
  @inclusion.values_at(:in, :message)
71
74
  else
72
75
  [@inclusion, DEFAULT_MESSAGE]
@@ -33,15 +33,17 @@ class ServiceActor::Checks::MustCheck < ServiceActor::Checks::Base
33
33
 
34
34
  private_constant :DEFAULT_MESSAGE
35
35
 
36
- def self.check(check_name:, input_key:, actor:, conditions:, result:, **) # rubocop:disable Metrics/ParameterLists
37
- return unless check_name == :must
36
+ class << self
37
+ def check(check_name:, input_key:, actor:, conditions:, result:, **)
38
+ return unless check_name == :must
38
39
 
39
- new(
40
- input_key: input_key,
41
- actor: actor,
42
- nested_checks: conditions,
43
- value: result[input_key],
44
- ).check
40
+ new(
41
+ input_key: input_key,
42
+ actor: actor,
43
+ nested_checks: conditions,
44
+ value: result[input_key],
45
+ ).check
46
+ end
45
47
  end
46
48
 
47
49
  def initialize(input_key:, actor:, nested_checks:, value:)
@@ -86,6 +88,7 @@ class ServiceActor::Checks::MustCheck < ServiceActor::Checks::Base
86
88
 
87
89
  def define_check_and_message_from(nested_check_conditions)
88
90
  if nested_check_conditions.is_a?(Hash)
91
+ nested_check_conditions[:message] ||= DEFAULT_MESSAGE
89
92
  nested_check_conditions.values_at(:is, :message)
90
93
  else
91
94
  [nested_check_conditions, DEFAULT_MESSAGE]