Class: Axn::Factory

Inherits:
Object
  • Object
show all
Defined in:
lib/axn/factory.rb

Constant Summary collapse

NOT_PROVIDED =
:__not_provided__

Class Method Summary collapse

Class Method Details

.build(callable = nil, superclass: nil, expose_return_as: nil, include: [], extend: [], prepend: [], exposes: [], expects: [], success: nil, error: nil, axn_name: nil, description: NOT_PROVIDED, semantic_hints: nil, fails_on: nil, tag: nil, dimension: nil, before: nil, after: nil, around: nil, on_success: nil, on_failure: nil, on_error: nil, on_exception: nil, use: [], async: nil, auto_log: NOT_PROVIDED, _creating_action_class_for: nil, &block) ⇒ Object

rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/ParameterLists

Raises:

  • (ArgumentError)


9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/axn/factory.rb', line 9

def build(
  callable = nil,
  # Builder-specific options
  superclass: nil,
  expose_return_as: nil,

  # Module inclusion options
  include: [],
  extend: [],
  prepend: [],

  # Expose standard class-level options
  exposes: [],
  expects: [],
  success: nil,
  error: nil,

  # Naming and metadata
  axn_name: nil,
  description: NOT_PROVIDED,
  semantic_hints: nil,

  # Failure reclassification
  fails_on: nil,

  # Observability facets (single spec or list of specs)
  tag: nil,
  dimension: nil,

  # Hooks
  before: nil,
  after: nil,
  around: nil,

  # Callbacks
  on_success: nil,
  on_failure: nil,
  on_error: nil,
  on_exception: nil,

  # Strategies
  use: [],

  # Async configuration
  async: nil,

  # Logging configuration
  auto_log: NOT_PROVIDED,

  # Internal flag to prevent recursion during action class creation
  # Tracks which target class is having an action class created for it
  _creating_action_class_for: nil,

  &block
)
  raise ArgumentError, "[Axn::Factory] Cannot receive both a callable and a block" if callable.present? && block_given?

  executable = callable || block
  raise ArgumentError, "[Axn::Factory] Must provide either a callable or a block" unless executable

  expose_return_as = _canonical_expose_return_as(expose_return_as)

  args = executable.parameters.each_with_object(_hash_with_default_array) { |(type, field), hash| hash[type] << field }

  if args[:opt].present? || args[:req].present? || args[:rest].present?
    raise ArgumentError,
          "[Axn::Factory] Cannot convert callable to action: callable expects positional arguments"
  end
  raise ArgumentError, "[Axn::Factory] Cannot convert callable to action: callable expects a splat of keyword arguments" if args[:keyrest].present?

  if args[:key].present?
    raise ArgumentError,
          "[Axn::Factory] Cannot convert callable to action: callable expects keyword arguments with defaults (ruby does not allow introspecting)"
  end

  expects = _hydrate_hash(expects)
  exposes = _hydrate_hash(exposes)

  Array(args[:keyreq]).each do |field|
    expects[field] ||= {}
  end

  # NOTE: inheriting from wrapping class, so we can set default values (e.g. for HTTP headers)
  _build_axn_class(superclass:, args:, executable:, expose_return_as:, include:, extend:, prepend:, _creating_action_class_for:).tap do |axn|
    expects.each do |field, opts|
      axn.expects(field, **opts)
    end

    exposes.each do |field, opts|
      axn.exposes(field, **opts)
    end

    # Naming and metadata
    axn.axn_name(axn_name) unless axn_name.nil?
    # Write the backing attribute directly rather than calling `axn.description(...)`: the
    # `description` DSL is only extended onto axn when no non-Axn ancestor already defines
    # `description` (PRO-2875), so a `axn.description(...)` call would hit the ancestor's
    # setter when building on a base class (e.g. a tool base) that defines its own.
    #
    # NOT_PROVIDED (not nil) is the omission sentinel: `_axn_description` is an inherited
    # class_attribute, so a nil default would be indistinguishable from an explicit
    # `description: nil`. A caller building on a superclass with an inherited description needs
    # `description: nil` to CLEAR it (else the subclass republishes stale provider text), so an
    # explicit nil must write through while a truly-omitted arg leaves the inherited value.
    axn._axn_description = description unless description == NOT_PROVIDED
    # `semantic_hints` needs no omission sentinel (unlike `description`): the DSL's setter rejects
    # nil (`semantic_hints(nil)` raises on `nil.to_sym`), so nil is NOT a meaningful value and is
    # treated as omission — leaving an inherited class_attribute intact, which is what an adapter
    # forwarding an unset option expects. The explicit clear is an empty list: it can't go through
    # the DSL (its zero-arg call is the getter), so it writes the backing attribute directly to
    # clear/override inherited hints; a non-empty list goes through the DSL for vocab validation.
    unless semantic_hints.nil?
      hints = Array(semantic_hints)
      hints.empty? ? (axn._semantic_hints = [].freeze) : axn.semantic_hints(*hints)
    end

    # Observability facets (fan out a single spec or a list)
    _apply_facets(axn, :tag, tag)
    _apply_facets(axn, :dimension, dimension)

    # Apply logging configuration (always apply if provided to override defaults).
    # A Hash forwards as per-outcome keywords (auto_log success: :info, ...); anything else
    # is the positional level (auto_log :warn / auto_log false).
    unless auto_log == NOT_PROVIDED
      auto_log.is_a?(Hash) ? axn.auto_log(**auto_log) : axn.auto_log(auto_log)
    end

    # Apply success and error handlers
    _apply_handlers(axn, :success, success, Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor)
    _apply_handlers(axn, :error, error, Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor)

    # Failure reclassification — applied AFTER the error handlers on purpose. `fails_on Klass, "msg"`
    # wires a conditional `error` message; the message registry is last-defined-wins, and among
    # competing REASONS the most-recently-declared matches first. Mirroring the conventional
    # hand-written order (`error ...` then `fails_on ...`), the fan-out runs here so a
    # reclassification message out-specifies an `error:` reason that also matches the exception,
    # rather than being shadowed by it.
    _apply_fails_on(axn, fails_on)

    # Hooks
    axn.before(before) if before.present?
    axn.after(after) if after.present?
    axn.around(around) if around.present?

    # Callbacks
    _apply_handlers(axn, :on_success, on_success, Axn::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
    _apply_handlers(axn, :on_failure, on_failure, Axn::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
    _apply_handlers(axn, :on_error, on_error, Axn::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
    _apply_handlers(axn, :on_exception, on_exception, Axn::Core::Flow::Handlers::Descriptors::CallbackDescriptor)

    # Strategies
    Array(use).each do |strategy|
      if strategy.is_a?(Array)
        strategy_name, *config_args = strategy
        if config_args.last.is_a?(Hash)
          *other_args, config = config_args
          axn.use(strategy_name, *other_args, **config)
        else
          axn.use(strategy_name, *config_args)
        end
      else
        axn.use(strategy)
      end
    end

    # Async configuration
    unless async.nil?
      async_array = Array(async)
      # Skip async configuration if adapter is nil (but not if array is empty)
      if !async_array.empty? && async_array[0].nil?
        # Do nothing - skip async configuration
      else
        _apply_async_config(axn, async_array)
      end
    end

    # Default exposure
    axn.exposes(expose_return_as, optional: true) if expose_return_as
  end
end