typed_operation 1.0.0.beta3 → 1.0.0.beta4
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 +4 -4
- data/README.md +76 -724
- data/lib/generators/typed_operation/install/install_generator.rb +3 -0
- data/lib/generators/typed_operation_generator.rb +8 -4
- data/lib/typed_operation/action_policy_auth.rb +44 -24
- data/lib/typed_operation/base.rb +4 -1
- data/lib/typed_operation/callable_resolver.rb +30 -0
- data/lib/typed_operation/chains/chained_operation.rb +27 -0
- data/lib/typed_operation/chains/fallback_chain.rb +32 -0
- data/lib/typed_operation/chains/map_chain.rb +37 -0
- data/lib/typed_operation/chains/sequence_chain.rb +54 -0
- data/lib/typed_operation/chains/smart_chain.rb +161 -0
- data/lib/typed_operation/chains/splat_chain.rb +53 -0
- data/lib/typed_operation/configuration.rb +52 -0
- data/lib/typed_operation/context.rb +193 -0
- data/lib/typed_operation/curried.rb +14 -1
- data/lib/typed_operation/explainable.rb +14 -0
- data/lib/typed_operation/immutable_base.rb +4 -1
- data/lib/typed_operation/instrumentation/trace.rb +71 -0
- data/lib/typed_operation/instrumentation/tree_formatter.rb +141 -0
- data/lib/typed_operation/instrumentation.rb +214 -0
- data/lib/typed_operation/operations/composition.rb +41 -0
- data/lib/typed_operation/operations/executable.rb +27 -1
- data/lib/typed_operation/operations/introspection.rb +9 -1
- data/lib/typed_operation/operations/lifecycle.rb +4 -1
- data/lib/typed_operation/operations/parameters.rb +11 -5
- data/lib/typed_operation/operations/partial_application.rb +4 -0
- data/lib/typed_operation/operations/property_builder.rb +46 -22
- data/lib/typed_operation/partially_applied.rb +33 -10
- data/lib/typed_operation/pipeline/builder.rb +88 -0
- data/lib/typed_operation/pipeline/chainable_wrapper.rb +23 -0
- data/lib/typed_operation/pipeline/empty_pipeline_chain.rb +25 -0
- data/lib/typed_operation/pipeline/step_wrapper.rb +94 -0
- data/lib/typed_operation/pipeline.rb +176 -0
- data/lib/typed_operation/prepared.rb +13 -0
- data/lib/typed_operation/railtie.rb +4 -0
- data/lib/typed_operation/result/adapters/built_in.rb +28 -0
- data/lib/typed_operation/result/adapters/dry_monads.rb +36 -0
- data/lib/typed_operation/result/failure.rb +78 -0
- data/lib/typed_operation/result/mixin.rb +24 -0
- data/lib/typed_operation/result/success.rb +75 -0
- data/lib/typed_operation/result.rb +39 -0
- data/lib/typed_operation/version.rb +5 -1
- data/lib/typed_operation.rb +18 -1
- metadata +27 -3
- data/lib/typed_operation/operations/callable.rb +0 -23
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
require "rails/generators/base"
|
|
4
4
|
|
|
5
5
|
module TypedOperation
|
|
6
|
+
# Contains installation-related generators for TypedOperation.
|
|
6
7
|
module Install
|
|
8
|
+
# Rails generator for installing TypedOperation into a Rails application.
|
|
9
|
+
# Creates an ApplicationOperation base class with optional dry-monads or action_policy support.
|
|
7
10
|
class InstallGenerator < Rails::Generators::Base
|
|
8
11
|
class_option :dry_monads, type: :boolean, default: false
|
|
9
12
|
class_option :action_policy, type: :boolean, default: false
|
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
require "rails/generators/named_base"
|
|
4
4
|
|
|
5
|
+
# Rails generator for creating new typed operation files and their tests.
|
|
5
6
|
class TypedOperationGenerator < Rails::Generators::NamedBase
|
|
6
7
|
source_root File.expand_path("templates", __dir__)
|
|
7
8
|
|
|
8
9
|
class_option :path, type: :string, default: "app/operations"
|
|
9
10
|
|
|
10
11
|
def generate_operation
|
|
12
|
+
source_root = self.class.source_root
|
|
13
|
+
operation_path = options[:path]
|
|
14
|
+
|
|
11
15
|
template(
|
|
12
|
-
File.join(
|
|
13
|
-
File.join(
|
|
16
|
+
File.join(source_root, "operation.rb"),
|
|
17
|
+
File.join(operation_path, "#{file_name}.rb")
|
|
14
18
|
)
|
|
15
19
|
template(
|
|
16
|
-
File.join(
|
|
17
|
-
File.join("test/",
|
|
20
|
+
File.join(source_root, "operation_test.rb"),
|
|
21
|
+
File.join("test/", operation_path.gsub(/\Aapp\//, ""), "#{file_name}_test.rb")
|
|
18
22
|
)
|
|
19
23
|
end
|
|
20
24
|
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
1
2
|
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require "action_policy"
|
|
4
5
|
|
|
5
|
-
# An optional module to include in your operation to enable authorization via ActionPolicies
|
|
6
|
+
# An optional module to include in your operation to enable authorization via ActionPolicies.
|
|
6
7
|
module TypedOperation
|
|
7
8
|
class MissingAuthentication < StandardError; end
|
|
8
9
|
|
|
9
10
|
module ActionPolicyAuth
|
|
10
|
-
# Base class for any action policy classes used by operations
|
|
11
|
+
# Base class for any action policy classes used by operations.
|
|
11
12
|
class OperationPolicy
|
|
12
13
|
include ActionPolicy::Policy::Core
|
|
13
14
|
include ActionPolicy::Policy::Authorization
|
|
@@ -19,15 +20,24 @@ module TypedOperation
|
|
|
19
20
|
include ActionPolicy::Policy::CachedApply
|
|
20
21
|
end
|
|
21
22
|
|
|
23
|
+
#: (Module) -> void
|
|
22
24
|
def self.included(base)
|
|
23
25
|
base.include ::ActionPolicy::Behaviour
|
|
24
26
|
base.extend ClassMethods
|
|
25
27
|
end
|
|
26
28
|
|
|
29
|
+
# Class-level methods for configuring authorization on operations.
|
|
27
30
|
module ClassMethods
|
|
28
|
-
#
|
|
31
|
+
# @rbs @_authorized_via_param: Array[Symbol]?
|
|
32
|
+
# @rbs @_policy_method: Symbol?
|
|
33
|
+
# @rbs @_policy_class: Class?
|
|
34
|
+
# @rbs @_to_authorize_param: Symbol?
|
|
35
|
+
# @rbs @_action_type: Symbol?
|
|
36
|
+
# @rbs @_verify_authorized: bool?
|
|
37
|
+
|
|
38
|
+
# Configure the operation to use ActionPolicy for authorization.
|
|
39
|
+
#: (*Symbol, ?with: Class?, ?to: Symbol?, ?record: Symbol?) ?{ () -> bool } -> void
|
|
29
40
|
def authorized_via(*via, with: nil, to: nil, record: nil, &auth_block)
|
|
30
|
-
# If a block is provided, you must not provide a policy class or method
|
|
31
41
|
raise ArgumentError, "You must not provide a policy class or method when using a block" if auth_block && (with || to)
|
|
32
42
|
|
|
33
43
|
parameters = positional_parameters + keyword_parameters
|
|
@@ -35,10 +45,8 @@ module TypedOperation
|
|
|
35
45
|
@_authorized_via_param = via
|
|
36
46
|
|
|
37
47
|
action_type_method = :"#{action_type}?" if action_type
|
|
38
|
-
# If an method name is provided, use it
|
|
39
48
|
policy_method = to || action_type_method || raise(::TypedOperation::InvalidOperationError, "You must provide an action type or policy method name")
|
|
40
49
|
@_policy_method = policy_method
|
|
41
|
-
# If a policy class is provided, use it
|
|
42
50
|
@_policy_class = if with
|
|
43
51
|
with
|
|
44
52
|
elsif auth_block
|
|
@@ -60,44 +68,52 @@ module TypedOperation
|
|
|
60
68
|
@_to_authorize_param = record
|
|
61
69
|
end
|
|
62
70
|
|
|
63
|
-
# Configure action policy to use the param named in via as the context when instantiating the policy
|
|
64
|
-
# ::ActionPolicy::Behaviour does not provide a authorize(*ids) method, so we have call once per param
|
|
71
|
+
# Configure action policy to use the param named in via as the context when instantiating the policy.
|
|
72
|
+
# ::ActionPolicy::Behaviour does not provide a authorize(*ids) method, so we have call once per param.
|
|
65
73
|
via.each do |param|
|
|
66
74
|
authorize param
|
|
67
75
|
end
|
|
68
76
|
end
|
|
69
77
|
|
|
78
|
+
#: (?Symbol?) -> Symbol?
|
|
70
79
|
def action_type(type = nil)
|
|
71
80
|
@_action_type = type.to_sym if type
|
|
72
81
|
@_action_type
|
|
73
82
|
end
|
|
74
83
|
|
|
84
|
+
#: () -> Symbol?
|
|
75
85
|
def operation_policy_method
|
|
76
86
|
@_policy_method
|
|
77
87
|
end
|
|
78
88
|
|
|
89
|
+
#: () -> Class?
|
|
79
90
|
def operation_policy_class
|
|
80
91
|
@_policy_class
|
|
81
92
|
end
|
|
82
93
|
|
|
94
|
+
#: () -> Symbol?
|
|
83
95
|
def operation_record_to_authorize
|
|
84
96
|
@_to_authorize_param
|
|
85
97
|
end
|
|
86
98
|
|
|
99
|
+
#: () -> bool
|
|
87
100
|
def checks_authorization?
|
|
88
101
|
!(@_authorized_via_param.nil? || @_authorized_via_param.empty?)
|
|
89
102
|
end
|
|
90
103
|
|
|
91
|
-
# You can use this on an operation base class to ensure
|
|
104
|
+
# You can use this on an operation base class to ensure subclasses always enable authorization.
|
|
105
|
+
#: () -> void
|
|
92
106
|
def verify_authorized!
|
|
93
107
|
return if verify_authorized?
|
|
94
108
|
@_verify_authorized = true
|
|
95
109
|
end
|
|
96
110
|
|
|
111
|
+
#: () -> bool
|
|
97
112
|
def verify_authorized?
|
|
98
113
|
@_verify_authorized
|
|
99
114
|
end
|
|
100
115
|
|
|
116
|
+
#: (Class) -> void
|
|
101
117
|
def inherited(subclass)
|
|
102
118
|
super
|
|
103
119
|
subclass.instance_variable_set(:@_authorized_via_param, @_authorized_via_param)
|
|
@@ -110,31 +126,35 @@ module TypedOperation
|
|
|
110
126
|
|
|
111
127
|
private
|
|
112
128
|
|
|
113
|
-
|
|
129
|
+
#: () -> untyped
|
|
114
130
|
def execute_operation
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
operation_class = self.class
|
|
132
|
+
checks_auth = operation_class.checks_authorization?
|
|
133
|
+
if operation_class.verify_authorized? && !checks_auth
|
|
134
|
+
raise ::TypedOperation::MissingAuthentication, "Operation #{operation_class.name} must authorize. Remember to use `.authorize_via`"
|
|
117
135
|
end
|
|
118
|
-
operation_check_authorized! if
|
|
136
|
+
operation_check_authorized! if checks_auth
|
|
119
137
|
super
|
|
120
138
|
end
|
|
121
139
|
|
|
140
|
+
#: () -> void
|
|
122
141
|
def operation_check_authorized!
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
raise "No policy method provided or action_type not set for #{
|
|
142
|
+
operation_class = self.class
|
|
143
|
+
policy = operation_class.operation_policy_class
|
|
144
|
+
raise "No Action Policy policy class provided, or no #{operation_class.name}::Policy found for this action" unless policy
|
|
145
|
+
raise "No policy method provided or action_type not set for #{operation_class.name}" unless operation_class.operation_policy_method
|
|
127
146
|
# Record to authorize, if nil then action policy tries to work it out implicitly
|
|
128
|
-
record_to_authorize = send(
|
|
147
|
+
record_to_authorize = send(operation_class.operation_record_to_authorize) if operation_class.operation_record_to_authorize
|
|
129
148
|
|
|
130
|
-
authorize! record_to_authorize, to:
|
|
131
|
-
rescue ::ActionPolicy::Unauthorized =>
|
|
132
|
-
on_authorization_failure(
|
|
133
|
-
raise
|
|
149
|
+
authorize! record_to_authorize, to: operation_class.operation_policy_method, with: policy
|
|
150
|
+
rescue ::ActionPolicy::Unauthorized => error
|
|
151
|
+
on_authorization_failure(error)
|
|
152
|
+
raise error
|
|
134
153
|
end
|
|
135
154
|
|
|
136
|
-
# A hook for subclasses to override to do something on an authorization failure
|
|
137
|
-
|
|
155
|
+
# A hook for subclasses to override to do something on an authorization failure.
|
|
156
|
+
#: (ActionPolicy::Unauthorized) -> void
|
|
157
|
+
def on_authorization_failure(_authorization_error)
|
|
138
158
|
# noop
|
|
139
159
|
end
|
|
140
160
|
end
|
data/lib/typed_operation/base.rb
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
1
2
|
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
module TypedOperation
|
|
5
|
+
# Mutable base class for operations, built on Literal::Struct.
|
|
6
|
+
# Use this when operation state can be modified after initialization.
|
|
4
7
|
class Base < Literal::Struct
|
|
5
8
|
extend Operations::Introspection
|
|
6
9
|
extend Operations::Parameters
|
|
7
10
|
extend Operations::PartialApplication
|
|
11
|
+
extend Operations::Composition
|
|
8
12
|
|
|
9
13
|
include Operations::Lifecycle
|
|
10
|
-
include Operations::Callable
|
|
11
14
|
include Operations::Executable
|
|
12
15
|
end
|
|
13
16
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Utility module for resolving callable objects (operations, procs, chains)
|
|
6
|
+
# and determining how to invoke them (kwargs vs positional).
|
|
7
|
+
module CallableResolver
|
|
8
|
+
#: (_Callable) -> untyped
|
|
9
|
+
def extract_operation_class(operation)
|
|
10
|
+
case operation
|
|
11
|
+
when Class then operation
|
|
12
|
+
when TypedOperation::PartiallyApplied then operation.operation_class
|
|
13
|
+
when TypedOperation::ChainedOperation then extract_operation_class(operation.instance_variable_get(:@left))
|
|
14
|
+
when Proc then nil
|
|
15
|
+
else operation.class
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
#: (_Callable) -> bool
|
|
20
|
+
def uses_kwargs?(operation)
|
|
21
|
+
op_class = extract_operation_class(operation)
|
|
22
|
+
return false unless op_class.respond_to?(:positional_parameters)
|
|
23
|
+
|
|
24
|
+
positional = op_class.positional_parameters
|
|
25
|
+
keywords = op_class.keyword_parameters
|
|
26
|
+
|
|
27
|
+
keywords.any? && positional.empty?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Base class for operation chains.
|
|
6
|
+
#
|
|
7
|
+
# Values flow through chains as-is - no implicit splatting.
|
|
8
|
+
# If you need to convert a value to kwargs for the next operation,
|
|
9
|
+
# use .then_passes { |v| NextOp.call(**v) } or .transform to reshape first.
|
|
10
|
+
class ChainedOperation
|
|
11
|
+
include Operations::Composition
|
|
12
|
+
|
|
13
|
+
# @rbs @left: _Callable
|
|
14
|
+
# @rbs @right: _Callable
|
|
15
|
+
|
|
16
|
+
#: (_Callable, _Callable) -> void
|
|
17
|
+
def initialize(left, right)
|
|
18
|
+
@left = left
|
|
19
|
+
@right = right
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
#: (*untyped, **untyped) -> _Result[untyped, untyped]
|
|
23
|
+
def call(...)
|
|
24
|
+
raise NotImplementedError, "Subclasses must implement #call"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Fallback chain created by .or_else
|
|
6
|
+
# Calls the fallback only when the left operation fails.
|
|
7
|
+
# Passes through success unchanged.
|
|
8
|
+
#
|
|
9
|
+
# For blocks: passes the failure value.
|
|
10
|
+
# For operations: passes the original call arguments (so fallback can retry).
|
|
11
|
+
class FallbackChain < ChainedOperation
|
|
12
|
+
#: (*untyped, **untyped) -> _Result[untyped, untyped]
|
|
13
|
+
def call(*args, **kwargs)
|
|
14
|
+
result = @left.call(*args, **kwargs)
|
|
15
|
+
return result if result.success?
|
|
16
|
+
|
|
17
|
+
if Instrumentation.tracing?
|
|
18
|
+
Instrumentation.set_chain_context(
|
|
19
|
+
pass_mode: :fallback,
|
|
20
|
+
extracted_params: nil,
|
|
21
|
+
fallback_used: true
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if @right.is_a?(Proc)
|
|
26
|
+
@right.call(result.failure)
|
|
27
|
+
else
|
|
28
|
+
@right.call(*args, **kwargs)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Functor map chain created by .transform
|
|
6
|
+
# Block receives context, return value replaces context (wrapped in Success).
|
|
7
|
+
# Short-circuits on failure.
|
|
8
|
+
class MapChain < ChainedOperation
|
|
9
|
+
include Result::Mixin
|
|
10
|
+
|
|
11
|
+
#: (*untyped, **untyped) -> _Result[untyped, untyped]
|
|
12
|
+
def call(...)
|
|
13
|
+
result = @left.call(...)
|
|
14
|
+
return result if result.failure?
|
|
15
|
+
|
|
16
|
+
left_value = result.value!
|
|
17
|
+
context = to_context(left_value)
|
|
18
|
+
|
|
19
|
+
# Transform replaces context entirely (no merge)
|
|
20
|
+
transformed = @right.call(context)
|
|
21
|
+
Success(transformed)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
#: (untyped) -> Hash[Symbol, untyped]
|
|
27
|
+
def to_context(value)
|
|
28
|
+
if value.respond_to?(:to_hash)
|
|
29
|
+
value.to_hash
|
|
30
|
+
elsif value.respond_to?(:to_h)
|
|
31
|
+
value.to_h
|
|
32
|
+
else
|
|
33
|
+
{_value: value}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Sequential composition chain used by .then_passes
|
|
6
|
+
# Passes the success value (context) to the right operation/block.
|
|
7
|
+
# The result is merged back into context.
|
|
8
|
+
# Short-circuits on failure.
|
|
9
|
+
class SequenceChain < ChainedOperation
|
|
10
|
+
include Result::Mixin
|
|
11
|
+
|
|
12
|
+
#: (*untyped, **untyped) -> _Result[untyped, untyped]
|
|
13
|
+
def call(...)
|
|
14
|
+
result = @left.call(...)
|
|
15
|
+
return result if result.failure?
|
|
16
|
+
|
|
17
|
+
left_value = result.value!
|
|
18
|
+
context = to_context(left_value)
|
|
19
|
+
|
|
20
|
+
right_result = @right.call(context)
|
|
21
|
+
return right_result if right_result.failure?
|
|
22
|
+
|
|
23
|
+
# Merge right's output into context
|
|
24
|
+
right_value = right_result.value!
|
|
25
|
+
merged = merge_into_context(context, right_value)
|
|
26
|
+
|
|
27
|
+
Success(merged)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
#: (untyped) -> Hash[Symbol, untyped]
|
|
33
|
+
def to_context(value)
|
|
34
|
+
if value.respond_to?(:to_hash)
|
|
35
|
+
value.to_hash
|
|
36
|
+
elsif value.respond_to?(:to_h)
|
|
37
|
+
value.to_h
|
|
38
|
+
else
|
|
39
|
+
{_value: value}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (Hash[Symbol, untyped], untyped) -> Hash[Symbol, untyped]
|
|
44
|
+
def merge_into_context(context, value)
|
|
45
|
+
if value.respond_to?(:to_hash)
|
|
46
|
+
context.merge(value.to_hash)
|
|
47
|
+
elsif value.respond_to?(:to_h)
|
|
48
|
+
context.merge(value.to_h)
|
|
49
|
+
else
|
|
50
|
+
context.merge(_value: value)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Smart chain created by .then
|
|
6
|
+
# Accumulates context across chain steps and extracts required params for each operation.
|
|
7
|
+
class SmartChain < ChainedOperation
|
|
8
|
+
include Result::Mixin
|
|
9
|
+
include CallableResolver
|
|
10
|
+
|
|
11
|
+
# @rbs @extra_kwargs: Hash[Symbol, untyped]
|
|
12
|
+
# @rbs @uses_kwargs: bool
|
|
13
|
+
# @rbs @required_params: Array[Symbol]
|
|
14
|
+
# @rbs @prefilled_params: Hash[Symbol, untyped]
|
|
15
|
+
|
|
16
|
+
#: (_Callable, _Callable, ?Hash[Symbol, untyped]) -> void
|
|
17
|
+
def initialize(left, right, extra_kwargs = {})
|
|
18
|
+
super(left, right)
|
|
19
|
+
@extra_kwargs = extra_kwargs
|
|
20
|
+
@uses_kwargs = uses_kwargs?(right)
|
|
21
|
+
@required_params = extract_required_params(right)
|
|
22
|
+
@prefilled_params = extract_prefilled_params(right)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
#: (*untyped, **untyped) -> _Result[untyped, untyped]
|
|
26
|
+
def call(*args, **kwargs)
|
|
27
|
+
# Execute left side
|
|
28
|
+
result = @left.call(*args, **kwargs)
|
|
29
|
+
return result if result.failure?
|
|
30
|
+
|
|
31
|
+
# Build context from left's result
|
|
32
|
+
left_value = result.value!
|
|
33
|
+
context = to_context(left_value)
|
|
34
|
+
|
|
35
|
+
# Call right side with appropriate params
|
|
36
|
+
right_result = call_right(context)
|
|
37
|
+
return right_result if right_result.failure?
|
|
38
|
+
|
|
39
|
+
# Merge right's output into context
|
|
40
|
+
right_value = right_result.value!
|
|
41
|
+
merged = merge_into_context(context, right_value)
|
|
42
|
+
|
|
43
|
+
Success(merged)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
#: (untyped) -> Hash[Symbol, untyped]
|
|
49
|
+
def to_context(value)
|
|
50
|
+
if value.respond_to?(:to_hash)
|
|
51
|
+
value.to_hash
|
|
52
|
+
elsif value.respond_to?(:to_h)
|
|
53
|
+
value.to_h
|
|
54
|
+
else
|
|
55
|
+
{_value: value}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
#: (Hash[Symbol, untyped], untyped) -> Hash[Symbol, untyped]
|
|
60
|
+
def merge_into_context(context, value)
|
|
61
|
+
if value.respond_to?(:to_hash)
|
|
62
|
+
context.merge(value.to_hash)
|
|
63
|
+
elsif value.respond_to?(:to_h)
|
|
64
|
+
context.merge(value.to_h)
|
|
65
|
+
else
|
|
66
|
+
context.merge(_value: value)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#: (Hash[Symbol, untyped]) -> _Result[untyped, untyped]
|
|
71
|
+
def call_right(context)
|
|
72
|
+
if @uses_kwargs
|
|
73
|
+
# Extract only the params the operation needs from context
|
|
74
|
+
extracted = extract_from_context(context, @required_params)
|
|
75
|
+
|
|
76
|
+
# Merge: extracted from context < prefilled from .with < extra kwargs from .then
|
|
77
|
+
call_kwargs = extracted.merge(@prefilled_params).merge(@extra_kwargs)
|
|
78
|
+
|
|
79
|
+
# Check for missing required params
|
|
80
|
+
validate_required_params!(call_kwargs)
|
|
81
|
+
|
|
82
|
+
# Set trace context if tracing
|
|
83
|
+
if Instrumentation.tracing?
|
|
84
|
+
Instrumentation.set_chain_context(
|
|
85
|
+
pass_mode: :kwargs_splat,
|
|
86
|
+
extracted_params: extracted.keys
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
@right.call(**call_kwargs)
|
|
91
|
+
else
|
|
92
|
+
# Positional param operation - pass full context
|
|
93
|
+
if Instrumentation.tracing?
|
|
94
|
+
Instrumentation.set_chain_context(
|
|
95
|
+
pass_mode: :single_value,
|
|
96
|
+
extracted_params: nil
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
@right.call(context)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
#: (Hash[Symbol, untyped], Array[Symbol]) -> Hash[Symbol, untyped]
|
|
105
|
+
def extract_from_context(context, params)
|
|
106
|
+
result = {} #: Hash[Symbol, untyped]
|
|
107
|
+
params.each do |key|
|
|
108
|
+
result[key] = context[key] if context.key?(key)
|
|
109
|
+
end
|
|
110
|
+
result
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
#: (Hash[Symbol, untyped]) -> void
|
|
114
|
+
def validate_required_params!(kwargs)
|
|
115
|
+
op_class = extract_operation_class(@right)
|
|
116
|
+
return unless op_class.respond_to?(:required_keyword_parameters)
|
|
117
|
+
|
|
118
|
+
required = op_class.required_keyword_parameters
|
|
119
|
+
missing = required - kwargs.keys
|
|
120
|
+
|
|
121
|
+
if missing.any?
|
|
122
|
+
raise ArgumentError,
|
|
123
|
+
"Missing required parameters for #{op_class.name}: #{missing.join(", ")}. " \
|
|
124
|
+
"Available in context or provide via .with or .then(op, key: value)"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
#: (_Callable) -> bool
|
|
129
|
+
def uses_kwargs?(operation)
|
|
130
|
+
op_class = extract_operation_class(operation)
|
|
131
|
+
return false unless op_class.respond_to?(:positional_parameters)
|
|
132
|
+
|
|
133
|
+
positional = op_class.positional_parameters
|
|
134
|
+
keywords = op_class.keyword_parameters
|
|
135
|
+
|
|
136
|
+
if positional.any? && keywords.any?
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"Cannot chain to operation with both positional and keyword params. Use .transform to adapt."
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
super
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
#: (_Callable) -> Array[Symbol]
|
|
145
|
+
def extract_required_params(operation)
|
|
146
|
+
op_class = extract_operation_class(operation)
|
|
147
|
+
return [] unless op_class.respond_to?(:keyword_parameters)
|
|
148
|
+
op_class.keyword_parameters
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
#: (_Callable) -> Hash[Symbol, untyped]
|
|
152
|
+
def extract_prefilled_params(operation)
|
|
153
|
+
case operation
|
|
154
|
+
when TypedOperation::PartiallyApplied
|
|
155
|
+
operation.keyword_args
|
|
156
|
+
else
|
|
157
|
+
{}
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Kwargs spreading chain created by .then_spreads
|
|
6
|
+
# Always splats the full context as **kwargs to the next operation.
|
|
7
|
+
# Accumulates result into context.
|
|
8
|
+
class SplatChain < ChainedOperation
|
|
9
|
+
include Result::Mixin
|
|
10
|
+
|
|
11
|
+
#: (*untyped, **untyped) -> _Result[untyped, untyped]
|
|
12
|
+
def call(...)
|
|
13
|
+
result = @left.call(...)
|
|
14
|
+
return result if result.failure?
|
|
15
|
+
|
|
16
|
+
left_value = result.value!
|
|
17
|
+
context = to_context(left_value)
|
|
18
|
+
|
|
19
|
+
right_result = @right.call(**context)
|
|
20
|
+
return right_result if right_result.failure?
|
|
21
|
+
|
|
22
|
+
# Merge right's output into context
|
|
23
|
+
right_value = right_result.value!
|
|
24
|
+
merged = merge_into_context(context, right_value)
|
|
25
|
+
|
|
26
|
+
Success(merged)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
#: (untyped) -> Hash[Symbol, untyped]
|
|
32
|
+
def to_context(value)
|
|
33
|
+
if value.respond_to?(:to_hash)
|
|
34
|
+
value.to_hash
|
|
35
|
+
elsif value.respond_to?(:to_h)
|
|
36
|
+
value.to_h
|
|
37
|
+
else
|
|
38
|
+
raise ArgumentError, "Cannot splat #{value.class} as kwargs - must respond to #to_hash or #to_h"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
#: (Hash[Symbol, untyped], untyped) -> Hash[Symbol, untyped]
|
|
43
|
+
def merge_into_context(context, value)
|
|
44
|
+
if value.respond_to?(:to_hash)
|
|
45
|
+
context.merge(value.to_hash)
|
|
46
|
+
elsif value.respond_to?(:to_h)
|
|
47
|
+
context.merge(value.to_h)
|
|
48
|
+
else
|
|
49
|
+
context.merge(_value: value)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Global configuration for TypedOperation.
|
|
6
|
+
class Configuration
|
|
7
|
+
# @rbs @result_adapter: (Result::Adapters::BuiltIn | Result::Adapters::DryMonads | untyped)?
|
|
8
|
+
|
|
9
|
+
#: () -> void
|
|
10
|
+
def initialize
|
|
11
|
+
@result_adapter = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Get the current result adapter. Defaults to BuiltIn.
|
|
15
|
+
#: () -> (Result::Adapters::BuiltIn | Result::Adapters::DryMonads | untyped)
|
|
16
|
+
def result_adapter
|
|
17
|
+
@result_adapter ||= Result::Adapters::BuiltIn.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Set the result adapter.
|
|
21
|
+
#: (:built_in | :dry_monads | untyped) -> void
|
|
22
|
+
def result_adapter=(adapter)
|
|
23
|
+
@result_adapter = case adapter
|
|
24
|
+
when :built_in then Result::Adapters::BuiltIn.new
|
|
25
|
+
when :dry_monads then Result::Adapters::DryMonads.new
|
|
26
|
+
else adapter
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# @rbs @configuration: Configuration?
|
|
33
|
+
|
|
34
|
+
# Get the global configuration instance.
|
|
35
|
+
#: () -> Configuration
|
|
36
|
+
def configuration
|
|
37
|
+
@configuration ||= Configuration.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Configure TypedOperation with a block.
|
|
41
|
+
#: () { (Configuration) -> void } -> void
|
|
42
|
+
def configure
|
|
43
|
+
yield(configuration)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Reset configuration to defaults. Mainly for testing.
|
|
47
|
+
#: () -> void
|
|
48
|
+
def reset_configuration!
|
|
49
|
+
@configuration = nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|