discourse/lib/service/base.rb
Loïc Guitaut 719457e430 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) }
```
2024-11-19 12:01:07 +01:00

454 lines
13 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
module Service
module Base
extend ActiveSupport::Concern
# The only exception that can be raised by a service.
class Failure < StandardError
# @return [Context]
attr_reader :context
# @!visibility private
def initialize(context = nil)
@context = context
super
end
end
# Simple structure to hold the context of the service during its whole lifecycle.
class Context
delegate :slice, :dig, to: :store
def initialize(context = {})
@store = context.symbolize_keys
end
def [](key)
store[key.to_sym]
end
def []=(key, value)
store[key.to_sym] = value
end
def to_h
store.dup
end
# @return [Boolean] returns +true+ if the context is set as successful (default)
def success?
!failure?
end
# @return [Boolean] returns +true+ if the context is set as failed
# @see #fail!
# @see #fail
def failure?
@failure || false
end
# Marks the context as failed.
# @param context [Hash, Context] the context to merge into the current one
# @example
# context.fail!("failure": "something went wrong")
# @return [Context]
def fail!(context = {})
self.fail(context)
raise Failure, self
end
# Marks the context as failed without raising an exception.
# @param context [Hash, Context] the context to merge into the current one
# @example
# context.fail("failure": "something went wrong")
# @return [Context]
def fail(context = {})
store.merge!(context.symbolize_keys)
@failure = true
self
end
def inspect_steps
Service::StepsInspector.new(self)
end
private
attr_reader :store
def self.build(context = {})
self === context ? context : new(context)
end
def method_missing(method_name, *args, &block)
return super if args.present?
store[method_name]
end
end
# Internal module to define available steps as DSL
# @!visibility private
module StepsHelpers
def model(name = :model, step_name = :"fetch_#{name}", optional: false)
steps << ModelStep.new(name, step_name, optional: optional)
end
def params(name = :default, default_values_from: nil, &block)
contract_class = Class.new(Service::ContractBase).tap { _1.class_eval(&block) }
const_set("#{name.to_s.classify.sub("Default", "")}Contract", contract_class)
steps << ContractStep.new(
name,
class_name: contract_class,
default_values_from: default_values_from,
)
end
def policy(name = :default, class_name: nil)
steps << PolicyStep.new(name, class_name: class_name)
end
def step(name)
steps << Step.new(name)
end
def transaction(&block)
steps << TransactionStep.new(&block)
end
def options(&block)
klass = Class.new(Service::OptionsBase).tap { _1.class_eval(&block) }
const_set("Options", klass)
steps << OptionsStep.new(:default, class_name: klass)
end
def try(*exceptions, &block)
steps << TryStep.new(exceptions, &block)
end
end
# @!visibility private
class Step
attr_reader :name, :method_name, :class_name
def initialize(name, method_name = name, class_name: nil)
@name = name
@method_name = method_name
@class_name = class_name
end
def call(instance, context)
object = class_name&.new(context)
method = object&.method(:call) || instance.method(method_name)
if method.parameters.any? { _1[0] != :keyreq }
raise "In #{type} '#{name}': default values in step implementations are not allowed. Maybe they could be defined in a params or options block?"
end
args = context.slice(*method.parameters.select { _1[0] == :keyreq }.map(&:last))
context[result_key] = Context.build({ object: }.compact)
instance.instance_exec(**args, &method)
end
def result_key
"result.#{type}.#{name}"
end
private
def type
self.class.name.split("::").last.downcase.sub(/^(\w+)step$/, "\\1")
end
end
# @!visibility private
class ModelStep < Step
attr_reader :optional
def initialize(name, method_name = name, class_name: nil, optional: nil)
super(name, method_name, class_name: class_name)
@optional = optional.present?
end
def call(instance, context)
context[name] = super
if !optional && (!context[name] || context[name].try(:empty?))
raise ArgumentError, "Model not found"
end
if context[name].try(:invalid?)
context[result_key].fail(invalid: true)
context.fail!
end
rescue ArgumentError => exception
context[result_key].fail(exception: exception, not_found: true)
context.fail!
end
end
# @!visibility private
class PolicyStep < Step
def call(instance, context)
if !super
context[result_key].fail(reason: context[result_key].object&.reason)
context.fail!
end
end
end
# @!visibility private
class ContractStep < Step
attr_reader :default_values_from
def initialize(name, method_name = name, class_name: nil, default_values_from: nil)
super(name, method_name, class_name: class_name)
@default_values_from = default_values_from
end
def call(instance, context)
attributes = class_name.attribute_names.map(&:to_sym)
default_values = {}
default_values = context[default_values_from].slice(*attributes) if default_values_from
contract =
class_name.new(
**default_values.merge(context[:params].slice(*attributes)),
options: context[:options],
)
context[contract_name] = contract
context[result_key] = Context.build
if contract.invalid?
context[result_key].fail(errors: contract.errors, parameters: contract.raw_attributes)
context.fail!
end
contract.freeze
end
private
def contract_name
return :params if default?
:"#{name}_contract"
end
def default?
name.to_sym == :default
end
end
# @!visibility private
class TransactionStep < Step
include StepsHelpers
attr_reader :steps
def initialize(&block)
@steps = []
instance_exec(&block)
end
def call(instance, context)
ActiveRecord::Base.transaction { steps.each { |step| step.call(instance, context) } }
end
end
# @!visibility private
class TryStep < Step
include StepsHelpers
attr_reader :steps, :exceptions
def initialize(exceptions, &block)
@name = "default"
@steps = []
@exceptions = exceptions.presence || [StandardError]
instance_exec(&block)
end
def call(instance, context)
context[result_key] = Context.build
steps.each do |step|
@current_step = step
step.call(instance, context)
end
rescue *exceptions => e
raise e if e.is_a?(Failure)
context[@current_step.result_key].fail(raised_exception?: true, exception: e)
context[result_key].fail(exception: e)
context.fail!
end
end
# @!visibility private
class OptionsStep < Step
def call(instance, context)
context[result_key] = Context.build
context[:options] = class_name.new(context[:options])
end
end
included do
# The global context which is available from any step.
attr_reader :context
end
class_methods do
include StepsHelpers
def call(context = {}, &actions)
return new(context).tap(&:run).context unless block_given?
Service::Runner.call(self, context, &actions)
end
def call!(context = {})
new(context).tap(&:run!).context
end
def steps
@steps ||= []
end
end
# @!scope class
# @!method model(name = :model, step_name = :"fetch_#{name}", optional: false)
# @param name [Symbol] name of the model
# @param step_name [Symbol] name of the method to call for this step
# @param optional [Boolean] if +true+, then the step wont fail if its return value is falsy.
# Evaluates arbitrary code to build or fetch a model (typically from the
# DB). If the step returns a falsy value, then the step will fail.
#
# It stores the resulting model in +context[:model]+ by default (can be
# customized by providing the +name+ argument).
#
# @example
# model :channel
#
# private
#
# def fetch_channel(channel_id:)
# Chat::Channel.find_by(id: channel_id)
# end
# @!scope class
# @!method policy(name = :default, class_name: nil)
# @param name [Symbol] name for this policy
# @param class_name [Class] a policy object (should inherit from +PolicyBase+)
# Performs checks related to the state of the system. If the
# step doesnt return a truthy value, then the policy will fail.
#
# When using a policy object, there is no need to define a method on the
# service for the policy step. The policy object `#call` method will be
# called and if the result isnt truthy, a `#reason` method is expected to
# be implemented to explain the failure.
#
# Policy objects are usually useful for more complex logic.
#
# @example Without a policy object
# policy :no_direct_message_channel
#
# private
#
# def no_direct_message_channel(channel:)
# !channel.direct_message_channel?
# end
#
# @example With a policy object
# # in the service object
# policy :no_direct_message_channel, class_name: NoDirectMessageChannelPolicy
#
# # in the policy object File
# class NoDirectMessageChannelPolicy < PolicyBase
# def call
# !context.channel.direct_message_channel?
# end
#
# def reason
# "Direct message channels arent supported"
# end
# end
# @!scope class
# @!method params(name = :default, default_values_from: nil, &block)
# @param name [Symbol] name for this contract
# @param default_values_from [Symbol] name of the model to get default values from
# @param block [Proc] a block containing validations
# Checks the validity of the input parameters.
# Implements ActiveModel::Validations and ActiveModel::Attributes.
#
# It stores the resulting contract in +context[:params]+ by default
# (can be customized by providing the +name+ argument).
#
# @example
# params do
# attribute :name
# validates :name, presence: true
# end
# @!scope class
# @!method step(name)
# @param name [Symbol] the name of this step
# Runs arbitrary code. To mark a step as failed, a call to {#fail!} needs
# to be made explicitly.
#
# @example
# step :update_channel
#
# private
#
# def update_channel(channel:, params_to_edit:)
# channel.update!(params_to_edit)
# end
# @example using {#fail!} in a step
# step :save_channel
#
# private
#
# def save_channel(channel:)
# fail!("something went wrong") if !channel.save
# end
# @!scope class
# @!method transaction(&block)
# @param block [Proc] a block containing steps to be run inside a transaction
# Runs steps inside a DB transaction.
#
# @example
# transaction do
# step :prevents_slug_collision
# step :soft_delete_channel
# step :log_channel_deletion
# end
# @!scope class
# @!method options(&block)
# @param block [Proc] a block containing options definition
# This is used to define options allowing to parameterize the service
# behavior. The resulting options are available in `context[:options]`.
#
# @example
# options do
# attribute :my_option, :boolean, default: false
# end
# @!visibility private
def initialize(initial_context = {})
@context = Context.build(initial_context.merge(__steps__: self.class.steps))
end
# @!visibility private
def run
run!
rescue Failure => exception
raise if context.object_id != exception.context.object_id
end
# @!visibility private
def run!
self.class.steps.each { |step| step.call(self, context) }
end
# @!visibility private
def fail!(message)
step_name = caller_locations(1, 1)[0].base_label
context["result.step.#{step_name}"].fail(error: message)
context.fail!
end
end
end