2023-03-17 21:24:38 +08:00
|
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
|
|
module Service
|
|
|
|
|
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.
|
2024-10-04 00:05:45 +08:00
|
|
|
|
class Context
|
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-18 23:45:47 +08:00
|
|
|
|
delegate :slice, :dig, to: :store
|
2024-10-04 00:05:45 +08:00
|
|
|
|
|
|
|
|
|
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
|
2023-07-06 00:18:27 +08:00
|
|
|
|
|
2023-04-04 01:27:32 +08:00
|
|
|
|
# @return [Boolean] returns +true+ if the context is set as successful (default)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
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 = {})
|
2023-12-07 06:25:00 +08:00
|
|
|
|
self.fail(context)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
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 = {})
|
2024-10-04 00:05:45 +08:00
|
|
|
|
store.merge!(context.symbolize_keys)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
@failure = true
|
|
|
|
|
self
|
|
|
|
|
end
|
|
|
|
|
|
2023-05-16 20:51:13 +08:00
|
|
|
|
def inspect_steps
|
2024-12-04 01:15:54 +08:00
|
|
|
|
Service::StepsInspector.new(self).inspect
|
2023-05-16 20:51:13 +08:00
|
|
|
|
end
|
|
|
|
|
|
2023-03-17 21:24:38 +08:00
|
|
|
|
private
|
|
|
|
|
|
2024-10-04 00:05:45 +08:00
|
|
|
|
attr_reader :store
|
|
|
|
|
|
2023-03-17 21:24:38 +08:00
|
|
|
|
def self.build(context = {})
|
|
|
|
|
self === context ? context : new(context)
|
|
|
|
|
end
|
2024-10-04 00:05:45 +08:00
|
|
|
|
|
|
|
|
|
def method_missing(method_name, *args, &block)
|
|
|
|
|
return super if args.present?
|
|
|
|
|
store[method_name]
|
|
|
|
|
end
|
2023-03-17 21:24:38 +08:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Internal module to define available steps as DSL
|
|
|
|
|
# @!visibility private
|
|
|
|
|
module StepsHelpers
|
2023-07-06 17:33:41 +08:00
|
|
|
|
def model(name = :model, step_name = :"fetch_#{name}", optional: false)
|
|
|
|
|
steps << ModelStep.new(name, step_name, optional: optional)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
end
|
|
|
|
|
|
DEV: Replace `params` by the contract object in services
This patch replaces the parameters provided to a service through
`params` by the contract object.
That way, it allows better consistency when accessing input params. For
example, if you have a service without a contract, to access a
parameter, you need to use `params[:my_parameter]`. But with a contract,
you do this through `contract.my_parameter`. Now, with this patch,
you’ll be able to access it through `params.my_parameter` or
`params[:my_parameter]`.
Some methods have been added to the contract object to better mimic a
Hash. That way, when accessing/using `params`, you don’t have to think
too much about it:
- `params.my_key` is also accessible through `params[:my_key]`.
- `params.my_key = value` can also be done through `params[:my_key] =
value`.
- `#slice` and `#merge` are available.
- `#to_hash` has been implemented, so the contract object will be
automatically cast as a hash by Ruby depending on the context. For
example, with an AR model, you can do this: `user.update(**params)`.
2024-10-23 23:57:48 +08:00
|
|
|
|
def params(name = :default, default_values_from: nil, &block)
|
DEV: Have `contract` take a block in services
Currently in services, the `contract` step is only used to define where
the contract will be called in the execution flow. Then, a `Contract`
class has to be defined with validations in it.
This patch allows the `contract` step to take a block containing
validations, attributes, etc. directly. No need to then open a
`Contract` class later in the service.
It also has a nice side effect, as it’s now easy to define multiples
contracts inside the same service. Before, we had the `class_name:`
option, but it wasn’t really useful as you had to redefine a complete
new contract class.
Now, when using a name for the contract other than `default`, a new
contract will be created automatically using the provided name.
Example:
```ruby
contract(:user) do
attribute :user_id, :integer
validates :user_id, presence: true
end
```
This will create a `UserContract` class and use it, also putting the
resulting contract in `context[:user_contract]`.
2024-10-01 23:17:14 +08:00
|
|
|
|
contract_class = Class.new(Service::ContractBase).tap { _1.class_eval(&block) }
|
|
|
|
|
const_set("#{name.to_s.classify.sub("Default", "")}Contract", contract_class)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
steps << ContractStep.new(
|
|
|
|
|
name,
|
DEV: Have `contract` take a block in services
Currently in services, the `contract` step is only used to define where
the contract will be called in the execution flow. Then, a `Contract`
class has to be defined with validations in it.
This patch allows the `contract` step to take a block containing
validations, attributes, etc. directly. No need to then open a
`Contract` class later in the service.
It also has a nice side effect, as it’s now easy to define multiples
contracts inside the same service. Before, we had the `class_name:`
option, but it wasn’t really useful as you had to redefine a complete
new contract class.
Now, when using a name for the contract other than `default`, a new
contract will be created automatically using the provided name.
Example:
```ruby
contract(:user) do
attribute :user_id, :integer
validates :user_id, presence: true
end
```
This will create a `UserContract` class and use it, also putting the
resulting contract in `context[:user_contract]`.
2024-10-01 23:17:14 +08:00
|
|
|
|
class_name: contract_class,
|
2023-03-17 21:24:38 +08:00
|
|
|
|
default_values_from: default_values_from,
|
|
|
|
|
)
|
|
|
|
|
end
|
|
|
|
|
|
2023-05-24 22:32:49 +08:00
|
|
|
|
def policy(name = :default, class_name: nil)
|
|
|
|
|
steps << PolicyStep.new(name, class_name: class_name)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def step(name)
|
|
|
|
|
steps << Step.new(name)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def transaction(&block)
|
|
|
|
|
steps << TransactionStep.new(&block)
|
|
|
|
|
end
|
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-18 23:45:47 +08:00
|
|
|
|
|
|
|
|
|
def options(&block)
|
|
|
|
|
klass = Class.new(Service::OptionsBase).tap { _1.class_eval(&block) }
|
|
|
|
|
const_set("Options", klass)
|
|
|
|
|
steps << OptionsStep.new(:default, class_name: klass)
|
|
|
|
|
end
|
2024-11-09 00:24:40 +08:00
|
|
|
|
|
|
|
|
|
def try(*exceptions, &block)
|
|
|
|
|
steps << TryStep.new(exceptions, &block)
|
|
|
|
|
end
|
2023-03-17 21:24:38 +08:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# @!visibility private
|
|
|
|
|
class Step
|
2024-12-04 01:15:54 +08:00
|
|
|
|
attr_reader :name, :method_name, :class_name, :instance, :context
|
2023-03-17 21:24:38 +08:00
|
|
|
|
|
|
|
|
|
def initialize(name, method_name = name, class_name: nil)
|
|
|
|
|
@name = name
|
|
|
|
|
@method_name = method_name
|
|
|
|
|
@class_name = class_name
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def call(instance, context)
|
2024-12-04 01:15:54 +08:00
|
|
|
|
@instance, @context = instance, context
|
|
|
|
|
context[result_key] = Context.build
|
|
|
|
|
with_runtime { run_step }
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def result_key
|
|
|
|
|
"result.#{type}.#{name}"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
private
|
|
|
|
|
|
|
|
|
|
def run_step
|
2023-05-24 22:32:49 +08:00
|
|
|
|
object = class_name&.new(context)
|
|
|
|
|
method = object&.method(:call) || instance.method(method_name)
|
2024-09-19 18:37:47 +08:00
|
|
|
|
if method.parameters.any? { _1[0] != :keyreq }
|
DEV: Replace `params` by the contract object in services
This patch replaces the parameters provided to a service through
`params` by the contract object.
That way, it allows better consistency when accessing input params. For
example, if you have a service without a contract, to access a
parameter, you need to use `params[:my_parameter]`. But with a contract,
you do this through `contract.my_parameter`. Now, with this patch,
you’ll be able to access it through `params.my_parameter` or
`params[:my_parameter]`.
Some methods have been added to the contract object to better mimic a
Hash. That way, when accessing/using `params`, you don’t have to think
too much about it:
- `params.my_key` is also accessible through `params[:my_key]`.
- `params.my_key = value` can also be done through `params[:my_key] =
value`.
- `#slice` and `#merge` are available.
- `#to_hash` has been implemented, so the contract object will be
automatically cast as a hash by Ruby depending on the context. For
example, with an AR model, you can do this: `user.update(**params)`.
2024-10-23 23:57:48 +08:00
|
|
|
|
raise "In #{type} '#{name}': default values in step implementations are not allowed. Maybe they could be defined in a params or options block?"
|
2024-09-19 18:37:47 +08:00
|
|
|
|
end
|
2024-10-04 00:05:45 +08:00
|
|
|
|
args = context.slice(*method.parameters.select { _1[0] == :keyreq }.map(&:last))
|
2024-12-04 01:15:54 +08:00
|
|
|
|
context[result_key][:object] = object if object
|
2023-03-17 21:24:38 +08:00
|
|
|
|
instance.instance_exec(**args, &method)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def type
|
|
|
|
|
self.class.name.split("::").last.downcase.sub(/^(\w+)step$/, "\\1")
|
|
|
|
|
end
|
2024-12-04 01:15:54 +08:00
|
|
|
|
|
|
|
|
|
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
|
2023-03-17 21:24:38 +08:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# @!visibility private
|
|
|
|
|
class ModelStep < Step
|
2023-07-06 17:33:41 +08:00
|
|
|
|
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
|
|
|
|
|
|
2024-12-04 01:15:54 +08:00
|
|
|
|
def run_step
|
2023-03-17 21:24:38 +08:00
|
|
|
|
context[name] = super
|
2024-08-20 18:05:41 +08:00
|
|
|
|
if !optional && (!context[name] || context[name].try(:empty?))
|
|
|
|
|
raise ArgumentError, "Model not found"
|
|
|
|
|
end
|
2023-04-24 07:15:16 +08:00
|
|
|
|
if context[name].try(:invalid?)
|
|
|
|
|
context[result_key].fail(invalid: true)
|
|
|
|
|
context.fail!
|
|
|
|
|
end
|
2023-03-17 21:24:38 +08:00
|
|
|
|
rescue ArgumentError => exception
|
2024-08-20 18:05:41 +08:00
|
|
|
|
context[result_key].fail(exception: exception, not_found: true)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
context.fail!
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# @!visibility private
|
|
|
|
|
class PolicyStep < Step
|
2024-12-04 01:15:54 +08:00
|
|
|
|
def run_step
|
2023-03-17 21:24:38 +08:00
|
|
|
|
if !super
|
2023-05-24 22:32:49 +08:00
|
|
|
|
context[result_key].fail(reason: context[result_key].object&.reason)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
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
|
|
|
|
|
|
2024-12-04 01:15:54 +08:00
|
|
|
|
def run_step
|
2023-03-17 21:24:38 +08:00
|
|
|
|
attributes = class_name.attribute_names.map(&:to_sym)
|
|
|
|
|
default_values = {}
|
|
|
|
|
default_values = context[default_values_from].slice(*attributes) if default_values_from
|
2024-10-28 23:04:39 +08:00
|
|
|
|
contract =
|
|
|
|
|
class_name.new(
|
|
|
|
|
**default_values.merge(context[:params].slice(*attributes)),
|
|
|
|
|
options: context[:options],
|
|
|
|
|
)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
context[contract_name] = contract
|
|
|
|
|
if contract.invalid?
|
2024-06-13 17:55:57 +08:00
|
|
|
|
context[result_key].fail(errors: contract.errors, parameters: contract.raw_attributes)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
context.fail!
|
|
|
|
|
end
|
2024-10-28 23:04:39 +08:00
|
|
|
|
contract.freeze
|
2023-03-17 21:24:38 +08:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
private
|
|
|
|
|
|
|
|
|
|
def contract_name
|
DEV: Replace `params` by the contract object in services
This patch replaces the parameters provided to a service through
`params` by the contract object.
That way, it allows better consistency when accessing input params. For
example, if you have a service without a contract, to access a
parameter, you need to use `params[:my_parameter]`. But with a contract,
you do this through `contract.my_parameter`. Now, with this patch,
you’ll be able to access it through `params.my_parameter` or
`params[:my_parameter]`.
Some methods have been added to the contract object to better mimic a
Hash. That way, when accessing/using `params`, you don’t have to think
too much about it:
- `params.my_key` is also accessible through `params[:my_key]`.
- `params.my_key = value` can also be done through `params[:my_key] =
value`.
- `#slice` and `#merge` are available.
- `#to_hash` has been implemented, so the contract object will be
automatically cast as a hash by Ruby depending on the context. For
example, with an AR model, you can do this: `user.update(**params)`.
2024-10-23 23:57:48 +08:00
|
|
|
|
return :params if default?
|
2023-03-17 21:24:38 +08:00
|
|
|
|
:"#{name}_contract"
|
|
|
|
|
end
|
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-18 23:45:47 +08:00
|
|
|
|
|
|
|
|
|
def default?
|
|
|
|
|
name.to_sym == :default
|
|
|
|
|
end
|
2023-03-17 21:24:38 +08:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# @!visibility private
|
|
|
|
|
class TransactionStep < Step
|
|
|
|
|
include StepsHelpers
|
|
|
|
|
|
|
|
|
|
attr_reader :steps
|
|
|
|
|
|
|
|
|
|
def initialize(&block)
|
|
|
|
|
@steps = []
|
|
|
|
|
instance_exec(&block)
|
|
|
|
|
end
|
|
|
|
|
|
2024-12-04 01:15:54 +08:00
|
|
|
|
def run_step
|
2023-03-17 21:24:38 +08:00
|
|
|
|
ActiveRecord::Base.transaction { steps.each { |step| step.call(instance, context) } }
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2024-11-09 00:24:40 +08:00
|
|
|
|
# @!visibility private
|
|
|
|
|
class TryStep < Step
|
|
|
|
|
include StepsHelpers
|
|
|
|
|
|
|
|
|
|
attr_reader :steps, :exceptions
|
|
|
|
|
|
|
|
|
|
def initialize(exceptions, &block)
|
|
|
|
|
@name = "default"
|
|
|
|
|
@steps = []
|
|
|
|
|
@exceptions = exceptions.presence || [StandardError]
|
|
|
|
|
instance_exec(&block)
|
|
|
|
|
end
|
|
|
|
|
|
2024-12-04 01:15:54 +08:00
|
|
|
|
def run_step
|
2024-11-09 00:24:40 +08:00
|
|
|
|
steps.each do |step|
|
|
|
|
|
@current_step = step
|
|
|
|
|
step.call(instance, context)
|
|
|
|
|
end
|
|
|
|
|
rescue *exceptions => e
|
|
|
|
|
raise e if e.is_a?(Failure)
|
|
|
|
|
context[@current_step.result_key].fail(raised_exception?: true, exception: e)
|
2024-12-04 01:15:54 +08:00
|
|
|
|
context[result_key][:exception] = e
|
2024-11-09 00:24:40 +08:00
|
|
|
|
context.fail!
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
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-18 23:45:47 +08:00
|
|
|
|
# @!visibility private
|
|
|
|
|
class OptionsStep < Step
|
2024-12-04 01:15:54 +08:00
|
|
|
|
def run_step
|
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-18 23:45:47 +08:00
|
|
|
|
context[:options] = class_name.new(context[:options])
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2023-03-17 21:24:38 +08:00
|
|
|
|
included do
|
|
|
|
|
# The global context which is available from any step.
|
|
|
|
|
attr_reader :context
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
class_methods do
|
|
|
|
|
include StepsHelpers
|
|
|
|
|
|
2024-09-04 00:30:22 +08:00
|
|
|
|
def call(context = {}, &actions)
|
|
|
|
|
return new(context).tap(&:run).context unless block_given?
|
2024-09-19 23:52:44 +08:00
|
|
|
|
Service::Runner.call(self, context, &actions)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def call!(context = {})
|
|
|
|
|
new(context).tap(&:run!).context
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def steps
|
|
|
|
|
@steps ||= []
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# @!scope class
|
2023-07-06 17:33:41 +08:00
|
|
|
|
# @!method model(name = :model, step_name = :"fetch_#{name}", optional: false)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# @param name [Symbol] name of the model
|
|
|
|
|
# @param step_name [Symbol] name of the method to call for this step
|
2023-07-06 17:33:41 +08:00
|
|
|
|
# @param optional [Boolean] if +true+, then the step won’t fail if its return value is falsy.
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# 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
|
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-18 23:45:47 +08:00
|
|
|
|
# model :channel
|
2023-03-17 21:24:38 +08:00
|
|
|
|
#
|
|
|
|
|
# private
|
|
|
|
|
#
|
2024-03-07 22:44:12 +08:00
|
|
|
|
# def fetch_channel(channel_id:)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# Chat::Channel.find_by(id: channel_id)
|
|
|
|
|
# end
|
|
|
|
|
|
|
|
|
|
# @!scope class
|
2023-05-24 22:32:49 +08:00
|
|
|
|
# @!method policy(name = :default, class_name: nil)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# @param name [Symbol] name for this policy
|
2023-05-24 22:32:49 +08:00
|
|
|
|
# @param class_name [Class] a policy object (should inherit from +PolicyBase+)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# Performs checks related to the state of the system. If the
|
|
|
|
|
# step doesn’t return a truthy value, then the policy will fail.
|
|
|
|
|
#
|
2023-05-24 22:32:49 +08:00
|
|
|
|
# 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
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# policy :no_direct_message_channel
|
|
|
|
|
#
|
|
|
|
|
# private
|
|
|
|
|
#
|
2024-03-07 22:44:12 +08:00
|
|
|
|
# def no_direct_message_channel(channel:)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# !channel.direct_message_channel?
|
|
|
|
|
# end
|
2023-05-24 22:32:49 +08:00
|
|
|
|
#
|
|
|
|
|
# @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
|
2023-03-17 21:24:38 +08:00
|
|
|
|
|
|
|
|
|
# @!scope class
|
DEV: Replace `params` by the contract object in services
This patch replaces the parameters provided to a service through
`params` by the contract object.
That way, it allows better consistency when accessing input params. For
example, if you have a service without a contract, to access a
parameter, you need to use `params[:my_parameter]`. But with a contract,
you do this through `contract.my_parameter`. Now, with this patch,
you’ll be able to access it through `params.my_parameter` or
`params[:my_parameter]`.
Some methods have been added to the contract object to better mimic a
Hash. That way, when accessing/using `params`, you don’t have to think
too much about it:
- `params.my_key` is also accessible through `params[:my_key]`.
- `params.my_key = value` can also be done through `params[:my_key] =
value`.
- `#slice` and `#merge` are available.
- `#to_hash` has been implemented, so the contract object will be
automatically cast as a hash by Ruby depending on the context. For
example, with an AR model, you can do this: `user.update(**params)`.
2024-10-23 23:57:48 +08:00
|
|
|
|
# @!method params(name = :default, default_values_from: nil, &block)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# @param name [Symbol] name for this contract
|
|
|
|
|
# @param default_values_from [Symbol] name of the model to get default values from
|
DEV: Have `contract` take a block in services
Currently in services, the `contract` step is only used to define where
the contract will be called in the execution flow. Then, a `Contract`
class has to be defined with validations in it.
This patch allows the `contract` step to take a block containing
validations, attributes, etc. directly. No need to then open a
`Contract` class later in the service.
It also has a nice side effect, as it’s now easy to define multiples
contracts inside the same service. Before, we had the `class_name:`
option, but it wasn’t really useful as you had to redefine a complete
new contract class.
Now, when using a name for the contract other than `default`, a new
contract will be created automatically using the provided name.
Example:
```ruby
contract(:user) do
attribute :user_id, :integer
validates :user_id, presence: true
end
```
This will create a `UserContract` class and use it, also putting the
resulting contract in `context[:user_contract]`.
2024-10-01 23:17:14 +08:00
|
|
|
|
# @param block [Proc] a block containing validations
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# Checks the validity of the input parameters.
|
|
|
|
|
# Implements ActiveModel::Validations and ActiveModel::Attributes.
|
|
|
|
|
#
|
DEV: Replace `params` by the contract object in services
This patch replaces the parameters provided to a service through
`params` by the contract object.
That way, it allows better consistency when accessing input params. For
example, if you have a service without a contract, to access a
parameter, you need to use `params[:my_parameter]`. But with a contract,
you do this through `contract.my_parameter`. Now, with this patch,
you’ll be able to access it through `params.my_parameter` or
`params[:my_parameter]`.
Some methods have been added to the contract object to better mimic a
Hash. That way, when accessing/using `params`, you don’t have to think
too much about it:
- `params.my_key` is also accessible through `params[:my_key]`.
- `params.my_key = value` can also be done through `params[:my_key] =
value`.
- `#slice` and `#merge` are available.
- `#to_hash` has been implemented, so the contract object will be
automatically cast as a hash by Ruby depending on the context. For
example, with an AR model, you can do this: `user.update(**params)`.
2024-10-23 23:57:48 +08:00
|
|
|
|
# It stores the resulting contract in +context[:params]+ by default
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# (can be customized by providing the +name+ argument).
|
|
|
|
|
#
|
|
|
|
|
# @example
|
DEV: Replace `params` by the contract object in services
This patch replaces the parameters provided to a service through
`params` by the contract object.
That way, it allows better consistency when accessing input params. For
example, if you have a service without a contract, to access a
parameter, you need to use `params[:my_parameter]`. But with a contract,
you do this through `contract.my_parameter`. Now, with this patch,
you’ll be able to access it through `params.my_parameter` or
`params[:my_parameter]`.
Some methods have been added to the contract object to better mimic a
Hash. That way, when accessing/using `params`, you don’t have to think
too much about it:
- `params.my_key` is also accessible through `params[:my_key]`.
- `params.my_key = value` can also be done through `params[:my_key] =
value`.
- `#slice` and `#merge` are available.
- `#to_hash` has been implemented, so the contract object will be
automatically cast as a hash by Ruby depending on the context. For
example, with an AR model, you can do this: `user.update(**params)`.
2024-10-23 23:57:48 +08:00
|
|
|
|
# params do
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# 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
|
|
|
|
|
#
|
2024-03-07 22:44:12 +08:00
|
|
|
|
# def update_channel(channel:, params_to_edit:)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# channel.update!(params_to_edit)
|
|
|
|
|
# end
|
|
|
|
|
# @example using {#fail!} in a step
|
|
|
|
|
# step :save_channel
|
|
|
|
|
#
|
|
|
|
|
# private
|
|
|
|
|
#
|
2024-03-07 22:44:12 +08:00
|
|
|
|
# def save_channel(channel:)
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
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-18 23:45:47 +08:00
|
|
|
|
# @!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
|
|
|
|
|
|
2023-03-17 21:24:38 +08:00
|
|
|
|
# @!visibility private
|
|
|
|
|
def initialize(initial_context = {})
|
2024-12-04 01:15:54 +08:00
|
|
|
|
@context =
|
|
|
|
|
Context.build(
|
2024-12-12 00:58:17 +08:00
|
|
|
|
initial_context
|
|
|
|
|
.compact
|
|
|
|
|
.reverse_merge(params: {})
|
|
|
|
|
.merge(__steps__: self.class.steps, __service_class__: self.class),
|
2024-12-04 01:15:54 +08:00
|
|
|
|
)
|
2024-12-12 00:58:17 +08:00
|
|
|
|
initialize_params
|
2023-03-17 21:24:38 +08:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# @!visibility private
|
|
|
|
|
def run
|
|
|
|
|
run!
|
|
|
|
|
rescue Failure => exception
|
|
|
|
|
raise if context.object_id != exception.context.object_id
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# @!visibility private
|
|
|
|
|
def run!
|
|
|
|
|
self.class.steps.each { |step| step.call(self, context) }
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# @!visibility private
|
|
|
|
|
def fail!(message)
|
2023-09-07 14:57:29 +08:00
|
|
|
|
step_name = caller_locations(1, 1)[0].base_label
|
2023-03-17 21:24:38 +08:00
|
|
|
|
context["result.step.#{step_name}"].fail(error: message)
|
|
|
|
|
context.fail!
|
|
|
|
|
end
|
2024-12-12 00:58:17 +08:00
|
|
|
|
|
|
|
|
|
private
|
|
|
|
|
|
|
|
|
|
def initialize_params
|
|
|
|
|
klass =
|
|
|
|
|
Data.define(*context[:params].keys) do
|
|
|
|
|
alias to_hash to_h
|
|
|
|
|
|
|
|
|
|
delegate :slice, :merge, to: :to_h
|
|
|
|
|
|
|
|
|
|
def method_missing(*)
|
|
|
|
|
nil
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
context[:params] = klass.new(*context[:params].values)
|
|
|
|
|
end
|
2023-03-17 21:24:38 +08:00
|
|
|
|
end
|
|
|
|
|
end
|