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