discourse/plugins/chat/app/services/base.rb

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

428 lines
13 KiB
Ruby
Raw Normal View History

DEV: Chat service object initial implementation (#19814) This is a combined work of Martin Brennan, Loïc Guitaut, and Joffrey Jaffeux. --- This commit implements a base service object when working in chat. The documentation is available at https://discourse.github.io/discourse/chat/backend/Chat/Service.html Generating documentation has been made as part of this commit with a bigger goal in mind of generally making it easier to dive into the chat project. Working with services generally involves 3 parts: - The service object itself, which is a series of steps where few of them are specialized (model, transaction, policy) ```ruby class UpdateAge include Chat::Service::Base model :user, :fetch_user policy :can_see_user contract step :update_age class Contract attribute :age, :integer end def fetch_user(user_id:, **) User.find_by(id: user_id) end def can_see_user(guardian:, **) guardian.can_see_user(user) end def update_age(age:, **) user.update!(age: age) end end ``` - The `with_service` controller helper, handling success and failure of the service within a service and making easy to return proper response to it from the controller ```ruby def update with_service(UpdateAge) do on_success { render_serialized(result.user, BasicUserSerializer, root: "user") } end end ``` - Rspec matchers and steps inspector, improving the dev experience while creating specs for a service ```ruby RSpec.describe(UpdateAge) do subject(:result) do described_class.call(guardian: guardian, user_id: user.id, age: age) end fab!(:user) { Fabricate(:user) } fab!(:current_user) { Fabricate(:admin) } let(:guardian) { Guardian.new(current_user) } let(:age) { 1 } it { expect(user.reload.age).to eq(age) } end ``` Note in case of unexpected failure in your spec, the output will give all the relevant information: ``` 1) UpdateAge when no channel_id is given is expected to fail to find a model named 'user' Failure/Error: it { is_expected.to fail_to_find_a_model(:user) } Expected model 'foo' (key: 'result.model.user') was not found in the result object. [1/4] [model] 'user' ❌ [2/4] [policy] 'can_see_user' [3/4] [contract] 'default' [4/4] [step] 'update_age' /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/update_age.rb:32:in `fetch_user': missing keyword: :user_id (ArgumentError) from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:202:in `instance_exec' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:202:in `call' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:219:in `call' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:417:in `block in run!' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:417:in `each' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:417:in `run!' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:411:in `run' from <internal:kernel>:90:in `tap' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:302:in `call' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/spec/services/update_age_spec.rb:15:in `block (3 levels) in <main>' ```
2023-02-13 07:09:57 -05:00
# frozen_string_literal: true
module Chat
module Service
# Module to be included to provide steps DSL to any class. This allows to
# create easy to understand services as the whole service cycle is visible
# simply by reading the beginning of its class.
#
# 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:
#
# * +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
# +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.
# * +contract(name = :default)+: used to validate the input parameters,
# typically provided by a user calling an endpoint. A special embedded
# +Contract+ class has to be defined to holds the validations. If the
# validations fail, the step will fail. Otherwise, the resulting contract
# will be available in +context[:contract]+.
# * +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.
#
# The methods defined on the service are automatically provided with
# the whole context passed as keyword arguments. This allows to define in a
# very explicit way what dependencies are used by the method. If for
# whatever reason a key isnt found in the current context, then Ruby will
# raise an exception when the method is called.
#
# Regarding contract classes, they have automatically {ActiveModel} modules
# included so all the {ActiveModel} API is available.
#
# @example An example from the {TrashChannel} service
# class TrashChannel
# include Base
#
# model :channel, :fetch_channel
# policy :invalid_access
# transaction do
# step :prevents_slug_collision
# step :soft_delete_channel
# step :log_channel_deletion
# end
# step :enqueue_delete_channel_relations_job
#
# private
#
# def fetch_channel(channel_id:, **)
# ChatChannel.find_by(id: channel_id)
# end
#
# def invalid_access(guardian:, channel:, **)
# guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel?
# end
#
# def prevents_slug_collision(channel:, **)
# …
# end
#
# def soft_delete_channel(guardian:, channel:, **)
# …
# end
#
# def log_channel_deletion(guardian:, channel:, **)
# …
# end
#
# def enqueue_delete_channel_relations_job(channel:, **)
# …
# end
# end
# @example An example from the {UpdateChannelStatus} service which uses a contract
# class UpdateChannelStatus
# include Base
#
# model :channel, :fetch_channel
# contract
# policy :check_channel_permission
# step :change_status
#
# class Contract
# attribute :status
# validates :status, inclusion: { in: ChatChannel.editable_statuses.keys }
# end
#
# …
# end
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 < OpenStruct
# @return [Boolean] returns +true+ if the conext 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 = {})
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 = {})
merge(context)
@failure = true
self
end
# Merges the given context into the current one.
# @!visibility private
def merge(other_context = {})
other_context.each { |key, value| self[key.to_sym] = value }
self
end
private
def self.build(context = {})
self === context ? context : new(context)
end
end
# Internal module to define available steps as DSL
# @!visibility private
module StepsHelpers
def model(name = :model, step_name = :"fetch_#{name}")
steps << ModelStep.new(name, step_name)
end
def contract(name = :default, class_name: self::Contract, default_values_from: nil)
steps << ContractStep.new(
name,
class_name: class_name,
default_values_from: default_values_from,
)
end
def policy(name = :default)
steps << PolicyStep.new(name)
end
def step(name)
steps << Step.new(name)
end
def transaction(&block)
steps << TransactionStep.new(&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)
method = instance.method(method_name)
args = {}
args = context.to_h unless method.arity.zero?
context[result_key] = Context.build
instance.instance_exec(**args, &method)
end
private
def type
self.class.name.split("::").last.downcase.sub(/^(\w+)step$/, "\\1")
end
def result_key
"result.#{type}.#{name}"
end
end
# @!visibility private
class ModelStep < Step
def call(instance, context)
context[name] = super
raise ArgumentError, "Model not found" unless context[name]
rescue ArgumentError => exception
context[result_key].fail(exception: exception)
context.fail!
end
end
# @!visibility private
class PolicyStep < Step
def call(instance, context)
unless super
context[result_key].fail
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.to_h.slice(*attributes)))
context[contract_name] = contract
context[result_key] = Context.build
unless contract.valid?
context[result_key].fail(errors: contract.errors)
context.fail!
end
end
private
def contract_name
return :contract if name.to_sym == :default
:"#{name}_contract"
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
included do
# The global context which is available from any step.
attr_reader :context
# @!visibility private
# Internal class used to setup the base contract of the service.
self::Contract =
Class.new do
include ActiveModel::API
include ActiveModel::Attributes
include ActiveModel::AttributeMethods
include ActiveModel::Validations::Callbacks
end
end
class_methods do
include StepsHelpers
def call(context = {})
new(context).tap(&:run).context
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}")
# @param name [Symbol] name of the model
# @param step_name [Symbol] name of the method to call for this step
# 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, :fetch_channel
#
# private
#
# def fetch_channel(channel_id:, **)
# ChatChannel.find_by(id: channel_id)
# end
# @!scope class
# @!method policy(name = :default)
# @param name [Symbol] name for this policy
# Performs checks related to the state of the system. If the
# step doesnt return a truthy value, then the policy will fail.
#
# @example
# policy :no_direct_message_channel
#
# private
#
# def no_direct_message_channel(channel:, **)
# !channel.direct_message_channel?
# end
# @!scope class
# @!method contract(name = :default, class_name: self::Contract, default_values_from: nil)
# @param name [Symbol] name for this contract
# @param class_name [Class] a class defining the contract
# @param default_values_from [Symbol] name of the model to get default values from
# Checks the validity of the input parameters.
# Implements ActiveModel::Validations and ActiveModel::Attributes.
#
# It stores the resulting contract in +context[:contract]+ by default
# (can be customized by providing the +name+ argument).
#
# @example
# contract
#
# class Contract
# 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") unless 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
# @!visibility private
def initialize(initial_context = {})
@initial_context = initial_context.with_indifferent_access
@context = Context.build(initial_context.merge(__steps__: self.class.steps))
end
private
def run
run!
rescue Failure => exception
raise if context.object_id != exception.context.object_id
end
def run!
self.class.steps.each { |step| step.call(self, context) }
end
def fail!(message)
step_name = caller_locations(1, 1)[0].label
context["result.step.#{step_name}"].fail(error: message)
context.fail!
end
end
end
end