discourse/lib/service/base.rb
Jarek Radosz d89531f2f6
DEV: Enable some minor rubocop rules (#40094)
* Style/RedundantFreeze
* Style/RedundantConditional
* Lint/DuplicateMagicComment
* Lint/IdentityComparison
* Lint/SymbolConversion

(to be enabled in the shared config)
2026-05-19 15:29:38 +02:00

286 lines
9.1 KiB
Ruby
Vendored

# frozen_string_literal: true
# @!parse
# module Service::Base
# # @!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
#
# # @!scope class
# # @!method try(*exceptions, &block)
# # @param exceptions [Array<Class>] one or more exception classes to catch (defaults to +StandardError+)
# # @param block [Proc] a block containing steps to be wrapped
# # Wraps steps and catches specified exceptions. If any wrapped step
# # raises a matching exception, the step fails and the execution flow
# # is halted. The caught exception is available on the result object.
# #
# # @example
# # try do
# # step :risky_operation
# # end
# #
# # @example catching specific exceptions
# # try(ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid) do
# # step :save_record
# # end
#
# # @!scope class
# # @!method lock(*keys, &block)
# # @param keys [Array<Symbol>] one or more keys to build a unique lock name
# # @param block [Proc] a block containing steps to be wrapped
# # Wraps steps inside a +DistributedMutex+. Keys are resolved from
# # +params+ first, then from the service context. When a resolved value
# # responds to +id+ (e.g. an ActiveRecord model), its +id+ is used
# # automatically. Fails if the lock cannot be acquired.
# #
# # @example locking on a param
# # lock(:user_id) do
# # step :update_user
# # end
# #
# # @example locking on a model from context
# # model :topic
# # lock(:topic) do
# # step :update_topic
# # end
#
# # @!scope class
# # @!method only_if(name, &block)
# # @param name [Symbol] the name of the condition to check
# # @param block [Proc] a block containing steps to conditionally run
# # Conditionally runs the steps in its block. If the condition method
# # returns a falsy value, the steps are skipped but the execution flow
# # is not halted.
# #
# # @example
# # only_if(:has_post) do
# # step :update_post
# # step :log_post_update
# # end
#
# # @!scope class
# # @!method each(collection_name, as: nil, persist: nil, &block)
# # @param collection_name [Symbol] the name of the context key holding the collection
# # @param as [Symbol] override the singularized item name
# # @param persist [Hash, Array, Symbol] keys that accumulate across iterations
# # @param block [Proc] a block containing steps to run for each item
# # Iterates over a collection and runs nested steps for each item.
# # The singularized item name and +index+ are provided as keyword
# # arguments. Iteration stops on first failure. Variables set inside
# # the loop are isolated unless listed in +persist+.
# #
# # @example basic iteration
# # each :users do
# # policy :can_delete
# # step :destroy
# # end
# #
# # @example accumulating results
# # each :tag_names, persist: { results: -> { [] } } do
# # step :create_tag
# # end
# end
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
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
# @!visibility private
def initialize(initial_context = {})
@context =
Context.build(
initial_context
.compact
.reverse_merge(params: {})
.merge(__steps__: self.class.steps, __service_class__: self.class),
)
initialize_params
end
# @!visibility private
def run
run!
rescue Failure => exception
raise if !context.equal?(exception.context)
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
private
def initialize_params
klass =
Data.define(*context[:params].keys) do
alias to_hash to_h
delegate :slice, :merge, to: :to_h
def method_missing(*)
nil
end
end
context[:params] = klass.new(*context[:params].values)
end
end
end