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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6ba4df4f3ce11e1a31eb8dfadde856141ed9dc6d10002efe1b544abcb580431
4
- data.tar.gz: 0a27844c8b7c341315c8a1d9212fa0745866086cd3fbb65140b813a125ac8496
3
+ metadata.gz: dcfb310e364c4fb2c7159a6bdc1ff6766c0025b3b0741a62316d3e18cb5f3bf7
4
+ data.tar.gz: 7af1c3d88a69c0d109983c85fb6dc1b5ddf162568ee1e1aa516f15bf1d12a1d2
5
5
  SHA512:
6
- metadata.gz: 250b751b9a583d7206c9ada4e162ce8d2ee96816466515812e187e3dcdeb50f7024a595968c4e42f3f91a63909a38074e59b738a4f0216a0aaf1f15154e99452
7
- data.tar.gz: cba3bee954fb153a99a65ff30478ae1d048fd49fe7834d4ba2446e4ff5e6ff70b3cfe588bc20a5d042665aa5bd9797cc4df10a4483cd59ce5b5d99b85bc4f645
6
+ metadata.gz: 13868e68f4a7b9a45bebbcf793bba1855ec2fa524ccd6f24fdaf1364bb143fcd4cfd862f0906d6455d10e61492b723ffbdc2c836a887052b030d509bf18171b0
7
+ data.tar.gz: 53014437d7a3972ec584195b2c20955ff9f2c1138dba3eeb35df2e82db8d62ec3b3561ec6b1d870472fc7fcea79dd1c2abe759cf341c46e024ae143d266bfa64
data/.rubocop.yml CHANGED
@@ -48,7 +48,7 @@ Naming/MethodParameterName:
48
48
  AllowedNames: e
49
49
 
50
50
  Metrics/ParameterLists:
51
- Max: 6
51
+ Max: 7
52
52
 
53
53
  Layout/LineLength:
54
54
  Max: 160
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`
@@ -4,7 +4,36 @@
4
4
  * TODO: document testing patterns
5
5
  :::
6
6
 
7
- Configuring rspec to treat files in spec/actions as service specs:
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|
@@ -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 three additional custom validators:
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
- * `boolean: true` - wrapper to handle a boolean field (since ruby doesn't have a Boolean class, so we can't use `type:` directly)
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
@@ -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
  ```
@@ -35,7 +35,7 @@ module Action
35
35
  return axn_klass
36
36
  end
37
37
 
38
- Axn::Factory.build(superclass: superclass || self, **kwargs, &block)
38
+ Axn::Factory.build(superclass: superclass || self, name:, **kwargs, &block)
39
39
  end
40
40
  end
41
41
  end
@@ -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[:expose_return_as] ||= :value
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 = Class.new { include(Action) }.call
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 = "Something went wrong")
100
- Class.new { include(Action) }.tap do |klass|
101
- klass.define_method(:call) { fail!(msg) }
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
@@ -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 validations.key?(:boolean)
82
- validations[:presence] = false
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? { |type| value.is_a?(type) }
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
- context_for_logging(:outbound).presence&.inspect,
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, name), hash| hash[type] << name }
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 |name|
42
- expects[name] ||= {}
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 |name, hash|
51
- hash[name] = public_send(name)
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 |name, opts|
59
- axn.expects(name, **opts)
66
+ expects.each do |field, opts|
67
+ axn.expects(field, **opts)
60
68
  end
61
69
 
62
- exposes.each do |name, opts|
63
- axn.exposes(name, **opts)
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
- acc[key] = {}
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Axn
4
- VERSION = "0.1.0-alpha.2"
4
+ VERSION = "0.1.0-alpha.2.2"
5
5
  end
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-04-28 00:00:00.000000000 Z
11
+ date: 2025-05-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel