axn 0.1.0.pre.alpha.2.6 → 0.1.0.pre.alpha.2.6.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49a6ab763d534efa878ff7f0d833beefeaae604bddb1c2e89090788a1e7df1a0
4
- data.tar.gz: 828cfbd5ad2b9f753bf938bdb84201edbb406b367c159d4b1f437054a3bc213f
3
+ metadata.gz: 30c00229f0062bdba5979d0e69de4ffddb6bece3f31413f83b7f7778d3075ef7
4
+ data.tar.gz: 92d944caf300ddfc9a134d34ae2cb73e6d235e0af9f7a10a3ee93065ae834690
5
5
  SHA512:
6
- metadata.gz: 8be59eb695a3df836513fb477b2338bb539da209821d17ab6502314ea57e62204e9a0e5ae49ec579db5d5c8e99ca6de57596acf577ee2027d8410e23a5013f15
7
- data.tar.gz: 1a559aff5de117890928f67a5e0adefe1f2615c24f15ac6357dff0c74bd3f9a9470110618228a30aaba35ae489844cca4efd1f701aba0f1805c18b5c71edd7a9
6
+ metadata.gz: 600b1572f324b8cbbaf2b5dd48108546db86e63e3c7d49a04e1eb9f0f2e83720eafdaabb94ccf0550ff1c0e63b3ec555950443eb5c064698dbc65cf97c2b1733
7
+ data.tar.gz: e9229f25cd10dcc54f1f7c5b83ff81b997d96263296818caaf2cfc245fde93ab24d7f0605700ea042c085ab7456f5fc1b20c2cc643324609bc0f4662ecbdcd21
data/.rubocop.yml CHANGED
@@ -42,8 +42,11 @@ Metrics/BlockLength:
42
42
  Metrics/ModuleLength:
43
43
  Enabled: false
44
44
 
45
+ Metrics/ClassLength:
46
+ Max: 110
47
+
45
48
  Metrics/MethodLength:
46
- Max: 60
49
+ Max: 70
47
50
 
48
51
  Metrics/PerceivedComplexity:
49
52
  Max: 16
data/CHANGELOG.md CHANGED
@@ -3,6 +3,19 @@
3
3
  ## Unreleased
4
4
  * N/A
5
5
 
6
+ ## 0.1.0-alpha.2.6.1
7
+ * [FEAT] Added `elapsed_time` and `outcome` methods to `Action::Result`
8
+ * `elapsed_time` returns execution time in milliseconds (Float)
9
+ * `outcome` returns execution outcome as symbol (`:success`, `:failure`, or `:exception`)
10
+ * [BREAKING] `emit_metrics` hook now receives the full `Action::Result` object instead of just the outcome
11
+ * Provides access to both outcome and elapsed time for richer metrics
12
+ * Example: `proc { |resource, result| TS::Metrics.histogram("action.duration", result.elapsed_time) }`
13
+ * [BREAKING] Replaced `Action.config.default_log_level` and `default_autolog_level` with simpler `log_level`
14
+ * [BREAKING] `autolog_level` method overrides with e.g. `auto_log :warn` or `auto_log false`
15
+ * [BREAKING] Direct access to exposed fields in callables no longer works -- `foo` becomes `result.foo`
16
+ * [BREAKING] Removed `success?` check on Action::Result (use `ok?` instead)
17
+ * [FEAT] Added callback and strategy support to Axn::Factory.build
18
+
6
19
  ## 0.1.0-alpha.2.6
7
20
  * Inline interactor code (no more dependency on unpublished forked branch to support inheritance)
8
21
  * Refactor internals to clean implementation now that we have direct control
@@ -9,6 +9,8 @@ Every `call` invocation on an Action will return an `Action::Result` instance, w
9
9
  | `success` | User-facing success message (string), if `ok?` (else nil)
10
10
  | `message` | User-facing message (string), always defined (`ok? ? success : error`)
