DEV: Add a try step to services

This patch adds a new step to services named `try`.

It’s useful to rescue exceptions that some steps could raise. That way,
if an exception is caught, the service will stop its execution and can
be inspected like with any other steps.

Just wrap the steps that can raise with a `try` block:
```ruby
try do
  step :step_that_can_raise
  step :another_step_that_can_raise
end
```
By default, `try` will catch any exception inheriting from
`StandardError`, but we can specify what exceptions to catch:
```ruby
try(ArgumentError, RuntimeError) do
  step :will_raise
end
```

An outcome matcher has been added: `on_exceptions`. By default it will
be executed for any exception caught by the `try` step.
Here also, we can specify what exceptions to catch:
```ruby
on_exceptions(ArgumentError, RuntimeError) do |exception|
  …
end
```

Finally, an RSpec matcher has been added:
```ruby
  it { is_expected.to fail_with_exception }
  # or
  it { is_expected.to fail_with_exception(ArgumentError) }
```
This commit is contained in:
Loïc Guitaut
2024-11-08 17:24:40 +01:00
committed by Loïc Guitaut
parent 682e8df007
commit 719457e430
6 changed files with 295 additions and 74 deletions

View File

@ -22,10 +22,13 @@
# named `name` is not present
# * +on_model_errors(name)+: will execute the provided block if the model named
# `name` contains validation errors
# * +on_exceptions(*exceptions)+: will execute the provided block if any
# exceptions were caught by the `try` block. One or more exception classes
# can be provided to specifically handle those exceptions.
#
# All the specialized steps receive the failing step result object as an
# argument to their block. `on_model_errors` receives the actual model so it’s
# easier to inspect it.
# easier to inspect it, and `on_exceptions` receives the actual exception.
#
# @example In a controller
# def create
@ -92,6 +95,16 @@ class Service::Runner
key: [],
default_name: "model",
},
on_exceptions: {
condition: ->(*exceptions) do
next unless failure_for?("result.try.default")
next true if exceptions.empty?
exceptions.any? { result["result.try.default"].exception.is_a?(_1) }
end,
key: %w[result try],
name: "default",
property: :exception,
},
}.with_indifferent_access.freeze
# @!visibility private
@ -143,7 +156,9 @@ class Service::Runner
-> { instance_exec(*args, &action[:condition]) },
-> do
object.instance_exec(
result[[*action[:key], args.first || action[:default_name]].join(".")],
result[
[*action[:key], action[:name] || args.first || action[:default_name]].join(".")
].public_send(action[:property] || :itself),
**result.slice(*block.parameters.filter_map { _1.last if _1.first == :keyreq }),
&block
)