mirror of
https://github.com/discourse/discourse.git
synced 2024-12-19 17:34:25 +08:00
719457e430
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) } ```
454 lines
13 KiB
Ruby
454 lines
13 KiB
Ruby
# 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 won’t 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 doesn’t 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 isn’t 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 aren’t 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
|