axn 0.1.0.pre.alpha.1.1 → 0.1.0.pre.alpha.2

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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/.tool-versions +1 -0
  4. data/CHANGELOG.md +10 -2
  5. data/CONTRIBUTING.md +1 -1
  6. data/README.md +1 -1
  7. data/docs/.vitepress/config.mjs +18 -10
  8. data/docs/advanced/rough.md +2 -0
  9. data/docs/index.md +11 -3
  10. data/docs/{guide/index.md → intro/overview.md} +11 -32
  11. data/docs/recipes/memoization.md +46 -0
  12. data/docs/{usage → recipes}/testing.md +4 -2
  13. data/docs/reference/action-result.md +32 -9
  14. data/docs/reference/class.md +69 -12
  15. data/docs/reference/configuration.md +28 -15
  16. data/docs/reference/instance.md +96 -13
  17. data/docs/usage/setup.md +0 -2
  18. data/docs/usage/using.md +7 -15
  19. data/docs/usage/writing.md +45 -6
  20. data/lib/action/attachable/base.rb +43 -0
  21. data/lib/action/attachable/steps.rb +47 -0
  22. data/lib/action/attachable/subactions.rb +43 -0
  23. data/lib/action/attachable.rb +17 -0
  24. data/lib/action/{configuration.rb → core/configuration.rb} +1 -1
  25. data/lib/action/{context_facade.rb → core/context_facade.rb} +18 -28
  26. data/lib/action/{contract.rb → core/contract.rb} +10 -2
  27. data/lib/action/{exceptions.rb → core/exceptions.rb} +11 -0
  28. data/lib/action/{hoist_errors.rb → core/hoist_errors.rb} +3 -2
  29. data/lib/action/{swallow_exceptions.rb → core/swallow_exceptions.rb} +52 -7
  30. data/lib/axn/factory.rb +102 -0
  31. data/lib/axn/version.rb +1 -1
  32. data/lib/axn.rb +20 -10
  33. metadata +28 -22
  34. data/lib/action/organizer.rb +0 -41
  35. /data/docs/{usage → advanced}/conventions.md +0 -0
  36. /data/docs/{about/index.md → intro/about.md} +0 -0
  37. /data/docs/{advanced → recipes}/validating-user-input.md +0 -0
  38. /data/lib/action/{contract_validator.rb → core/contract_validator.rb} +0 -0
  39. /data/lib/action/{enqueueable.rb → core/enqueueable.rb} +0 -0
  40. /data/lib/action/{logging.rb → core/logging.rb} +0 -0
  41. /data/lib/action/{top_level_around_hook.rb → core/top_level_around_hook.rb} +0 -0
data/docs/usage/using.md CHANGED
@@ -7,11 +7,11 @@ outline: deep
7
7
 
8
8
  ## Common Case
9
9
 
10
- An action is usually executed via `#call`, and _always_ returns an instance of the `Action::Result` class.
10
+ An action executed via `#call` _always_ returns an instance of the `Action::Result` class.
11
11
 
12
- This means the result _always_ implements a consistent interface, including `ok?` and `error` (see [full details](/reference/action-result)) as well as any variables that it `exposes`. Remember any exceptions have been swallowed.
12
+ This means the result _always_ implements a consistent interface, including `ok?` and `error` (see [full details](/reference/action-result)) as well as any variables that the action `exposes`.
13
13
 
14
- As a consumer, you usually want a conditional that surfaces `error` unless the result is `ok?`, and otherwise takes whatever success action is relevant.
14
+ As a consumer, you usually want a conditional that surfaces `error` unless the result is `ok?` (remember that any exceptions have been swallowed), and otherwise takes whatever success action is relevant.
15
15
 
16
16
  For example:
17
17
 
@@ -23,9 +23,9 @@ class MessagesController < ApplicationController
23
23
  message: params[:message],
24
24
  )
25
25
 
