axn 0.1.0.pre.alpha.2 → 0.1.0.pre.alpha.2.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +9 -1
- data/docs/recipes/testing.md +30 -1
- data/docs/reference/class.md +3 -2
- data/docs/usage/writing.md +5 -2
- data/lib/action/attachable/base.rb +1 -1
- data/lib/action/attachable/subactions.rb +28 -1
- data/lib/action/core/context_facade.rb +14 -4
- data/lib/action/core/contract.rb +11 -8
- data/lib/action/core/contract_validator.rb +9 -11
- data/lib/action/core/top_level_around_hook.rb +12 -1
- data/lib/axn/factory.rb +27 -13
- data/lib/axn/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dcfb310e364c4fb2c7159a6bdc1ff6766c0025b3b0741a62316d3e18cb5f3bf7
|
4
|
+
data.tar.gz: 7af1c3d88a69c0d109983c85fb6dc1b5ddf162568ee1e1aa516f15bf1d12a1d2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 13868e68f4a7b9a45bebbcf793bba1855ec2fa524ccd6f24fdaf1364bb143fcd4cfd862f0906d6455d10e61492b723ffbdc2c836a887052b030d509bf18171b0
|
7
|
+
data.tar.gz: 53014437d7a3972ec584195b2c20955ff9f2c1138dba3eeb35df2e82db8d62ec3b3561ec6b1d870472fc7fcea79dd1c2abe759cf341c46e024ae143d266bfa64
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,8 +1,16 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
3
|
## UNRELEASED
|
4
|
-
* N/A
|
5
4
|
|
5
|
+
## 0.1.0-alpha.2.2
|
6
|
+
* Expands `Action::Result.ok` and `Action::Result.error` to better support mocking in specs
|
7
|
+
|
8
|
+
## 0.1.0-alpha.2.1
|
9
|
+
* Expects/Exposes: Add `allow_nil` option
|
10
|
+
* Expects/Exposes: Replace `boolean: true` with `type: :boolean`
|
11
|
+
* Truncate default debug line at 100 chars
|
12
|
+
* Support complex Axn::Factory configurations
|
13
|
+
* Auto-generate self.class.name for attachables
|
6
14
|
|
7
15
|
## 0.1.0-alpha.2
|
8
16
|
* Renamed `.rescues` to `.error_from`
|
data/docs/recipes/testing.md
CHANGED
@@ -4,7 +4,36 @@
|
|
4
4
|
* TODO: document testing patterns
|
5
5
|
:::
|
6
6
|
|
7
|
-
|
7
|
+
## Mocking Axn calls
|
8
|
+
|
9
|
+
Say you're writing unit specs for PrimaryAction that calls Subaction, and you want to mock out the Subaction call.
|
10
|
+
|
11
|
+
To generate a successful Action::Result:
|
12
|
+
|
13
|
+
* Base case: `Action::Result.ok`
|
14
|
+
* [Optional] Custom message: `Action::Result.ok("It went awesome")`
|
15
|
+
* [Optional] Custom exposures: `Action::Result.ok("It went awesome", some_var: 123)`
|
16
|
+
|
17
|
+
To generate a failed Action::Result:
|
18
|
+
|
19
|
+
* Base case: `Action::Result.error`
|
20
|
+
* [Optional] Custom message: `Action::Result.error("It went poorly")`
|
21
|
+
* [Optional] Custom exposures: `Action::Result.error("It went poorly", some_var: 123)`
|
22
|
+
* [Optional] Custom exception: `Action::Result.error(some_var: 123) { raise FooBarException.new("bad thing") }`
|
23
|
+
|
24
|
+
Either way, using those to mock an actual call would look something like this in your rspec:
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
let(:subaction_response) { Action::Result.ok("custom message", foo: 1) }
|
28
|
+
|
29
|
+
before do
|
30
|
+
expect(Subaction).to receive(:call).and_return(subaction_response)
|
31
|
+
end
|
32
|
+
```
|
33
|
+
|
34
|
+
## RSpec configuration
|
35
|
+
|
36
|
+
Configuring rspec to treat files in spec/actions as service specs (very optional):
|
8
37
|
|
9
38
|
```ruby
|
10
39
|
RSpec.configure do |config|
|
data/docs/reference/class.md
CHANGED
@@ -10,6 +10,7 @@ Both `expects` and `exposes` support the same core options:
|
|
10
10
|
| -- | -- | -- |
|
11
11
|
| `sensitive` | `expects :password, sensitive: true` | Filters the field's value when logging, reporting errors, or calling `inspect`
|
12
12
|
| `default` | `expects :foo, default: 123` | If `foo` isn't explicitly set, it'll default to this value
|
13
|
+
| `allow_nil` | `expects :foo, allow_nil: true` | Don't fail if the value is `nil`
|
13
14
|
| `allow_blank` | `expects :foo, allow_blank: true` | Don't fail if the value is blank
|
14
15
|
| `type` | `expects :foo, type: String` | Custom type validation -- fail unless `name.is_a?(String)`
|
15
16
|
| anything else | `expects :foo, inclusion: { in: [:apple, :peach] }` | Any other arguments will be processed [as ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html) (i.e. as if passed to `validates :foo, <...>` on an ActiveRecord model)
|
@@ -21,9 +22,9 @@ Both `expects` and `exposes` support the same core options:
|
|
21
22
|
While we _support_ complex interface validations, in practice you usually just want a `type`, if anything. Remember this is your validation about how the action is called, _not_ pretty user-facing errors (there's [a different pattern for that](/recipes/validating-user-input)).
|
22
23
|
:::
|
23
24
|
|
24
|
-
In addition to the [standard ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html), we also support
|
25
|
+
In addition to the [standard ActiveModel validations](https://guides.rubyonrails.org/active_record_validations.html), we also support two additional custom validators:
|
25
26
|
* `type: Foo` - fails unless the provided value `.is_a?(Foo)`
|
26
|
-
* `
|
27
|
+
* Edge case: use `type: :boolean` to handle a boolean field (since ruby doesn't have a Boolean class to pass in directly)
|
27
28
|
* `validate: [callable]` - Support custom validations (fails if any string is returned OR if it raises an exception)
|
28
29
|
* Example:
|
29
30
|
```ruby
|
data/docs/usage/writing.md
CHANGED
@@ -22,7 +22,7 @@ The first step is to determine what arguments you expect to be passed into `call
|
|
22
22
|
|
23
23
|
If you want to expose any results to the caller, declare that via the `exposes` keyword.
|
24
24
|
|
25
|
-
Both of these optionally accept `type:`, `allow_blank:`, and any other ActiveModel validation (see: [reference](/reference/class)).
|
25
|
+
Both of these optionally accept `type:`, `allow_nil:`, `allow_blank:`, and any other ActiveModel validation (see: [reference](/reference/class)).
|
26
26
|
|
27
27
|
|
28
28
|
```ruby
|
@@ -116,6 +116,8 @@ If you define a `#rollback` method, it'll be called (_before_ returning an `Acti
|
|
116
116
|
|
117
117
|
`before` and `after` hooks are also supported. They can receive a block directly, or the symbol name of a local method.
|
118
118
|
|
119
|
+
Note execution is halted whenever `fail!` is called or an exception is raised (so a `before` block failure won't execute `call` or `after`, while an `after` block failure will make `resuilt.ok?` be false even though `call` completed successfully).
|
120
|
+
|
119
121
|
### Concrete example
|
120
122
|
|
121
123
|
Given this series of methods and hooks:
|
@@ -129,7 +131,6 @@ class Foo
|
|
129
131
|
|
130
132
|
def call
|
131
133
|
log("in call")
|
132
|
-
raise "oh no something borked"
|
133
134
|
end
|
134
135
|
|
135
136
|
def rollback
|
@@ -140,6 +141,8 @@ class Foo
|
|
140
141
|
|
141
142
|
def log_after
|
142
143
|
log("after hook")
|
144
|
+
raise "oh no something borked"
|
145
|
+
log("after after hook raised")
|
143
146
|
end
|
144
147
|
end
|
145
148
|
```
|
@@ -5,11 +5,18 @@ module Action
|
|
5
5
|
module Subactions
|
6
6
|
extend ActiveSupport::Concern
|
7
7
|
|
8
|
+
included do
|
9
|
+
class_attribute :_axnable_methods, default: {}
|
10
|
+
class_attribute :_axns, default: {}
|
11
|
+
end
|
12
|
+
|
8
13
|
class_methods do
|
9
14
|
def axnable_method(name, axn_klass = nil, **action_kwargs, &block)
|
10
15
|
raise ArgumentError, "Unable to attach Axn -- '#{name}' is already taken" if respond_to?(name)
|
11
16
|
|
12
|
-
action_kwargs
|
17
|
+
self._axnable_methods = _axnable_methods.merge(name => { axn_klass:, action_kwargs:, block: })
|
18
|
+
|
19
|
+
action_kwargs[:expose_return_as] ||= :value unless axn_klass
|
13
20
|
axn_klass = axn_for_attachment(name:, axn_klass:, **action_kwargs, &block)
|
14
21
|
|
15
22
|
define_singleton_method("#{name}_axn") do |**kwargs|
|
@@ -25,6 +32,8 @@ module Action
|
|
25
32
|
def axn(name, axn_klass = nil, **action_kwargs, &block)
|
26
33
|
raise ArgumentError, "Unable to attach Axn -- '#{name}' is already taken" if respond_to?(name)
|
27
34
|
|
35
|
+
self._axns = _axns.merge(name => { axn_klass:, action_kwargs:, block: })
|
36
|
+
|
28
37
|
axn_klass = axn_for_attachment(name:, axn_klass:, **action_kwargs, &block)
|
29
38
|
|
30
39
|
define_singleton_method(name) do |**kwargs|
|
@@ -36,6 +45,24 @@ module Action
|
|
36
45
|
define_singleton_method("#{name}!") do |**kwargs|
|
37
46
|
axn_klass.call!(**kwargs)
|
38
47
|
end
|
48
|
+
|
49
|
+
self._axns = _axns.merge(name => axn_klass)
|
50
|
+
end
|
51
|
+
|
52
|
+
def inherited(subclass)
|
53
|
+
super
|
54
|
+
|
55
|
+
return unless subclass.name.present? # TODO: not sure why..
|
56
|
+
|
57
|
+
# Need to redefine the axnable methods on the subclass to ensure they properly reference the subclass's
|
58
|
+
# helper method definitions and not the superclass's.
|
59
|
+
_axnable_methods.each do |name, config|
|
60
|
+
subclass.axnable_method(name, config[:axn_klass], **config[:action_kwargs], &config[:block])
|
61
|
+
end
|
62
|
+
|
63
|
+
_axns.each do |name, config|
|
64
|
+
subclass.axn(name, config[:axn_klass], **config[:action_kwargs], &config[:block])
|
65
|
+
end
|
39
66
|
end
|
40
67
|
end
|
41
68
|
end
|
@@ -94,11 +94,21 @@ module Action
|
|
94
94
|
class Result < ContextFacade
|
95
95
|
# For ease of mocking return results in tests
|
96
96
|
class << self
|
97
|
-
def ok =
|
97
|
+
def ok(msg = nil, **exposures)
|
98
|
+
Axn::Factory.build(exposes: exposures.keys, messages: { success: msg }) do
|
99
|
+
exposures.each do |key, value|
|
100
|
+
expose(key, value)
|
101
|
+
end
|
102
|
+
end.call
|
103
|
+
end
|
98
104
|
|
99
|
-
def error(msg =
|
100
|
-
|
101
|
-
|
105
|
+
def error(msg = nil, **exposures, &block)
|
106
|
+
Axn::Factory.build(exposes: exposures.keys, messages: { error: msg }) do
|
107
|
+
exposures.each do |key, value|
|
108
|
+
expose(key, value)
|
109
|
+
end
|
110
|
+
block.call if block_given?
|
111
|
+
fail!
|
102
112
|
end.call
|
103
113
|
end
|
104
114
|
end
|
data/lib/action/core/contract.rb
CHANGED
@@ -34,9 +34,9 @@ module Action
|
|
34
34
|
FieldConfig = Data.define(:field, :validations, :default, :preprocess, :sensitive)
|
35
35
|
|
36
36
|
module ClassMethods
|
37
|
-
def expects(*fields, allow_blank: false, default: nil, preprocess: nil, sensitive: false,
|
37
|
+
def expects(*fields, allow_blank: false, allow_nil: false, default: nil, preprocess: nil, sensitive: false,
|
38
38
|
**validations)
|
39
|
-
_parse_field_configs(*fields, allow_blank:, default:, preprocess:, sensitive:, **validations).tap do |configs|
|
39
|
+
_parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess:, sensitive:, **validations).tap do |configs|
|
40
40
|
duplicated = internal_field_configs.map(&:field) & configs.map(&:field)
|
41
41
|
raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
|
42
42
|
|
@@ -45,8 +45,8 @@ module Action
|
|
45
45
|
end
|
46
46
|
end
|
47
47
|
|
48
|
-
def exposes(*fields, allow_blank: false, default: nil, sensitive: false, **validations)
|
49
|
-
_parse_field_configs(*fields, allow_blank:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
|
48
|
+
def exposes(*fields, allow_blank: false, allow_nil: false, default: nil, sensitive: false, **validations)
|
49
|
+
_parse_field_configs(*fields, allow_blank:, allow_nil:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
|
50
50
|
duplicated = external_field_configs.map(&:field) & configs.map(&:field)
|
51
51
|
raise Action::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
|
52
52
|
|
@@ -63,7 +63,7 @@ module Action
|
|
63
63
|
called! rollback! each_pair success? exception ok ok? error success message
|
64
64
|
].freeze
|
65
65
|
|
66
|
-
def _parse_field_configs(*fields, allow_blank: false, default: nil, preprocess: nil, sensitive: false,
|
66
|
+
def _parse_field_configs(*fields, allow_nil: false, allow_blank: false, default: nil, preprocess: nil, sensitive: false,
|
67
67
|
**validations)
|
68
68
|
# Allow local access to explicitly-expected fields -- even externally-expected needs to be available locally
|
69
69
|
# (e.g. to allow success message callable to reference exposed fields)
|
@@ -78,10 +78,13 @@ module Action
|
|
78
78
|
v = { value: v } unless v.is_a?(Hash)
|
79
79
|
{ allow_blank: true }.merge(v)
|
80
80
|
end
|
81
|
-
elsif
|
82
|
-
validations
|
81
|
+
elsif allow_nil
|
82
|
+
validations.transform_values! do |v|
|
83
|
+
v = { value: v } unless v.is_a?(Hash)
|
84
|
+
{ allow_nil: true }.merge(v)
|
85
|
+
end
|
83
86
|
else
|
84
|
-
validations[:presence] = true unless validations.key?(:presence)
|
87
|
+
validations[:presence] = true unless validations.key?(:presence) || Array(validations[:type]).include?(:boolean)
|
85
88
|
end
|
86
89
|
|
87
90
|
fields.map { |field| FieldConfig.new(field:, validations:, default:, preprocess:, sensitive:) }
|
@@ -43,23 +43,21 @@ module Action
|
|
43
43
|
end
|
44
44
|
end
|
45
45
|
|
46
|
-
class BooleanValidator < ActiveModel::EachValidator
|
47
|
-
def validate_each(record, attribute, value)
|
48
|
-
return if [true, false].include?(value)
|
49
|
-
|
50
|
-
record.errors.add(attribute, "must be true or false")
|
51
|
-
end
|
52
|
-
end
|
53
|
-
|
54
46
|
class TypeValidator < ActiveModel::EachValidator
|
55
47
|
def validate_each(record, attribute, value)
|
56
|
-
return if value.blank? # Handled with a separate default presence validator
|
57
|
-
|
58
48
|
# TODO: the last one (:value) might be my fault from the make-it-a-hash fallback in #parse_field_configs
|
59
49
|
types = options[:in].presence || Array(options[:with]).presence || Array(options[:value]).presence
|
60
50
|
|
51
|
+
return if value.blank? && !types.include?(:boolean) # Handled with a separate default presence validator
|
52
|
+
|
61
53
|
msg = types.size == 1 ? "is not a #{types.first}" : "is not one of #{types.join(", ")}"
|
62
|
-
record.errors.add attribute, (options[:message] || msg) unless types.any?
|
54
|
+
record.errors.add attribute, (options[:message] || msg) unless types.any? do |type|
|
55
|
+
if type == :boolean
|
56
|
+
[true, false].include?(value)
|
57
|
+
else
|
58
|
+
value.is_a?(type)
|
59
|
+
end
|
60
|
+
end
|
63
61
|
end
|
64
62
|
end
|
65
63
|
end
|
@@ -44,10 +44,21 @@ module Action
|
|
44
44
|
|
45
45
|
debug [
|
46
46
|
"Execution completed (with outcome: #{outcome}) in #{elapsed_mils} milliseconds",
|
47
|
-
|
47
|
+
_log_after_data_peak,
|
48
48
|
].compact.join(". Set: ")
|
49
49
|
end
|
50
50
|
|
51
|
+
def _log_after_data_peak
|
52
|
+
return unless (data = context_for_logging(:outbound)).present?
|
53
|
+
|
54
|
+
max_length = 100
|
55
|
+
suffix = "...<truncated>...}"
|
56
|
+
|
57
|
+
data.inspect.tap do |str|
|
58
|
+
return str[0, max_length - suffix.length] + suffix if str.length > max_length
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
51
62
|
def _call_and_return_outcome(hooked)
|
52
63
|
hooked.call
|
53
64
|
|
data/lib/axn/factory.rb
CHANGED
@@ -6,12 +6,13 @@ module Axn
|
|
6
6
|
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/ParameterLists
|
7
7
|
def build(
|
8
8
|
# Builder-specific options
|
9
|
+
name: nil,
|
9
10
|
superclass: nil,
|
10
11
|
expose_return_as: :nil,
|
11
12
|
|
12
13
|
# Expose standard class-level options
|
13
|
-
exposes:
|
14
|
-
expects:
|
14
|
+
exposes: [],
|
15
|
+
expects: [],
|
15
16
|
messages: {},
|
16
17
|
before: nil,
|
17
18
|
after: nil,
|
@@ -21,7 +22,7 @@ module Axn
|
|
21
22
|
rollback: nil,
|
22
23
|
&block
|
23
24
|
)
|
24
|
-
args = block.parameters.each_with_object(_hash_with_default_array) { |(type,
|
25
|
+
args = block.parameters.each_with_object(_hash_with_default_array) { |(type, field), hash| hash[type] << field }
|
25
26
|
|
26
27
|
if args[:opt].present? || args[:req].present? || args[:rest].present?
|
27
28
|
raise ArgumentError,
|
@@ -38,32 +39,39 @@ module Axn
|
|
38
39
|
expects = _hydrate_hash(expects)
|
39
40
|
exposes = _hydrate_hash(exposes)
|
40
41
|
|
41
|
-
Array(args[:keyreq]).each do |
|
42
|
-
expects[
|
42
|
+
Array(args[:keyreq]).each do |field|
|
43
|
+
expects[field] ||= {}
|
43
44
|
end
|
44
45
|
|
45
46
|
# NOTE: inheriting from wrapping class, so we can set default values (e.g. for HTTP headers)
|
46
47
|
Class.new(superclass || Object) do
|
47
48
|
include Action unless self < Action
|
48
49
|
|
50
|
+
define_singleton_method(:name) do
|
51
|
+
[
|
52
|
+
superclass&.name.presence || "AnonymousAction",
|
53
|
+
name,
|
54
|
+
].compact.join("#")
|
55
|
+
end
|
56
|
+
|
49
57
|
define_method(:call) do
|
50
|
-
unwrapped_kwargs = Array(args[:keyreq]).each_with_object({}) do |
|
51
|
-
hash[
|
58
|
+
unwrapped_kwargs = Array(args[:keyreq]).each_with_object({}) do |field, hash|
|
59
|
+
hash[field] = public_send(field)
|
52
60
|
end
|
53
61
|
|
54
62
|
retval = instance_exec(**unwrapped_kwargs, &block)
|
55
63
|
expose(expose_return_as => retval) if expose_return_as.present?
|
56
64
|
end
|
57
65
|
end.tap do |axn| # rubocop: disable Style/MultilineBlockChain
|
58
|
-
expects.each do |
|
59
|
-
axn.expects(
|
66
|
+
expects.each do |field, opts|
|
67
|
+
axn.expects(field, **opts)
|
60
68
|
end
|
61
69
|
|
62
|
-
exposes.each do |
|
63
|
-
axn.exposes(
|
70
|
+
exposes.each do |field, opts|
|
71
|
+
axn.exposes(field, **opts)
|
64
72
|
end
|
65
73
|
|
66
|
-
axn.messages(**messages) if messages.present?
|
74
|
+
axn.messages(**messages) if messages.present? && messages.values.any?(&:present?)
|
67
75
|
|
68
76
|
# Hooks
|
69
77
|
axn.before(before) if before.present?
|
@@ -94,7 +102,13 @@ module Axn
|
|
94
102
|
return given if given.is_a?(Hash)
|
95
103
|
|
96
104
|
Array(given).each_with_object({}) do |key, acc|
|
97
|
-
|
105
|
+
if key.is_a?(Hash)
|
106
|
+
key.each_key do |k|
|
107
|
+
acc[k] = key[k]
|
108
|
+
end
|
109
|
+
else
|
110
|
+
acc[key] = {}
|
111
|
+
end
|
98
112
|
end
|
99
113
|
end
|
100
114
|
end
|
data/lib/axn/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: axn
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.0.pre.alpha.2
|
4
|
+
version: 0.1.0.pre.alpha.2.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Kali Donovan
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-05-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activemodel
|