typed_operation 0.4.2 → 1.0.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,14 +4,17 @@
4
4
  module <%= namespace_name %>
5
5
  class <%= name %> < ::ApplicationOperation
6
6
  # Replace with implementation...
7
- param :required_param, String
8
- param :an_optional_param, Integer, convert: true, allow_nil: true
7
+ positional_param :required_positional_param, String
8
+ param :required_named_param, String
9
+ param :an_optional_param, Integer, optional: true do |value|
10
+ value.to_i
11
+ end
9
12
 
10
13
  def prepare
11
14
  # Prepare...
12
15
  end
13
16
 
14
- def call
17
+ def perform
15
18
  # Perform...
16
19
  "Hello World!"
17
20
  end
@@ -20,14 +23,17 @@ end
20
23
  <% else %>
21
24
  class <%= name %> < ::ApplicationOperation
22
25
  # Replace with implementation...
23
- param :required_param, String
24
- param :an_optional_param, Integer, convert: true, allow_nil: true
26
+ positional_param :required_positional_param, String
27
+ param :required_named_param, String
28
+ param :an_optional_param, Integer, optional: true do |value|
29
+ value.to_i
30
+ end
25
31
 
26
32
  def prepare
27
33
  # Prepare...
28
34
  end
29
35
 
30
- def call
36
+ def perform
31
37
  # Perform...
32
38
  "Hello World!"
33
39
  end
@@ -0,0 +1,141 @@
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,58 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "vident/typed"
4
- require "vident/typed/attributes"
5
-
6
3
  module TypedOperation
7
- class Base
8
- include Vident::Typed::Attributes
9
-
10
- class << self
11
- def call(...)
12
- new(...).call
13
- end
14
-
15
- def curry(**args)
16
- PartiallyApplied.new(self, **args).curry
17
- end
18
- alias_method :[], :curry
19
- alias_method :with, :curry
20
-
21
- def to_proc
22
- method(:call).to_proc
23
- end
24
-
25
- def operation_key
26
- name.underscore.to_sym
27
- end
4
+ class Base < Literal::Struct
5
+ extend Operations::Introspection
6
+ extend Operations::Parameters
7
+ extend Operations::PartialApplication
28
8
 
29
- # property are required by default, you can fall back to attribute or set allow_nil: true if you want optional
30
- def param(name, signature = :any, **options, &converter)
31
- attribute(name, signature, **{allow_nil: false}.merge(options), &converter)
32
- end
33
- end
9
+ include Operations::Lifecycle
10
+ include Operations::Callable
11
+ include Operations::Deconstruct
12
+ include Operations::Executable
34
13
 
35
- def initialize(**attributes)
36
- begin
37
- prepare_attributes(attributes)
38
- rescue ::Dry::Struct::Error => e
39
- raise ParameterError, e.message
14
+ class << self
15
+ def attribute(name, type, special = nil, reader: :public, writer: :public, positional: false, default: nil)
16
+ super(name, type, special, reader:, writer: false, positional:, default:)
40
17
  end
41
- prepare if respond_to?(:prepare)
42
18
  end
43
19
 
44
- def call
45
- raise InvalidOperationError, "You must implement #call"
46
- end
47
-
48
- def to_proc
49
- method(:call).to_proc
50
- end
51
-
52
- private
53
-
54
- def operation_key
55
- self.class.operation_key
20
+ def with(...)
21
+ # copy to new operation with new attrs
22
+ self.class.new(**attributes.merge(...))
56
23
  end
57
24
  end
