typed_operation 0.4.2 → 1.0.0.beta1

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