typed_operation 1.0.0.beta3 → 1.0.0.pre1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6d2e6d373445f7a0c186b88963fb6cadb3947f32731f4411fbeb71588e65d63
4
- data.tar.gz: 1329be34dbc54ba576c97dae12d82b513696b420ef34885842b6d8b0bb11b643
3
+ metadata.gz: ff925e2cc5ce03a696a209e0ca61130bff94ddb6ce28f2670ebaaae18f1968ba
4
+ data.tar.gz: b14a0952694653f918135a57180c8c7746aeffcb14385d17aeef7a1283726e99
5
5
  SHA512:
6
- metadata.gz: d18d142eddd2506739786b89028a952322b0bf0baded051519107e07ed7db4c2444f9134ec1e6471254e7b86549d98a1766379e139bef50d2577c73d634f3c47
7
- data.tar.gz: bfa38ed9df98fc442e92b9f09ebf2aaf29b5fd3c28e9ccb016c64150c28bb3d42b21409b291258137b2efc2ad8b4e904181877a1e92c7eed1661b29d3f2cdd4d
6
+ metadata.gz: 0cb23aed491058bdefba04d75661ec4cb5110a2491ba9d4b3afc929d41e1863a09b9ac09b41bdbd6306856af6d5552f2c86c3057b53df3076d0674a9c1feafda
7
+ data.tar.gz: 143f6ba5a00d5cabadec3f7f326dc7a724fdd2666ca0dffa326cacf9f9af2f4dce797f77a727468cd919eb42a4b6de530105d6866e49797997c10ff5a36db9fe
data/README.md CHANGED
@@ -4,7 +4,9 @@ An implementation of a Command pattern, which is callable, and can be partially
4
4
 
