omni_service 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +209 -0
- data/lib/omni_service/async.rb +106 -0
- data/lib/omni_service/collection.rb +78 -0
- data/lib/omni_service/component.rb +81 -0
- data/lib/omni_service/context.rb +29 -0
- data/lib/omni_service/convenience.rb +125 -0
- data/lib/omni_service/error.rb +56 -0
- data/lib/omni_service/find_many.rb +261 -0
- data/lib/omni_service/find_one.rb +172 -0
- data/lib/omni_service/helpers.rb +20 -0
- data/lib/omni_service/inspect.rb +41 -0
- data/lib/omni_service/namespace.rb +111 -0
- data/lib/omni_service/optional.rb +49 -0
- data/lib/omni_service/parallel.rb +70 -0
- data/lib/omni_service/params.rb +50 -0
- data/lib/omni_service/result.rb +107 -0
- data/lib/omni_service/sequence.rb +53 -0
- data/lib/omni_service/shortcut.rb +49 -0
- data/lib/omni_service/strict.rb +36 -0
- data/lib/omni_service/transaction.rb +127 -0
- data/lib/omni_service/version.rb +5 -0
- data/lib/omni_service.rb +78 -0
- metadata +154 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Swallows component failures, returning empty success instead.
|
|
4
|
+
# Useful for non-critical operations that shouldn't block the pipeline.
|
|
5
|
+
#
|
|
6
|
+
# On success: returns component result unchanged.
|
|
7
|
+
# On failure: returns Success({}) with empty context, discarding errors.
|
|
8
|
+
#
|
|
9
|
+
# @example Optional profile enrichment
|
|
10
|
+
# sequence(
|
|
11
|
+
# create_user,
|
|
12
|
+
# optional(fetch_avatar_from_gravatar), # Failure won't stop pipeline
|
|
13
|
+
# send_welcome_email
|
|
14
|
+
# )
|
|
15
|
+
#
|
|
16
|
+
# @example Optional cache warming
|
|
17
|
+
# sequence(
|
|
18
|
+
# update_post,
|
|
19
|
+
# optional(warm_cache) # Cache failure is acceptable
|
|
20
|
+
# )
|
|
21
|
+
#
|
|
22
|
+
class OmniService::Optional
|
|
23
|
+
extend Dry::Initializer
|
|
24
|
+
include Dry::Equalizer(:component)
|
|
25
|
+
include OmniService::Inspect.new(:component)
|
|
26
|
+
include OmniService::Strict
|
|
27
|
+
|
|
28
|
+
param :component, OmniService::Types::Interface(:call)
|
|
29
|
+
|
|
30
|
+
def call(*params, **context)
|
|
31
|
+
result = component_wrapper.call(*params, **context)
|
|
32
|
+
|
|
33
|
+
if result.success?
|
|
34
|
+
result
|
|
35
|
+
else
|
|
36
|
+
OmniService::Result.build(self, params: result.params, context: {})
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def signature
|
|
41
|
+
@signature ||= [component_wrapper.signature.first, true]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def component_wrapper
|
|
47
|
+
@component_wrapper ||= OmniService::Component.wrap(component)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Executes all components, collecting errors from all instead of short-circuiting.
|
|
4
|
+
# Useful for validation where you want to report all issues at once.
|
|
5
|
+
# Context merges from all components; params can be distributed or packed.
|
|
6
|
+
#
|
|
7
|
+
# @example Validate multiple fields, collect all errors
|
|
8
|
+
# parallel(
|
|
9
|
+
# validate_title, # => Failure([{ path: [:title], code: :blank }])
|
|
10
|
+
# validate_body, # => Failure([{ path: [:body], code: :too_short }])
|
|
11
|
+
# validate_author # => Success(...)
|
|
12
|
+
# )
|
|
13
|
+
# # => Result with all errors collected
|
|
14
|
+
#
|
|
15
|
+
# @example Pack params from multiple sources into single hash
|
|
16
|
+
# parallel(
|
|
17
|
+
# parse_post_params, # => Success({ title: 'Hi' }, ...)
|
|
18
|
+
# parse_metadata_params, # => Success({ tags: [...] }, ...)
|
|
19
|
+
# pack_params: true
|
|
20
|
+
# )
|
|
21
|
+
# # => Result(params: [{ title: 'Hi', tags: [...] }])
|
|
22
|
+
#
|
|
23
|
+
class OmniService::Parallel
|
|
24
|
+
extend Dry::Initializer
|
|
25
|
+
include Dry::Equalizer(:components)
|
|
26
|
+
include OmniService::Inspect.new(:components)
|
|
27
|
+
include OmniService::Strict
|
|
28
|
+
|
|
29
|
+
param :components, OmniService::Types::Array.of(OmniService::Types::Interface(:call))
|
|
30
|
+
option :pack_params, OmniService::Types::Bool, default: proc { false }
|
|
31
|
+
|
|
32
|
+
def initialize(*args, **)
|
|
33
|
+
super(args.flatten(1), **)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(*params, **context) # rubocop:disable Metrics/AbcSize
|
|
37
|
+
leftovers, result = component_wrappers
|
|
38
|
+
.inject([params, OmniService::Result.build(self, context:)]) do |(params_left, result), component|
|
|
39
|
+
break [params_left, result] if result.shortcut?
|
|
40
|
+
|
|
41
|
+
component_params = params_left[...component.signature.first]
|
|
42
|
+
component_result = component.call(*component_params, **result.context)
|
|
43
|
+
result_params = if pack_params
|
|
44
|
+
[(result.params.first || {}).merge(component_result.params.first)]
|
|
45
|
+
else
|
|
46
|
+
result.params + component_result.params
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
[
|
|
50
|
+
params.one? ? params : params_left[component_params.size..],
|
|
51
|
+
result.merge(component_result, params: result_params)
|
|
52
|
+
]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
params.one? ? result : result.merge(params: result.params + leftovers)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def signature
|
|
59
|
+
@signature ||= begin
|
|
60
|
+
param_counts = component_wrappers.map { |component| component.signature.first }
|
|
61
|
+
[param_counts.all? ? param_counts.sum : nil, true]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def component_wrappers
|
|
68
|
+
@component_wrappers ||= OmniService::Component.wrap(components)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Validates params against a Dry::Validation::Contract.
|
|
4
|
+
class OmniService::Params
|
|
5
|
+
extend Dry::Initializer
|
|
6
|
+
|
|
7
|
+
param :contract, OmniService::Types::Instance(Dry::Validation::Contract)
|
|
8
|
+
option :optional, OmniService::Types::Bool, default: proc { false }
|
|
9
|
+
|
|
10
|
+
def self.to_failure(message)
|
|
11
|
+
{
|
|
12
|
+
code: (message.predicate if message.respond_to?(:predicate)) || :invalid,
|
|
13
|
+
message: message.dump,
|
|
14
|
+
path: message.path,
|
|
15
|
+
tokens: message.respond_to?(:meta) ? message.meta : {}
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.params(**, &)
|
|
20
|
+
new(**) { params(&) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(contract = nil, **, &)
|
|
24
|
+
contract_class = Class.new(Dry::Validation::Contract, &)
|
|
25
|
+
super(contract || contract_class.new, **)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(params = {}, **context)
|
|
29
|
+
contract_result = contract.call(params, context)
|
|
30
|
+
|
|
31
|
+
if optional && contract_result.to_h.empty?
|
|
32
|
+
OmniService::Result.build(self, params: [{}])
|
|
33
|
+
else
|
|
34
|
+
to_operation_result(contract_result)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def to_operation_result(contract_result)
|
|
41
|
+
OmniService::Result.build(
|
|
42
|
+
self,
|
|
43
|
+
params: [contract_result.to_h],
|
|
44
|
+
context: contract_result.context.each.to_h,
|
|
45
|
+
errors: contract_result.errors.map do |error|
|
|
46
|
+
OmniService::Error.build(self, **self.class.to_failure(error))
|
|
47
|
+
end
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Structured result of component execution.
|
|
4
|
+
#
|
|
5
|
+
# Attributes:
|
|
6
|
+
# - operation: the component that produced this result
|
|
7
|
+
# - shortcut: component that triggered early exit (if any)
|
|
8
|
+
# - params: array of transformed params
|
|
9
|
+
# - context: accumulated key-value pairs from all components
|
|
10
|
+
# - errors: array of OmniService::Error
|
|
11
|
+
# - on_success: results from transaction success callbacks
|
|
12
|
+
# - on_failure: results from transaction failure callbacks
|
|
13
|
+
#
|
|
14
|
+
# Components return Success/Failure monads which are converted to Result:
|
|
15
|
+
# - Success(key: value) => Result(context: { key: value })
|
|
16
|
+
# - Success(params, key: value) => Result(params: [params], context: { key: value })
|
|
17
|
+
# - Failure(:code) => Result(errors: [Error(code: :code)])
|
|
18
|
+
# - Failure([{ code:, path:, message: }]) => Result(errors: [...])
|
|
19
|
+
#
|
|
20
|
+
# @example Checking result
|
|
21
|
+
# result = operation.call(params, **context)
|
|
22
|
+
# result.success? # => true/false
|
|
23
|
+
# result.failure? # => true/false
|
|
24
|
+
# result.context # => { post: <Post>, author: <Author> }
|
|
25
|
+
# result.errors # => [#<Error code=:blank path=[:title]>]
|
|
26
|
+
#
|
|
27
|
+
# @example Converting to monad
|
|
28
|
+
# result.to_monad # => Success(result) or Failure(result)
|
|
29
|
+
#
|
|
30
|
+
class OmniService::Result
|
|
31
|
+
extend Dry::Initializer
|
|
32
|
+
include Dry::Monads[:result]
|
|
33
|
+
include Dry::Equalizer(:operation, :params, :context, :errors, :on_success, :on_failure)
|
|
34
|
+
include OmniService::Inspect.new(:operation, :params, :context, :errors, :on_success, :on_failure)
|
|
35
|
+
|
|
36
|
+
option :operation, type: OmniService::Types::Interface(:call)
|
|
37
|
+
option :shortcut, type: OmniService::Types::Interface(:call).optional, default: proc {}
|
|
38
|
+
option :params, type: OmniService::Types::Array
|
|
39
|
+
option :context, type: OmniService::Types::Hash.map(OmniService::Types::Symbol, OmniService::Types::Any)
|
|
40
|
+
option :errors, type: OmniService::Types::Array.of(OmniService::Error)
|
|
41
|
+
option :on_success, type: OmniService::Types::Array.of(
|
|
42
|
+
OmniService::Types::Instance(OmniService::Result) | OmniService::Types::Instance(Concurrent::Promises::Future)
|
|
43
|
+
), default: proc { [] }
|
|
44
|
+
option :on_failure, type: OmniService::Types::Array.of(OmniService::Types::Instance(OmniService::Result)), default: proc { [] }
|
|
45
|
+
|
|
46
|
+
def self.process(component, result)
|
|
47
|
+
case result
|
|
48
|
+
in OmniService::Result
|
|
49
|
+
result
|
|
50
|
+
in Success(params, *other_params, Hash => context) if context.keys.all?(Symbol)
|
|
51
|
+
OmniService::Result.build(component, params: [params, *other_params], context:)
|
|
52
|
+
in Success(Hash => context) if context.keys.all?(Symbol)
|
|
53
|
+
OmniService::Result.build(component, context:)
|
|
54
|
+
in Failure[*failures]
|
|
55
|
+
OmniService::Result.build(component, errors: OmniService::Error.process(component, failures))
|
|
56
|
+
else
|
|
57
|
+
raise "Invalid callable result `#{result.inspect}` returned by `#{component.inspect}`"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.build(operation, **)
|
|
62
|
+
new(operation:, params: [], context: {}, errors: [], **)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def merge(other = nil, **changes)
|
|
66
|
+
if other
|
|
67
|
+
self.class.new(
|
|
68
|
+
operation:,
|
|
69
|
+
shortcut: shortcut || other.shortcut,
|
|
70
|
+
**merged_attributes(other),
|
|
71
|
+
**changes
|
|
72
|
+
)
|
|
73
|
+
else
|
|
74
|
+
self.class.new(**self.class.dry_initializer.attributes(self), **changes)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
alias shortcut? shortcut
|
|
79
|
+
|
|
80
|
+
def success?
|
|
81
|
+
errors.empty?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def failure?
|
|
85
|
+
!success?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def to_monad
|
|
89
|
+
success? ? Success(self) : Failure(self)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def deconstruct_keys(_)
|
|
93
|
+
{ operation:, params:, context:, errors: }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def merged_attributes(other)
|
|
99
|
+
{
|
|
100
|
+
params: other.params.empty? ? params : other.params,
|
|
101
|
+
context: context.merge(other.context),
|
|
102
|
+
errors: errors + other.errors,
|
|
103
|
+
on_success: on_success + other.on_success,
|
|
104
|
+
on_failure: on_failure + other.on_failure
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Executes components sequentially, short-circuiting on first failure or shortcut.
|
|
4
|
+
# Context accumulates across components; each receives merged context from previous.
|
|
5
|
+
#
|
|
6
|
+
# @example Blog post creation pipeline
|
|
7
|
+
# sequence(
|
|
8
|
+
# validate_params, # Failure here stops the pipeline
|
|
9
|
+
# find_author, # Adds :author to context
|
|
10
|
+
# create_post, # Receives :author, adds :post
|
|
11
|
+
# notify_subscribers # Receives :author and :post
|
|
12
|
+
# )
|
|
13
|
+
#
|
|
14
|
+
# @example With shortcut for idempotency
|
|
15
|
+
# sequence(
|
|
16
|
+
# shortcut(find_existing_post), # Success here exits early
|
|
17
|
+
# validate_params,
|
|
18
|
+
# create_post
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
class OmniService::Sequence
|
|
22
|
+
extend Dry::Initializer
|
|
23
|
+
include Dry::Equalizer(:components)
|
|
24
|
+
include OmniService::Inspect.new(:components)
|
|
25
|
+
include OmniService::Strict
|
|
26
|
+
|
|
27
|
+
param :components, OmniService::Types::Array.of(OmniService::Types::Interface(:call))
|
|
28
|
+
|
|
29
|
+
def initialize(*args, **)
|
|
30
|
+
super(args.flatten(1), **)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(*params, **context)
|
|
34
|
+
component_wrappers.inject(OmniService::Result.build(self, params:, context:)) do |result, component|
|
|
35
|
+
break result if result.failure? || result.shortcut?
|
|
36
|
+
|
|
37
|
+
result.merge(component.call(*result.params, **result.context))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def signature
|
|
42
|
+
@signature ||= begin
|
|
43
|
+
param_counts = component_wrappers.map { |component| component.signature.first }
|
|
44
|
+
[param_counts.all? ? param_counts.max : nil, true]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def component_wrappers
|
|
51
|
+
@component_wrappers ||= OmniService::Component.wrap(components)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Enables early pipeline exit on success. Used for idempotency and guard clauses.
|
|
4
|
+
#
|
|
5
|
+
# On success: marks result with shortcut flag, causing Sequence to exit immediately.
|
|
6
|
+
# On failure: returns empty success, allowing pipeline to continue.
|
|
7
|
+
#
|
|
8
|
+
# @example Idempotent post creation
|
|
9
|
+
# sequence(
|
|
10
|
+
# shortcut(find_existing_post), # Found? Exit with existing post
|
|
11
|
+
# validate_params, # Not found? Continue creation
|
|
12
|
+
# create_post
|
|
13
|
+
# )
|
|
14
|
+
#
|
|
15
|
+
# @example Guard clause
|
|
16
|
+
# sequence(
|
|
17
|
+
# shortcut(check_already_subscribed), # Already subscribed? Done
|
|
18
|
+
# validate_subscription,
|
|
19
|
+
# create_subscription
|
|
20
|
+
# )
|
|
21
|
+
#
|
|
22
|
+
class OmniService::Shortcut
|
|
23
|
+
extend Dry::Initializer
|
|
24
|
+
include Dry::Equalizer(:component)
|
|
25
|
+
include OmniService::Inspect.new(:component)
|
|
26
|
+
include OmniService::Strict
|
|
27
|
+
|
|
28
|
+
param :component, OmniService::Types::Interface(:call)
|
|
29
|
+
|
|
30
|
+
def call(*params, **context)
|
|
31
|
+
result = component_wrapper.call(*params, **context)
|
|
32
|
+
|
|
33
|
+
if result.success?
|
|
34
|
+
result.merge(shortcut: component)
|
|
35
|
+
else
|
|
36
|
+
OmniService::Result.build(self, params:, context:)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def signature
|
|
41
|
+
@signature ||= [component_wrapper.signature.first, true]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def component_wrapper
|
|
47
|
+
@component_wrapper ||= OmniService::Component.wrap(component)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniService
|
|
4
|
+
# Exception raised by call! when operation fails.
|
|
5
|
+
# Contains the full operation result for inspection.
|
|
6
|
+
class OperationFailed < StandardError
|
|
7
|
+
attr_reader :operation_result
|
|
8
|
+
|
|
9
|
+
def initialize(operation_result)
|
|
10
|
+
@operation_result = operation_result
|
|
11
|
+
super(operation_result.errors.pretty_inspect)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Provides call! method that raises OperationFailed on failure.
|
|
16
|
+
# Included in all framework components.
|
|
17
|
+
#
|
|
18
|
+
# @example Raising on failure
|
|
19
|
+
# operation.call!(params) # => raises OperationFailed if result.failure?
|
|
20
|
+
#
|
|
21
|
+
# @example Rescuing
|
|
22
|
+
# begin
|
|
23
|
+
# operation.call!(params)
|
|
24
|
+
# rescue OmniService::OperationFailed => e
|
|
25
|
+
# e.operation_result.errors # => [#<Error ...>]
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
module Strict
|
|
29
|
+
def call!(*params, **context)
|
|
30
|
+
result = call(*params, **context)
|
|
31
|
+
raise OmniService::OperationFailed, result if result.failure?
|
|
32
|
+
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Wraps component execution in a database transaction with callback support.
|
|
4
|
+
# Rolls back on failure; executes on_success callbacks after commit, on_failure after rollback.
|
|
5
|
+
#
|
|
6
|
+
# Callbacks run asynchronously by default (configurable via OmniService.with_sync_callbacks).
|
|
7
|
+
# on_success callbacks receive the same params and context as the main component.
|
|
8
|
+
# on_failure callbacks receive the failed Result as their first param.
|
|
9
|
+
#
|
|
10
|
+
# @example Post creation with notifications
|
|
11
|
+
# transaction(
|
|
12
|
+
# sequence(validate_params, create_post),
|
|
13
|
+
# on_success: [send_notifications, update_feed],
|
|
14
|
+
# on_failure: [log_failure, alert_admin]
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
# @example Nested transactions
|
|
18
|
+
# transaction(
|
|
19
|
+
# sequence(
|
|
20
|
+
# create_post,
|
|
21
|
+
# transaction(create_comments, on_success: [notify_commenters])
|
|
22
|
+
# ),
|
|
23
|
+
# on_success: [notify_author]
|
|
24
|
+
# )
|
|
25
|
+
#
|
|
26
|
+
class OmniService::Transaction
|
|
27
|
+
extend Dry::Initializer
|
|
28
|
+
include Dry::Equalizer(:component, :on_success, :on_failure)
|
|
29
|
+
include OmniService::Inspect.new(:component, :on_success, :on_failure)
|
|
30
|
+
include OmniService::Strict
|
|
31
|
+
|
|
32
|
+
# Internal exception to trigger transaction rollback on component failure.
|
|
33
|
+
class Halt < StandardError
|
|
34
|
+
attr_reader :result
|
|
35
|
+
|
|
36
|
+
def initialize(result)
|
|
37
|
+
@result = result
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
param :component, OmniService::Types::Interface(:call)
|
|
43
|
+
option :on_success, OmniService::Types::Coercible::Array.of(OmniService::Types::Interface(:call)),
|
|
44
|
+
default: proc { [] }
|
|
45
|
+
option :on_failure, OmniService::Types::Coercible::Array.of(OmniService::Types::Interface(:call)),
|
|
46
|
+
default: proc { [] }
|
|
47
|
+
|
|
48
|
+
def call(*params, **context)
|
|
49
|
+
result = call_transaction(*params, **context).merge(operation: self)
|
|
50
|
+
|
|
51
|
+
return result if result.success?
|
|
52
|
+
|
|
53
|
+
on_failure_results = on_failure_callbacks.map { |callback| callback.call(result) }
|
|
54
|
+
result.merge(on_failure: result.on_failure + on_failure_results)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def signature
|
|
58
|
+
@signature ||= [component_wrapper.signature.first, true]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def call_transaction(*params, **context)
|
|
64
|
+
transaction_result = ActiveRecord::Base.transaction(requires_new: true) do |transaction|
|
|
65
|
+
result = component_wrapper.call(*params, **context)
|
|
66
|
+
|
|
67
|
+
return result if result.shortcut?
|
|
68
|
+
raise Halt, result if result.failure?
|
|
69
|
+
|
|
70
|
+
handle_on_success(transaction, result)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
resolve_callback_promises(transaction_result)
|
|
74
|
+
rescue Halt => e
|
|
75
|
+
e.result
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def component_wrapper
|
|
79
|
+
@component_wrapper ||= OmniService::Component.wrap(component)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def on_success_callbacks
|
|
83
|
+
@on_success_callbacks ||= OmniService::Component.wrap(on_success)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def on_failure_callbacks
|
|
87
|
+
@on_failure_callbacks ||= OmniService::Component.wrap(on_failure)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def on_success_promises(result)
|
|
91
|
+
captured_sync = OmniService.sync_callbacks?
|
|
92
|
+
executor = captured_sync ? :immediate : :io
|
|
93
|
+
|
|
94
|
+
on_success_callbacks.map do |callback|
|
|
95
|
+
promise = Concurrent::Promises
|
|
96
|
+
.delay_on(executor, callback, result, captured_sync) do |on_success_callback, operation_result, sync_value|
|
|
97
|
+
OmniService.with_sync_callbacks(sync_value) do
|
|
98
|
+
on_success_callback.call(*operation_result.params, **operation_result.context)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# In async mode, escape promise exception capture so error tracking services can catch it via thread reporting.
|
|
103
|
+
promise = promise.on_rejection { |error| Thread.new { raise error } } unless captured_sync
|
|
104
|
+
promise
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def handle_on_success(transaction, result)
|
|
109
|
+
on_success_promises = on_success_promises(result)
|
|
110
|
+
|
|
111
|
+
if OmniService.sync_callbacks?
|
|
112
|
+
transaction.after_commit { Concurrent::Promises.zip(*on_success_promises).wait }
|
|
113
|
+
else
|
|
114
|
+
transaction.after_commit { on_success_promises.each(&:touch) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
result.merge(on_success: result.on_success + on_success_promises)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def resolve_callback_promises(result)
|
|
121
|
+
result.merge(on_success: result.on_success.map do |callback_result|
|
|
122
|
+
next callback_result unless callback_result.is_a?(Concurrent::Promises::Future)
|
|
123
|
+
|
|
124
|
+
OmniService.sync_callbacks? ? callback_result.value! : callback_result
|
|
125
|
+
end)
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/omni_service.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry/core/constants'
|
|
4
|
+
require 'dry/monads'
|
|
5
|
+
require 'dry/monads/do'
|
|
6
|
+
require 'dry-struct'
|
|
7
|
+
require 'dry-initializer'
|
|
8
|
+
require 'dry/types'
|
|
9
|
+
require 'active_support'
|
|
10
|
+
require 'concurrent-ruby'
|
|
11
|
+
|
|
12
|
+
# Framework for composable business operations with railway-oriented programming.
|
|
13
|
+
#
|
|
14
|
+
# Core concepts:
|
|
15
|
+
# - Components: callables returning Success/Failure monads
|
|
16
|
+
# - Results: structured output with context, params, and errors
|
|
17
|
+
# - Composition: sequence, parallel, transaction, namespace, collection
|
|
18
|
+
#
|
|
19
|
+
# @example Simple operation
|
|
20
|
+
# class Posts::Create
|
|
21
|
+
# extend OmniService::Convenience
|
|
22
|
+
#
|
|
23
|
+
# option :post_repo, default: -> { PostRepository.new }
|
|
24
|
+
#
|
|
25
|
+
# def self.system
|
|
26
|
+
# @system ||= sequence(
|
|
27
|
+
# validate_params,
|
|
28
|
+
# transaction(create_post, on_success: [notify_subscribers])
|
|
29
|
+
# )
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# result = Posts::Create.system.call({ title: 'Hello' }, current_user: user)
|
|
34
|
+
# result.success? # => true
|
|
35
|
+
# result.context # => { post: <Post> }
|
|
36
|
+
#
|
|
37
|
+
# @see OmniService::Convenience DSL for building operations
|
|
38
|
+
# @see OmniService::Result structured result object
|
|
39
|
+
#
|
|
40
|
+
module OmniService
|
|
41
|
+
include Dry::Core::Constants
|
|
42
|
+
|
|
43
|
+
Types = Dry.Types()
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
def sync_callbacks?
|
|
47
|
+
!!Thread.current[:omni_service_sync_callbacks]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def with_sync_callbacks(value = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
51
|
+
old_value = Thread.current[:omni_service_sync_callbacks]
|
|
52
|
+
Thread.current[:omni_service_sync_callbacks] = value
|
|
53
|
+
yield
|
|
54
|
+
ensure
|
|
55
|
+
Thread.current[:omni_service_sync_callbacks] = old_value
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
require_relative 'omni_service/version'
|
|
61
|
+
require_relative 'omni_service/strict'
|
|
62
|
+
require_relative 'omni_service/helpers'
|
|
63
|
+
require_relative 'omni_service/inspect'
|
|
64
|
+
require_relative 'omni_service/error'
|
|
65
|
+
require_relative 'omni_service/result'
|
|
66
|
+
require_relative 'omni_service/component'
|
|
67
|
+
require_relative 'omni_service/context'
|
|
68
|
+
require_relative 'omni_service/sequence'
|
|
69
|
+
require_relative 'omni_service/parallel'
|
|
70
|
+
require_relative 'omni_service/collection'
|
|
71
|
+
require_relative 'omni_service/optional'
|
|
72
|
+
require_relative 'omni_service/find_one'
|
|
73
|
+
require_relative 'omni_service/find_many'
|
|
74
|
+
require_relative 'omni_service/namespace'
|
|
75
|
+
require_relative 'omni_service/shortcut'
|
|
76
|
+
require_relative 'omni_service/transaction'
|
|
77
|
+
require_relative 'omni_service/convenience'
|
|
78
|
+
require_relative 'omni_service/async'
|