26
- if result.ok? # [!code focus:2]
26
+ if result.ok? # [!code focus:3]
27
27
  @thread_id = result.thread_id # Because `thread_id` was explicitly exposed
28
- flash.now[:success] = "Sent the Slack message"
28
+ flash.now[:success] = result.success
29
29
  else
30
30
  flash[:alert] = result.error # [!code focus]
31
31
  redirect_to action: :new
@@ -34,21 +34,13 @@ class MessagesController < ApplicationController
34
34
  end
35
35
  ```
36
36
 
37
- <!-- TODO: replace manual flash success with result.success (here and in guide?) -->
38
-
39
-
40
37
  ## Advanced Usage
41
38
 
42
39
  ### `#call!`
43
40
 
44
- ::: danger ALPHA
45
- * TODO - flesh out this section
46
- :::
47
-
41
+ An action executed via `#call!` (note the `!`) does _not_ swallow exceptions -- a _successful_ action will return an `Action::Result` just like `call`, but any exceptions will bubble up uncaught (note: technically they _will_ be caught, your on_exception handler triggered, and then re-raised) and any explicit `fail!` calls will raise an `Action::Failure` exception with your custom message.
48
42
 
49
- * `call!`
50
- * call! -- will raise any exceptions OR our own Action::Failure if user-facing error occurred (otherwise non-bang will never raise)
51
- * note call! still logs completion even if failure (from configuration's on_exception)
43
+ This is a much less common pattern, as you're giving up the benefits of error swallowing and the consistent return interface guarantee, but it can be useful in limited contexts (usually for smaller, one-off scripts where it's easier to just let a failure bubble up rather than worry about adding conditionals for error handling).
52
44
 
53
45
 
54
46
  ### `#enqueue`
@@ -66,10 +66,39 @@ See [the reference doc](/reference/instance) for a few more handy helper methods
66
66
 
67
67
  ## Customizing messages
68
68
 
69
- ::: danger ALPHA
70
- * TODO: document `messages` setup
71
- :::
69
+ The default `error` and `success` message strings ("Something went wrong" / "Action completed successfully", respectively) _are_ technically safe to show users, but you'll often want to set them to something more useful.
70
+
71
+ There's a `messages` declaration for that -- you can set strings (most common) or a callable (note for the error case, if you give it a callable that expects a single argument, the exception that was raised will be passed in).
72
+
73
+ For instance, configuring the action like this:
74
+
75
+ ```ruby
76
+ class Foo
77
+ include Action
78
+
79
+ expects :name, type: String
80
+ exposes :meaning_of_life
81
+
82
+ messages success: -> { "Revealed the secret of life to #{name}" }, # [!code focus:2]
83
+ error: ->(e) { "No secret of life for you: #{e.message}" }
84
+
85
+ def call
86
+ fail! "Douglas already knows the meaning" if name == "Doug"
72
87
 
88
+ msg = "Hello #{name}, the meaning of life is 42"
89
+ expose meaning_of_life: msg
90
+ end
91
+ end
92
+ ```
93
+
94
+ Would give us these outputs:
95
+
96
+ ```ruby
97
+ Foo.call.error # => "No secret of life for you: Name can't be blank"
98
+ Foo.call(name: "Doug").error # => "Douglas already knows the meaning"
99
+ Foo.call(name: "Adams").success # => "Revealed the secret of life to Adams"
100
+ Foo.call(name: "Adams").meaning_of_life # => "Hello Adams, the meaning of life is 42"
101
+ ```
73
102
 
74
103
  ## Lifecycle methods
75
104
 
@@ -77,11 +106,15 @@ In addition to `#call`, there are a few additional pieces to be aware of:
77
106
 
78
107
  ### `#rollback`
79
108
 
109
+ ::: danger ALPHA
110
+ * ⚠️ `#rollback` is _expected_ to be added shortly, but is not yet functional!
111
+ :::
112
+
80
113
  If you define a `#rollback` method, it'll be called (_before_ returning an `Action::Result` to the caller) whenever your action fails.
