typed_operation 1.0.0.beta2 → 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: 501477c614c107723d1c915099dd06209336708ba5e9672c26622d02a9cd4782
4
- data.tar.gz: b5ab846baf478e3cce3fd8ea1752c04ee708c9a7f84d2be6d47d4c9a94505ac5
3
+ metadata.gz: ff925e2cc5ce03a696a209e0ca61130bff94ddb6ce28f2670ebaaae18f1968ba
4
+ data.tar.gz: b14a0952694653f918135a57180c8c7746aeffcb14385d17aeef7a1283726e99
5
5
  SHA512:
6
- metadata.gz: c3c399b74903b5b9e40ab7838c7d20bafa68969d1200bf2afb11457e41437ae8fa823e7afc14fabe71cd290cd798823f97a0918c4a12d50f6a8768ac1a77ea8e
7
- data.tar.gz: b1ce5f67d1b87ef907f00904288b25edd678a2afab0f68a5f2c5804831f5d05df0fad134a4c28a4893d3867f61c671d548bf687f37b822ff32754355845d9abd
6
+ metadata.gz: 0cb23aed491058bdefba04d75661ec4cb5110a2491ba9d4b3afc929d41e1863a09b9ac09b41bdbd6306856af6d5552f2c86c3057b53df3076d0674a9c1feafda
7
+ data.tar.gz: 143f6ba5a00d5cabadec3f7f326dc7a724fdd2666ca0dffa326cacf9f9af2f4dce797f77a727468cd919eb42a4b6de530105d6866e49797997c10ff5a36db9fe
data/README.md CHANGED
@@ -6,12 +6,7 @@ Inputs to the operation are specified as typed attributes (uses [`literal`](http
6
6
 
7
7
  Type of result of the operation is up to you, eg you could use [`literal` monads](https://github.com/joeldrapper/literal) or [`Dry::Monads`](https://dry-rb.org/gems/dry-monads/1.3/).
8
8
 
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
-
11
- ```ruby
12
- gem "literal", github: "joeldrapper/literal", branch: "main"
13
- gem "typed_operation", "~> 1.0.0.beta2"
14
- ```
9
+ **Note the version described here (~ 1.0.0) is not yet released on Rubygems, it is waiting for a release of `literal`)**
15
10
 
16
11
  ## Features
17
12
 
@@ -44,39 +39,26 @@ class ShelveBookOperation < ::TypedOperation::Base
44
39
  # Or if you prefer:
45
40
  # `param :description, String`
46
41
 
47
- # `param` creates named parameters by default
48
- param :author_id, Integer, &:to_i
49
- param :isbn, String
42
+ named_param :author_id, Integer, &:to_i
43
+ named_param :isbn, String
50
44
 
51
45
  # Optional parameters are specified by wrapping the type constraint in the `optional` method, or using the `optional:` option
52
- param :shelf_code, optional(Integer)
46
+ named_param :shelf_code, optional(Integer)
53
47
  # Or if you prefer:
54
48
  # `named_param :shelf_code, Integer, optional: true`
55
49
 
56
- param :category, String, default: "unknown".freeze
50
+ named_param :category, String, default: "unknown".freeze
57
51
 
58
- # optional hook called when the operation is initialized, and after the parameters have been set
52
+ # to setup (optional)
59
53
  def prepare
60
54
  raise ArgumentError, "ISBN is invalid" unless valid_isbn?
61
55
  end
62
56
 
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
57
+ # The 'work' of the operation
58
+ def call
71
59
  "Put away '#{title}' by author ID #{author_id}#{shelf_code ? " on shelf #{shelf_code}" : "" }"
72
60
  end
73
61
 
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
62
  private
81
63
 
82
64
  def valid_isbn?
@@ -110,15 +92,13 @@ shelve.call(author_id: "1", isbn: false)
110
92
 
111
93
  ### Partially applying parameters
112
94
 
113
- Operations can also be partially applied and curried:
114
-
115
95
  ```ruby
116
96
  class TestOperation < ::TypedOperation::Base
117
97
  param :foo, String, positional: true
118
98
  param :bar, String
119
99
  param :baz, String, &:to_s
120
100
 
121
- def perform = "It worked! (#{foo}, #{bar}, #{baz})"
101
+ def call = "It worked! (#{foo}, #{bar}, #{baz})"
122
102
  end
123
103
 
124
104
  # Invoking the operation directly
@@ -171,76 +151,21 @@ TestOperation.with("1").with(bar: "2").with(baz: 3).operation
171
151
 
172
152
  ## Documentation
173
153
 
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.
154
+ ### Create an operation (subclass `TypedOperation::Base`)
177
155
 
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).
156
+ Create an operation by subclassing `TypedOperation::Base` and specifying the parameters the operation requires.
182
157
 
183
- 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.
184
159
 
185
160
  The operation can also implement:
186
161
 
187
162
  - `#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
163
 
210
164
  ### Specifying parameters (using `.param`)
211
165
 
212
166
  Parameters are specified using the provided class methods (`.positional_param` and `.named_param`),
213
167
  or using the underlying `.param` method.
214
168
 
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
169
  Type constraints can be modified to make the parameter optional using `.optional`.
245
170
 
246
171
  #### Your own aliases
@@ -298,7 +223,7 @@ class MyOperation < ::TypedOperation::Base
298
223
  # Or alternatively => `param :name, String, positional: true`
299
224
  positional_param :age, Integer, default: -> { 0 }
300
225
 
301
- def perform
226
+ def call
302
227
  puts "Hello #{name} (#{age})"
303
228
  end
304
229
  end
@@ -329,7 +254,7 @@ class MyOperation < ::TypedOperation::Base
329
254
  # Or alternatively => `param :name, String`
330
255
  named_param :age, Integer, default: -> { 0 }
331
256
 
332
- def perform
257
+ def call
333
258
  puts "Hello #{name} (#{age})"
334
259
  end
335
260
  end
@@ -350,7 +275,7 @@ class MyOperation < ::TypedOperation::Base
350
275
  positional_param :name, String
351
276
  named_param :age, Integer, default: -> { 0 }
352
277
 
353
- def perform
278
+ def call
354
279
  puts "Hello #{name} (#{age})"
355
280
  end
356
281
  end
@@ -394,7 +319,7 @@ You can specify a block after a parameter definition to coerce the argument valu
394
319
 
395
320
  ```ruby
396
321
  param :name, String, &:to_s
397
- param :choice, Literal::Types::BooleanType do |v|
322
+ param :choice, Union(FalseClass, TrueClass) do |v|
398
323
  v == "y"
399
324
  end
400
325
  ```
@@ -441,10 +366,9 @@ An operation can be invoked by:
441
366
 
442
367
  - instantiating it with at least required params and then calling the `#call` method on the instance
443
368
  - 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.
369
+ method on TypedOperation::Prepared can be used to instantiate and call the operation.
445
370
  - once an operation is curried, the `#call` method on last TypedOperation::Curried in the chain will invoke the operation
446
371
  - 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
372
 
449
373
  See the many examples in this document.
450
374
 
@@ -522,8 +446,7 @@ This is an example of a `ApplicationOperation` in a Rails app that uses `Dry::Mo
522
446
 
523
447
  class ApplicationOperation < ::TypedOperation::Base
524
448
  # 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)
449
+ include Dry::Monads[:result, :do]
527
450
 
528
451
  class << self
529
452
  # Setup our own preferred names for the DSL methods
@@ -564,155 +487,41 @@ class ApplicationOperation < ::TypedOperation::Base
564
487
  end
565
488
  ```
566
489
 
567
- ### Using with Action Policy (`action_policy` gem)
568
-
569
- > Note, this optional feature requires the `action_policy` gem to be installed and does not yet work with `ImmutableBase`.
570
-
571
- Add `TypedOperation::ActionPolicyAuth` to your `ApplicationOperation` (first `require` the module):
572
-
573
- ```ruby
574
- require "typed_operation/action_policy_auth"
575
-
576
- class ApplicationOperation < ::TypedOperation::Base
577
- # ...
578
- include TypedOperation::ActionPolicyAuth
579
-
580
- # You can specify a parameter to take the authorization context object, eg a user (can also be optional if some
581
- # operations don't require authorization)
582
- param :initiator, ::User # or optional(::User)
583
- end
584
- ```
585
-
586
- #### Specify the action with `.action_type`
587
-
588
- Every operation must define what action_type it is, eg:
589
-
590
- ```ruby
591
- class MyUpdateOperation < ApplicationOperation
592
- action_type :update
593
- end
594
- ```
595
-
596
- Any symbol can be used as the `action_type` and this is by default used to determine which policy method to call.
597
-
598
- #### Configuring auth with `.authorized_via`
599
-
600
- `.authorized_via` is used to specify how to authorize the operation. You must specify the name of a parameter
601
- for the policy authorization context. You can also specify multiple parameters if you wish.
490
+ ### Using with `literal` monads
602
491
 
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:
492
+ You can use the `literal` gem to provide a `Result` type for your operations.
608
493
 
609
494
  ```ruby
610
- class MyUpdateOperation < ApplicationOperation
611
- param :initiator, ::AdminUser
612
- param :order, ::Order
613
-
614
- action_type :update
615
-
616
- authorized_via :initiator, record: :order do
617
- # ... the permissions check, admin users can edit orders that are not finalized
618
- initiator.admin? && !record.finalized
619
- end
620
-
621
- def perform
622
- # ...
623
- end
624
- end
625
- ```
626
-
627
- You can instead provide a policy class implementation:
495
+ class MyOperation < ::TypedOperation::Base
496
+ param :account_name, String
497
+ param :owner, String
628
498
 
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?
499
+ def call
500
+ create_account.bind do |account|
501
+ associate_owner(account).bind do
502
+ account
503
+ end
638
504
  end
639
-
640
- # def my_check?
641
- # # ... the permissions check
642
- # end
643
505
  end
644
-
645
- authorized_via :initiator, with: MyPolicyClass
646
-
647
- # It is also possible to specify which policy method to call
648
- # authorized_via :initiator, with: MyPolicyClass, to: :my_check?
649
- end
650
- ```
651
506
 
652
- with multiple parameters:
507
+ private
653
508
 
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?
509
+ def create_account
510
+ # returns Literal::Success(account) or Literal::Failure(:cant_create)
511
+ Literal::Success.new(account_name)
662
512
  end
663
513
 
664
- # ...
665
- end
666
- ```
667
-
668
- #### `.verify_authorized!`
669
-
670
- To ensure that subclasses always implement authentication you can add a call to `.verify_authorized!` to your base
671
- operation class.
672
-
673
- This will cause the execution of any subclasses to fail if no authorization is performed.
674
-
675
- ```ruby
676
- class MustAuthOperation < ApplicationOperation
677
- verify_authorized!
678
- end
679
-
680
- class MyUpdateOperation < MustAuthOperation
681
- def perform
514
+ def associate_owner(account)
682
515
  # ...
516
+ Literal::Failure.new(:cant_associate_owner)
683
517
  end
684
518
  end
685
519
 
686
- MyUpdateOperation.call # => Raises an error that MyUpdateOperation does not perform any authorization
687
- ```
688
-
689
- #### `#on_authorization_failure(err)`
520
+ MyOperation.new(account_name: "foo", owner: "bar").call
521
+ # => Literal::Failure(:cant_associate_owner)
690
522
 
691
- A hook is provided to allow you to do some work on an authorization failure.
692
-
693
- Simply override the `#on_authorization_failure(err)` method in your operation.
694
-
695
- ```ruby
696
- class MyUpdateOperation < ApplicationOperation
697
- action_type :update
698
-
699
- authorized_via :initiator do
700
- # ... the permissions check
701
- initiator.admin?
702
- end
703
-
704
- def perform
705
- # ...
706
- end
707
-
708
- def on_authorization_failure(err)
709
- # ... do something with the error, eg logging
710
- end
711
- end
712
523
  ```
713
524
 
714
- Note you are provided the ActionPolicy error object, but you cannot stop the error from being re-raised.
715
-
716
525
  ### Using with `Dry::Monads`
717
526
 
718
527
  As per the example in [`Dry::Monads` documentation](https://dry-rb.org/gems/dry-monads/1.0/do-notation/)
@@ -720,14 +529,14 @@ As per the example in [`Dry::Monads` documentation](https://dry-rb.org/gems/dry-
720
529
  ```ruby
721
530
  class MyOperation < ::TypedOperation::Base
722
531
  include Dry::Monads[:result]
723
- include Dry::Monads::Do.for(:perform, :create_account)
532
+ include Dry::Monads::Do.for(:call)
724
533
 
725
534
  param :account_name, String
726
535
  param :owner, ::Owner
727
536
 
728
- def perform
537
+ def call
729
538
  account = yield create_account(account_name)
730
- yield AnotherOperation.call(account, owner)
539
+ yield associate_owner(account, owner)
731
540
 
732
541
  Success(account)
733
542
  end
@@ -767,11 +576,8 @@ bin/rails g typed_operation:install
767
576
  Use the `--dry_monads` switch to `include Dry::Monads[:result]` into your `ApplicationOperation` (don't forget to also
768
577
  add `gem "dry-monads"` to your Gemfile)
769
578
 
770
- Use the `--action_policy` switch to add the `TypedOperation::ActionPolicyAuth` module to your `ApplicationOperation`
771
- (and you will also need to add `gem "action_policy"` to your Gemfile).
772
-
773
579
  ```ruby
774
- bin/rails g typed_operation:install --dry_monads --action_policy
580
+ bin/rails g typed_operation:install --dry_monads
775
581
  ```
776
582
 
777
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.beta2"
2
+ VERSION = "1.0.0.pre1"
3
3
  end
@@ -2,19 +2,11 @@ if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.2.0")
2
2
  require "polyfill-data"
3
3
  end
4
4
 
5
- require "literal"
6
-
7
5
  require "typed_operation/version"
8
6
  require "typed_operation/railtie" if defined?(Rails::Railtie)
9
- require "typed_operation/operations/introspection"
10
- require "typed_operation/operations/parameters"
11
- require "typed_operation/operations/partial_application"
12
- require "typed_operation/operations/callable"
13
- require "typed_operation/operations/lifecycle"
14
- require "typed_operation/operations/property_builder"
15
- require "typed_operation/operations/executable"
7
+ require "typed_operation/nilable_type"
8
+ require "typed_operation/attribute_builder"
16
9
  require "typed_operation/curried"
17
- require "typed_operation/immutable_base"
18
10
  require "typed_operation/base"
19
11
  require "typed_operation/partially_applied"
20
12
  require "typed_operation/prepared"
metadata CHANGED
@@ -1,17 +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.beta2
4
+ version: 1.0.0.pre1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen Ierodiaconou
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-06-24 00:00:00.000000000 Z
11
+ date: 2023-08-20 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: Command pattern, which is callable, and can be partially applied, curried
14
- and has typed parameters. Authorization to execute via action_policy if desired.
13
+ description: TypedOperation is a command pattern implementation where inputs can be
14
+ defined with runtime type checks. Operations can be partially applied.
15
15
  email:
16
16
  - stevegeek@gmail.com
17
17
  executables: []
@@ -20,6 +20,7 @@ extra_rdoc_files: []
20
20
  files:
21
21
  - MIT-LICENSE
22
22
  - README.md
23
+ - Rakefile
23
24
  - lib/generators/USAGE
24
25
  - lib/generators/templates/operation.rb
25
26
  - lib/generators/templates/operation_test.rb
@@ -27,18 +28,12 @@ files:
27
28
  - lib/generators/typed_operation/install/install_generator.rb
28
29
  - lib/generators/typed_operation/install/templates/application_operation.rb
29
30
  - lib/generators/typed_operation_generator.rb
31
+ - lib/tasks/typed_operation_tasks.rake
30
32
  - lib/typed_operation.rb
31
- - lib/typed_operation/action_policy_auth.rb
33
+ - lib/typed_operation/attribute_builder.rb
32
34
  - lib/typed_operation/base.rb
33
35
  - lib/typed_operation/curried.rb
34
- - lib/typed_operation/immutable_base.rb
35
- - lib/typed_operation/operations/callable.rb
36
- - lib/typed_operation/operations/executable.rb
37
- - lib/typed_operation/operations/introspection.rb
38
- - lib/typed_operation/operations/lifecycle.rb
39
- - lib/typed_operation/operations/parameters.rb
40
- - lib/typed_operation/operations/partial_application.rb
41
- - lib/typed_operation/operations/property_builder.rb
36
+ - lib/typed_operation/nilable_type.rb
42
37
  - lib/typed_operation/partially_applied.rb
43
38
  - lib/typed_operation/prepared.rb
44
39
  - lib/typed_operation/railtie.rb
@@ -57,16 +52,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
57
52
  requirements:
58
53
  - - ">="
59
54
  - !ruby/object:Gem::Version
60
- version: '3.2'
55
+ version: '3.1'
61
56
  required_rubygems_version: !ruby/object:Gem::Requirement
62
57
  requirements:
63
- - - ">="
58
+ - - ">"
64
59
  - !ruby/object:Gem::Version
65
- version: '0'
60
+ version: 1.3.1
66
61
  requirements: []
67
- rubygems_version: 3.5.3
62
+ rubygems_version: 3.4.18
68
63
  signing_key:
69
64
  specification_version: 4
70
- summary: TypedOperation is a command pattern with typed parameters, which is callable,
71
- and can be partially applied.
65
+ summary: TypedOperation is a command pattern implementation
72
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}?".to_sym 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,36 +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 self.prop(name, type, kind = :keyword, reader: :public, writer: :public, default: nil)
9
- super(name, type, kind, reader:, writer: false, default:)
10
- end
11
-
12
- # Parameter for keyword argument, or a positional argument if you use positional: true
13
- # Required, but you can set a default or use optional: true if you want optional
14
- def param(name, signature = :any, **options, &converter)
15
- PropertyBuilder.new(self, name, signature, options).define(&converter)
16
- end
17
-
18
- # Alternative DSL
19
-
20
- # Parameter for positional argument
21
- def positional_param(name, signature = :any, **options, &converter)
22
- param(name, signature, **options.merge(positional: true), &converter)
23
- end
24
-
25
- # Parameter for a keyword or named argument
26
- def named_param(name, signature = :any, **options, &converter)
27
- param(name, signature, **options.merge(positional: false), &converter)
28
- end
29
-
30
- # Wrap a type signature in a NilableType meaning it is optional to TypedOperation
31
- def optional(type_signature)
32
- Literal::Types::NilableType.new(type_signature)
33
- end
34
- end
35
- end
36
- 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