5
5
  Inputs to the operation are specified as typed attributes (uses [`literal`](https://github.com/joeldrapper/literal)).
6
6
 
7
- Type of result of the operation is up to you, eg you could use [`Dry::Monads`](https://dry-rb.org/gems/dry-monads/1.3/).
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
+
9
+ **Note the version described here (~ 1.0.0) is not yet released on Rubygems, it is waiting for a release of `literal`)**
8
10
 
9
11
  ## Features
10
12
 
@@ -37,39 +39,26 @@ class ShelveBookOperation < ::TypedOperation::Base
37
39
  # Or if you prefer:
38
40
  # `param :description, String`
39
41
 
40
- # `param` creates named parameters by default
41
- param :author_id, Integer, &:to_i
42
- param :isbn, String
42
+ named_param :author_id, Integer, &:to_i
43
+ named_param :isbn, String
43
44
 
44
45
  # Optional parameters are specified by wrapping the type constraint in the `optional` method, or using the `optional:` option
45
- param :shelf_code, optional(Integer)
46
+ named_param :shelf_code, optional(Integer)
46
47
  # Or if you prefer:
47
48
  # `named_param :shelf_code, Integer, optional: true`
48
49
 
49
- param :category, String, default: "unknown".freeze
50
+ named_param :category, String, default: "unknown".freeze
50
51
 
51
- # optional hook called when the operation is initialized, and after the parameters have been set
52
+ # to setup (optional)
52
53
  def prepare
53
54
  raise ArgumentError, "ISBN is invalid" unless valid_isbn?
54
55
  end
55
56
 
56
- # optionally hook in before execution ... and call super to allow subclasses to hook in too
57
- def before_execute_operation
58
- # ...
59
- super
60
- end
61
-
62
- # The 'work' of the operation, this is the main body of the operation and must be implemented
63
- def perform
57
+ # The 'work' of the operation
58
+ def call
64
59
  "Put away '#{title}' by author ID #{author_id}#{shelf_code ? " on shelf #{shelf_code}" : "" }"
65
60
  end
66
61
 
67
- # optionally hook in after execution ... and call super to allow subclasses to hook in too
68
- def after_execute_operation(result)
69
- # ...
70
- super
71
- end
72
-
73
62
  private
74
63
 
75
64
  def valid_isbn?
@@ -103,15 +92,13 @@ shelve.call(author_id: "1", isbn: false)
103
92
 
104
93
  ### Partially applying parameters
105
94
 
106
- Operations can also be partially applied and curried:
107
-
108
95
  ```ruby
109
96
  class TestOperation < ::TypedOperation::Base
110
97
  param :foo, String, positional: true
111
98
  param :bar, String
112
99
  param :baz, String, &:to_s
113
100
 
114
- def perform = "It worked! (#{foo}, #{bar}, #{baz})"
101
+ def call = "It worked! (#{foo}, #{bar}, #{baz})"
115
102
  end
116
103
 
117
104
  # Invoking the operation directly
@@ -164,78 +151,21 @@ TestOperation.with("1").with(bar: "2").with(baz: 3).operation
164
151
 
165
152
  ## Documentation
166
153
 
167
- ### Create an operation (subclass `TypedOperation::Base` or `TypedOperation::ImmutableBase`)
168
-
169
- Create an operation by subclassing `TypedOperation::Base` or `TypedOperation::ImmutableBase` and specifying the parameters the operation requires.
154
+ ### Create an operation (subclass `TypedOperation::Base`)
170
155
 
171
- - `TypedOperation::Base` (uses `Literal::Struct`) is the parent class for an operation where the arguments are potentially mutable (ie not frozen).
172
- No attribute writer methods are defined, so the arguments can not be changed after initialization, but the values passed in are not guaranteed to be frozen.
173
- - `TypedOperation::ImmutableBase` (uses `Literal::Data`) is the parent class for an operation where the operation instance is frozen on initialization,
174
- thus giving a somewhat stronger immutability guarantee.
156
+ Create an operation by subclassing `TypedOperation::Base` and specifying the parameters the operation requires.
175
157
 
176
- > Note: you cannot include `TypedOperation::ActionPolicyAuth` into a `TypedOperation::ImmutableBase`.
177
-
178
- The subclass must implement the `#perform` method which is where the operations main work is done.
158
+ The subclass must implement the `#call` method which is where the operations main work is done.
179
159
 
180
160
  The operation can also implement:
181
161
 
182
162
  - `#prepare` - called when the operation is initialized, and after the parameters have been set
183
- - `#before_execute_operation` - optionally hook in before execution ... and call super to allow subclasses to hook in too
184
- - `#after_execute_operation` - optionally hook in after execution ... and call super to allow subclasses to hook in too
185
-
186
- ```ruby
187
- # optionally hook in before execution...
188
- def before_execute_operation
189
- # Remember to call super
190
- super
191
- end
192
-
193
- def perform
194
- # ... implement me!
195
- end
196
-
197
- # optionally hook in after execution...
198
- def after_execute_operation(result)
199
- # Remember to call super, note the result is passed in and the return value of this method is the result of the operation
200
- # thus allowing you to modify the result if you wish
201
- super
202
- end
203
- ```
204
163
 
205
164
  ### Specifying parameters (using `.param`)
206
165
 
207
166
  Parameters are specified using the provided class methods (`.positional_param` and `.named_param`),
208
167
  or using the underlying `.param` method.
209
168
 
210
- Types are specified using the `literal` gem. In many cases this simply means providing the class of the
211
- expected type, but there are also some other useful types provided by `literal` (eg `Union`).
212
-
213
- These can be either accessed via the `Literal` module, eg `Literal::Types::BooleanType`:
214
-
215
- ```ruby
216
- class MyOperation < ::TypedOperation::Base
217
- param :name, String
218
- param :age, Integer, optional: true
219
- param :choices, Literal::Types::ArrayType.new(String)
220
- param :chose, Literal::Types::BooleanType
221
- end
222
-
223
- MyOperation.new(name: "bob", choices: ["st"], chose: true)
224
- ```
225
-
226
- or by including the `Literal::Types` module into your operation class, and using the aliases provided:
227
-
228
- ```ruby
229
- class MyOperation < ::TypedOperation::Base
230
- include Literal::Types
231
-
232
- param :name, String
233
- param :age, _Nilable(Integer) # optional can also be specifed using `.optional`
234
- param :choices, _Array(String)
235
- param :chose, _Boolean
236
- end
237
- ```
238
-
239
169
  Type constraints can be modified to make the parameter optional using `.optional`.
240
170
 
241
171
  #### Your own aliases
@@ -293,7 +223,7 @@ class MyOperation < ::TypedOperation::Base
293
223
  # Or alternatively => `param :name, String, positional: true`
294
224
  positional_param :age, Integer, default: -> { 0 }
295
225
 
296
- def perform
226
+ def call
297
227
  puts "Hello #{name} (#{age})"
298
228
  end
299
229
  end
@@ -324,7 +254,7 @@ class MyOperation < ::TypedOperation::Base
324
254
  # Or alternatively => `param :name, String`
325
255
  named_param :age, Integer, default: -> { 0 }
326
256
 
327
- def perform
257
+ def call
328
258
  puts "Hello #{name} (#{age})"
329
259
  end
330
260
  end
@@ -345,7 +275,7 @@ class MyOperation < ::TypedOperation::Base
345
275
  positional_param :name, String
346
276
  named_param :age, Integer, default: -> { 0 }
347
277
 
348
- def perform
278
+ def call
349
279
  puts "Hello #{name} (#{age})"
350
280
  end
351
281
  end
@@ -389,7 +319,7 @@ You can specify a block after a parameter definition to coerce the argument valu
389
319
 
390
320
  ```ruby
391
321
  param :name, String, &:to_s
392
- param :choice, Literal::Types::BooleanType do |v|
322
+ param :choice, Union(FalseClass, TrueClass) do |v|
393
323
  v == "y"
394
324
  end
395
325
  ```
@@ -436,10 +366,9 @@ An operation can be invoked by:
436
366
 
437
367
  - instantiating it with at least required params and then calling the `#call` method on the instance
438
368
  - once a partially applied operation has been prepared (all required parameters have been set), the call
439
- method on `TypedOperation::Prepared` can be used to instantiate and call the operation.
369
+ method on TypedOperation::Prepared can be used to instantiate and call the operation.
440
370
  - once an operation is curried, the `#call` method on last TypedOperation::Curried in the chain will invoke the operation
441
371
  - calling `#call` on a partially applied operation and passing in any remaining required parameters
442
- - calling `#execute_operation` on an operation instance (this is the method that is called by `#call`)
443
372
 
444
373
  See the many examples in this document.
445
374
 
@@ -517,8 +446,7 @@ This is an example of a `ApplicationOperation` in a Rails app that uses `Dry::Mo
517
446
 
518
447
  class ApplicationOperation < ::TypedOperation::Base
519
448
  # We choose to use dry-monads for our operations, so include the required modules
520
- include Dry::Monads[:result]
521
- include Dry::Monads::Do.for(:perform)
449
+ include Dry::Monads[:result, :do]
522
450
 
523
451
  class << self
524
452
  # Setup our own preferred names for the DSL methods
@@ -559,155 +487,41 @@ class ApplicationOperation < ::TypedOperation::Base
559
487
  end
560
488
  ```
561
489
 
562
- ### Using with Action Policy (`action_policy` gem)
563
-
564
- > Note, this optional feature requires the `action_policy` gem to be installed and does not yet work with `ImmutableBase`.
565
-
566
- Add `TypedOperation::ActionPolicyAuth` to your `ApplicationOperation` (first `require` the module):
567
-
568
- ```ruby
569
- require "typed_operation/action_policy_auth"
570
-
571
- class ApplicationOperation < ::TypedOperation::Base
572
- # ...
573
- include TypedOperation::ActionPolicyAuth
574
-
575
- # You can specify a parameter to take the authorization context object, eg a user (can also be optional if some
576
- # operations don't require authorization)
577
- param :initiator, ::User # or optional(::User)
578
- end
579
- ```
580
-
581
- #### Specify the action with `.action_type`
490
+ ### Using with `literal` monads
582
491
 
583
- Every operation must define what action_type it is, eg:
492
+ You can use the `literal` gem to provide a `Result` type for your operations.
584
493
 
585
494
  ```ruby
586
- class MyUpdateOperation < ApplicationOperation
587
- action_type :update
588
- end
589
- ```
590
-
591
- Any symbol can be used as the `action_type` and this is by default used to determine which policy method to call.
592
-
593
- #### Configuring auth with `.authorized_via`
594
-
595
- `.authorized_via` is used to specify how to authorize the operation. You must specify the name of a parameter
596
- for the policy authorization context. You can also specify multiple parameters if you wish.
597
-
598
- You can then either provide a block with the logic to perform the authorization check, or provide a policy class.
599
-
600
- The `record:` option lets you provide the name of the parameter which will be passed as the policy 'record'.
601
-
602
- For example:
603
-
604
- ```ruby
605
- class MyUpdateOperation < ApplicationOperation
606
- param :initiator, ::AdminUser
607
- param :order, ::Order
608
-
609
- action_type :update
610
-
611
- authorized_via :initiator, record: :order do
612
- # ... the permissions check, admin users can edit orders that are not finalized
613
- initiator.admin? && !record.finalized
614
- end
615
-
616
- def perform
617
- # ...
618
- end
619
- end
620
- ```
621
-
622
- You can instead provide a policy class implementation:
495
+ class MyOperation < ::TypedOperation::Base
496
+ param :account_name, String
497
+ param :owner, String
623
498
 
624
- ```ruby
625
- class MyUpdateOperation < ApplicationOperation
626
- action_type :update
627
-
628
- class MyPolicyClass < OperationPolicy
629
- # The action_type defined on the operation determines which method is called on the policy class
630
- def update?
631
- # ... the permissions check
632
- initiator.admin?
499
+ def call
500
+ create_account.bind do |account|
501
+ associate_owner(account).bind do
502
+ account
503
+ end
633
504
  end
634
-
635
- # def my_check?
636
- # # ... the permissions check
637
- # end
638
505
  end
639
-
640
- authorized_via :initiator, with: MyPolicyClass
641
-
642
- # It is also possible to specify which policy method to call
643
- # authorized_via :initiator, with: MyPolicyClass, to: :my_check?
644
- end
645
- ```
646
506
 
647
- with multiple parameters:
507
+ private
648
508
 
649
- ```ruby
650
- class MyUpdateOperation < ApplicationOperation
651
- # ...
652
- param :initiator, ::AdminUser
653
- param :user, ::User
654
-
655
- authorized_via :initiator, :user do
656
- initiator.active? && user.active?
509
+ def create_account
510
+ # returns Literal::Success(account) or Literal::Failure(:cant_create)
511
+ Literal::Success.new(account_name)
657
512
  end
658
513
 
659
- # ...
660
- end
661
- ```
662
-
663
- #### `.verify_authorized!`
664
-
665
- To ensure that subclasses always implement authentication you can add a call to `.verify_authorized!` to your base
666
- operation class.
667
-
668
- This will cause the execution of any subclasses to fail if no authorization is performed.
669
-
670
- ```ruby
671
- class MustAuthOperation < ApplicationOperation
672
- verify_authorized!
673
- end
674
-
675
- class MyUpdateOperation < MustAuthOperation
676
- def perform
514
+ def associate_owner(account)
677
515
  # ...
516
+ Literal::Failure.new(:cant_associate_owner)
678
517
  end
679
518
  end
680
519
 
681
- MyUpdateOperation.call # => Raises an error that MyUpdateOperation does not perform any authorization
682
- ```
683
-
684
- #### `#on_authorization_failure(err)`
520
+ MyOperation.new(account_name: "foo", owner: "bar").call
521
+ # => Literal::Failure(:cant_associate_owner)
685
522
 
686
- A hook is provided to allow you to do some work on an authorization failure.
687
-
688
- Simply override the `#on_authorization_failure(err)` method in your operation.
689
-
690
- ```ruby
691
- class MyUpdateOperation < ApplicationOperation
692
- action_type :update
693
-
694
- authorized_via :initiator do
695
- # ... the permissions check
696
- initiator.admin?
697
- end
698
-
699
- def perform
700
- # ...
701
- end
702
-
703
- def on_authorization_failure(err)
704
- # ... do something with the error, eg logging
705
- end
706
- end
707
523
  ```
708
524
 
709
- Note you are provided the ActionPolicy error object, but you cannot stop the error from being re-raised.
710
-
711
525
  ### Using with `Dry::Monads`
712
526
 
713
527
  As per the example in [`Dry::Monads` documentation](https://dry-rb.org/gems/dry-monads/1.0/do-notation/)
@@ -715,14 +529,14 @@ As per the example in [`Dry::Monads` documentation](https://dry-rb.org/gems/dry-
715
529
  ```ruby
716
530
  class MyOperation < ::TypedOperation::Base
717
531
  include Dry::Monads[:result]
718
- include Dry::Monads::Do.for(:perform, :create_account)
532
+ include Dry::Monads::Do.for(:call)
719
533
 
720
534
  param :account_name, String
721
535
  param :owner, ::Owner
722
536
 
723
- def perform
537
+ def call
724
538
  account = yield create_account(account_name)
725
- yield AnotherOperation.call(account, owner)
539
+ yield associate_owner(account, owner)
726
540
 
727
541
  Success(account)
728
542
  end
@@ -762,11 +576,8 @@ bin/rails g typed_operation:install
762
576
  Use the `--dry_monads` switch to `include Dry::Monads[:result]` into your `ApplicationOperation` (don't forget to also
763
577
  add `gem "dry-monads"` to your Gemfile)
764
578
 
765
- Use the `--action_policy` switch to add the `TypedOperation::ActionPolicyAuth` module to your `ApplicationOperation`
766
- (and you will also need to add `gem "action_policy"` to your Gemfile).
767
-
768
579
  ```ruby
769
- bin/rails g typed_operation:install --dry_monads --action_policy
580
+ bin/rails g typed_operation:install --dry_monads
770
581
  ```
771
582
 
772
583
  ## Generate a new Operation
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -14,7 +14,7 @@ module <%= namespace_name %>
14
14
  # Prepare...
15
15
  end
16
16
 
17
- def perform
17
+ def call
18
18
  # Perform...
19
19
  "Hello World!"
20
20
  end
@@ -33,7 +33,7 @@ class <%= name %> < ::ApplicationOperation
33
33
  # Prepare...
34
34
  end
35
35
 
36
- def perform
36
+ def call
37
37
  # Perform...
38
38
  "Hello World!"
39
39
  end
@@ -12,7 +12,6 @@ rails generate typed_operation:install
12
12
  Options:
13
13
  --------
14
14
  --dry_monads: if specified the ApplicationOperation will include dry-monads Result and Do notation.
15
- --action_policy: if specified the ApplicationOperation will include action_policy authorization integration.
16
15
 
17
16
  Example:
18
17
  --------
@@ -6,7 +6,6 @@ module TypedOperation
6
6
  module Install
7
7
  class InstallGenerator < Rails::Generators::Base
8
8
  class_option :dry_monads, type: :boolean, default: false
9
- class_option :action_policy, type: :boolean, default: false
10
9
 
11
10
  source_root File.expand_path("templates", __dir__)
12
11
 
@@ -19,10 +18,6 @@ module TypedOperation
19
18
  def include_dry_monads?
20
19
  options[:dry_monads]
21
20
  end
22
-
23
- def include_action_policy?
24
- options[:action_policy]
25
- end
26
21
  end
27
22
  end
28
23
  end
@@ -1,36 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ApplicationOperation < ::TypedOperation::Base
4
- <% if include_action_policy? -%>
5
- include TypedOperation::ActionPolicyAuth
6
-
7
- <% end -%>
8
4
  <% if include_dry_monads? -%>
9
- include Dry::Monads[:result]
10
- include Dry::Monads::Do.for(:perform)
5
+ include Dry::Monads[:result, :do]
11
6
 
12
- # Helper to execute then unwrap a successful result or raise an exception
13
7
  def call!
14
8
  call.value!
15
9
  end
16
10
 
17
11
  <% end -%>
18
12
  # Other common parameters & methods for Operations of this application...
19
- # Some examples:
20
- #
21
- # def self.operation_key
22
- # name.underscore.to_sym
23
- # end
24
- #
25
- # def operation_key
26
- # self.class.operation_key
27
- # end
28
- #
29
- # # Translation and localization
30
- #
31
- # def translate(key, **)
32
- # key = "operations.#{operation_key}.#{key}" if key.start_with?(".")
33
- # I18n.t(key, **)
34
- # end
35
- # alias_method :t, :translate
13
+ # ...
36
14
  end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :typed_operation do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ class AttributeBuilder
5
+ def initialize(typed_operation, parameter_name, type_signature, options)
6
+ @typed_operation = typed_operation
7
+ @name = parameter_name
8
+ @signature = type_signature
9
+ @optional = options[:optional]
10
+ @positional = options[:positional]
11
+ @reader = options[:reader] || :public
12
+ @default_key = options.key?(:default)
13
+ @default = options[:default]
14
+
15
+ prepare_type_signature_for_literal
16
+ end
17
+
18
+ def define(&converter)
19
+ @typed_operation.attribute(
20
+ @name,
21
+ @signature,
22
+ default: default_value_for_literal,
23
+ positional: @positional,
24
+ reader: @reader,
25
+ &converter
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ def prepare_type_signature_for_literal
32
+ @signature = NilableType.new(@signature) if needs_to_be_nilable?
33
+ union_with_nil_to_support_nil_default
34
+ validate_positional_order_params! if @positional
35
+ end
36
+
37
+ # If already wrapped in a Nilable then don't wrap again
38
+ def needs_to_be_nilable?
39
+ @optional && !type_nilable?
40
+ end
41
+
42
+ def type_nilable?
43
+ @signature.is_a?(NilableType)
44
+ end
45
+
46
+ def union_with_nil_to_support_nil_default
47
+ @signature = Literal::Union.new(@signature, NilClass) if has_default_value_nil?
48
+ end
49
+
50
+ def has_default_value_nil?
51
+ default_provided? && @default.nil?
52
+ end
53
+
54
+ def validate_positional_order_params!
55
+ # Optional ones can always be added after required ones, or before any others, but required ones must be first
56
+ unless type_nilable? || @typed_operation.optional_positional_parameters.empty?
57
+ raise ParameterError, "Cannot define required positional parameter '#{@name}' after optional positional parameters"
58
+ end
59
+ end
60
+
61
+ def default_provided?
62
+ @default_key
63
+ end
64
+
65
+ def default_value_for_literal
66
+ if has_default_value_nil? || type_nilable?
67
+ -> {}
68
+ else
69
+ @default
70
+ end
71
+ end
72
+ end
73
+ end
@@ -1,13 +1,106 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "literal"
4
+
3
5
  module TypedOperation
4
- class Base < Literal::Struct
5
- extend Operations::Introspection
6
- extend Operations::Parameters
7
- extend Operations::PartialApplication
8
-
9
- include Operations::Lifecycle
10
- include Operations::Callable
11
- include Operations::Executable
6
+ class Base < Literal::Data
7
+ class << self
8
+ def call(...)
9
+ new(...).call
10
+ end
11
+
12
+ def with(...)
13
+ PartiallyApplied.new(self, ...).with
14
+ end
15
+ alias_method :[], :with
16
+
17
+ def curry
18
+ Curried.new(self)
19
+ end
20
+
21
+ def to_proc
22
+ method(:call).to_proc
23
+ end
24
+
25
+ # Method to define parameters for your operation.
26
+
27
+ # Parameter for keyword argument, or a positional argument if you use positional: true
28
+ # Required, but you can set a default or use optional: true if you want optional
29
+ def param(name, signature = :any, **options, &converter)
30
+ AttributeBuilder.new(self, name, signature, options).define(&converter)
31
+ end
32
+
33
+ # Alternative DSL
34
+
35
+ # Parameter for positional argument
36
+ def positional_param(name, signature = :any, **options, &converter)
37
+ param(name, signature, **options.merge(positional: true), &converter)
38
+ end
39
+
40
+ # Parameter for a keyword or named argument
41
+ def named_param(name, signature = :any, **options, &converter)
42
+ param(name, signature, **options.merge(positional: false), &converter)
43
+ end
44
+
45
+ # Wrap a type signature in a NilableType meaning it is optional to TypedOperation
46
+ def optional(type_signature)
47
+ NilableType.new(type_signature)
48
+ end
49
+
50
+ # Introspection methods
51
+
52
+ def positional_parameters
53
+ literal_attributes.filter_map { |name, attribute| name if attribute.positional? }
54
+ end
55
+
56
+ def keyword_parameters
57
+ literal_attributes.filter_map { |name, attribute| name unless attribute.positional? }
58
+ end
59
+
60
+ def required_positional_parameters
61
+ required_parameters.filter_map { |name, attribute| name if attribute.positional? }
62
+ end
63
+
64
+ def required_keyword_parameters
65
+ required_parameters.filter_map { |name, attribute| name unless attribute.positional? }
66
+ end
67
+
68
+ def optional_positional_parameters
69
+ positional_parameters - required_positional_parameters
70
+ end
71
+
72
+ def optional_keyword_parameters
73
+ keyword_parameters - required_keyword_parameters
74
+ end
75
+
76
+ private
77
+
78
+ def required_parameters
79
+ literal_attributes.filter do |name, attribute|
80
+ attribute.default.nil? # Any optional parameters will have a default value/proc in their Literal::Attribute
81
+ end
82
+ end
83
+ end
84
+
85
+ def after_initialization
86
+ prepare if respond_to?(:prepare)
87
+ end
88
+
89
+ def call
90
+ raise InvalidOperationError, "You must implement #call"
91
+ end
92
+
93
+ def to_proc
94
+ method(:call).to_proc
95
+ end
96
+
97
+ def deconstruct
98
+ attributes.values
99
+ end
100
+
101
+ def deconstruct_keys(keys)
102
+ h = attributes.to_h
103
+ keys ? h.slice(*keys) : h
104
+ end
12
105
  end
13
106
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "literal"
4
+
5
+ module TypedOperation
6
+ class NilableType < Literal::Union
7
+ def initialize(*types)
8
+ @types = types + [NilClass]
9
+ end
10
+ end
11
+ end
@@ -1,3 +1,3 @@
1
1
  module TypedOperation
2
- VERSION = "1.0.0.beta3"
2
+ VERSION = "1.0.0.pre1"
3
3
  end
@@ -1,16 +1,12 @@
1
- require "literal"
1
+ if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.2.0")
2
+ require "polyfill-data"
3
+ end
2
4
 
3
5
  require "typed_operation/version"
4
6
  require "typed_operation/railtie" if defined?(Rails::Railtie)
5
- require "typed_operation/operations/introspection"
6
- require "typed_operation/operations/parameters"
7
- require "typed_operation/operations/partial_application"
8
- require "typed_operation/operations/callable"
9
- require "typed_operation/operations/lifecycle"
10
- require "typed_operation/operations/property_builder"
11
- require "typed_operation/operations/executable"
7
+ require "typed_operation/nilable_type"
8
+ require "typed_operation/attribute_builder"
12
9
  require "typed_operation/curried"
13
- require "typed_operation/immutable_base"
14
10
  require "typed_operation/base"
15
11
  require "typed_operation/partially_applied"
16
12
  require "typed_operation/prepared"
metadata CHANGED
@@ -1,36 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typed_operation
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.beta3
4
+ version: 1.0.0.pre1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen Ierodiaconou
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 2025-04-07 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: literal
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: 1.0.0
19
- - - "<"
20
- - !ruby/object:Gem::Version
21
- version: 2.0.0
22
- type: :runtime
23
- prerelease: false
24
- version_requirements: !ruby/object:Gem::Requirement
25
- requirements:
26
- - - ">="
27
- - !ruby/object:Gem::Version
28
- version: 1.0.0
29
- - - "<"
30
- - !ruby/object:Gem::Version
31
- version: 2.0.0
32
- description: Command pattern, which is callable, and can be partially applied, curried
33
- and has typed parameters. Authorization to execute via action_policy if desired.
11
+ date: 2023-08-20 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: TypedOperation is a command pattern implementation where inputs can be
14
+ defined with runtime type checks. Operations can be partially applied.
34
15
  email:
35
16
  - stevegeek@gmail.com
36
17
  executables: []
@@ -39,6 +20,7 @@ extra_rdoc_files: []
39
20
  files:
40
21
  - MIT-LICENSE
41
22
  - README.md
23
+ - Rakefile
42
24
  - lib/generators/USAGE
43
25
  - lib/generators/templates/operation.rb
44
26
  - lib/generators/templates/operation_test.rb
@@ -46,18 +28,12 @@ files:
46
28
  - lib/generators/typed_operation/install/install_generator.rb
47
29
  - lib/generators/typed_operation/install/templates/application_operation.rb
48
30
  - lib/generators/typed_operation_generator.rb
31
+ - lib/tasks/typed_operation_tasks.rake
49
32
  - lib/typed_operation.rb
50
- - lib/typed_operation/action_policy_auth.rb
33
+ - lib/typed_operation/attribute_builder.rb
51
34
  - lib/typed_operation/base.rb
52
35
  - lib/typed_operation/curried.rb
53
- - lib/typed_operation/immutable_base.rb
54
- - lib/typed_operation/operations/callable.rb
55
- - lib/typed_operation/operations/executable.rb
56
- - lib/typed_operation/operations/introspection.rb
57
- - lib/typed_operation/operations/lifecycle.rb
58
- - lib/typed_operation/operations/parameters.rb
59
- - lib/typed_operation/operations/partial_application.rb
60
- - lib/typed_operation/operations/property_builder.rb
36
+ - lib/typed_operation/nilable_type.rb
61
37
  - lib/typed_operation/partially_applied.rb
62
38
  - lib/typed_operation/prepared.rb
63
39
  - lib/typed_operation/railtie.rb
@@ -68,6 +44,7 @@ licenses:
68
44
  metadata:
69
45
  homepage_uri: https://github.com/stevegeek/typed_operation
70
46
  source_code_uri: https://github.com/stevegeek/typed_operation
47
+ post_install_message:
71
48
  rdoc_options: []
72
49
  require_paths:
73
50
  - lib
@@ -75,15 +52,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
75
52
  requirements:
76
53
  - - ">="
77
54
  - !ruby/object:Gem::Version
78
- version: '3.2'
55
+ version: '3.1'
79
56
  required_rubygems_version: !ruby/object:Gem::Requirement
80
57
  requirements:
81
- - - ">="
58
+ - - ">"
82
59
  - !ruby/object:Gem::Version
83
- version: '0'
60
+ version: 1.3.1
84
61
  requirements: []
85
- rubygems_version: 3.6.2
62
+ rubygems_version: 3.4.18
63
+ signing_key:
86
64
  specification_version: 4
87
- summary: TypedOperation is a command pattern with typed parameters, which is callable,
88
- and can be partially applied.
65
+ summary: TypedOperation is a command pattern implementation
89
66
  test_files: []
@@ -1,141 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "action_policy"
4
-
5
- # An optional module to include in your operation to enable authorization via ActionPolicies
6
- module TypedOperation
7
- class MissingAuthentication < StandardError; end
8
-
9
- module ActionPolicyAuth
10
- # Base class for any action policy classes used by operations
11
- class OperationPolicy
12
- include ActionPolicy::Policy::Core
13
- include ActionPolicy::Policy::Authorization
14
- include ActionPolicy::Policy::PreCheck
15
- include ActionPolicy::Policy::Reasons
16
- include ActionPolicy::Policy::Aliases
17
- include ActionPolicy::Policy::Scoping
18
- include ActionPolicy::Policy::Cache
19
- include ActionPolicy::Policy::CachedApply
20
- end
21
-
22
- def self.included(base)
23
- base.include ::ActionPolicy::Behaviour
24
- base.extend ClassMethods
25
- end
26
-
27
- module ClassMethods
28
- # Configure the operation to use ActionPolicy for authorization
29
- def authorized_via(*via, with: nil, to: nil, record: nil, &auth_block)
30
- # If a block is provided, you must not provide a policy class or method
31
- raise ArgumentError, "You must not provide a policy class or method when using a block" if auth_block && (with || to)
32
-
33
- parameters = positional_parameters + keyword_parameters
34
- raise ArgumentError, "authorize_via must be called with a valid param name" unless via.all? { |param| parameters.include?(param) }
35
- @_authorized_via_param = via
36
-
37
- action_type_method = :"#{action_type}?" if action_type
38
- # If an method name is provided, use it
39
- policy_method = to || action_type_method || raise(::TypedOperation::InvalidOperationError, "You must provide an action type or policy method name")
40
- @_policy_method = policy_method
41
- # If a policy class is provided, use it
42
- @_policy_class = if with
43
- with
44
- elsif auth_block
45
- policy_class = Class.new(OperationPolicy) do
46
- authorize(*via)
47
-
48
- define_method(policy_method, &auth_block)
49
- end
50
- const_set(:Policy, policy_class)
51
- policy_class
52
- else
53
- raise ::TypedOperation::InvalidOperationError, "You must provide either a policy class or a block"
54
- end
55
-
56
- if record
57
- unless parameters.include?(record) || method_defined?(record) || private_instance_methods.include?(record)
58
- raise ArgumentError, "to_authorize must be called with a valid param or method name"
59
- end
60
- @_to_authorize_param = record
61
- end
62
-
63
- # Configure action policy to use the param named in via as the context when instantiating the policy
64
- # ::ActionPolicy::Behaviour does not provide a authorize(*ids) method, so we have call once per param
65
- via.each do |param|
66
- authorize param
67
- end
68
- end
69
-
70
- def action_type(type = nil)
71
- @_action_type = type.to_sym if type
72
- @_action_type
73
- end
74
-
75
- def operation_policy_method
76
- @_policy_method
77
- end
78
-
79
- def operation_policy_class
80
- @_policy_class
81
- end
82
-
83
- def operation_record_to_authorize
84
- @_to_authorize_param
85
- end
86
-
87
- def checks_authorization?
88
- !(@_authorized_via_param.nil? || @_authorized_via_param.empty?)
89
- end
90
-
91
- # You can use this on an operation base class to ensure and subclasses always enable authorization
92
- def verify_authorized!
93
- return if verify_authorized?
94
- @_verify_authorized = true
95
- end
96
-
97
- def verify_authorized?
98
- @_verify_authorized
99
- end
100
-
101
- def inherited(subclass)
102
- super
103
- subclass.instance_variable_set(:@_authorized_via_param, @_authorized_via_param)
104
- subclass.instance_variable_set(:@_verify_authorized, @_verify_authorized)
105
- subclass.instance_variable_set(:@_policy_class, @_policy_class)
106
- subclass.instance_variable_set(:@_policy_method, @_policy_method)
107
- subclass.instance_variable_set(:@_action_type, @_action_type)
108
- end
109
- end
110
-
111
- private
112
-
113
- # Redefine it as private
114
- def execute_operation
115
- if self.class.verify_authorized? && !self.class.checks_authorization?
116
- raise ::TypedOperation::MissingAuthentication, "Operation #{self.class.name} must authorize. Remember to use `.authorize_via`"
117
- end
118
- operation_check_authorized! if self.class.checks_authorization?
119
- super
120
- end
121
-
122
- def operation_check_authorized!
123
- policy = self.class.operation_policy_class
124
- raise "No Action Policy policy class provided, or no #{self.class.name}::Policy found for this action" unless policy
125
- policy_method = self.class.operation_policy_method
126
- raise "No policy method provided or action_type not set for #{self.class.name}" unless policy_method
127
- # Record to authorize, if nil then action policy tries to work it out implicitly
128
- record_to_authorize = send(self.class.operation_record_to_authorize) if self.class.operation_record_to_authorize
129
-
130
- authorize! record_to_authorize, to: policy_method, with: policy
131
- rescue ::ActionPolicy::Unauthorized => e
132
- on_authorization_failure(e)
133
- raise e
134
- end
135
-
136
- # A hook for subclasses to override to do something on an authorization failure
137
- def on_authorization_failure(authorization_error)
138
- # noop
139
- end
140
- end
141
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TypedOperation
4
- class ImmutableBase < Literal::Data
5
- extend Operations::Introspection
6
- extend Operations::Parameters
7
- extend Operations::PartialApplication
8
-
9
- include Operations::Lifecycle
10
- include Operations::Callable
11
- include Operations::Executable
12
- end
13
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TypedOperation
4
- module Operations
5
- module Callable
6
- def self.included(base)
7
- base.extend(CallableMethods)
8
- end
9
-
10
- module CallableMethods
11
- def call(...)
12
- new(...).call
13
- end
14
-
15
- def to_proc
16
- method(:call).to_proc
17
- end
18
- end
19
-
20
- include CallableMethods
21
- end
22
- end
23
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TypedOperation
4
- module Operations
5
- module Executable
6
- def call
7
- execute_operation
8
- end
9
-
10
- def execute_operation
11
- before_execute_operation
12
- retval = perform
13
- after_execute_operation(retval)
14
- end
15
-
16
- def before_execute_operation
17
- # noop
18
- end
19
-
20
- def after_execute_operation(retval)
21
- retval
22
- end
23
-
24
- def perform
25
- raise InvalidOperationError, "Operation #{self.class} does not implement #perform"
26
- end
27
- end
28
- end
29
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TypedOperation
4
- module Operations
5
- # Introspection methods
6
- module Introspection
7
- def positional_parameters
8
- literal_properties.filter_map { |property| property.name if property.positional? }
9
- end
10
-
11
- def keyword_parameters
12
- literal_properties.filter_map { |property| property.name if property.keyword? }
13
- end
14
-
15
- def required_parameters
16
- literal_properties.filter { |property| property.required? }
17
- end
18
-
19
- def required_positional_parameters
20
- required_parameters.filter_map { |property| property.name if property.positional? }
21
- end
22
-
23
- def required_keyword_parameters
24
- required_parameters.filter_map { |property| property.name if property.keyword? }
25
- end
26
-
27
- def optional_positional_parameters
28
- positional_parameters - required_positional_parameters
29
- end
30
-
31
- def optional_keyword_parameters
32
- keyword_parameters - required_keyword_parameters
33
- end
34
- end
35
- end
36
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TypedOperation
4
- module Operations
5
- module Lifecycle
6
- # This is called by Literal on initialization of underlying Struct/Data
7
- def after_initialize
8
- prepare if respond_to?(:prepare)
9
- end
10
- end
11
- end
12
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TypedOperation
4
- module Operations
5
- # Method to define parameters for your operation.
6
- module Parameters
7
- # Override literal `prop` to prevent creating writers (Literal::Data does this by default)
8
- def prop(name, type, kind = :keyword, reader: :public, writer: :public, default: nil)
9
- if self < ImmutableBase
10
- super(name, type, kind, reader:, default:)
11
- else
12
- super(name, type, kind, reader:, writer: false, default:)
13
- end
14
- end
15
-
16
- # Parameter for keyword argument, or a positional argument if you use positional: true
17
- # Required, but you can set a default or use optional: true if you want optional
18
- def param(name, signature = :any, **options, &converter)
19
- PropertyBuilder.new(self, name, signature, options).define(&converter)
20
- end
21
-
22
- # Alternative DSL
23
-
24
- # Parameter for positional argument
25
- def positional_param(name, signature = :any, **options, &converter)
26
- param(name, signature, **options.merge(positional: true), &converter)
27
- end
28
-
29
- # Parameter for a keyword or named argument
30
- def named_param(name, signature = :any, **options, &converter)
31
- param(name, signature, **options.merge(positional: false), &converter)
32
- end
33
-
34
- # Wrap a type signature in a NilableType meaning it is optional to TypedOperation
35
- def optional(type_signature)
36
- Literal::Types::NilableType.new(type_signature)
37
- end
38
- end
39
- end
40
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TypedOperation
4
- module Operations
5
- module PartialApplication
6
- def with(...)
7
- PartiallyApplied.new(self, ...).with
8
- end
9
- alias_method :[], :with
10
-
11
- def curry
12
- Curried.new(self)
13
- end
14
- end
15
- end
16
- end
@@ -1,81 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TypedOperation
4
- module Operations
5
- class PropertyBuilder
6
- def initialize(typed_operation, parameter_name, type_signature, options)
7
- @typed_operation = typed_operation
8
- @name = parameter_name
9
- @signature = type_signature
10
- @optional = options[:optional] # Wraps signature in NilableType
11
- @positional = options[:positional] # Changes kind to positional
12
- @reader = options[:reader] || :public
13
- @default_key = options.key?(:default)
14
- @default = options[:default]
15
-
16
- prepare_type_signature_for_literal
17
- end
18
-
19
- def define(&converter)
20
- # If nilable, then converter should not attempt to call the type converter block if the value is nil
21
- coerce_by = if type_nilable? && converter
22
- ->(v) { (v == Literal::Null || v.nil?) ? v : converter.call(v) }
23
- else
24
- converter
25
- end
26
- @typed_operation.prop(
27
- @name,
28
- @signature,
29
- @positional ? :positional : :keyword,
30
- default: default_value_for_literal,
31
- reader: @reader,
32
- &coerce_by
33
- )
34
- end
35
-
36
- private
37
-
38
- def prepare_type_signature_for_literal
39
- @signature = Literal::Types::NilableType.new(@signature) if needs_to_be_nilable?
40
- union_with_nil_to_support_nil_default
41
- validate_positional_order_params! if @positional
42
- end
43
-
44
- # If already wrapped in a Nilable then don't wrap again
45
- def needs_to_be_nilable?
46
- @optional && !type_nilable?
47
- end
48
-
49
- def type_nilable?
50
- @signature.is_a?(Literal::Types::NilableType)
51
- end
52
-
53
- def union_with_nil_to_support_nil_default
54
- @signature = Literal::Types::UnionType.new(@signature, NilClass) if has_default_value_nil?
55
- end
56
-
57
- def has_default_value_nil?
58
- default_provided? && @default.nil?
59
- end
60
-
61
- def validate_positional_order_params!
62
- # Optional ones can always be added after required ones, or before any others, but required ones must be first
63
- unless type_nilable? || @typed_operation.optional_positional_parameters.empty?
64
- raise ParameterError, "Cannot define required positional parameter '#{@name}' after optional positional parameters"
65
- end
66
- end
67
-
68
- def default_provided?
69
- @default_key
70
- end
71
-
72
- def default_value_for_literal
73
- if has_default_value_nil? || type_nilable?
74
- -> {}
75
- else
76
- @default
77
- end
78
- end
79
- end
80
- end
81
- end