discourse/lib/service/steps_inspector.rb
Loïc Guitaut 41584ab40c DEV: Provide user input to services using params key
Currently in services, we don’t make a distinction between input
parameters, options and dependencies.

This can lead to user input modifying the service behavior, whereas it
was not the developer intention.

This patch addresses the issue by changing how data is provided to
services:
- `params` is now used to hold all data coming from outside (typically
  user input from a controller) and a contract will take its values from
  `params`.
- `options` is a new key to provide options to a service. This typically
  allows changing a service behavior at runtime. It is, of course,
  totally optional.
- `dependencies` is actually anything else provided to the service (like
  `guardian`) and available directly from the context object.

The `service_params` helper in controllers has been updated to reflect
those changes, so most of the existing services didn’t need specific
changes.

The options block has the same DSL as contracts, as it’s also based on
`ActiveModel`. There aren’t any validations, though. Here’s an example:
```ruby
options do
  attribute :allow_changing_hidden, :boolean, default: false
end
```
And here’s an example of how to call a service with the new keys:
```ruby
MyService.call(params: { key1: value1, … }, options: { my_option: true }, guardian:, …)
```
2024-10-25 09:57:59 +02:00

129 lines
3.0 KiB
Ruby

# frozen_string_literal: true
# = Service::StepsInspector
#
# This class takes a {Service::Base::Context} object and inspects it.
# It will output a list of steps and what is their known state.
class Service::StepsInspector
# @!visibility private
class Step
attr_reader :step, :result, :nesting_level
delegate :name, to: :step
delegate :failure?, :success?, :error, to: :step_result, allow_nil: true
def self.for(step, result, nesting_level: 0)
class_name =
"#{module_parent_name}::#{step.class.name.split("::").last.sub(/^(\w+)Step$/, "\\1")}"
class_name.constantize.new(step, result, nesting_level: nesting_level)
end
def initialize(step, result, nesting_level: 0)
@step = step
@result = result
@nesting_level = nesting_level
end
def type
self.class.name.split("::").last.downcase
end
def emoji
"#{result_emoji}#{unexpected_result_emoji}"
end
def steps
[self]
end
def inspect
"#{" " * nesting_level}[#{type}] '#{name}' #{emoji}".rstrip
end
private
def step_result
result["result.#{type}.#{name}"]
end
def result_emoji
return "" if failure?
return "" if success?
""
end
def unexpected_result_emoji
" ⚠️#{unexpected_result_text}" if step_result.try(:[], "spec.unexpected_result")
end
def unexpected_result_text
return " <= expected to return true but got false instead" if failure?
" <= expected to return false but got true instead"
end
end
# @!visibility private
class Model < Step
def error
return result[name].errors.inspect if step_result.invalid
step_result.exception.full_message
end
end
# @!visibility private
class Contract < Step
def error
"#{step_result.errors.inspect}\n\nProvided parameters: #{step_result.parameters.pretty_inspect}"
end
end
# @!visibility private
class Policy < Step
def error
step_result.reason
end
end
# @!visibility private
class Transaction < Step
def steps
[self, *step.steps.map { Step.for(_1, result, nesting_level: nesting_level + 1).steps }]
end
def inspect
"#{" " * nesting_level}[#{type}]"
end
def step_result
nil
end
end
#
# @!visibility private
class Options < Step
end
attr_reader :steps, :result
def initialize(result)
@steps = result.__steps__.map { Step.for(_1, result).steps }.flatten
@result = result
end
# Inspect the provided result object.
# Example output:
# [1/4] [model] 'channel' ✅
# [2/4] [contract] 'default' ✅
# [3/4] [policy] 'check_channel_permission' ❌
# [4/4] [step] 'change_status'
# @return [String] the steps of the result object with their state
def inspect
steps.map.with_index { |step, index| "[#{index + 1}/#{steps.size}] #{step.inspect}" }.join("\n")
end
# @return [String, nil] the first available error, if any.
def error
steps.detect(&:failure?)&.error
end
end