81
114
 
82
115
  ### Hooks
83
116
 
84
- `before`, `after`, and `around` hooks are also supported.
117
+ `before` and `after` hooks are also supported. They can receive a block directly, or the symbol name of a local method.
85
118
 
86
119
  ### Concrete example
87
120
 
@@ -91,8 +124,8 @@ Given this series of methods and hooks:
91
124
  class Foo
92
125
  include Action
93
126
 
94
- before { log("before hook") }
95
- after { log("after hook") }
127
+ before { log("before hook") } # [!code focus:2]
128
+ after :log_after
96
129
 
97
130
  def call
98
131
  log("in call")
@@ -102,6 +135,12 @@ class Foo
102
135
  def rollback
103
136
  log("rolling back")
104
137
  end
138
+
139
+ private
140
+
141
+ def log_after
142
+ log("after hook")
143
+ end
105
144
  end
106
145
  ```
107
146
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Attachable
5
+ module Base
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def axn_for_attachment(
10
+ attachment_type: "Action",
11
+ name: nil,
12
+ axn_klass: nil,
13
+ superclass: nil,
14
+ **kwargs,
15
+ &block
16
+ )
17
+ raise ArgumentError, "#{attachment_type} name must be a string or symbol" unless name.is_a?(String) || name.is_a?(Symbol)
18
+ raise ArgumentError, "#{attachment_type} '#{name}' must be given an existing action class or a block" if axn_klass.nil? && !block_given?
19
+
20
+ if axn_klass && block_given?
21
+ raise ArgumentError,
22
+ "#{attachment_type} '#{name}' was given both an existing action class and a block - only one is allowed"
23
+ end
24
+
25
+ if axn_klass
26
+ unless axn_klass.respond_to?(:<) && axn_klass < Action
27
+ raise ArgumentError,
28
+ "#{attachment_type} '#{name}' was given an already-existing class #{axn_klass.name} that does NOT inherit from Action as expected"
29
+ end
30
+
31
+ if kwargs.present?
32
+ raise ArgumentError, "#{attachment_type} '#{name}' was given an existing action class and also keyword arguments - only one is allowed"
33
+ end
34
+
35
+ return axn_klass
36
+ end
37
+
38
+ Axn::Factory.build(superclass: superclass || self, **kwargs, &block)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Attachable
5
+ module Steps
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :_axn_steps, default: []
10
+ end
11
+
12
+ Entry = Data.define(:label, :axn)
13
+
14
+ class_methods do
15
+ def steps(*steps)
16
+ self._axn_steps += Array(steps).compact
17
+ end
18
+
19
+ def step(name, axn_klass = nil, **kwargs, &block)
20
+ axn_klass = axn_for_attachment(
21
+ name:,
22
+ axn_klass:,
23
+ attachment_type: "Step",
24
+ superclass: Object, # NOTE: steps skip inheriting from the wrapping class (to avoid duplicate field expectations/exposures)
25
+ **kwargs,
26
+ &block
27
+ )
28
+
29
+ # Add the step to the list of steps
30
+ steps Entry.new(label: name, axn: axn_klass)
31
+ end
32
+ end
33
+
34
+ def call
35
+ self.class._axn_steps.each_with_index do |step, idx|
36
+ # Set a default label if we were just given an array of unlabeled steps
37
+ # TODO: should Axn have a default label passed in already that we could pull out?
38
+ step = Entry.new(label: "Step #{idx + 1}", axn: step) if step.is_a?(Class)
39
+
40
+ hoist_errors(prefix: "#{step.label} step") do
41
+ step.axn.call(@context)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ module Attachable
5
+ module Subactions
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def axnable_method(name, axn_klass = nil, **action_kwargs, &block)
10
+ raise ArgumentError, "Unable to attach Axn -- '#{name}' is already taken" if respond_to?(name)
11
+
12
+ action_kwargs[:expose_return_as] ||= :value
13
+ axn_klass = axn_for_attachment(name:, axn_klass:, **action_kwargs, &block)
14
+
15
+ define_singleton_method("#{name}_axn") do |**kwargs|
16
+ axn_klass.call(**kwargs)
17
+ end
18
+
19
+ define_singleton_method("#{name}!") do |**kwargs|
20
+ result = axn_klass.call!(**kwargs)
21
+ result.public_send(action_kwargs[:expose_return_as])
22
+ end
23
+ end
24
+
25
+ def axn(name, axn_klass = nil, **action_kwargs, &block)
26
+ raise ArgumentError, "Unable to attach Axn -- '#{name}' is already taken" if respond_to?(name)
27
+
28
+ axn_klass = axn_for_attachment(name:, axn_klass:, **action_kwargs, &block)
29
+
30
+ define_singleton_method(name) do |**kwargs|
31
+ axn_klass.call(**kwargs)
32
+ end
33
+
34
+ # TODO: do we also need an instance-level version that auto-wraps in hoist_errors(label: name)?
35
+
36
+ define_singleton_method("#{name}!") do |**kwargs|
37
+ axn_klass.call!(**kwargs)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "attachable/base"
4
+ require_relative "attachable/steps"
5
+ require_relative "attachable/subactions"
6
+
7
+ module Action
8
+ module Attachable
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ include Base
13
+ include Steps
14
+ include Subactions
15
+ end
16
+ end
17
+ end
@@ -12,7 +12,7 @@ module Action
12
12
 
13
13
  def on_exception(e, action:, context: {})
14
14
  if @on_exception
15
- # TODO: only pass action: or context: if requested
15
+ # TODO: only pass action: or context: if requested (and update documentation)
16
16
  @on_exception.call(e, action:, context:)
17
17
  else
18
18
  log("[#{action.class.name.presence || "Anonymous Action"}] Exception swallowed: #{e.class.name} - #{e.message}")
@@ -51,36 +51,15 @@ module Action
51
51
  def determine_error_message(only_default: false)
52
52
  return @context.error_from_user if @context.error_from_user.present?
53
53
 
54
+ exception = @context.exception || (only_default ? Action::Failure.new(@context) : nil)
55
+ msg = action._error_msg
56
+
54
57
  unless only_default
55
- msg = message_from_rescues
56
- return msg if msg.present?
58
+ interceptor = action.class._error_interceptor_for(exception:, action:)
59
+ msg = interceptor.message if interceptor
57
60
  end
58
61
 
59
- the_exception = @context.exception || (only_default ? Action::Failure.new(@context) : nil)
60
- stringified(action._error_msg, exception: the_exception).presence || "Something went wrong"
61
- end
62
-
63
- def message_from_rescues
64
- Array(action._error_rescues).each do |(matcher, value)|
65
- matches = if matcher.respond_to?(:call)
66
- if matcher.arity == 1
67
- !!action.instance_exec(exception, &matcher)
68
- else
69
- !!action.instance_exec(&matcher)
70
- end
71
- elsif matcher.is_a?(String) || matcher.is_a?(Symbol)
72
- klass = Object.const_get(matcher.to_s)
73
- klass && exception.is_a?(klass)
74
- elsif matcher < Exception
75
- exception.is_a?(matcher)
76
- else
77
- action.warn("Ignoring matcher #{matcher.inspect} in rescues command")
78
- end
79
-
80
- return stringified(value, exception:) if matches
81
- end
82
-
83
- nil
62
+ stringified(msg, exception:).presence || "Something went wrong"
84
63
  end
85
64
 
86
65
  # Allow for callable OR string messages
@@ -113,11 +92,22 @@ module Action
113
92
 
114
93
  # Outbound / External ContextFacade
115
94
  class Result < ContextFacade
95
+ # For ease of mocking return results in tests
96
+ class << self
97
+ def ok = Class.new { include(Action) }.call
98
+
99
+ def error(msg = "Something went wrong")
100
+ Class.new { include(Action) }.tap do |klass|
101
+ klass.define_method(:call) { fail!(msg) }
102
+ end.call
103
+ end
104
+ end
105
+
116
106
  # Poke some holes for necessary internal control methods
117
107
  delegate :called!, :rollback!, :each_pair, to: :context
118
108
 
119
109
  # External interface
120
- delegate :success?, :failure?, :exception, to: :context
110
+ delegate :success?, :exception, to: :context
121
111
  def ok? = success?
122
112
 
123
113
  def error
@@ -4,8 +4,8 @@ require "active_model"
4
4
  require "active_support/core_ext/enumerable"
5
5
  require "active_support/core_ext/module/delegation"
6
6
 
7
- require "action/contract_validator"
8
- require "action/context_facade"
7
+ require "action/core/contract_validator"
8
+ require "action/core/context_facade"
9
9
 
10
10
  module Action
11
11
  module Contract
@@ -57,11 +57,19 @@ module Action
57
57
 
58
58
  private
59
59
 
60
+ RESERVED_FIELD_NAMES = %w[
61
+ declared_fields inspect fail!
62
+ default_error
63
+ called! rollback! each_pair success? exception ok ok? error success message
64
+ ].freeze
65
+
60
66
  def _parse_field_configs(*fields, allow_blank: false, default: nil, preprocess: nil, sensitive: false,
61
67
  **validations)
62
68
  # Allow local access to explicitly-expected fields -- even externally-expected needs to be available locally
63
69
  # (e.g. to allow success message callable to reference exposed fields)
64
70
  fields.each do |field|
71
+ raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES.include?(field.to_s)
72
+
65
73
  define_method(field) { internal_context.public_send(field) }
66
74
  end
67
75
 
@@ -12,6 +12,8 @@ module Action
12
12
  end
13
13
 
14
14
  def message = @message.presence || "Execution was halted"
15
+
16
+ def inspect = "#<#{self.class.name} '#{message}'>"
15
17
  end
16
18
 
17
19
  class StepsRequiredForInheritanceSupportError < StandardError
@@ -33,6 +35,15 @@ module Action
33
35
  end
34
36
 
35
37
  class ContractViolation < StandardError
38
+ class ReservedAttributeError < ContractViolation
39
+ def initialize(name)
40
+ @name = name
41
+ super()
42
+ end
43
+
44
+ def message = "Cannot call expects or exposes with reserved field name: #{@name}"
45
+ end
46
+
36
47
  class MethodNotAllowed < ContractViolation; end
37
48
  class PreprocessingError < ContractViolation; end
38
49
 
@@ -25,7 +25,7 @@ module Action
25
25
  result = begin
26
26
  yield
27
27
  rescue StandardError => e
28
- warn "hoist_errors block swallowed an exception: #{e.message}"
28
+ log "hoist_errors block transforming a #{e.class.name} exception: #{e.message}"
29
29
  MinimalFailedResult.new(error: nil, exception: e)
30
30
  end
31
31
 
@@ -44,7 +44,8 @@ module Action
44
44
  @context.exception = result.exception if result.exception.present?
45
45
  @context.error_prefix = prefix if prefix.present?
46
46
 
47
- fail! result.error
47
+ error = result.exception.is_a?(Action::Failure) ? result.exception.message : result.error
48
+ fail! error
48
49
  end
49
50
  end
50
51
  end
@@ -2,20 +2,43 @@
2
2
 
3
3
  module Action
4
4
  module SwallowExceptions
5
+ CustomErrorInterceptor = Data.define(:matcher, :message, :should_report_error)
6
+ class CustomErrorInterceptor
7
+ def matches?(exception:, action:)
8
+ if matcher.respond_to?(:call)
9
+ if matcher.arity == 1
10
+ !!action.instance_exec(exception, &matcher)
11
+ else
12
+ !!action.instance_exec(&matcher)
13
+ end
14
+ elsif matcher.is_a?(String) || matcher.is_a?(Symbol)
15
+ klass = Object.const_get(matcher.to_s)
16
+ klass && exception.is_a?(klass)
17
+ elsif matcher < Exception
18
+ exception.is_a?(matcher)
19
+ else
20
+ action.warn("Ignoring apparently-invalid matcher #{matcher.inspect} -- could not find way to apply it")
21
+ false
22
+ end
23
+ rescue StandardError => e
24
+ action.warn("Ignoring #{e.class.name} raised while determining matcher: #{e.message}")
25
+ false
26
+ end
27
+ end
28
+
5
29
  def self.included(base)
6
30
  base.class_eval do
7
31
  class_attribute :_success_msg, :_error_msg
8
- class_attribute :_error_rescues, default: []
32
+ class_attribute :_custom_error_interceptors, default: []
9
33
 
10
34
  include InstanceMethods
11
35
  extend ClassMethods
12
36
 
13
37
  def run_with_exception_swallowing!
14
38
  original_run!
15
- rescue Action::Failure => e
16
- # Just re-raise these (so we don't hit the unexpected-error case below)
17
- raise e
18
39
  rescue StandardError => e
40
+ raise if e.is_a?(Action::Failure) # TODO: avoid raising if this was passed along from a child action (esp. if wrapped in hoist_errors)
41
+
19
42
  # Add custom hook for intercepting exceptions (e.g. Teamshares automatically logs to Honeybadger)
20
43
  trigger_on_exception(e)
21
44
 
@@ -36,6 +59,9 @@ module Action
36
59
  end
37
60
 
38
61
  def trigger_on_exception(e)
62
+ interceptor = self.class._error_interceptor_for(exception: e, action: self)
63
+ return if interceptor&.should_report_error == false
64
+
39
65
  Action.config.on_exception(e,
40
66
  action: self,
41
67
  context: respond_to?(:context_for_logging) ? context_for_logging : @context.to_h)
@@ -69,13 +95,32 @@ module Action
69
95
  true
70
96
  end
71
97
 
72
- def rescues(matcher = nil, message = nil, **match_and_messages)
73
- raise ArgumentError, "rescues must be called with a key, value pair or else keyword args" if [matcher, message].compact.size == 1
98
+ def error_from(matcher = nil, message = nil, **match_and_messages)
99
+ _register_error_interceptor(matcher, message, should_report_error: true, **match_and_messages)
100
+ end
74
101
 
75
- { matcher => message }.compact.merge(match_and_messages).each { |mam| self._error_rescues += [mam] }
102
+ def rescues(matcher = nil, message = nil, **match_and_messages)
103
+ _register_error_interceptor(matcher, message, should_report_error: false, **match_and_messages)
76
104
  end
77
105
 
78
106
  def default_error = new.internal_context.default_error
107
+
108
+ # Private helpers
109
+
110
+ def _error_interceptor_for(exception:, action:)
111
+ Array(_custom_error_interceptors).detect do |int|
112
+ int.matches?(exception:, action:)
113
+ end
114
+ end
115
+
116
+ def _register_error_interceptor(matcher, message, should_report_error:, **match_and_messages)
117
+ method_name = should_report_error ? "error_from" : "rescues"
118
+ raise ArgumentError, "#{method_name} must be called with a key/value pair, or else keyword args" if [matcher, message].compact.size == 1
119
+
120
+ { matcher => message }.compact.merge(match_and_messages).each do |(matcher, message)| # rubocop:disable Lint/ShadowingOuterLocalVariable
121
+ self._custom_error_interceptors += [CustomErrorInterceptor.new(matcher:, message:, should_report_error:)]
122
+ end
123
+ end
79
124
  end
80
125
 
81
126
  module InstanceMethods
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ class Factory
5
+ class << self
6
+ # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/ParameterLists
7
+ def build(
8
+ # Builder-specific options
9
+ superclass: nil,
10
+ expose_return_as: :nil,
11
+
12
+ # Expose standard class-level options
13
+ exposes: {},
14
+ expects: {},
15
+ messages: {},
16
+ before: nil,
17
+ after: nil,
18
+ around: nil,
19
+
20
+ # Allow dynamically assigning rollback method
21
+ rollback: nil,
22
+ &block
23
+ )
24
+ args = block.parameters.each_with_object(_hash_with_default_array) { |(type, name), hash| hash[type] << name }
25
+
26
+ if args[:opt].present? || args[:req].present? || args[:rest].present?
27
+ raise ArgumentError,
28
+ "[Axn::Factory] Cannot convert block to action: block expects positional arguments"
29
+ end
30
+ raise ArgumentError, "[Axn::Factory] Cannot convert block to action: block expects a splat of keyword arguments" if args[:keyrest].present?
31
+
32
+ # TODO: is there any way to support default arguments? (if so, set allow_blank: true for those)
33
+ if args[:key].present?
34
+ raise ArgumentError,
35
+ "[Axn::Factory] Cannot convert block to action: block expects keyword arguments with defaults (ruby does not allow introspecting)"
36
+ end
37
+
38
+ expects = _hydrate_hash(expects)
39
+ exposes = _hydrate_hash(exposes)
40
+
41
+ Array(args[:keyreq]).each do |name|
42
+ expects[name] ||= {}
43
+ end
44
+
45
+ # NOTE: inheriting from wrapping class, so we can set default values (e.g. for HTTP headers)
46
+ Class.new(superclass || Object) do
47
+ include Action unless self < Action
48
+
49
+ define_method(:call) do
50
+ unwrapped_kwargs = Array(args[:keyreq]).each_with_object({}) do |name, hash|
51
+ hash[name] = public_send(name)
52
+ end
53
+
54
+ retval = instance_exec(**unwrapped_kwargs, &block)
55
+ expose(expose_return_as => retval) if expose_return_as.present?
56
+ end
57
+ end.tap do |axn| # rubocop: disable Style/MultilineBlockChain
58
+ expects.each do |name, opts|
59
+ axn.expects(name, **opts)
60
+ end
61
+
62
+ exposes.each do |name, opts|
63
+ axn.exposes(name, **opts)
64
+ end
65
+
66
+ axn.messages(**messages) if messages.present?
67
+
68
+ # Hooks
69
+ axn.before(before) if before.present?
70
+ axn.after(after) if after.present?
71
+ axn.around(around) if around.present?
72
+
73
+ # Rollback
74
+ if rollback.present?
75
+ raise ArgumentError, "[Axn::Factory] Rollback must be a callable" unless rollback.respond_to?(:call) && rollback.respond_to?(:arity)
76
+ raise ArgumentError, "[Axn::Factory] Rollback must be a callable with no arguments" unless rollback.arity.zero?
77
+
78
+ axn.define_method(:rollback) do
79
+ instance_exec(&rollback)
80
+ end
81
+ end
82
+
83
+ # Default exposure
84
+ axn.exposes(expose_return_as, allow_blank: true) if expose_return_as.present?
85
+ end
86
+ end
87
+ # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/ParameterLists
88
+
89
+ private
90
+
91
+ def _hash_with_default_array = Hash.new { |h, k| h[k] = [] }
92
+
93
+ def _hydrate_hash(given)
94
+ return given if given.is_a?(Hash)
95
+
96
+ Array(given).each_with_object({}) do |key, acc|
97
+ acc[key] = {}
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
data/lib/axn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Axn
4
- VERSION = "0.1.0-alpha.1.1"
4
+ VERSION = "0.1.0-alpha.2"
5
5
  end