typed_operation 0.4.2 → 1.0.0.beta2

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
@@ -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