11
11
  | `exception` | If not `ok?` because an exception was swallowed, will be set to the swallowed exception (note: rarely used outside development; prefer to let the library automatically handle exception handling for you)
12
+ | `outcome` | The execution outcome as a symbol (`:success`, `:failure`, or `:exception`)
13
+ | `elapsed_time` | Execution time in milliseconds (Float)
12
14
  | any `expose`d values | guaranteed to be set if `ok?` (since they have outgoing presence validations by default; any missing would have failed the action)
13
15
 
14
16
  NOTE: `success` and `error` (and so implicitly `message`) can be configured per-action via [the `messages` declaration](/reference/class#messages).
@@ -89,10 +89,16 @@ will succeed if given _either_ an actual Date object _or_ a string that Date.par
89
89
 
90
90
  The `messages` declaration allows you to customize the `error` and `success` messages on the returned result.
91
91
 
92
- Accepts `error` and/or `success` keys. Values can be a string (returned directly) or a callable (evaluated in the action's context, so can access instance methods). If `error` is provided with a callable that expects a positional argument, the exception that was raised will be passed in as that value.
92
+ Accepts `error` and/or `success` keys. Values can be a string (returned directly) or a callable (evaluated in the action's context, so can access instance methods and variables). If `error` is provided with a callable that expects a positional argument, the exception that was raised will be passed in as that value.
93
+
94
+ In callables, you can access:
95
+ - **Input data**: Use field names directly (e.g., `name`)
96
+ - **Output data**: Use `result.field` pattern (e.g., `result.greeting`)
97
+ - **Instance methods and variables**: Direct access
93
98
 
94
99
  ```ruby
95
- messages success: "All good!", error: ->(e) { "Bad news: #{e.message}" }
100
+ messages success: -> { "Hello #{name}, your greeting: #{result.greeting}" },
101
+ error: ->(e) { "Bad news: #{e.message}" }
96
102
  ```
97
103
 
98
104
  ## `error_from` and `rescues`
@@ -101,15 +107,17 @@ While `.messages` sets the _default_ error/success messages and is more commonly
101
107
 
102
108
  `error_from` and `rescues` both register a matcher (exception class, exception class name (string), or callable) and a message to use if the matcher succeeds. They act exactly the same, except if a matcher registered with `rescues` succeeds, the exception _will not_ trigger the configured exception handlers (global or specific to this class).
103
109
 
110
+ Callable matchers and messages follow the same data access patterns as other callables: input fields directly, output fields via `result.field`, instance variables, and methods.
111
+
104
112
  ```ruby
105
113
  messages error: "bad"
106
114
 
107
115
  # Note this will NOT trigger Action.config.on_exception
108
116
  rescues ActiveRecord::InvalidRecord => "Invalid params provided"
109
117
 
110
- # These WILL trigger error handler (second demonstrates callable matcher AND message)
118
+ # These WILL trigger error handler (callable matcher + message with data access)
111
119
  error_from ArgumentError, ->(e) { "Argument error: #{e.message}" }
112
- error_from -> { name == "bad" }, -> { "was given bad name: #{name}" }
120
+ error_from -> { name == "bad" }, -> { "Bad input #{name}, result: #{result.status}" }
113
121
  ```
114
122
 
115
123
  ## Callbacks
@@ -4,18 +4,16 @@ Somewhere at boot (e.g. `config/initializers/actions.rb` in Rails), you can call
4
4
 
5
5
 
6
6
  ```ruby
7
- Action.configure do |c|
8
- c.on_exception = ...
9
-
10
- c.top_level_around_hook = ...
11
-
12
- c.additional_includes = []
13
-
14
- c.default_log_level = :info
15
- c.default_autolog_level = :debug
16
-
17
- c.logger = ...
7
+ Action.configure do |c|
8
+ c.log_level = :info
9
+ c.logger = ...
10
+ c.on_exception = proc do |e, action:, context:|
11
+ message = "[#{action.class.name}] Failing due to #{e.class.name}: #{e.message}"
12
+
13
+ Rails.logger.warn(message)
14
+ Honeybadger.notify(message, context: { axn_context: context })
18
15
  end
16
+ end
19
17
  ```
20
18
 
21
19
  ## `on_exception`
@@ -68,7 +66,7 @@ If you're using an APM provider, observability can be greatly enhanced by adding
68
66
  The framework provides two distinct hooks for observability:
69
67
 
70
68
  - **`wrap_with_trace`**: An around hook that wraps the entire action execution. You MUST call the provided block to execute the action.
71
- - **`emit_metrics`**: A post-execution hook that receives the action outcome. Do NOT call any blocks.
69
+ - **`emit_metrics`**: A post-execution hook that receives the action result. Do NOT call any blocks.
72
70
 
73
71
  For example, to wire up Datadog:
74
72
 
@@ -80,8 +78,9 @@ For example, to wire up Datadog:
80
78
  end
81
79
  end
82
80
 
83
- c.emit_metrics = proc do |resource, outcome|
84
- TS::Metrics.increment("action.#{resource.underscore}", tags: { outcome:, resource: })
81
+ c.emit_metrics = proc do |resource, result|
82
+ TS::Metrics.increment("action.#{resource.underscore}", tags: { outcome: result.outcome, resource: })
83
+ TS::Metrics.histogram("action.duration", result.elapsed_time, tags: { resource: })
85
84
  end
86
85
  end
87
86
  ```
@@ -89,9 +88,9 @@ For example, to wire up Datadog:
89
88
  A couple notes:
90
89
 
91
90
  * `Datadog::Tracing` is provided by [the datadog gem](https://rubygems.org/gems/datadog)
92
- * `TS::Metrics` is a custom implementation to set a Datadog count metric, but the relevant part to note is that outcome (`success`, `failure`, `exception`) of the action is reported so you can easily track e.g. success rates per action.
91
+ * `TS::Metrics` is a custom implementation to set a Datadog count metric, but the relevant part to note is that the result object provides access to the outcome (`success`, `failure`, `exception`) and elapsed time of the action.
93
92
  * The `wrap_with_trace` hook is an around hook - you must call the provided block to execute the action
94
- * The `emit_metrics` hook is called after execution with the outcome - do not call any blocks
93
+ * The `emit_metrics` hook is called after execution with the result - do not call any blocks
95
94
 
96
95
 
97
96
  ## `logger`
@@ -112,11 +111,11 @@ For example:
112
111
 
113
112
  For a practical example of this in practice, see [our 'memoization' recipe](/recipes/memoization).
114
113
 
115
- ## `default_log_level`
114
+ ## `log_level`
116
115
 
117
- Sets the log level used when you call `log "Some message"` in your Action. Note this is read via a `default_log_level` class method, so you can easily use inheritance to support different log levels for different sets of actions.
116
+ Sets the log level used when you call `log "Some message"` in your Action. Note this is read via a `log_level` class method, so you can easily use inheritance to support different log levels for different sets of actions.
118
117
 
119
- ## `default_autolog_level`
118
+ ## Automatic Logging
120
119
 
121
120
  By default, every `action.call` will emit log lines when it is called and after it completes:
122
121
 
@@ -125,4 +124,27 @@ By default, every `action.call` will emit log lines when it is called and after
125
124
  [YourCustomAction] Execution completed (with outcome: success) in 0.957 milliseconds
126
125
  ```
127
126
 
128
- You can change the default _auto_-log level separately from the log level used for your explicit `log` calls (just like above, via Action.config or a `default_autolog_level` class method).
127
+ Automatic logging will log at `Action.config.log_level` by default, but can be overridden or disabled using the declarative `auto_log` method:
128
+
129
+ ```ruby
130
+ # Set default for all actions (affects both explicit logging and automatic logging)
131
+ Action.configure do |c|
132
+ c.log_level = :debug
133
+ end
134
+
135
+ # Override for specific actions
136
+ class MyAction
137
+ auto_log :warn # Use warn level for this action
138
+ end
139
+
140
+ class SilentAction
141
+ auto_log false # Disable automatic logging for this action
142
+ end
143
+
144
+ # Use default level (no auto_log call needed)
145
+ class DefaultAction
146
+ # Uses Action.config.log_level
147
+ end
148
+ ```
149
+
150
+ The `auto_log` method supports inheritance, so subclasses will inherit the setting from their parent class unless explicitly overridden.
@@ -79,7 +79,7 @@ class Foo
79
79
  expects :name, type: String
80
80
  exposes :meaning_of_life
81
81
 
82
- messages success: -> { "Revealed the secret of life to #{name}" }, # [!code focus:2]
82
+ messages success: -> { "Revealed to #{name}: #{result.meaning_of_life}" }, # [!code focus:2]
83
83
  error: ->(e) { "No secret of life for you: #{e.message}" }
84
84
 
85
85
  def call
@@ -38,10 +38,25 @@ module Action
38
38
  step = Entry.new(label: "Step #{idx + 1}", axn: step) if step.is_a?(Class)
39
39
 
40
40
  hoist_errors(prefix: "#{step.label} step") do
41
- step.axn.call(@context)
41
+ step.axn.call(**merged_context_data).tap do |step_result|
42
+ merge_step_exposures!(step_result)
43
+ end
42
44
  end
43
45
  end
44
46
  end
47
+
48
+ private
49
+
50
+ def merged_context_data
51
+ @__context.__combined_data
52
+ end
53
+
54
+ # Each step can expect the data exposed from the previous steps
55
+ def merge_step_exposures!(step_result)
56
+ step_result.declared_fields.each do |field|
57
+ @__context.exposed_data[field] = step_result.public_send(field)
58
+ end
59
+ end
45
60
  end
46
61
  end
47
62
  end
@@ -3,10 +3,9 @@
3
3
  module Action
4
4
  class Configuration
5
5
  attr_accessor :wrap_with_trace, :emit_metrics
6
- attr_writer :logger, :env, :on_exception, :additional_includes, :default_log_level, :default_autolog_level
6
+ attr_writer :logger, :env, :on_exception, :additional_includes, :log_level
7
7
 
8
- def default_log_level = @default_log_level ||= :info
9
- def default_autolog_level = @default_autolog_level ||= :info
8
+ def log_level = @log_level ||= :info
10
9
 
11
10
  def additional_includes = @additional_includes ||= []
12
11
 
@@ -1,28 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # NOTE: This is a temporary file to be removed when we have a better way to handle context.
4
- # rubocop:disable Style/OpenStructUse, Style/CaseEquality
5
- require "ostruct"
6
-
7
3
  module Action
8
- class Context < OpenStruct
9
- def self.build(context = {})
10
- self === context ? context : new(context)
11
- end
4
+ class Context
5
+ attr_accessor :provided_data, :exposed_data
12
6
 
13
- def success?
14
- !failure?
15
- end
7
+ def initialize(**provided_data)
8
+ @provided_data = provided_data
9
+ @exposed_data = {}
16
10
 
17
- def failure?
18
- @failure || false
11
+ # Framework-managed fields
12
+ @failure = false
13
+ @exception = nil
14
+ @error_from_user = nil
15
+ @error_prefix = nil
16
+ @elapsed_time = nil
19
17
  end
20
18
 
21
- def fail!(context = {})
22
- context.each { |key, value| self[key.to_sym] = value }
23
- @failure = true
24
- raise Action::Failure, self
19
+ def fail!(message = nil)
20
+ @error_from_user = message if message.present?
21
+ raise Action::Failure, message
25
22
  end
23
+
24
+ # INTERNAL: base for further filtering (for logging) or providing user with usage hints
25
+ def __combined_data = @provided_data.merge(@exposed_data)
26
+
27
+ # Framework state methods
28
+ def ok? = !@failure
29
+ def failed? = @failure || false
30
+
31
+ # Framework field accessors
32
+ attr_accessor :exception, :error_from_user, :error_prefix, :elapsed_time
33
+
34
+ # Internal failure state setter (for framework use)
35
+ attr_writer :failure
36
+ private :failure=
26
37
  end
27
38
  end
28
- # rubocop:enable Style/OpenStructUse, Style/CaseEquality
@@ -7,19 +7,34 @@ module Action
7
7
  base.class_eval do
8
8
  extend ClassMethods
9
9
  include InstanceMethods
10
+
11
+ # Single class_attribute - nil means disabled, any level means enabled
12
+ class_attribute :auto_log_level, default: Action.config.log_level
10
13
  end
11
14
  end
12
15
 
13
16
  module ClassMethods
14
- def autolog_level = Action.config.default_autolog_level
17
+ def auto_log(level)
18
+ self.auto_log_level = level.presence
19
+ end
15
20
  end
16
21
 
17
22
  module InstanceMethods
18
23
  private
19
24
 
25
+ def _with_logging
26
+ _log_before if self.class.auto_log_level
27
+ yield
28
+ ensure
29
+ _log_after if self.class.auto_log_level
30
+ end
31
+
20
32
  def _log_before
21
- public_send(
22
- self.class.autolog_level,
33
+ level = self.class.auto_log_level
34
+ return unless level
35
+
36
+ self.class.public_send(
37
+ level,
23
38
  [
24
39
  "About to execute",
25
40
  _log_context(:inbound),
@@ -30,13 +45,14 @@ module Action
30
45
  Axn::Util.piping_error("logging before hook", action: self, exception: e)
31
46
  end
32
47
 
33
- def _log_after(outcome:, timing_start:)
34
- elapsed_mils = Core::Timing.elapsed_ms(timing_start)
48
+ def _log_after
49
+ level = self.class.auto_log_level
50
+ return unless level
35
51
 
36
- public_send(
37
- self.class.autolog_level,
52
+ self.class.public_send(
53
+ level,
38
54
  [
39
- "Execution completed (with outcome: #{outcome}) in #{elapsed_mils} milliseconds",
55
+ "Execution completed (with outcome: #{result.outcome}) in #{result.elapsed_time} milliseconds",
40
56
  _log_context(:outbound),
41
57
  ].compact.join(". Set: "),
42
58
  after: Action.config.env.production? ? nil : "\n------\n",
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/parameter_filter"
4
+
5
+ module Action
6
+ class ContextFacade
7
+ def initialize(action:, context:, declared_fields:, implicitly_allowed_fields: nil)
8
+ if self.class.name == "Action::ContextFacade" # rubocop:disable Style/ClassEqualityComparison
9
+ raise "Action::ContextFacade is an abstract class and should not be instantiated directly"
10
+ end
11
+
12
+ @context = context
13
+ @action = action
14
+ @declared_fields = declared_fields
15
+
16
+ (@declared_fields + Array(implicitly_allowed_fields)).each do |field|
17
+ singleton_class.define_method(field) do
18
+ context_data_source[field]
19
+ end
20
+ end
21
+ end
22
+
23
+ attr_reader :declared_fields
24
+
25
+ def inspect = ContextFacadeInspector.new(facade: self, action:, context:).call
26
+
27
+ def fail!(...)
28
+ raise Action::ContractViolation::MethodNotAllowed, "Call fail! directly rather than on the context"
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :action, :context
34
+
35
+ def action_name = @action.class.name.presence || "The action"
36
+
37
+ def context_data_source = raise NotImplementedError
38
+
39
+ def determine_error_message(only_default: false)
40
+ return @context.error_from_user if @context.error_from_user.present?
41
+
42
+ # We need an exception for interceptors, and also in case the messages.error callable expects an argument
43
+ exception = @context.exception || Action::Failure.new
44
+
45
+ msg = action._error_msg
46
+
47
+ unless only_default
48
+ interceptor = action.class._error_interceptor_for(exception:, action:)
49
+ msg = interceptor.message if interceptor
50
+ end
51
+
52
+ stringified(msg, exception:).presence || "Something went wrong"
53
+ end
54
+
55
+ # Allow for callable OR string messages
56
+ def stringified(msg, exception: nil)
57
+ return msg.presence unless msg.respond_to?(:call)
58
+
59
+ # The error message callable can take the exception as an argument
60
+ if exception && msg.arity == 1
61
+ action.instance_exec(exception, &msg)
62
+ else
63
+ action.instance_exec(&msg)
64
+ end
65
+ rescue StandardError => e
66
+ Axn::Util.piping_error("determining message callable", action:, exception: e)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ class ContextFacadeInspector
5
+ def initialize(action:, facade:, context:)
6
+ @action = action
7
+ @facade = facade
8
+ @context = context
9
+ end
10
+
11
+ def call
12
+ str = [status, visible_fields].compact_blank.join(" ")
13
+
14
+ "#<#{class_name} #{str}>"
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :action, :facade, :context
20
+
21
+ def status
22
+ return unless facade.is_a?(Action::Result)
23
+
24
+ return "[OK]" if context.ok?
25
+ unless context.exception
26
+ return context.error_from_user.present? ? "[failed with '#{context.error_from_user}']" : "[failed]"
27
+ end
28
+
29
+ %([failed with #{context.exception.class.name}: '#{context.exception.message}'])
30
+ end
31
+
32
+ def visible_fields
33
+ declared_fields.map do |field|
34
+ value = facade.public_send(field)
35
+
36
+ "#{field}: #{format_for_inspect(field, value)}"
37
+ end.join(", ")
38
+ end
39
+
40
+ def class_name = facade.class.name
41
+ def declared_fields = facade.send(:declared_fields)
42
+
43
+ def format_for_inspect(field, value)
44
+ return value.inspect if value.nil?
45
+
46
+ # Initially based on https://github.com/rails/rails/blob/800976975253be2912d09a80757ee70a2bb1e984/activerecord/lib/active_record/attribute_methods.rb#L527
47
+ inspected_value = if value.is_a?(String) && value.length > 50
48
+ "#{value[0, 50]}...".inspect
49
+ elsif value.is_a?(Date) || value.is_a?(Time)
50
+ %("#{value.to_fs(:inspect)}")
51
+ elsif defined?(::ActiveRecord::Relation) && value.instance_of?(::ActiveRecord::Relation)
52
+ # Avoid hydrating full AR relation (i.e. avoid loading records just to report an error)
53
+ "#{value.name}::ActiveRecord_Relation"
54
+ else
55
+ value.inspect
56
+ end
57
+
58
+ inspection_filter.filter_param(field, inspected_value)
59
+ end
60
+
61
+ def inspection_filter = action.send(:inspection_filter)
62
+ end
63
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action/core/context/facade"
4
+
5
+ module Action
6
+ # Inbound / Internal ContextFacade
7
+ class InternalContext < ContextFacade
8
+ # So can be referenced from within e.g. rescues callables
9
+ def default_error
10
+ [@context.error_prefix, determine_error_message(only_default: true)].compact.join(" ").squeeze(" ")
11
+ end
12
+
13
+ private
14
+
15
+ def context_data_source = @context.provided_data
16
+
17
+ def method_missing(method_name, ...) # rubocop:disable Style/MissingRespondToMissing (because we're not actually responding to anything additional)
18
+ if @context.__combined_data.key?(method_name.to_sym)
19
+ msg = <<~MSG
20
+ Method ##{method_name} is not available on Action::InternalContext!
21
+
22
+ #{action_name} may be missing a line like:
23
+ expects :#{method_name}
24
+ MSG
25
+
26
+ raise Action::ContractViolation::MethodNotAllowed, msg
27
+ end
28
+
29
+ super
30
+ end
31
+ end
32
+ end
@@ -4,7 +4,8 @@ require "active_support/core_ext/enumerable"
4
4
  require "active_support/core_ext/module/delegation"
5
5
 
6
6
  require "action/core/validation/fields"
7
- require "action/core/context_facade"
7
+ require "action/result"
8
+ require "action/core/context/internal"
8
9
 
9
10
  module Action
10
11
  module Core
@@ -70,13 +71,13 @@ module Action
70
71
  private
71
72
 
72
73
  RESERVED_FIELD_NAMES_FOR_EXPECTATIONS = %w[
73
- fail! success? ok?
74
+ fail! ok?
74
75
  inspect default_error
75
76
  each_pair
76
77
  ].freeze
77
78
 
78
79
  RESERVED_FIELD_NAMES_FOR_EXPOSURES = %w[
79
- fail! success? ok?
80
+ fail! ok?
80
81
  inspect each_pair default_error
81
82
  ok error success message
82
83
  ].freeze
@@ -114,13 +115,15 @@ module Action
114
115
  define_method(field) { internal_context.public_send(field) }
115
116
  end
116
117
 
117
- def _define_model_reader(field, klass)
118
+ def _define_model_reader(field, klass, &id_extractor)
118
119
  name = field.to_s.delete_suffix("_id")
119
120
  raise ArgumentError, "Model validation expects to be given a field ending in _id (given: #{field})" unless field.to_s.end_with?("_id")
120
121
  raise ArgumentError, "Failed to define model reader - #{name} is already defined" if method_defined?(name)
121
122
 
123
+ id_extractor ||= -> { public_send(field) }
124
+
122
125
  define_memoized_reader_method(name) do
123
- Validators::ModelValidator.instance_for(field:, klass:, id: public_send(field))
126
+ Validators::ModelValidator.instance_for(field:, klass:, id: instance_exec(&id_extractor))
124
127
  end
125
128
  end
126
129
 
@@ -166,23 +169,37 @@ module Action
166
169
  kwargs.each do |key, value|
167
170
  raise Action::ContractViolation::UnknownExposure, key unless result.respond_to?(key)
168
171
 
169
- @context.public_send("#{key}=", value)
172
+ @__context.exposed_data[key] = value
170
173
  end
171
174
  end
172
175
 
173
176
  def context_for_logging(direction = nil)
174
- inspection_filter.filter(@context.to_h.slice(*_declared_fields(direction)))
177
+ inspection_filter.filter(@__context.__combined_data.slice(*_declared_fields(direction)))
175
178
  end
176
179
 
177
180
  private
178
181
 
182
+ def _with_contract
183
+ _apply_inbound_preprocessing!
184
+ _apply_defaults!(:inbound)
185
+ _validate_contract!(:inbound)
186
+
187
+ yield
188
+
189
+ _apply_defaults!(:outbound)
190
+ _validate_contract!(:outbound)
191
+
192
+ # TODO: improve location of this triggering
193
+ _trigger_on_success if respond_to?(:_trigger_on_success)
194
+ end
195
+
179
196
  def _build_context_facade(direction)
180
197
  raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
181
198
 
182
199
  klass = direction == :inbound ? Action::InternalContext : Action::Result
183
200
  implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
184
201
 
185
- klass.new(action: self, context: @context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
202
+ klass.new(action: self, context: @__context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
186
203
  end
187
204
 
188
205
  def inspection_filter
@@ -86,7 +86,7 @@ module Action
86
86
  Action::Validation::Subfields.extract(field, public_send(on))
87
87
  end
88
88
 
89
- _define_model_reader(field, validations[:model]) if validations.key?(:model)
89
+ _define_model_reader(field, validations[:model]) { Action::Validation::Subfields.extract(field, public_send(on)) } if validations.key?(:model)
90
90
  end
91
91
  end
92
92