typed_operation 0.4.2 → 1.0.0.beta2

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
@@ -12,6 +12,7 @@ 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.
15
16
 
16
17
  Example:
17
18
  --------
@@ -6,6 +6,7 @@ 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
9
10
 
10
11
  source_root File.expand_path("templates", __dir__)
11
12
 
@@ -18,6 +19,10 @@ module TypedOperation
18
19
  def include_dry_monads?
19
20
  options[:dry_monads]
20
21
  end
22
+
23
+ def include_action_policy?
24
+ options[:action_policy]
25
+ end
21
26
  end
22
27
  end
23
28
  end
@@ -1,14 +1,36 @@
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 -%>
4
8
  <% if include_dry_monads? -%>
5
- include Dry::Monads[:result, :do]
9
+ include Dry::Monads[:result]
10
+ include Dry::Monads::Do.for(:perform)
6
11
 
12
+ # Helper to execute then unwrap a successful result or raise an exception
7
13
  def call!
8
14
  call.value!
9
15
  end
10
16
 
11
17
  <% end -%>
12
18
  # Other common parameters & methods for Operations of this application...
13
- # ...
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
14
36
  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,13 @@
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
28
-
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
34
-
35
- def initialize(**attributes)
36
- begin
37
- prepare_attributes(attributes)
38
- rescue ::Dry::Struct::Error => e
39
- raise ParameterError, e.message
40
- end
41
- prepare if respond_to?(:prepare)
42
- end
43
-
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
56
- end
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
57
12
  end
58
13
  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,13 @@
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
@@ -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,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,36 @@
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
@@ -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_initialize
8
+ prepare if respond_to?(:prepare)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,36 @@
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
@@ -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
@@ -0,0 +1,81 @@
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