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 +4 -4
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +13 -0
- data/docs/reference/action-result.md +2 -0
- data/docs/reference/class.md +12 -4
- data/docs/reference/configuration.md +42 -20
- data/docs/usage/writing.md +1 -1
- data/lib/action/attachable/steps.rb +16 -1
- data/lib/action/configuration.rb +2 -3
- data/lib/action/context.rb +28 -18
- data/lib/action/core/automatic_logging.rb +24 -8
- data/lib/action/core/context/facade.rb +69 -0
- data/lib/action/core/context/facade_inspector.rb +63 -0
- data/lib/action/core/context/internal.rb +32 -0
- data/lib/action/core/contract.rb +25 -8
- data/lib/action/core/contract_for_subfields.rb +1 -1
- data/lib/action/core/contract_validation.rb +15 -4
- data/lib/action/core/flow/callbacks.rb +54 -0
- data/lib/action/core/flow/exception_execution.rb +79 -0
- data/lib/action/core/flow/messages.rb +61 -0
- data/lib/action/core/flow.rb +19 -0
- data/lib/action/core/hoist_errors.rb +2 -2
- data/lib/action/core/hooks.rb +15 -15
- data/lib/action/core/logging.rb +2 -2
- data/lib/action/core/timing.rb +18 -0
- data/lib/action/core/tracing.rb +17 -0
- data/lib/action/core/validation/fields.rb +2 -0
- data/lib/action/core.rb +25 -78
- data/lib/action/enqueueable/via_sidekiq.rb +2 -2
- data/lib/action/result.rb +95 -0
- data/lib/axn/factory.rb +30 -0
- data/lib/axn/version.rb +1 -1
- metadata +11 -4
- data/lib/action/core/context_facade.rb +0 -209
- data/lib/action/core/handle_exceptions.rb +0 -143
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 30c00229f0062bdba5979d0e69de4ffddb6bece3f31413f83b7f7778d3075ef7
|
4
|
+
data.tar.gz: 92d944caf300ddfc9a134d34ae2cb73e6d235e0af9f7a10a3ee93065ae834690
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 600b1572f324b8cbbaf2b5dd48108546db86e63e3c7d49a04e1eb9f0f2e83720eafdaabb94ccf0550ff1c0e63b3ec555950443eb5c064698dbc65cf97c2b1733
|
7
|
+
data.tar.gz: e9229f25cd10dcc54f1f7c5b83ff81b997d96263296818caaf2cfc245fde93ab24d7f0605700ea042c085ab7456f5fc1b20c2cc643324609bc0f4662ecbdcd21
|
data/.rubocop.yml
CHANGED
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).
|
data/docs/reference/class.md
CHANGED
@@ -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:
|
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 (
|
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" }, -> { "
|
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
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
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
|
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,
|
84
|
-
TS::Metrics.increment("action.#{resource.underscore}", tags: { outcome
|
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`)
|
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
|
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
|
-
## `
|
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 `
|
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
|
-
##
|
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
|
-
|
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.
|
data/docs/usage/writing.md
CHANGED
@@ -79,7 +79,7 @@ class Foo
|
|
79
79
|
expects :name, type: String
|
80
80
|
exposes :meaning_of_life
|
81
81
|
|
82
|
-
messages success: -> { "Revealed
|
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(
|
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
|
data/lib/action/configuration.rb
CHANGED
@@ -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, :
|
6
|
+
attr_writer :logger, :env, :on_exception, :additional_includes, :log_level
|
7
7
|
|
8
|
-
def
|
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
|
|
data/lib/action/context.rb
CHANGED
@@ -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
|
9
|
-
|
10
|
-
self === context ? context : new(context)
|
11
|
-
end
|
4
|
+
class Context
|
5
|
+
attr_accessor :provided_data, :exposed_data
|
12
6
|
|
13
|
-
def
|
14
|
-
|
15
|
-
|
7
|
+
def initialize(**provided_data)
|
8
|
+
@provided_data = provided_data
|
9
|
+
@exposed_data = {}
|
16
10
|
|
17
|
-
|
18
|
-
@failure
|
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!(
|
22
|
-
|
23
|
-
|
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
|
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
|
-
|
22
|
-
|
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
|
34
|
-
|
48
|
+
def _log_after
|
49
|
+
level = self.class.auto_log_level
|
50
|
+
return unless level
|
35
51
|
|
36
|
-
public_send(
|
37
|
-
|
52
|
+
self.class.public_send(
|
53
|
+
level,
|
38
54
|
[
|
39
|
-
"Execution completed (with outcome: #{outcome}) in #{
|
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
|
data/lib/action/core/contract.rb
CHANGED
@@ -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/
|
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!
|
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!
|
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:
|
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
|
-
@
|
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(@
|
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: @
|
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
|
|