typed_operation 0.4.2 → 1.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
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).