discourse/plugins/chat/lib/endpoint.rb
Martin Brennan 60ad836313
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 13:09:57 +01:00

110 lines
3.5 KiB
Ruby

# frozen_string_literal: true
#
# = Chat::Endpoint
#
# This class is to be used via its helper +with_service+ in a controller. 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
# controller.
#
# 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_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 service
# fails and its model is not present
#
# @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) { redirect_to root_path }
# on_failure { render :new }
# 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 Chat::Endpoint
# @!visibility private
NULL_RESULT = OpenStruct.new(failure?: false)
# @!visibility private
AVAILABLE_ACTIONS = {
on_success: -> { result.success? },
on_failure: -> { result.failure? },
on_failed_policy: ->(name = "default") { failure_for?("result.policy.#{name}") },
on_failed_contract: ->(name = "default") { failure_for?("result.contract.#{name}") },
on_model_not_found: ->(name = "model") { failure_for?("result.model.#{name}") },
}.with_indifferent_access.freeze
# @!visibility private
attr_reader :service, :controller, :dependencies
delegate :result, to: :controller
# @!visibility private
def initialize(service, controller, **dependencies)
@service = service
@controller = controller
@dependencies = dependencies
@actions = {}
end
# @param service [Class] a class including {Chat::Service::Base}
# @param block [Proc] a block containing the steps to match on
# @return [void]
def self.call(service, controller, **dependencies, &block)
new(service, controller, **dependencies).call(&block)
end
# @!visibility private
def call(&block)
instance_eval(&block)
controller.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)
(controller.result[key] || NULL_RESULT).failure?
end
def add_action(name, *args, &block)
actions[[name, *args].join("_").to_sym] = [
-> { instance_exec(*args, &AVAILABLE_ACTIONS[name]) },
-> { controller.instance_eval(&block) },
]
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