discourse/lib/service/base.rb

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

454 lines
13 KiB
Ruby
Raw Normal View History

# 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
2023-04-04 01:27:32 +08:00
# @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