typed_operation 0.4.2 → 1.0.0.beta1

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