typed_operation 0.4.2 → 1.0.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,33 +1,553 @@
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.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.
184
+
185
+ The operation can also implement:
186
+
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
+ ```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
208
+ ```
209
+
210
+ ### Specifying parameters (using `.param`)
211
+
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
227
+
228
+ MyOperation.new(name: "bob", choices: ["st"], chose: true)
229
+ ```
230
+
231
+ or by including the `Literal::Types` module into your operation class, and using the aliases provided:
232
+
233
+ ```ruby
234
+ class MyOperation < ::TypedOperation::Base
235
+ include Literal::Types
236
+
237
+ 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`.
245
+
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
+ def perform
302
+ puts "Hello #{name} (#{age})"
303
+ end
304
+ end
305
+
306
+ MyOperation.new("Steve").call
307
+ # => "Hello Steve (0)"
308
+
309
+ MyOperation.with("Steve").call(20)
310
+ # => "Hello Steve (20)"
311
+ ```
312
+
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.
325
+
326
+ ```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)"
339
+
340
+ MyOperation.with(name: "Steve").call(age: 20)
341
+ # => "Hello Steve (20)"
342
+ ```
343
+
344
+ #### Using both positional and named parameters
345
+
346
+ You can use both positional and named parameters in the same operation.
347
+
348
+ ```ruby
349
+ class MyOperation < ::TypedOperation::Base
350
+ positional_param :name, String
351
+ named_param :age, Integer, default: -> { 0 }
352
+
353
+ def perform
354
+ puts "Hello #{name} (#{age})"
355
+ end
356
+ end
357
+
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)"
366
+ ```
367
+
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.
371
+
372
+ An optional parameter can be specified by:
373
+ - using the `optional:` option
374
+ - using the `.optional` method around the type constraint
375
+
376
+ ```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")
387
+ ```
388
+
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.
394
+
395
+ ```ruby
396
+ param :name, String, &:to_s
397
+ param :choice, Literal::Types::BooleanType do |v|
398
+ v == "y"
399
+ 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.
12
407
 
13
408
  ```ruby
409
+ param :name, String, default: "Steve".freeze
410
+ param :age, Integer, default: -> { rand(100) }
411
+ ```
412
+
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.
422
+
423
+ **An important caveat about partial application is that type checking is not done until the operation is instantiated**
424
+
425
+ ```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
478
+
479
+ #### `.prepared?`
480
+
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
+
14
523
  class ApplicationOperation < ::TypedOperation::Base
15
- include Dry::Monads[:result, :do]
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
16
533
 
17
- param :initiator, ::RegisteredUser, allow_nil: true
534
+ # Parameters common to all Operations in this application
535
+ named :initiator, optional(::User)
18
536
 
19
537
  private
20
538
 
539
+ # We setup some helper methods for our operations to use
540
+
21
541
  def succeeded(value)
22
542
  Success(value)
23
543
  end
24
544
 
25
545
  def failed_with_value(value, message: "Operation failed", error_code: nil)
26
- failed(error_code || self.class.operation_key, message, value)
546
+ failed(error_code || operation_key, message, value)
27
547
  end
28
548
 
29
549
  def failed_with_message(message, error_code: nil)
30
- failed(error_code || self.class.operation_key, message)
550
+ failed(error_code || operation_key, message)
31
551
  end
32
552
 
33
553
  def failed(error_code, message = "Operation failed", value = nil)
@@ -37,48 +557,191 @@ class ApplicationOperation < ::TypedOperation::Base
37
557
  def failed_with_code_and_value(error_code, value, message: "Operation failed")
38
558
  failed(error_code, message, value)
39
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)
40
583
  end
584
+ ```
585
+
586
+ #### Specify the action with `.action_type`
587
+
588
+ Every operation must define what action_type it is, eg:
41
589
 
590
+ ```ruby
591
+ class MyUpdateOperation < ApplicationOperation
592
+ action_type :update
593
+ end
42
594
  ```
43
595
 
44
- A simple operation:
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:
45
608
 
46
609
  ```ruby
47
- class TestOperation < ::ApplicationOperation
48
- param :foo, String
49
- param :bar, String
50
- param :baz, String, convert: true
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
+ def perform
622
+ # ...
623
+ end
624
+ end
625
+ ```
51
626
 
52
- def prepare
53
- # to setup (optional)
54
- puts "lets go!"
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
55
643
  end
56
644
 
57
- def call
58
- succeeded("It worked!")
59
- # failed_with_message("It failed!")
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
650
+ ```
651
+
652
+ with multiple parameters:
653
+
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?
60
662
  end
663
+
664
+ # ...
61
665
  end
62
666
  ```
63
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
+
64
695
  ```ruby
65
- TestOperation.(foo: "1", bar: "2", baz: 3)
66
- # => Success("It worked!")
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
+ ```
67
713
 
68
- TestOperation.with(foo: "1").with(bar: "2")
69
- # => #<TypedOperation::PartiallyApplied:0x000000014a655310 @applied_args={:foo=>"1", :bar=>"2"}, @operation=TestOperation>
714
+ Note you are provided the ActionPolicy error object, but you cannot stop the error from being re-raised.
70
715
 
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>
716
+ ### Using with `Dry::Monads`
73
717
 
74
- TestOperation.with(foo: "1").with(bar: "2").with(baz: 3).call
75
- # => Success("It worked!")
718
+ As per the example in [`Dry::Monads` documentation](https://dry-rb.org/gems/dry-monads/1.0/do-notation/)
76
719
 
77
- TestOperation.with(foo: "1").with(bar: "2").with(baz: 3).operation
78
- # => <TestOperation:0x000000014a0048a8 @__attributes=#<TestOperation::TypedSchema foo="1" bar="2" baz="3">>
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
79
741
  ```
80
742
 
81
743
  ## Installation
744
+
82
745
  Add this line to your application's Gemfile:
83
746
 
84
747
  ```ruby
@@ -104,8 +767,11 @@ bin/rails g typed_operation:install
104
767
  Use the `--dry_monads` switch to `include Dry::Monads[:result]` into your `ApplicationOperation` (don't forget to also
105
768
  add `gem "dry-monads"` to your Gemfile)
106
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
+
107
773
  ```ruby
108
- bin/rails g typed_operation:install --dry_monads
774
+ bin/rails g typed_operation:install --dry_monads --action_policy
109
775
  ```
110
776
 
111
777
  ## Generate a new Operation
@@ -125,7 +791,13 @@ The default path is `app/operations`.
125
791
  The generator will also create a test file.
126
792
 
127
793
  ## Contributing
128
- Contribution directions go here.
794
+
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).
129
796
 
130
797
  ## License
798
+
131
799
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
800
+
801
+ ## Code of Conduct
802
+
803
+ 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).