discourse/plugins/chat/lib/service_runner.rb
Loïc Guitaut 0733dda1cb DEV: Add policy objects to services
This patch introduces policy objects to chat services. It allows putting
more complex logic in a dedicated class, which will make services
thinner. It also allows providing a reason why the policy failed.

Some change has been made to the service runner too to use more easily
these new policy objects: when matching a failing policy (or any failing
step actually), the result object is now provided to the block. This
way, instead of having to access the reason why the policy failed by
doing `result["result.policy.policy_name"].reason` inside the block,
this one can be simply written like this:
```ruby
  on_failed_policy(:policy_name) { |policy| policy.reason }
```
2023-05-25 12:34:00 +02:00

161 lines
4.9 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
#
# = ServiceRunner
#
# This class is to be used via its helper +with_service+ in any class. Its
# main purpose is to ease how actions can be run upon a service completion.
# Since a service will likely return the same kind of things over and over,
# this allows us to not have to repeat the same boilerplate code in every
# object.
#
# There are several available actions and we can add new ones very easily:
#
# * +on_success+: will execute the provided block if the service succeeds
# * +on_failure+: will execute the provided block if the service fails
# * +on_failed_step(name)+: will execute the provided block if the step named
# `name` fails
# * +on_failed_policy(name)+: will execute the provided block if the policy
# named `name` fails
# * +on_failed_contract(name)+: will execute the provided block if the contract
# named `name` fails
# * +on_model_not_found(name)+: will execute the provided block if the model
# named `name` is not present
# * +on_model_errors(name)+: will execute the provided block if the model named
# `name` contains validation errors
#
# All the specialized steps receive the failing step result object as an
# argument to their block. `on_model_errors` receives the actual model so its
# easier to inspect it.
#
# Default actions for each of these are defined in [Chat::ApiController#default_actions_for_service]
#
# @example In a controller
# def create
# with_service MyService do
# on_success do
# flash[:notice] = "Success!"
# redirect_to a_path
# end
# on_failed_policy(:a_named_policy) { |policy| redirect_to root_path, alert: policy.reason }
# on_failure { render :new }
# end
# end
#
# @example In a job (inheriting from +ServiceJob+)
# def execute(args = {})
# with_service(MyService, **args) do
# on_success { Rails.logger.info "SUCCESS" }
# on_failure { Rails.logger.error "FAILURE" }
# end
# end
#
# The actions will be evaluated in the order they appear. So even if the
# service will ultimately fail with a failed policy, in this example only the
# +on_failed_policy+ action will be executed and not the +on_failure+ one.
# The only exception to this being +on_failure+ as it will always be executed
# last.
#
class ServiceRunner
# @!visibility private
AVAILABLE_ACTIONS = {
on_success: {
condition: -> { result.success? },
key: [],
},
on_failure: {
condition: -> { result.failure? },
key: [],
},
on_failed_step: {
condition: ->(name) { failure_for?("result.step.#{name}") },
key: %w[result step],
},
on_failed_policy: {
condition: ->(name = "default") { failure_for?("result.policy.#{name}") },
key: %w[result policy],
default_name: "default",
},
on_failed_contract: {
condition: ->(name = "default") { failure_for?("result.contract.#{name}") },
key: %w[result contract],
default_name: "default",
},
on_model_not_found: {
condition: ->(name = "model") { failure_for?("result.model.#{name}") && result[name].blank? },
key: %w[result model],
default_name: "model",
},
on_model_errors: {
condition: ->(name = "model") do
failure_for?("result.model.#{name}") && result["result.model.#{name}"].invalid
end,
key: [],
default_name: "model",
},
}.with_indifferent_access.freeze
# @!visibility private
attr_reader :service, :object, :dependencies
delegate :result, to: :object
# @!visibility private
def initialize(service, object, **dependencies)
@service = service
@object = object
@dependencies = dependencies
@actions = {}
end
# @param service [Class] a class including {Service::Base}
# @param block [Proc] a block containing the steps to match on
# @return [void]
def self.call(service, object, **dependencies, &block)
new(service, object, **dependencies).call(&block)
end
# @!visibility private
def call(&block)
instance_eval(&block)
object.run_service(service, dependencies)
# Always have `on_failure` as the last action
(
actions
.except(:on_failure)
.merge(actions.slice(:on_failure))
.detect { |name, (condition, _)| condition.call } || [-> {}]
).flatten.last.call
end
private
attr_reader :actions
def failure_for?(key)
object.result[key]&.failure?
end
def add_action(name, *args, &block)
action = AVAILABLE_ACTIONS[name]
actions[[name, *args].join("_").to_sym] = [
-> { instance_exec(*args, &action[:condition]) },
-> do
object.instance_exec(
result[[*action[:key], args.first || action[:default_name]].join(".")],
&block
)
end,
]
end
def method_missing(method_name, *args, &block)
return super unless AVAILABLE_ACTIONS[method_name]
add_action(method_name, *args, &block)
end
def respond_to_missing?(method_name, include_private = false)
AVAILABLE_ACTIONS[method_name] || super
end
end