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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -729
  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 +45 -25
  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 +17 -7
  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 +17 -4
  45. metadata +49 -8
  46. data/lib/typed_operation/operations/callable.rb +0 -23
data/README.md CHANGED
@@ -1,798 +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 [`literal` monads](https://github.com/joeldrapper/literal) or [`Dry::Monads`](https://dry-rb.org/gems/dry-monads/1.3/).
8
+ ## Why TypedOperation?
8
9
 
9
- **Note the version described here (~ 1.0.0) is pre-release on Rubygems (v1.0.0 is waiting for a release of `literal`). To use it now you can simply require `literal` from github in your Gemfile:**
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
- ```ruby
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
- The operation can also implement:
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
- # optionally hook in before execution...
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
- ### Specifying parameters (using `.param`)
24
+ For Rails, generate the base operation class:
211
25
 
212
- Parameters are specified using the provided class methods (`.positional_param` and `.named_param`),
213
- or using the underlying `.param` method.
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
- MyOperation.new(name: "bob", choices: ["st"], chose: true)
29
+ # With optional integrations
30
+ bin/rails g typed_operation:install --dry_monads --action_policy
229
31
  ```
230
32
 
231
- or by including the `Literal::Types` module into your operation class, and using the aliases provided:
33
+ ## Quick Start
232
34
 
233
35
  ```ruby
234
- class MyOperation < ::TypedOperation::Base
235
- include Literal::Types
236
-
36
+ class GreetUser < TypedOperation::Base
237
37
  param :name, String
238
- param :age, _Nilable(Integer) # optional can also be specifed using `.optional`
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
- puts "Hello #{name} (#{age})"
41
+ "#{greeting}, #{name}!"
303
42
  end
304
43
  end
305
44
 
306
- MyOperation.new("Steve").call
307
- # => "Hello Steve (0)"
45
+ # Direct invocation
46
+ GreetUser.call(name: "World")
47
+ # => "Hello, World!"
308
48
 
309
- MyOperation.with("Steve").call(20)
310
- # => "Hello Steve (20)"
49
+ # Partial application
50
+ greeter = GreetUser.with(greeting: "Welcome")
51
+ greeter.call(name: "Alice")
52
+ # => "Welcome, Alice!"
311
53
  ```
312
54
 
313
- #### Named (keyword) parameters
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 MyOperation < ::TypedOperation::Base
328
- named_param :name, String
329
- # Or alternatively => `param :name, String`
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
- MyOperation.with(name: "Steve").call(age: 20)
341
- # => "Hello Steve (20)"
342
- ```
62
+ # Named parameters (keyword arguments)
63
+ param :body, String
64
+ param :author, User
343
65
 
344
- #### Using both positional and named parameters
66
+ # Optional with default
67
+ param :status, String, default: "draft"
345
68
 
346
- You can use both positional and named parameters in the same operation.
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
- puts "Hello #{name} (#{age})"
73
+ Post.create!(title: title, body: body, author: author, status: status)
355
74
  end
356
75
  end
357
76
 
358
- MyOperation.new("Steve").call
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
- #### Optional parameters (using `optional:` or `.optional`)
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
- An optional parameter can be specified by:
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
- class MyOperation < ::TypedOperation::Base
378
- param :name, String
379
- param :age, Integer, optional: true
380
- param :nickname, optional(String)
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
- This `.optional` class method effectively makes the type signature a union of the provided type and `NilClass`.
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
- param :name, String, &:to_s
397
- param :choice, Literal::Types::BooleanType do |v|
398
- v == "y"
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
- ```ruby
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
- If using the directive `# frozen_string_literal: true` then you string values are frozen by default.
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
- **An important caveat about partial application is that type checking is not done until the operation is instantiated**
107
+ TypedOperation includes built-in `Success`/`Failure` types for explicit error handling:
424
108
 
425
109
  ```ruby
426
- MyOperation.new(123)
427
- # => Raises an error as the type of the first parameter is incorrect:
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
- #### `.prepared?`
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
- You can instead provide a policy class implementation:
628
-
629
- ```ruby
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
- with multiple parameters:
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
- ```ruby
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
- 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:
790
131
 
791
- 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
792
139
 
793
140
  ## Contributing
794
141
 
795
- 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.
796
143
 
797
144
  ## License
798
145