58
25
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ class Curried
5
+ def initialize(operation_class, partial_operation = nil)
6
+ @operation_class = operation_class
7
+ @partial_operation = partial_operation || operation_class.with
8
+ end
9
+
10
+ def call(arg)
11
+ raise ArgumentError, "A prepared operation should not be curried" if @partial_operation.prepared?
12
+
13
+ next_partially_applied = if next_parameter_positional?
14
+ @partial_operation.with(arg)
15
+ else
16
+ @partial_operation.with(next_keyword_parameter => arg)
17
+ end
18
+ if next_partially_applied.prepared?
19
+ next_partially_applied.call
20
+ else
21
+ Curried.new(@operation_class, next_partially_applied)
22
+ end
23
+ end
24
+
25
+ def to_proc
26
+ method(:call).to_proc
27
+ end
28
+
29
+ private
30
+
31
+ def next_keyword_parameter
32
+ remaining = @operation_class.required_keyword_parameters - @partial_operation.keyword_args.keys
33
+ remaining.first
34
+ end
35
+
36
+ def next_parameter_positional?
37
+ @partial_operation.positional_args.size < @operation_class.required_positional_parameters.size
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,14 @@
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::Deconstruct
12
+ include Operations::Executable
13
+ end
14
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ module Operations
5
+ class AttributeBuilder
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]
11
+ @positional = options[: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.attribute(
27
+ @name,
28
+ @signature,
29
+ default: default_value_for_literal,
30
+ positional: @positional,
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::Union.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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ module Operations
5
+ module Deconstruct
6
+ def deconstruct
7
+ attributes.values
8
+ end
9
+
10
+ def deconstruct_keys(keys)
11
+ h = attributes.to_h
12
+ keys ? h.slice(*keys) : h
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ module Operations
5
+ # Introspection methods
6
+ module Introspection
7
+ def positional_parameters
8
+ literal_attributes.filter_map { |name, attribute| name if attribute.positional? }
9
+ end
10
+
11
+ def keyword_parameters
12
+ literal_attributes.filter_map { |name, attribute| name unless attribute.positional? }
13
+ end
14
+
15
+ def required_parameters
16
+ literal_attributes.filter do |name, attribute|
17
+ attribute.default.nil? # Any optional parameters will have a default value/proc in their Literal::Attribute
18
+ end
19
+ end
20
+
21
+ def required_positional_parameters
22
+ required_parameters.filter_map { |name, attribute| name if attribute.positional? }
23
+ end
24
+
25
+ def required_keyword_parameters
26
+ required_parameters.filter_map { |name, attribute| name unless attribute.positional? }
27
+ end
28
+
29
+ def optional_positional_parameters
30
+ positional_parameters - required_positional_parameters
31
+ end
32
+
33
+ def optional_keyword_parameters
34
+ keyword_parameters - required_keyword_parameters
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
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_initialization
8
+ prepare if respond_to?(:prepare)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ module Operations
5
+ # Method to define parameters for your operation.
6
+ module Parameters
7
+ # Parameter for keyword argument, or a positional argument if you use positional: true
8
+ # Required, but you can set a default or use optional: true if you want optional
9
+ def param(name, signature = :any, **options, &converter)
10
+ AttributeBuilder.new(self, name, signature, options).define(&converter)
11
+ end
12
+
13
+ # Alternative DSL
14
+
15
+ # Parameter for positional argument
16
+ def positional_param(name, signature = :any, **options, &converter)
17
+ param(name, signature, **options.merge(positional: true), &converter)
18
+ end
19
+
20
+ # Parameter for a keyword or named argument
21
+ def named_param(name, signature = :any, **options, &converter)
22
+ param(name, signature, **options.merge(positional: false), &converter)
23
+ end
24
+
25
+ # Wrap a type signature in a NilableType meaning it is optional to TypedOperation
26
+ def optional(type_signature)
27
+ Literal::Types::NilableType.new(type_signature)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,16 @@
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
@@ -2,34 +2,87 @@
2
2
 
3
3
  module TypedOperation
4
4
  class PartiallyApplied
5
- def initialize(operation, **applied_args)
6
- @operation = operation
7
- @applied_args = applied_args
5
+ def initialize(operation_class, *positional_args, **keyword_args)
6
+ @operation_class = operation_class
7
+ @positional_args = positional_args
8
+ @keyword_args = keyword_args
8
9
  end
9
10
 
10
- def curry(**params)
11
- all_args = @applied_args.merge(params)
12
- # check if required attrs are in @applied_args
13
- required_keys = @operation.attribute_names.select do |name|
14
- meta = @operation.attribute_metadata(name)
15
- meta[:required] != false && !meta[:typed_attribute_options].key?(:default)
16
- end
17
- missing_keys = required_keys - all_args.keys
11
+ def with(*positional, **keyword)
12
+ all_positional = positional_args + positional
13
+ all_kw_args = keyword_args.merge(keyword)
14
+
15
+ validate_positional_arg_count!(all_positional.size)
18
16
 
19
- if missing_keys.size > 0
20
- # Partially apply the arguments
21
- PartiallyApplied.new(@operation, **all_args)
17
+ if partially_applied?(all_positional, all_kw_args)
18
+ PartiallyApplied.new(operation_class, *all_positional, **all_kw_args)
22
19
  else
23
- Prepared.new(@operation, **all_args)
20
+ Prepared.new(operation_class, *all_positional, **all_kw_args)
24
21
  end
25
22
  end
26
- alias_method :[], :curry
27
- alias_method :with, :curry
23
+ alias_method :[], :with
24
+
25
+ def curry
26
+ Curried.new(operation_class, self)
27
+ end
28
28
 
29
29
  def call(...)
30
- prepared = curry(...)
30
+ prepared = with(...)
31
31
  return prepared.operation.call if prepared.is_a?(Prepared)
32
- raise TypedOperation::MissingParameterError, "Cannot call PartiallyApplied operation #{@operation.name} (key: #{@operation.operation_key}), are you expecting it to be Prepared?"
32
+ raise MissingParameterError, "Cannot call PartiallyApplied operation #{operation_class.name} (key: #{operation_class.name}), are you expecting it to be Prepared?"
33
+ end
34
+
35
+ def operation
36
+ raise MissingParameterError, "Cannot instantiate Operation #{operation_class.name} (key: #{operation_class.name}), as it is only partially applied."
37
+ end
38
+
39
+ def prepared?
40
+ false
41
+ end
42
+
43
+ def to_proc
44
+ method(:call).to_proc
45
+ end
46
+
47
+ def deconstruct
48
+ positional_args + keyword_args.values
49
+ end
50
+
51
+ def deconstruct_keys(keys)
52
+ h = keyword_args.dup
53
+ positional_args.each_with_index { |v, i| h[positional_parameters[i]] = v }
54
+ keys ? h.slice(*keys) : h
55
+ end
56
+
57
+ attr_reader :positional_args, :keyword_args
58
+
59
+ private
60
+
61
+ attr_reader :operation_class
62
+
63
+ def required_positional_parameters
64
+ @required_positional_parameters ||= operation_class.required_positional_parameters
65
+ end
66
+
67
+ def required_keyword_parameters
68
+ @required_keyword_parameters ||= operation_class.required_keyword_parameters
69
+ end
70
+
71
+ def positional_parameters
72
+ @positional_parameters ||= operation_class.positional_parameters
73
+ end
74
+
75
+ def validate_positional_arg_count!(count)
76
+ if count > positional_parameters.size
77
+ raise ArgumentError, "Too many positional arguments provided for #{operation_class.name} (key: #{operation_class.name})"
78
+ end
79
+ end
80
+
81
+ def partially_applied?(all_positional, all_kw_args)
82
+ missing_positional = required_positional_parameters.size - all_positional.size
83
+ missing_keys = required_keyword_parameters - all_kw_args.keys
84
+
85
+ missing_positional > 0 || missing_keys.size > 0
33
86
  end
34
87
  end
35
88
  end
@@ -3,7 +3,11 @@
3
3
  module TypedOperation
4
4
  class Prepared < PartiallyApplied
5
5
  def operation
6
- @operation.new(**@applied_args)
6
+ operation_class.new(*@positional_args, **@keyword_args)
7
+ end
8
+
9
+ def prepared?
10
+ true
7
11
  end
8
12
  end
9
13
  end