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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniService
4
+ VERSION = '0.1.0'
5
+ end
@@ -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'