Module: FlowOrganizer

Defined in:
lib/flow_organizer.rb,
lib/flow_organizer/version.rb

Overview

FlowOrganizer passes the result of a callable to another callable (as long as the result is successfull).

It is mostly useful when you need to execute a series of operations ressembling a pipeline.

You might alredy be familiar with some solutions that deal with this (Promises, Railway Programming, Pipe operators): ‘FlowOrganizer` is a flavor of functional interactor.

### Introduction Describing a list of operations often leads to code that is difficult to follow or nested requires a lot of nesting. For instance: “‘ruby fire_user_created_event(persist_user(validate_password(validate_email({ email: email, password: password }))))

# or

valid_email? = validate_email(email) valid_password? = validate_password(password) if valid_email? && valid_password?

user = persist_user(email: email, password: password)
if user
  fire_user_created_event(user: user)
end

end “‘

With ‘FlowOrganizer`, this is expressed as: “`ruby FlowOrganizer.call(

list: [
  [:alias, :validate_email],
  [:alias, :validate_password],
  [:alias, :persist_user],
  [:alias, :fire_user_created_event],
],
ctx: {
  email: '',
  password: '',
},

) “‘

#### Context An organizer uses a context. The context contains everything the set of operations need to work. When an operation is called, it can affect the context.

#### Callable A callable is expected to return a result tupple of the following format: “‘ruby

:ok

|| [:ok, context_update] || [:halt] || [:halt, context_update] || [:error] || [:error, context_update]

“‘

Defined Under Namespace

Modules: Callable, Context Classes: Error

Constant Summary collapse

VERSION =
'1.0.0'

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.exception_reporterObject

Optional callback invoked when a callable raises. Signature: ‘->(exception:) { … }`. Wire this to Sentry/Honeybadger/etc. The default is no-op.



57
58
59
# File 'lib/flow_organizer.rb', line 57

def exception_reporter
  @exception_reporter
end

Class Method Details

.call(list:, list_error: nil, ctx: nil, raise_exception: false) ⇒ Object

Run a ‘list` of `operations` (callables) in order.

Each results update the initial ‘ctx` which is then sent to the next operation.

An ‘operation` needs to be a callable, but it can be resolved from other format (see `#to_callable`)

NOTE: Every operation is expected to return a tupple of the format ‘[:ok]` or `[:error]` with an optional context update (`[:ok, { new_ctx_key: ’value’ }]‘, `[:errors, { errors: [{ detail: ’Error explaination’ }], }]‘). If an `:error` tupple is returned, the next operations are canceled and `call` will return.

Parameters:

  • list

    An array of operations (callables) that will be called in order

  • ctx (defaults to: nil)

    A hash containing values to send to the operations (callables). It will be updated after every operation.

Returns:

  • The updated context.



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/flow_organizer.rb', line 71

def self.call(list:, list_error: nil, ctx: nil, raise_exception: false)
  ctx        = (ctx || {}).dup
  list_error = [] unless list_error.is_a?(Array)

  status, ctx = call_list(list, :ok, ctx, raise_exception)

  # If there is an error on the "success" track (list), switch to the "error" track, (list_error)
  if status == :error
    status, ctx = call_list(list_error, status, ctx, raise_exception)
  end

  status = :ok if status == :halt

  [status, ctx]
end

.call_list(list, status, ctx, raise_exception) ⇒ Object



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/flow_organizer.rb', line 87

def self.call_list(list, status, ctx, raise_exception)
  list.each do |el|
    # Inline fast path for raw callables — skip the resolver allocation.
    if el.respond_to?(:call)
      callable = el
    else
      _, resolved = FlowOrganizer::Callable.resolve(target: el)
      callable    = resolved[:callable]
    end

    # Generate arguments compatible with what the callable expects
    local_ctx = FlowOrganizer::Context.generate_callable_ctx(callable: callable, ctx: ctx)

    # Skip the empty `**` splat when the callable takes no kwargs.
    result = local_ctx.nil? ? callable.call : callable.call(**local_ctx)
    status, local_ctx = result

    # `sanitize_errors` is a no-op on `:ok` — only call when it might do work.
    if status == :error
      result = sanitize_errors(result)
      _, local_ctx = result
    end

    # Mutate ctx in place (we own it — `call` dup'd it) and skip when nothing to merge.
    if local_ctx && !local_ctx.empty?
      FlowOrganizer::Context.update_context!(ctx: ctx, local_ctx: local_ctx)
    end

    # Stop execution status is not `:ok`
    break if status == :error || status == :halt
  rescue StandardError => e
    status      = :error
    ctx[:error] = e

    if raise_exception
      raise e
    else
      exception_reporter&.call(exception: e)
    end

    break
  end

  [status, ctx]
end

.sanitize_errors(result) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Note:

Enable simpler error return format from an organized callable.

Sanitizes returned errors, if any.

Examples:

Returning an error as a string

sanitize_result([:error, 'Error details']) => [:error, { errors: [{ detail: 'Error details' }] }]

Returning an error as hash with detail

sanitize_result([:error, { detail: 'Error details' }]) => [:error, { errors: [{ detail: 'Error details' }] }]

Returning an array of errors in the two former formats

sanitize_result([:error, ['Error1 detail', { detail: 'Error2 details' }]) => [:error, { errors: [{ detail: 'Error1 details' }, { detail: 'Error2 details' }] }]


144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/flow_organizer.rb', line 144

def self.sanitize_errors(result)
  status, ctx = result

  return result if status != :error

  case ctx
  when String
    ctx = { errors: [{ title: ctx }] }
  when Hash
    if !ctx[:errors]
      if ctx[:title] || ctx[:detail] || ctx[:code]
        ctx = { errors: [ctx] }
      elsif ctx[:error]
        ctx = { errors: [ctx[:error]] }
      end
    end
  when Array
    ctx = { errors: ctx.map { |el| (el.is_a?(String)) ? { title: el } : el } }
  end

  [status, ctx]
end