typed_operation 1.0.0.beta2 → 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 -729
- 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 +45 -25
- 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 +17 -7
- 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 +17 -4
- metadata +49 -8
- data/lib/typed_operation/operations/callable.rb +0 -23
data/README.md
CHANGED
|
@@ -1,798 +1,145 @@
|
|
|
1
1
|
# TypedOperation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
|
+

|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
A Command pattern implementation for Ruby with typed parameters, partial application, and operation composition.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
## Why TypedOperation?
|
|
8
9
|
|
|
9
|
-
**
|
|
10
|
+
- **Type-safe parameters** - Runtime validation using the [`literal`](https://github.com/joeldrapper/literal) gem
|
|
11
|
+
- **Partial application & currying** - Build specialized operations from general ones
|
|
12
|
+
- **Operation composition** - Chain operations together with `.then`, pipelines, and railway-oriented error handling
|
|
13
|
+
- **Result types** - Built-in Success/Failure types, or use [`Dry::Monads`](https://dry-rb.org/gems/dry-monads/)
|
|
14
|
+
- **Rails integration** - Generators and Action Policy authorization support
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
gem "literal", github: "joeldrapper/literal", branch: "main"
|
|
13
|
-
gem "typed_operation", "~> 1.0.0.beta2"
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
## Features
|
|
17
|
-
|
|
18
|
-
- Operations can be **partially applied** or **curried**
|
|
19
|
-
- Operations are **callable**
|
|
20
|
-
- Operations can be **pattern matched** on
|
|
21
|
-
- Parameters:
|
|
22
|
-
- specified with **type constraints** (uses `literal` gem)
|
|
23
|
-
- can be **positional** or **named**
|
|
24
|
-
- can be **optional**, or have **default** values
|
|
25
|
-
- can be **coerced** by providing a block
|
|
26
|
-
|
|
27
|
-
### Example
|
|
28
|
-
|
|
29
|
-
```ruby
|
|
30
|
-
class ShelveBookOperation < ::TypedOperation::Base
|
|
31
|
-
# Parameters can be specified with `positional_param`/`named_param` or directly with the
|
|
32
|
-
# underlying `param` method.
|
|
33
|
-
|
|
34
|
-
# Note that you may also like to simply alias the param methods to your own preferred names:
|
|
35
|
-
# `positional`/`named` or `arg`/`key` for example.
|
|
36
|
-
|
|
37
|
-
# A positional parameter (positional argument passed to the operation when creating it).
|
|
38
|
-
positional_param :title, String
|
|
39
|
-
# Or if you prefer:
|
|
40
|
-
# `param :title, String, positional: true`
|
|
41
|
-
|
|
42
|
-
# A named parameter (keyword argument passed to the operation when creating it).
|
|
43
|
-
named_param :description, String
|
|
44
|
-
# Or if you prefer:
|
|
45
|
-
# `param :description, String`
|
|
46
|
-
|
|
47
|
-
# `param` creates named parameters by default
|
|
48
|
-
param :author_id, Integer, &:to_i
|
|
49
|
-
param :isbn, String
|
|
50
|
-
|
|
51
|
-
# Optional parameters are specified by wrapping the type constraint in the `optional` method, or using the `optional:` option
|
|
52
|
-
param :shelf_code, optional(Integer)
|
|
53
|
-
# Or if you prefer:
|
|
54
|
-
# `named_param :shelf_code, Integer, optional: true`
|
|
55
|
-
|
|
56
|
-
param :category, String, default: "unknown".freeze
|
|
57
|
-
|
|
58
|
-
# optional hook called when the operation is initialized, and after the parameters have been set
|
|
59
|
-
def prepare
|
|
60
|
-
raise ArgumentError, "ISBN is invalid" unless valid_isbn?
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# optionally hook in before execution ... and call super to allow subclasses to hook in too
|
|
64
|
-
def before_execute_operation
|
|
65
|
-
# ...
|
|
66
|
-
super
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# The 'work' of the operation, this is the main body of the operation and must be implemented
|
|
70
|
-
def perform
|
|
71
|
-
"Put away '#{title}' by author ID #{author_id}#{shelf_code ? " on shelf #{shelf_code}" : "" }"
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# optionally hook in after execution ... and call super to allow subclasses to hook in too
|
|
75
|
-
def after_execute_operation(result)
|
|
76
|
-
# ...
|
|
77
|
-
super
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
private
|
|
81
|
-
|
|
82
|
-
def valid_isbn?
|
|
83
|
-
# ...
|
|
84
|
-
true
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
shelve = ShelveBookOperation.new("The Hobbit", description: "A book about a hobbit", author_id: "1", isbn: "978-0261103283")
|
|
89
|
-
# => #<ShelveBookOperation:0x0000000108b3e490 @attributes={:title=>"The Hobbit", :description=>"A book about a hobbit", :author_id=>1, :isbn=>"978-0261103283", :shelf_code=>nil, :category=>"unknown"}, ...
|
|
90
|
-
|
|
91
|
-
shelve.call
|
|
92
|
-
# => "Put away 'The Hobbit' by author ID 1"
|
|
93
|
-
|
|
94
|
-
shelve = ShelveBookOperation.with("The Silmarillion", description: "A book about the history of Middle-earth", shelf_code: 1)
|
|
95
|
-
# => #<TypedOperation::PartiallyApplied:0x0000000103e6f560 ...
|
|
96
|
-
|
|
97
|
-
shelve.call(author_id: "1", isbn: "978-0261102736")
|
|
98
|
-
# => "Put away 'The Silmarillion' by author ID 1 on shelf 1"
|
|
99
|
-
|
|
100
|
-
curried = shelve.curry
|
|
101
|
-
# => #<TypedOperation::Curried:0x0000000108d98a10 ...
|
|
102
|
-
|
|
103
|
-
curried.(1).("978-0261102736")
|
|
104
|
-
# => "Put away 'The Silmarillion' by author ID 1 on shelf 1"
|
|
105
|
-
|
|
106
|
-
shelve.call(author_id: "1", isbn: false)
|
|
107
|
-
# => Raises an error because isbn is invalid
|
|
108
|
-
# :in `initialize': Expected `false` to be of type: `String`. (Literal::TypeError)
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
### Partially applying parameters
|
|
112
|
-
|
|
113
|
-
Operations can also be partially applied and curried:
|
|
114
|
-
|
|
115
|
-
```ruby
|
|
116
|
-
class TestOperation < ::TypedOperation::Base
|
|
117
|
-
param :foo, String, positional: true
|
|
118
|
-
param :bar, String
|
|
119
|
-
param :baz, String, &:to_s
|
|
120
|
-
|
|
121
|
-
def perform = "It worked! (#{foo}, #{bar}, #{baz})"
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# Invoking the operation directly
|
|
125
|
-
TestOperation.("1", bar: "2", baz: 3)
|
|
126
|
-
# => "It worked! (1, 2, 3)"
|
|
127
|
-
|
|
128
|
-
# Partial application of parameters
|
|
129
|
-
partially_applied = TestOperation.with("1").with(bar: "2")
|
|
130
|
-
# => #<TypedOperation::PartiallyApplied:0x0000000110270248 @keyword_args={:bar=>"2"}, @operation_class=TestOperation, @positional_args=["1"]>
|
|
131
|
-
|
|
132
|
-
# You can partially apply more than one parameter at a time, and chain calls to `.with`.
|
|
133
|
-
# With all the required parameters set, the operation is 'prepared' and can be instantiated and called
|
|
134
|
-
prepared = TestOperation.with("1", bar: "2").with(baz: 3)
|
|
135
|
-
# => #<TypedOperation::Prepared:0x0000000110a9df38 @keyword_args={:bar=>"2", :baz=>3}, @operation_class=TestOperation, @positional_args=["1"]>
|
|
136
|
-
|
|
137
|
-
# A 'prepared' operation can instantiated & called
|
|
138
|
-
prepared.call
|
|
139
|
-
# => "It worked! (1, 2, 3)"
|
|
140
|
-
|
|
141
|
-
# You can provide additional parameters when calling call on a partially applied operation
|
|
142
|
-
partially_applied.call(baz: 3)
|
|
143
|
-
# => "It worked! (1, 2, 3)"
|
|
144
|
-
|
|
145
|
-
# Partial application can be done using `.with or `.[]`
|
|
146
|
-
TestOperation.with("1")[bar: "2", baz: 3].call
|
|
147
|
-
# => "It worked! (1, 2, 3)"
|
|
148
|
-
|
|
149
|
-
# Currying an operation, note that *all required* parameters must be provided an argument in order
|
|
150
|
-
TestOperation.curry.("1").("2").(3)
|
|
151
|
-
# => "It worked! (1, 2, 3)"
|
|
152
|
-
|
|
153
|
-
# You can also curry from an already partially applied operation, so you can set optional named parameters first.
|
|
154
|
-
# Note currying won't let you set optional positional parameters.
|
|
155
|
-
partially_applied = TestOperation.with("1")
|
|
156
|
-
partially_applied.curry.("2").(3)
|
|
157
|
-
# => "It worked! (1, 2, 3)"
|
|
158
|
-
|
|
159
|
-
# > TestOperation.with("1").with(bar: "2").call
|
|
160
|
-
# => Raises an error because it is PartiallyApplied and so can't be called (it is missing required args)
|
|
161
|
-
# "Cannot call PartiallyApplied operation TestOperation (key: test_operation), are you expecting it to be Prepared? (TypedOperation::MissingParameterError)"
|
|
162
|
-
|
|
163
|
-
TestOperation.with("1").with(bar: "2").with(baz: 3).operation
|
|
164
|
-
# same as > TestOperation.new("1", bar: "2", baz: 3)
|
|
165
|
-
# => <TestOperation:0x000000014a0048a8 ...>
|
|
166
|
-
|
|
167
|
-
# > TestOperation.with(foo: "1").with(bar: "2").operation
|
|
168
|
-
# => Raises an error because it is PartiallyApplied so operation can't be instantiated
|
|
169
|
-
# "Cannot instantiate Operation TestOperation (key: test_operation), as it is only partially applied. (TypedOperation::MissingParameterError)"
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
## Documentation
|
|
173
|
-
|
|
174
|
-
### Create an operation (subclass `TypedOperation::Base` or `TypedOperation::ImmutableBase`)
|
|
175
|
-
|
|
176
|
-
Create an operation by subclassing `TypedOperation::Base` or `TypedOperation::ImmutableBase` and specifying the parameters the operation requires.
|
|
177
|
-
|
|
178
|
-
- `TypedOperation::Base` (uses `Literal::Struct`) is the parent class for an operation where the arguments are potentially mutable (ie not frozen).
|
|
179
|
-
No attribute writer methods are defined, so the arguments can not be changed after initialization, but the values passed in are not guaranteed to be frozen.
|
|
180
|
-
- `TypedOperation::ImmutableBase` (uses `Literal::Data`) is the parent class for an operation where the arguments are immutable (frozen on initialization),
|
|
181
|
-
thus giving a somewhat stronger immutability guarantee (ie that the operation does not mutate its arguments).
|
|
182
|
-
|
|
183
|
-
The subclass must implement the `#perform` method which is where the operations main work is done.
|
|
16
|
+
## Installation
|
|
184
17
|
|
|
185
|
-
|
|
18
|
+
Add to your Gemfile:
|
|
186
19
|
|
|
187
|
-
- `#prepare` - called when the operation is initialized, and after the parameters have been set
|
|
188
|
-
- `#before_execute_operation` - optionally hook in before execution ... and call super to allow subclasses to hook in too
|
|
189
|
-
- `#after_execute_operation` - optionally hook in after execution ... and call super to allow subclasses to hook in too
|
|
190
|
-
|
|
191
20
|
```ruby
|
|
192
|
-
|
|
193
|
-
def before_execute_operation
|
|
194
|
-
# Remember to call super
|
|
195
|
-
super
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def perform
|
|
199
|
-
# ... implement me!
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
# optionally hook in after execution...
|
|
203
|
-
def after_execute_operation(result)
|
|
204
|
-
# Remember to call super, note the result is passed in and the return value of this method is the result of the operation
|
|
205
|
-
# thus allowing you to modify the result if you wish
|
|
206
|
-
super
|
|
207
|
-
end
|
|
21
|
+
gem "typed_operation"
|
|
208
22
|
```
|
|
209
23
|
|
|
210
|
-
|
|
24
|
+
For Rails, generate the base operation class:
|
|
211
25
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
Types are specified using the `literal` gem. In many cases this simply means providing the class of the
|
|
216
|
-
expected type, but there are also some other useful types provided by `literal` (eg `Union`).
|
|
217
|
-
|
|
218
|
-
These can be either accessed via the `Literal` module, eg `Literal::Types::BooleanType`:
|
|
219
|
-
|
|
220
|
-
```ruby
|
|
221
|
-
class MyOperation < ::TypedOperation::Base
|
|
222
|
-
param :name, String
|
|
223
|
-
param :age, Integer, optional: true
|
|
224
|
-
param :choices, Literal::Types::ArrayType.new(String)
|
|
225
|
-
param :chose, Literal::Types::BooleanType
|
|
226
|
-
end
|
|
26
|
+
```bash
|
|
27
|
+
bin/rails g typed_operation:install
|
|
227
28
|
|
|
228
|
-
|
|
29
|
+
# With optional integrations
|
|
30
|
+
bin/rails g typed_operation:install --dry_monads --action_policy
|
|
229
31
|
```
|
|
230
32
|
|
|
231
|
-
|
|
33
|
+
## Quick Start
|
|
232
34
|
|
|
233
35
|
```ruby
|
|
234
|
-
class
|
|
235
|
-
include Literal::Types
|
|
236
|
-
|
|
36
|
+
class GreetUser < TypedOperation::Base
|
|
237
37
|
param :name, String
|
|
238
|
-
param :
|
|
239
|
-
param :choices, _Array(String)
|
|
240
|
-
param :chose, _Boolean
|
|
241
|
-
end
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
Type constraints can be modified to make the parameter optional using `.optional`.
|
|
38
|
+
param :greeting, String, default: "Hello"
|
|
245
39
|
|
|
246
|
-
#### Your own aliases
|
|
247
|
-
|
|
248
|
-
Note that you may also like to alias the param methods to your own preferred names in a common base operation class.
|
|
249
|
-
|
|
250
|
-
Some possible aliases are:
|
|
251
|
-
- `positional`/`named`
|
|
252
|
-
- `arg`/`key`
|
|
253
|
-
|
|
254
|
-
For example:
|
|
255
|
-
|
|
256
|
-
```ruby
|
|
257
|
-
class ApplicationOperation < ::TypedOperation::Base
|
|
258
|
-
class << self
|
|
259
|
-
alias_method :arg, :positional_param
|
|
260
|
-
alias_method :key, :named_param
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
class MyOperation < ApplicationOperation
|
|
265
|
-
arg :name, String
|
|
266
|
-
key :age, Integer
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
MyOperation.new("Steve", age: 20)
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
#### Positional parameters (`positional: true` or `.positional_param`)
|
|
273
|
-
|
|
274
|
-
Defines a positional parameter (positional argument passed to the operation when creating it).
|
|
275
|
-
|
|
276
|
-
The following are equivalent:
|
|
277
|
-
|
|
278
|
-
- `param <param_name>, <type>, positional: true, <**options>`
|
|
279
|
-
- `positional_param <param_name>, <type>, <**options>`
|
|
280
|
-
|
|
281
|
-
The `<para_name>` is a symbolic name, used to create the accessor method, and when deconstructing to a hash.
|
|
282
|
-
|
|
283
|
-
The `<type>` constraint provides the expected type of the parameter (the type is a type signature compatible with `literal`).
|
|
284
|
-
|
|
285
|
-
The `<options>` are:
|
|
286
|
-
- `default:` - a default value for the parameter (can be a proc or a frozen value)
|
|
287
|
-
- `optional:` - a boolean indicating whether the parameter is optional (default: false). Note you may prefer to use the
|
|
288
|
-
`.optional` method instead of this option.
|
|
289
|
-
|
|
290
|
-
**Note** when positional arguments are provided to the operation, they are matched in order of definition or positional
|
|
291
|
-
params. Also note that you cannot define required positional parameters after optional ones.
|
|
292
|
-
|
|
293
|
-
Eg
|
|
294
|
-
|
|
295
|
-
```ruby
|
|
296
|
-
class MyOperation < ::TypedOperation::Base
|
|
297
|
-
positional_param :name, String, positional: true
|
|
298
|
-
# Or alternatively => `param :name, String, positional: true`
|
|
299
|
-
positional_param :age, Integer, default: -> { 0 }
|
|
300
|
-
|
|
301
40
|
def perform
|
|
302
|
-
|
|
41
|
+
"#{greeting}, #{name}!"
|
|
303
42
|
end
|
|
304
43
|
end
|
|
305
44
|
|
|
306
|
-
|
|
307
|
-
|
|
45
|
+
# Direct invocation
|
|
46
|
+
GreetUser.call(name: "World")
|
|
47
|
+
# => "Hello, World!"
|
|
308
48
|
|
|
309
|
-
|
|
310
|
-
|
|
49
|
+
# Partial application
|
|
50
|
+
greeter = GreetUser.with(greeting: "Welcome")
|
|
51
|
+
greeter.call(name: "Alice")
|
|
52
|
+
# => "Welcome, Alice!"
|
|
311
53
|
```
|
|
312
54
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
Defines a named parameter (keyword argument passed to the operation when creating it).
|
|
316
|
-
|
|
317
|
-
The following are equivalent:
|
|
318
|
-
- `param <param_name>, <type>, <**options>`
|
|
319
|
-
- `named_param <param_name>, <type>, <**options>`
|
|
320
|
-
|
|
321
|
-
The `<para_name>` is a symbol, used as parameter name for the keyword arguments in the operation constructor, to
|
|
322
|
-
create the accessor method and when deconstructing to a hash.
|
|
323
|
-
|
|
324
|
-
The type constraint and options are the same as for positional parameters.
|
|
55
|
+
### Parameters
|
|
325
56
|
|
|
326
57
|
```ruby
|
|
327
|
-
class
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
named_param :age, Integer, default: -> { 0 }
|
|
331
|
-
|
|
332
|
-
def perform
|
|
333
|
-
puts "Hello #{name} (#{age})"
|
|
334
|
-
end
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
MyOperation.new(name: "Steve").call
|
|
338
|
-
# => "Hello Steve (0)"
|
|
58
|
+
class CreatePost < TypedOperation::Base
|
|
59
|
+
# Positional parameter
|
|
60
|
+
positional_param :title, String
|
|
339
61
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
62
|
+
# Named parameters (keyword arguments)
|
|
63
|
+
param :body, String
|
|
64
|
+
param :author, User
|
|
343
65
|
|
|
344
|
-
|
|
66
|
+
# Optional with default
|
|
67
|
+
param :status, String, default: "draft"
|
|
345
68
|
|
|
346
|
-
|
|
69
|
+
# Type coercion
|
|
70
|
+
param :view_count, Integer, &:to_i
|
|
347
71
|
|
|
348
|
-
```ruby
|
|
349
|
-
class MyOperation < ::TypedOperation::Base
|
|
350
|
-
positional_param :name, String
|
|
351
|
-
named_param :age, Integer, default: -> { 0 }
|
|
352
|
-
|
|
353
72
|
def perform
|
|
354
|
-
|
|
73
|
+
Post.create!(title: title, body: body, author: author, status: status)
|
|
355
74
|
end
|
|
356
75
|
end
|
|
357
76
|
|
|
358
|
-
|
|
359
|
-
# => "Hello Steve (0)"
|
|
360
|
-
|
|
361
|
-
MyOperation.new("Steve", age: 20).call
|
|
362
|
-
# => "Hello Steve (20)"
|
|
363
|
-
|
|
364
|
-
MyOperation.with("Steve").call(age: 20)
|
|
365
|
-
# => "Hello Steve (20)"
|
|
77
|
+
CreatePost.call("My Post", body: "Content", author: current_user)
|
|
366
78
|
```
|
|
367
79
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
Optional parameters are ones that do not need to be specified for the operation to be instantiated.
|
|
80
|
+
### Operation Composition
|
|
371
81
|
|
|
372
|
-
|
|
373
|
-
- using the `optional:` option
|
|
374
|
-
- using the `.optional` method around the type constraint
|
|
82
|
+
Chain operations using `.then`, `.transform`, and `.or_else`:
|
|
375
83
|
|
|
376
84
|
```ruby
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
MyOperation.new(name: "Steve")
|
|
385
|
-
MyOperation.new(name: "Steve", age: 20)
|
|
386
|
-
MyOperation.new(name: "Steve", nickname: "Steve-o")
|
|
85
|
+
ValidateOrder
|
|
86
|
+
.then(ProcessPayment)
|
|
87
|
+
.then(SendConfirmation)
|
|
88
|
+
.or_else { |failure| LogError.call(failure) }
|
|
89
|
+
.call(order_id: 123)
|
|
387
90
|
```
|
|
388
91
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
#### Coercing parameters
|
|
392
|
-
|
|
393
|
-
You can specify a block after a parameter definition to coerce the argument value.
|
|
92
|
+
Or use the Pipeline DSL for declarative composition:
|
|
394
93
|
|
|
395
94
|
```ruby
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
95
|
+
OrderPipeline = TypedOperation::Pipeline.build do
|
|
96
|
+
step ValidateOrder
|
|
97
|
+
step ProcessPayment
|
|
98
|
+
step SendConfirmation, if: ->(ctx) { ctx[:send_email] }
|
|
99
|
+
on_failure { |error| Logger.error(error) }
|
|
399
100
|
end
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
#### Default values (with `default:`)
|
|
403
|
-
|
|
404
|
-
You can specify a default value for a parameter using the `default:` option.
|
|
405
|
-
|
|
406
|
-
The default value can be a proc or a frozen value. If the value is specified as `nil` then the default value is literally nil and the parameter is optional.
|
|
407
101
|
|
|
408
|
-
|
|
409
|
-
param :name, String, default: "Steve".freeze
|
|
410
|
-
param :age, Integer, default: -> { rand(100) }
|
|
102
|
+
OrderPipeline.call(order_id: 123, send_email: true)
|
|
411
103
|
```
|
|
412
104
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
### Partially applying (fixing parameters) on an operation (using `.with`)
|
|
416
|
-
|
|
417
|
-
`.with(...)` creates a partially applied operation with the provided parameters.
|
|
418
|
-
|
|
419
|
-
It is aliased to `.[]` for an alternative syntax.
|
|
420
|
-
|
|
421
|
-
Note that `.with` can take both positional and keyword arguments, and can be chained.
|
|
105
|
+
### Result Types
|
|
422
106
|
|
|
423
|
-
|
|
107
|
+
TypedOperation includes built-in `Success`/`Failure` types for explicit error handling:
|
|
424
108
|
|
|
425
109
|
```ruby
|
|
426
|
-
|
|
427
|
-
#
|
|
428
|
-
# Expected `123` to be of type: `String`. (Literal::TypeError)
|
|
429
|
-
|
|
430
|
-
op = MyOperation.with(123)
|
|
431
|
-
# => #<TypedOperation::Prepared:0x000000010b1d3358 ...
|
|
432
|
-
# Does **not raise** an error, as the type of the first parameter is not checked until the operation is instantiated
|
|
433
|
-
|
|
434
|
-
op.call # or op.operation
|
|
435
|
-
# => Now raises an error as the type of the first parameter is incorrect and operation is instantiated
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
### Calling an operation (using `.call`)
|
|
439
|
-
|
|
440
|
-
An operation can be invoked by:
|
|
441
|
-
|
|
442
|
-
- instantiating it with at least required params and then calling the `#call` method on the instance
|
|
443
|
-
- once a partially applied operation has been prepared (all required parameters have been set), the call
|
|
444
|
-
method on `TypedOperation::Prepared` can be used to instantiate and call the operation.
|
|
445
|
-
- once an operation is curried, the `#call` method on last TypedOperation::Curried in the chain will invoke the operation
|
|
446
|
-
- calling `#call` on a partially applied operation and passing in any remaining required parameters
|
|
447
|
-
- calling `#execute_operation` on an operation instance (this is the method that is called by `#call`)
|
|
448
|
-
|
|
449
|
-
See the many examples in this document.
|
|
450
|
-
|
|
451
|
-
### Pattern matching on an operation
|
|
452
|
-
|
|
453
|
-
`TypedOperation::Base` and `TypedOperation::PartiallyApplied` implement `deconstruct` and `deconstruct_keys` methods,
|
|
454
|
-
so they can be pattern matched against.
|
|
455
|
-
|
|
456
|
-
```ruby
|
|
457
|
-
case MyOperation.new("Steve", age: 20)
|
|
458
|
-
in MyOperation[name, age]
|
|
459
|
-
puts "Hello #{name} (#{age})"
|
|
460
|
-
end
|
|
461
|
-
|
|
462
|
-
case MyOperation.new("Steve", age: 20)
|
|
463
|
-
in MyOperation[name:, age: 20]
|
|
464
|
-
puts "Hello #{name} (#{age})"
|
|
465
|
-
end
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
### Introspection of parameters & other methods
|
|
469
|
-
|
|
470
|
-
#### `.to_proc`
|
|
471
|
-
|
|
472
|
-
Get a proc that calls `.call(...)`
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
#### `#to_proc`
|
|
476
|
-
|
|
477
|
-
Get a proc that calls the `#call` method on an operation instance
|
|
110
|
+
class ProcessPayment < TypedOperation::Base
|
|
111
|
+
include TypedOperation::Result::Mixin # Built-in, no dependencies
|
|
478
112
|
|
|
479
|
-
|
|
113
|
+
param :amount, Numeric
|
|
480
114
|
|
|
481
|
-
Check if an operation is prepared
|
|
482
|
-
|
|
483
|
-
#### `.operation`
|
|
484
|
-
|
|
485
|
-
Return an operation instance from a Prepared operation. Will raise if called on a PartiallyApplied operation
|
|
486
|
-
|
|
487
|
-
#### `.positional_parameters`
|
|
488
|
-
|
|
489
|
-
List of the names of the positional parameters, in order
|
|
490
|
-
|
|
491
|
-
#### `.keyword_parameters`
|
|
492
|
-
|
|
493
|
-
List of the names of the keyword parameters
|
|
494
|
-
|
|
495
|
-
#### `.required_positional_parameters`
|
|
496
|
-
|
|
497
|
-
List of the names of the required positional parameters, in order
|
|
498
|
-
|
|
499
|
-
#### `.required_keyword_parameters`
|
|
500
|
-
|
|
501
|
-
List of the names of the required keyword parameters
|
|
502
|
-
|
|
503
|
-
#### `.optional_positional_parameters`
|
|
504
|
-
|
|
505
|
-
List of the names of the optional positional parameters, in order
|
|
506
|
-
|
|
507
|
-
#### `.optional_keyword_parameters`
|
|
508
|
-
|
|
509
|
-
List of the names of the optional keyword parameters
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
### Using with Rails
|
|
513
|
-
|
|
514
|
-
You can use the provided generator to create an `ApplicationOperation` class in your Rails project.
|
|
515
|
-
|
|
516
|
-
You can then extend this to add extra functionality to all your operations.
|
|
517
|
-
|
|
518
|
-
This is an example of a `ApplicationOperation` in a Rails app that uses `Dry::Monads`:
|
|
519
|
-
|
|
520
|
-
```ruby
|
|
521
|
-
# frozen_string_literal: true
|
|
522
|
-
|
|
523
|
-
class ApplicationOperation < ::TypedOperation::Base
|
|
524
|
-
# We choose to use dry-monads for our operations, so include the required modules
|
|
525
|
-
include Dry::Monads[:result]
|
|
526
|
-
include Dry::Monads::Do.for(:perform)
|
|
527
|
-
|
|
528
|
-
class << self
|
|
529
|
-
# Setup our own preferred names for the DSL methods
|
|
530
|
-
alias_method :positional, :positional_param
|
|
531
|
-
alias_method :named, :named_param
|
|
532
|
-
end
|
|
533
|
-
|
|
534
|
-
# Parameters common to all Operations in this application
|
|
535
|
-
named :initiator, optional(::User)
|
|
536
|
-
|
|
537
|
-
private
|
|
538
|
-
|
|
539
|
-
# We setup some helper methods for our operations to use
|
|
540
|
-
|
|
541
|
-
def succeeded(value)
|
|
542
|
-
Success(value)
|
|
543
|
-
end
|
|
544
|
-
|
|
545
|
-
def failed_with_value(value, message: "Operation failed", error_code: nil)
|
|
546
|
-
failed(error_code || operation_key, message, value)
|
|
547
|
-
end
|
|
548
|
-
|
|
549
|
-
def failed_with_message(message, error_code: nil)
|
|
550
|
-
failed(error_code || operation_key, message)
|
|
551
|
-
end
|
|
552
|
-
|
|
553
|
-
def failed(error_code, message = "Operation failed", value = nil)
|
|
554
|
-
Failure[error_code, message, value]
|
|
555
|
-
end
|
|
556
|
-
|
|
557
|
-
def failed_with_code_and_value(error_code, value, message: "Operation failed")
|
|
558
|
-
failed(error_code, message, value)
|
|
559
|
-
end
|
|
560
|
-
|
|
561
|
-
def operation_key
|
|
562
|
-
self.class.name
|
|
563
|
-
end
|
|
564
|
-
end
|
|
565
|
-
```
|
|
566
|
-
|
|
567
|
-
### Using with Action Policy (`action_policy` gem)
|
|
568
|
-
|
|
569
|
-
> Note, this optional feature requires the `action_policy` gem to be installed and does not yet work with `ImmutableBase`.
|
|
570
|
-
|
|
571
|
-
Add `TypedOperation::ActionPolicyAuth` to your `ApplicationOperation` (first `require` the module):
|
|
572
|
-
|
|
573
|
-
```ruby
|
|
574
|
-
require "typed_operation/action_policy_auth"
|
|
575
|
-
|
|
576
|
-
class ApplicationOperation < ::TypedOperation::Base
|
|
577
|
-
# ...
|
|
578
|
-
include TypedOperation::ActionPolicyAuth
|
|
579
|
-
|
|
580
|
-
# You can specify a parameter to take the authorization context object, eg a user (can also be optional if some
|
|
581
|
-
# operations don't require authorization)
|
|
582
|
-
param :initiator, ::User # or optional(::User)
|
|
583
|
-
end
|
|
584
|
-
```
|
|
585
|
-
|
|
586
|
-
#### Specify the action with `.action_type`
|
|
587
|
-
|
|
588
|
-
Every operation must define what action_type it is, eg:
|
|
589
|
-
|
|
590
|
-
```ruby
|
|
591
|
-
class MyUpdateOperation < ApplicationOperation
|
|
592
|
-
action_type :update
|
|
593
|
-
end
|
|
594
|
-
```
|
|
595
|
-
|
|
596
|
-
Any symbol can be used as the `action_type` and this is by default used to determine which policy method to call.
|
|
597
|
-
|
|
598
|
-
#### Configuring auth with `.authorized_via`
|
|
599
|
-
|
|
600
|
-
`.authorized_via` is used to specify how to authorize the operation. You must specify the name of a parameter
|
|
601
|
-
for the policy authorization context. You can also specify multiple parameters if you wish.
|
|
602
|
-
|
|
603
|
-
You can then either provide a block with the logic to perform the authorization check, or provide a policy class.
|
|
604
|
-
|
|
605
|
-
The `record:` option lets you provide the name of the parameter which will be passed as the policy 'record'.
|
|
606
|
-
|
|
607
|
-
For example:
|
|
608
|
-
|
|
609
|
-
```ruby
|
|
610
|
-
class MyUpdateOperation < ApplicationOperation
|
|
611
|
-
param :initiator, ::AdminUser
|
|
612
|
-
param :order, ::Order
|
|
613
|
-
|
|
614
|
-
action_type :update
|
|
615
|
-
|
|
616
|
-
authorized_via :initiator, record: :order do
|
|
617
|
-
# ... the permissions check, admin users can edit orders that are not finalized
|
|
618
|
-
initiator.admin? && !record.finalized
|
|
619
|
-
end
|
|
620
|
-
|
|
621
115
|
def perform
|
|
622
|
-
|
|
116
|
+
result = PaymentGateway.charge(amount)
|
|
117
|
+
result.ok? ? Success(result.id) : Failure(:payment_failed)
|
|
623
118
|
end
|
|
624
119
|
end
|
|
625
|
-
```
|
|
626
120
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
class MyUpdateOperation < ApplicationOperation
|
|
631
|
-
action_type :update
|
|
632
|
-
|
|
633
|
-
class MyPolicyClass < OperationPolicy
|
|
634
|
-
# The action_type defined on the operation determines which method is called on the policy class
|
|
635
|
-
def update?
|
|
636
|
-
# ... the permissions check
|
|
637
|
-
initiator.admin?
|
|
638
|
-
end
|
|
639
|
-
|
|
640
|
-
# def my_check?
|
|
641
|
-
# # ... the permissions check
|
|
642
|
-
# end
|
|
643
|
-
end
|
|
644
|
-
|
|
645
|
-
authorized_via :initiator, with: MyPolicyClass
|
|
646
|
-
|
|
647
|
-
# It is also possible to specify which policy method to call
|
|
648
|
-
# authorized_via :initiator, with: MyPolicyClass, to: :my_check?
|
|
649
|
-
end
|
|
121
|
+
result = ProcessPayment.call(amount: 100)
|
|
122
|
+
result.success? # => true/false
|
|
123
|
+
result.value! # => the value (raises on failure)
|
|
650
124
|
```
|
|
651
125
|
|
|
652
|
-
|
|
126
|
+
For advanced features like Do notation, use [Dry::Monads](https://dry-rb.org/gems/dry-monads/) instead. See [Getting Started: Error Handling](website/docs/getting-started.md#error-handling) for details.
|
|
653
127
|
|
|
654
|
-
|
|
655
|
-
class MyUpdateOperation < ApplicationOperation
|
|
656
|
-
# ...
|
|
657
|
-
param :initiator, ::AdminUser
|
|
658
|
-
param :user, ::User
|
|
659
|
-
|
|
660
|
-
authorized_via :initiator, :user do
|
|
661
|
-
initiator.active? && user.active?
|
|
662
|
-
end
|
|
663
|
-
|
|
664
|
-
# ...
|
|
665
|
-
end
|
|
666
|
-
```
|
|
667
|
-
|
|
668
|
-
#### `.verify_authorized!`
|
|
669
|
-
|
|
670
|
-
To ensure that subclasses always implement authentication you can add a call to `.verify_authorized!` to your base
|
|
671
|
-
operation class.
|
|
672
|
-
|
|
673
|
-
This will cause the execution of any subclasses to fail if no authorization is performed.
|
|
674
|
-
|
|
675
|
-
```ruby
|
|
676
|
-
class MustAuthOperation < ApplicationOperation
|
|
677
|
-
verify_authorized!
|
|
678
|
-
end
|
|
679
|
-
|
|
680
|
-
class MyUpdateOperation < MustAuthOperation
|
|
681
|
-
def perform
|
|
682
|
-
# ...
|
|
683
|
-
end
|
|
684
|
-
end
|
|
685
|
-
|
|
686
|
-
MyUpdateOperation.call # => Raises an error that MyUpdateOperation does not perform any authorization
|
|
687
|
-
```
|
|
688
|
-
|
|
689
|
-
#### `#on_authorization_failure(err)`
|
|
690
|
-
|
|
691
|
-
A hook is provided to allow you to do some work on an authorization failure.
|
|
692
|
-
|
|
693
|
-
Simply override the `#on_authorization_failure(err)` method in your operation.
|
|
694
|
-
|
|
695
|
-
```ruby
|
|
696
|
-
class MyUpdateOperation < ApplicationOperation
|
|
697
|
-
action_type :update
|
|
698
|
-
|
|
699
|
-
authorized_via :initiator do
|
|
700
|
-
# ... the permissions check
|
|
701
|
-
initiator.admin?
|
|
702
|
-
end
|
|
703
|
-
|
|
704
|
-
def perform
|
|
705
|
-
# ...
|
|
706
|
-
end
|
|
707
|
-
|
|
708
|
-
def on_authorization_failure(err)
|
|
709
|
-
# ... do something with the error, eg logging
|
|
710
|
-
end
|
|
711
|
-
end
|
|
712
|
-
```
|
|
713
|
-
|
|
714
|
-
Note you are provided the ActionPolicy error object, but you cannot stop the error from being re-raised.
|
|
715
|
-
|
|
716
|
-
### Using with `Dry::Monads`
|
|
717
|
-
|
|
718
|
-
As per the example in [`Dry::Monads` documentation](https://dry-rb.org/gems/dry-monads/1.0/do-notation/)
|
|
719
|
-
|
|
720
|
-
```ruby
|
|
721
|
-
class MyOperation < ::TypedOperation::Base
|
|
722
|
-
include Dry::Monads[:result]
|
|
723
|
-
include Dry::Monads::Do.for(:perform, :create_account)
|
|
724
|
-
|
|
725
|
-
param :account_name, String
|
|
726
|
-
param :owner, ::Owner
|
|
727
|
-
|
|
728
|
-
def perform
|
|
729
|
-
account = yield create_account(account_name)
|
|
730
|
-
yield AnotherOperation.call(account, owner)
|
|
731
|
-
|
|
732
|
-
Success(account)
|
|
733
|
-
end
|
|
734
|
-
|
|
735
|
-
private
|
|
736
|
-
|
|
737
|
-
def create_account(account_name)
|
|
738
|
-
# returns Success(account) or Failure(:cant_create)
|
|
739
|
-
end
|
|
740
|
-
end
|
|
741
|
-
```
|
|
742
|
-
|
|
743
|
-
## Installation
|
|
744
|
-
|
|
745
|
-
Add this line to your application's Gemfile:
|
|
746
|
-
|
|
747
|
-
```ruby
|
|
748
|
-
gem "typed_operation"
|
|
749
|
-
```
|
|
750
|
-
|
|
751
|
-
And then execute:
|
|
752
|
-
```bash
|
|
753
|
-
$ bundle
|
|
754
|
-
```
|
|
755
|
-
|
|
756
|
-
Or install it yourself as:
|
|
757
|
-
```bash
|
|
758
|
-
$ gem install typed_operation
|
|
759
|
-
```
|
|
760
|
-
|
|
761
|
-
### Add an `ApplicationOperation` to your project
|
|
762
|
-
|
|
763
|
-
```ruby
|
|
764
|
-
bin/rails g typed_operation:install
|
|
765
|
-
```
|
|
766
|
-
|
|
767
|
-
Use the `--dry_monads` switch to `include Dry::Monads[:result]` into your `ApplicationOperation` (don't forget to also
|
|
768
|
-
add `gem "dry-monads"` to your Gemfile)
|
|
769
|
-
|
|
770
|
-
Use the `--action_policy` switch to add the `TypedOperation::ActionPolicyAuth` module to your `ApplicationOperation`
|
|
771
|
-
(and you will also need to add `gem "action_policy"` to your Gemfile).
|
|
772
|
-
|
|
773
|
-
```ruby
|
|
774
|
-
bin/rails g typed_operation:install --dry_monads --action_policy
|
|
775
|
-
```
|
|
776
|
-
|
|
777
|
-
## Generate a new Operation
|
|
778
|
-
|
|
779
|
-
```ruby
|
|
780
|
-
bin/rails g typed_operation TestOperation
|
|
781
|
-
```
|
|
782
|
-
|
|
783
|
-
You can optionally specify the directory to generate the operation in:
|
|
784
|
-
|
|
785
|
-
```ruby
|
|
786
|
-
bin/rails g typed_operation TestOperation --path=app/operations
|
|
787
|
-
```
|
|
128
|
+
## Documentation
|
|
788
129
|
|
|
789
|
-
|
|
130
|
+
For complete documentation, visit the [TypedOperation documentation site](https://stevegeek.github.io/typed_operation/) or see the `website/docs/` directory:
|
|
790
131
|
|
|
791
|
-
|
|
132
|
+
- [Getting Started](website/docs/getting-started.md) - Installation and basic usage
|
|
133
|
+
- [API Reference](website/docs/api.md) - Complete API documentation
|
|
134
|
+
- [Pipelines](website/docs/pipelines.md) - Pipeline DSL and operation composition
|
|
135
|
+
- [Integrations](website/docs/integrations.md) - Rails, Dry::Monads, and Action Policy
|
|
136
|
+
- [Instrumentation](website/docs/instrumentation.md) - Debugging and tracing operations
|
|
137
|
+
- [Best Practices](website/docs/best-practices.md) - Patterns and recommendations
|
|
138
|
+
- [Examples](website/docs/examples.md) - Comprehensive code examples
|
|
792
139
|
|
|
793
140
|
## Contributing
|
|
794
141
|
|
|
795
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/stevegeek/typed_operation.
|
|
142
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/stevegeek/typed_operation.
|
|
796
143
|
|
|
797
144
|
## License
|
|
798
145
|
|