DEV: Extract step classes from Service::Base into individual files (#37956)

The `base.rb` file (640+ lines) contained 9 step classes, the `Context`
class, and the `StepsHelpers` DSL module alongside the core concern.
Each is now in its own file under `lib/service/base/`, following the
existing `lib/service.rb` + `lib/service/*.rb` pattern.

Also:
- Update stale inline documentation in `lib/service.rb`
- Use `NotImplementedError` instead of generic raise in `PolicyBase` and
`ActionBase`
- Move YARD DSL docs to a `@!parse` block at the top of `base.rb`
This commit is contained in:
Loïc Guitaut
2026-02-23 09:49:37 +01:00
committed by GitHub
parent c9391cdc75
commit 6759ad71ca
15 changed files with 635 additions and 520 deletions
+21 -16
View File
@@ -8,24 +8,29 @@ module Service
# Steps are executed in the order theyre defined. They will use their name
# to execute the corresponding method defined in the service class.
#
# Currently, there are 5 types of steps:
# Available step types:
#
# * +params(name = :default)+: used to validate the input parameters,
# typically provided by a user calling an endpoint. A block has to be
# defined to hold the validations. If the validations fail, the step will
# fail. Otherwise, the resulting contract will be available in
# +context[:params]+.
# * +model(name = :model)+: used to instantiate a model (either by building
# it or fetching it from the DB). If a falsy value is returned, then the
# step will fail. Otherwise the resulting object will be assigned in
# * +params(name = :default)+: validates input parameters using
# +ActiveModel+ validations. Fails if the contract is invalid.
# The resulting contract is available in +context[:params]+.
# * +model(name = :model)+: fetches or instantiates a model. Fails if the
# return value is falsy, empty, or invalid. The result is stored in
# +context[name]+ (+context[:model]+ by default).
# * +policy(name = :default)+: used to perform a check on the state of the
# system. Typically used to run guardians. If a falsy value is returned,
# the step will fail.
# * +step(name)+: used to run small snippets of arbitrary code. The step
# doesnt care about its return value, so to mark the service as failed,
# {#fail!} has to be called explicitly.
# * +transaction+: used to wrap other steps inside a DB transaction.
# * +policy(name = :default)+: performs a check on the state of the system.
# Typically used to run guardians. Fails if the return value is falsy.
# * +step(name)+: runs arbitrary code. Does not fail based on its return
# value; call {#fail!} explicitly to mark the service as failed.
# * +transaction+: wraps other steps inside a DB transaction. Any failing
# step inside the block causes a rollback.
# * +try(*exceptions)+: wraps other steps and catches specified exceptions
# (defaults to +StandardError+). Fails if an exception is caught.
# * +lock(*keys)+: wraps other steps inside a +DistributedMutex+. Keys are
# resolved from params then the service context. Fails if the lock
# cannot be acquired.
# * +options+: defines options to parameterize service behavior, similar to
# +params+ but without validations. Cannot fail.
# * +only_if(name)+: conditionally runs the steps in its block. If the
# condition is not met, the steps are skipped without failing.
#
# The methods defined on the service are automatically provided with
# the whole context passed as keyword arguments. This allows to define in a
+1 -1
View File
@@ -8,6 +8,6 @@ class Service::ActionBase
end
def call
raise "Not implemented"
raise NotImplementedError
end
end
+176 -501
View File
@@ -1,5 +1,181 @@
# 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
# end
module Service
module Base
extend ActiveSupport::Concern
@@ -16,386 +192,6 @@ module Service
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
def respond_to_missing?(name, include_all)
store.key?(name) || super
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:)
end
def params(
name = :default,
default_values_from: nil,
base_class: Service::ContractBase,
&block
)
contract_class = Class.new(base_class).tap { it.class_eval(&block) if block }
const_set("#{name.to_s.classify.sub("Default", "")}Contract", contract_class)
steps << ContractStep.new(name, class_name: contract_class, default_values_from:)
end
def policy(name = :default, class_name: nil)
steps << PolicyStep.new(name, class_name:)
end
def step(name)
steps << Step.new(name)
end
def transaction(&block)
steps << TransactionStep.new(&block)
end
def lock(*keys, &block)
steps << LockStep.new(*keys, &block)
end
def options(&block)
klass = Class.new(Service::OptionsBase).tap { it.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
def only_if(name, &block)
steps << OnlyIfStep.new(name, &block)
end
end
# @!visibility private
class Step
class DefaultValuesNotAllowed < StandardError
end
attr_reader :name, :method_name, :class_name
def initialize(name, method_name = name, class_name: nil)
@name, @method_name, @class_name = name, method_name, class_name
@instance = Concurrent::ThreadLocalVar.new
@context = Concurrent::ThreadLocalVar.new
end
def call(instance, context)
@instance.value, @context.value = instance, context
context[result_key] = Context.build
with_runtime { run_step }
end
def result_key
"result.#{type}.#{name}"
end
def instance = @instance.value
def context = @context.value
private
def run_step
object = class_name&.new(context)
method = object&.method(:call) || instance.method(method_name)
if !object && method.parameters.any? { it[0] != :keyreq }
raise DefaultValuesNotAllowed,
"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 { it[0] == :keyreq }.map(&:last))
context[result_key][:object] = object if object
instance.instance_exec(**args, &method)
end
def type
self.class.name.split("::").last.underscore.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
class NotFound < StandardError
end
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
raise NotFound if !optional && (!context[name] || context[name].try(:empty?))
if context[name].try(:has_changes_to_save?) && context[name].try(:invalid?)
context[result_key].fail(invalid: true)
context.fail!
end
rescue Failure, DefaultValuesNotAllowed
raise
rescue => exception
context[result_key].fail(
not_found: true,
exception: (exception unless exception.is_a?(NotFound)),
)
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:)
@default_values_from = default_values_from
end
def run_step
contract =
class_name.new(**default_values.merge(context[:params]), 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
def default_values
return {} unless default_values_from
model = context[default_values_from]
model.try(:attributes).try(:with_indifferent_access) || model
end
end
# @!visibility private
class TransactionStep < Step
include StepsHelpers
attr_reader :steps
def initialize(&block)
super("")
@steps = []
instance_exec(&block)
end
def run_step
ActiveRecord::Base.transaction { steps.each { |step| step.call(instance, context) } }
end
end
# @!visibility private
class LockStep < Step
include StepsHelpers
attr_reader :steps, :keys
def initialize(*keys, &block)
super(keys.join(":"))
@keys = keys
@steps = []
instance_exec(&block)
end
def run_step
success =
begin
DistributedMutex.synchronize(lock_name) do
steps.each { |step| step.call(instance, context) }
:success
end
rescue Discourse::ReadOnly
:read_only
end
if success != :success
context[result_key].fail(lock_not_acquired: true)
context.fail!
end
end
private
def lock_name
[
context.__service_class__.to_s.underscore,
*keys.flat_map do |key|
value = context[:params].public_send(key) || context[key]
[key, value.try(:id) || value]
end,
].join(":")
end
end
# @!visibility private
class TryStep < Step
module FilteredBacktrace
def filtered_backtrace
Array
.wrap(backtrace)
.chunk { it.match?(%r{/(gems|lib/service|ruby)/}) }
.flat_map do |excluded, lines|
next "(#{lines.size} framework line(s) excluded)" if excluded
lines
end
end
end
include StepsHelpers
attr_reader :steps, :exceptions
def initialize(exceptions, &block)
super("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)
e.singleton_class.prepend(FilteredBacktrace)
context[@current_step.result_key].fail(raised_exception?: true, exception: e)
context[result_key][:exception] = e
context.fail!
end
end
# @!visibility private
class OnlyIfStep < Step
include StepsHelpers
attr_reader :steps
def initialize(name, &block)
super(name)
@steps = []
instance_exec(&block)
end
def run_step
return context[result_key][:skipped?] = true unless super
steps.each { |step| step.call(instance, context) }
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
@@ -418,127 +214,6 @@ module Service
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 =
+80
View File
@@ -0,0 +1,80 @@
# frozen_string_literal: true
module Service
module Base
# Simple structure to hold the context of the service during its whole lifecycle.
class Context
delegate :slice, :dig, to: :store
def self.build(context = {})
self === context ? context : new(context)
end
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 method_missing(method_name, *args, &block)
return super if args.present?
store[method_name]
end
def respond_to_missing?(name, include_all)
store.key?(name) || super
end
end
end
end
+43
View File
@@ -0,0 +1,43 @@
# frozen_string_literal: true
module Service
module Base
# @!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:)
@default_values_from = default_values_from
end
def run_step
contract =
class_name.new(**default_values.merge(context[:params]), 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
def default_values
return {} unless default_values_from
model = context[default_values_from]
model.try(:attributes).try(:with_indifferent_access) || model
end
end
end
end
+48
View File
@@ -0,0 +1,48 @@
# frozen_string_literal: true
module Service
module Base
# @!visibility private
class LockStep < Step
include StepsHelpers
attr_reader :steps, :keys
def initialize(*keys, &block)
super(keys.join(":"))
@keys = keys
@steps = []
instance_exec(&block)
end
def run_step
success =
begin
DistributedMutex.synchronize(lock_name) do
steps.each { |step| step.call(instance, context) }
:success
end
rescue Discourse::ReadOnly
:read_only
end
if success != :success
context[result_key].fail(lock_not_acquired: true)
context.fail!
end
end
private
def lock_name
[
context.__service_class__.to_s.underscore,
*keys.flat_map do |key|
value = context[:params].public_send(key) || context[key]
[key, value.try(:id) || value]
end,
].join(":")
end
end
end
end
+35
View File
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Service
module Base
# @!visibility private
class ModelStep < Step
class NotFound < StandardError
end
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
raise NotFound if !optional && (!context[name] || context[name].try(:empty?))
if context[name].try(:has_changes_to_save?) && context[name].try(:invalid?)
context[result_key].fail(invalid: true)
context.fail!
end
rescue Failure, DefaultValuesNotAllowed
raise
rescue => exception
context[result_key].fail(
not_found: true,
exception: (exception unless exception.is_a?(NotFound)),
)
context.fail!
end
end
end
end
+23
View File
@@ -0,0 +1,23 @@
# frozen_string_literal: true
module Service
module Base
# @!visibility private
class OnlyIfStep < Step
include StepsHelpers
attr_reader :steps
def initialize(name, &block)
super(name)
@steps = []
instance_exec(&block)
end
def run_step
return context[result_key][:skipped?] = true unless super
steps.each { |step| step.call(instance, context) }
end
end
end
end
+12
View File
@@ -0,0 +1,12 @@
# frozen_string_literal: true
module Service
module Base
# @!visibility private
class OptionsStep < Step
def run_step
context[:options] = class_name.new(context[:options])
end
end
end
end
+15
View File
@@ -0,0 +1,15 @@
# frozen_string_literal: true
module Service
module Base
# @!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
end
end
+59
View File
@@ -0,0 +1,59 @@
# frozen_string_literal: true
module Service
module Base
# @!visibility private
class Step
class DefaultValuesNotAllowed < StandardError
end
attr_reader :name, :method_name, :class_name
def initialize(name, method_name = name, class_name: nil)
@name, @method_name, @class_name = name, method_name, class_name
@instance = Concurrent::ThreadLocalVar.new
@context = Concurrent::ThreadLocalVar.new
end
def call(instance, context)
@instance.value, @context.value = instance, context
context[result_key] = Context.build
with_runtime { run_step }
end
def result_key
"result.#{type}.#{name}"
end
def instance = @instance.value
def context = @context.value
private
def run_step
object = class_name&.new(context)
method = object&.method(:call) || instance.method(method_name)
if !object && method.parameters.any? { it[0] != :keyreq }
raise DefaultValuesNotAllowed,
"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 { it[0] == :keyreq }.map(&:last))
context[result_key][:object] = object if object
instance.instance_exec(**args, &method)
end
def type
self.class.name.split("::").last.underscore.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
end
end
+54
View File
@@ -0,0 +1,54 @@
# frozen_string_literal: true
module Service
module Base
# 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:)
end
def params(
name = :default,
default_values_from: nil,
base_class: Service::ContractBase,
&block
)
contract_class = Class.new(base_class).tap { it.class_eval(&block) if block }
const_set("#{name.to_s.classify.sub("Default", "")}Contract", contract_class)
steps << ContractStep.new(name, class_name: contract_class, default_values_from:)
end
def policy(name = :default, class_name: nil)
steps << PolicyStep.new(name, class_name:)
end
def step(name)
steps << Step.new(name)
end
def transaction(&block)
steps << TransactionStep.new(&block)
end
def lock(*keys, &block)
steps << LockStep.new(*keys, &block)
end
def options(&block)
klass = Class.new(Service::OptionsBase).tap { it.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
def only_if(name, &block)
steps << OnlyIfStep.new(name, &block)
end
end
end
end
+22
View File
@@ -0,0 +1,22 @@
# frozen_string_literal: true
module Service
module Base
# @!visibility private
class TransactionStep < Step
include StepsHelpers
attr_reader :steps
def initialize(&block)
super("")
@steps = []
instance_exec(&block)
end
def run_step
ActiveRecord::Base.transaction { steps.each { |step| step.call(instance, context) } }
end
end
end
end
+44
View File
@@ -0,0 +1,44 @@
# frozen_string_literal: true
module Service
module Base
# @!visibility private
class TryStep < Step
module FilteredBacktrace
def filtered_backtrace
Array
.wrap(backtrace)
.chunk { it.match?(%r{/(gems|lib/service|ruby)/}) }
.flat_map do |excluded, lines|
next "(#{lines.size} framework line(s) excluded)" if excluded
lines
end
end
end
include StepsHelpers
attr_reader :steps, :exceptions
def initialize(exceptions, &block)
super("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)
e.singleton_class.prepend(FilteredBacktrace)
context[@current_step.result_key].fail(raised_exception?: true, exception: e)
context[result_key][:exception] = e
context.fail!
end
end
end
end
+2 -2
View File
@@ -10,10 +10,10 @@ class Service::PolicyBase
end
def call
raise "Not implemented"
raise NotImplementedError
end
def reason
raise "Not implemented"
raise NotImplementedError
end
end