discourse/lib/service/base.rb
Loïc Guitaut a589b48f9a DEV: Display better output when inspecting service steps
This patch aims to improve the steps inspector output:
- The service class name is displayed at the top.
- Next to each step is displayed the time it took to run said step.
- Steps that didn’t run are hidden.
- `#inspect` automatically outputs the error when it is present.
2024-12-12 15:21:10 +01:00

468 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).inspect
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, :instance, :context
def initialize(name, method_name = name, class_name: nil)
@name = name
@method_name = method_name
@class_name = class_name
end
def call(instance, context)
@instance, @context = instance, context
context[result_key] = Context.build
with_runtime { run_step }
end
def result_key
"result.#{type}.#{name}"
end
private
def run_step
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][:object] = object if object
instance.instance_exec(**args, &method)
end
def type
self.class.name.split("::").last.downcase.sub(/^(\w+)step$/, "\\1")
end
def with_runtime
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield.tap do
ended_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
context[result_key][:__runtime__] = ended_at - started_at
end
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 run_step
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 run_step
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 run_step
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
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 run_step
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 run_step
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][:exception] = e
context.fail!
end
end
# @!visibility private
class OptionsStep < Step
def run_step
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, __service_class__: self.class),
)
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