axn 0.1.0.pre.alpha.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.
@@ -0,0 +1,17 @@
1
+ ::: danger ALPHA
2
+ * TODO: convert this rough outline into actual documentation
3
+ :::
4
+
5
+
6
+ While coding:
7
+ * `expose`
8
+ * `fail!`
9
+ * `log`
10
+ * `try` - any exceptions raised by the block will trigger the on_exception handler, but then will be swallowed (the action is _not_ failed)
11
+ * Edge case: explicit `fail!` calls _will_ still fail the action
12
+ * `hoist_errors`
13
+ * Edge case: intent is a single action call in the block -- if there are multiple calls, only the last one will be checked (anything explicitly _raised_ will still be handled).
14
+ <!-- TODO: is there difference between `SubAction.call!` and `hoist_errors { SubAction.call }`?? -->
15
+ * `context_for_logging` (and decent #inspect support)
16
+
17
+
@@ -0,0 +1,38 @@
1
+ ---
2
+ outline: deep
3
+ ---
4
+
5
+ # Conventions
6
+
7
+ This page serves as a repository for various softly-held opinions about _how_ it makes sense to use the library.
8
+
9
+ ::: warning DRAFT
10
+ These conventions are still in flux as the library is solidified and we gain more experience using it in production. Take these notes with a grain of salt.
11
+ :::
12
+
13
+ ## Organizing Actions (Rails)
14
+
15
+ You _can_ `include Action` into _any_ Ruby class, but to keep track of things we've found it helpful to:
16
+
17
+ * Create a new `app/actions` folder for our actions
18
+ * Name them `Actions::[DOMAIN]::[VERB]` where `[DOMAIN]` is a (possibly nested) identifier and `[VERB]` is the action to be taken.
19
+
20
+ Examples:
21
+ * `Actions::User::Create`
22
+ * `Actions::Slack::Notify`
23
+
24
+ For us, we've found the maintenance benefits of knowing roughly how the class will behave just by glancing at the name has been worth being a bit pedantic about the naming.
25
+
26
+ ## Naming conventions
27
+
28
+ ### The responsible user
29
+ When tracking _who_ is responsible for the action being taken, one option is to inject it globally via `Current.user` (see: [Current Attributes](https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html)), but that only works if you're _sure_ you're never going to want to enqueue the job on a background processor.
30
+
31
+ More generally, we've adopted the convention of passing in the responsible user as `actor`:
32
+
33
+ ```ruby
34
+ class Foo
35
+ include Action
36
+ expects :actor, type: User # [!code focus]
37
+ end
38
+ ```
@@ -0,0 +1,26 @@
1
+ ---
2
+ outline: deep
3
+ ---
4
+ # Getting Started
5
+
6
+ ## Installation
7
+
8
+ Adding `axn` to your Gemfile is enough to start using `include Action`.
9
+ <!-- todo bundler -->
10
+
11
+
12
+ ## Global Configuration
13
+
14
+ A few configuration steps are _highly_ recommended to get the full benefits (e.g. making sure all your swallowed exceptions are getting reported to your error tracking service).
15
+
16
+ The full set of available configuration settings is documented [over here](/reference/configuration), but there are two worth calling out specifically:
17
+
18
+ ### Error Tracking
19
+
20
+ By default any swallowed errors are noted in the logs, but it's _highly recommended_ to [wire up an `on_exception` handler](/reference/configuration#on-exception).
21
+
22
+ ### Metrics / Tracing
23
+
24
+ If you're using an APM provider, observability can be greatly enhanced by [configuring a `top_level_around_hook`](/reference/configuration#top-level-around-hook).
25
+
26
+
@@ -0,0 +1,13 @@
1
+ # Testing
2
+
3
+ ::: danger ALPHA
4
+ * TODO: document testing patterns
5
+ :::
6
+
7
+ Configuring rspec to treat files in spec/actions as service specs:
8
+
9
+ ```ruby
10
+ config.define_derived_metadata(file_path: %r{spec/actions}) do |metadata|
11
+ metadata[:type] = :service
12
+ end
13
+ ```
@@ -0,0 +1,65 @@
1
+ ---
2
+ outline: deep
3
+ ---
4
+
5
+
6
+ # How to _use_ an Action
7
+
8
+ ## Common Case
9
+
10
+ An action is usually executed via `#call`, and _always_ returns an instance of the `Action::Result` class.
11
+
12
+ This means the result _always_ implements a consistent interface, including `ok?` and `error` (see [full details](/reference/action-result)) as well as any variables that it `exposes`. Remember any exceptions have been swallowed.
13
+
14
+ As a consumer, you usually want a conditional that surfaces `error` unless the result is `ok?`, and otherwise takes whatever success action is relevant.
15
+
16
+ For example:
17
+
18
+ ```ruby
19
+ class MessagesController < ApplicationController
20
+ def create
21
+ result = Actions::Slack::Post.call( # [!code focus]
22
+ channel: "#engineering",
23
+ message: params[:message],
24
+ )
25
+
26
+ if result.ok? # [!code focus:2]
27
+ @thread_id = result.thread_id # Because `thread_id` was explicitly exposed
28
+ flash.now[:success] = "Sent the Slack message"
29
+ else
30
+ flash[:alert] = result.error # [!code focus]
31
+ redirect_to action: :new
32
+ end
33
+ end
34
+ end
35
+ ```
36
+
37
+ <!-- TODO: replace manual flash success with result.success (here and in guide?) -->
38
+
39
+
40
+ ## Advanced Usage
41
+
42
+ ### `#call!`
43
+
44
+ ::: danger ALPHA
45
+ * TODO - flesh out this section
46
+ :::
47
+
48
+
49
+ * `call!`
50
+ * call! -- will raise any exceptions OR our own Action::Failure if user-facing error occurred (otherwise non-bang will never raise)
51
+ * note call! still logs completion even if failure (from configuration's on_exception)
52
+
53
+
54
+ ### `#enqueue`
55
+
56
+ Before adopting this library, our code was littered with one-line workers whose only job was to fire off a service on a background job. We were able to remove that entire glue layer by directly supporting enqueueing sidekiq jobs from the Action itself.
57
+
58
+ ::: danger ALPHA
59
+ Sidekiq integration is NOT YET TESTED/NOT YET USED IN OUR APP, and naming will VERY LIKELY change to make it clearer which actions will be retried!
60
+ :::
61
+
62
+ * enqueue vs enqueue!
63
+ * enqueue will not retry even if fails
64
+ * enqueue! will go through normal sidekiq retries on any failure (including user-facing `fail!`)
65
+ * Note implicit GlobalID support (if not serializable, will get ArgumentError at callsite)
@@ -0,0 +1,118 @@
1
+ ---
2
+ outline: deep
3
+ ---
4
+
5
+ # How to _build_ an Action
6
+
7
+ The core boilerplate is pretty minimal:
8
+
9
+ ```ruby
10
+ class Foo
11
+ include Action
12
+
13
+ def call
14
+ # ... do some stuff here?
15
+ end
16
+ end
17
+ ```
18
+
19
+ ## Declare the interface
20
+
21
+ The first step is to determine what arguments you expect to be passed into `call`. These are declared via the `expects` keyword.
22
+
23
+ If you want to expose any results to the caller, declare that via the `exposes` keyword.
24
+
25
+ Both of these optionally accept `type:`, `allow_blank:`, and any other ActiveModel validation (see: [reference](/reference/class)).
26
+
27
+
28
+ ```ruby
29
+ class Foo
30
+ include Action
31
+
32
+ expects :name, type: String # [!code focus:2]
33
+ exposes :meaning_of_life
34
+
35
+ def call
36
+ # ... do some stuff here?
37
+ end
38
+ end
39
+ ```
40
+
41
+ ## Implement the action
42
+
43
+ Once the interface is defined, you're primarily focused on defining the `call` method.
44
+
45
+ To abort execution with a specific error message, call `fail!`.
46
+
47
+ If you declare that your action `exposes` anything, you need to actually `expose` it.
48
+
49
+ ```ruby
50
+ class Foo
51
+ include Action
52
+
53
+ expects :name, type: String
54
+ exposes :meaning_of_life
55
+
56
+ def call
57
+ fail! "Douglas already knows the meaning" if name == "Doug" # [!code focus]
58
+
59
+ msg = "Hello #{name}, the meaning of life is 42"
60
+ expose meaning_of_life: msg # [!code focus]
61
+ end
62
+ end
63
+ ```
64
+
65
+ See [the reference doc](/reference/instance) for a few more handy helper methods (e.g. `#log`).
66
+
67
+ ## Customizing messages
68
+
69
+ ::: danger ALPHA
70
+ * TODO: document `messages` setup
71
+ :::
72
+
73
+
74
+ ## Lifecycle methods
75
+
76
+ In addition to `#call`, there are a few additional pieces to be aware of:
77
+
78
+ ### `#rollback`
79
+
80
+ If you define a `#rollback` method, it'll be called (_before_ returning an `Action::Result` to the caller) whenever your action fails.
81
+
82
+ ### Hooks
83
+
84
+ `before`, `after`, and `around` hooks are also supported.
85
+
86
+ ### Concrete example
87
+
88
+ Given this series of methods and hooks:
89
+
90
+ ```ruby
91
+ class Foo
92
+ include Action
93
+
94
+ before { log("before hook") }
95
+ after { log("after hook") }
96
+
97
+ def call
98
+ log("in call")
99
+ raise "oh no something borked"
100
+ end
101
+
102
+ def rollback
103
+ log("rolling back")
104
+ end
105
+ end
106
+ ```
107
+
108
+ `Foo.call` would fail (because of the raise), but along the way would end up logging:
109
+
110
+ ```text
111
+ before hook
112
+ in call
113
+ after hook
114
+ rolling back
115
+ ```
116
+
117
+ ## Debugging
118
+ Remember you can [enable debug logging](/reference/configuration.html#global-debug-logging) to print log lines before and after each action is executed.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Action
4
+ class Configuration
5
+ include Action::Logging
6
+ attr_accessor :global_debug_logging, :top_level_around_hook
7
+ attr_writer :logger, :env, :on_exception, :additional_includes
8
+
9
+ def global_debug_logging? = !!global_debug_logging
10
+
11
+ def additional_includes = @additional_includes ||= []
12
+
13
+ def on_exception(e, action:, context: {})
14
+ if @on_exception
15
+ # TODO: only pass action: or context: if requested
16
+ @on_exception.call(e, action:, context:)
17
+ else
18
+ log("[#{action.class.name.presence || "Anonymous Action"}] Exception swallowed: #{e.class.name} - #{e.message}")
19
+ end
20
+ end
21
+
22
+ def logger
23
+ @logger ||= begin
24
+ Rails.logger
25
+ rescue NameError
26
+ Logger.new($stdout).tap do |l|
27
+ l.level = Logger::INFO
28
+ end
29
+ end
30
+ end
31
+
32
+ def env
33
+ @env ||= ENV["RACK_ENV"].presence || ENV["RAILS_ENV"].presence || "development"
34
+ ActiveSupport::StringInquirer.new(@env)
35
+ end
36
+ end
37
+
38
+ class << self
39
+ def config = @config ||= Configuration.new
40
+
41
+ def configure
42
+ self.config ||= Configuration.new
43
+ yield(config) if block_given?
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,201 @@
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) { @context.public_send(field) }
18
+ end
19
+ end
20
+
21
+ attr_reader :declared_fields
22
+
23
+ def inspect = Inspector.new(facade: self, action:, context:).call
24
+
25
+ def fail!(...)
26
+ raise Action::ContractViolation::MethodNotAllowed, "Call fail! directly rather than on the context"
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :action, :context
32
+
33
+ def exposure_method_name = raise NotImplementedError
34
+
35
+ # Add nice error message for missing methods
36
+ def method_missing(method_name, ...) # rubocop:disable Style/MissingRespondToMissing (because we're not actually responding to anything additional)
37
+ if context.respond_to?(method_name)
38
+ msg = <<~MSG
39
+ Method ##{method_name} is not available on #{self.class.name}!
40
+
41
+ #{@action.class.name || "The action"} may be missing a line like:
42
+ #{exposure_method_name} :#{method_name}
43
+ MSG
44
+
45
+ raise Action::ContractViolation::MethodNotAllowed, msg
46
+ end
47
+
48
+ super
49
+ end
50
+
51
+ def determine_error_message(only_default: false)
52
+ return @context.error_from_user if @context.error_from_user.present?
53
+
54
+ unless only_default
55
+ msg = message_from_rescues
56
+ return msg if msg.present?
57
+ end
58
+
59
+ the_exception = @context.exception || (only_default ? Action::Failure.new(@context) : nil)
60
+ stringified(action._error_msg, exception: the_exception).presence || "Something went wrong"
61
+ end
62
+
63
+ def message_from_rescues
64
+ Array(action._error_rescues).each do |(matcher, value)|
65
+ matches = if matcher.respond_to?(:call)
66
+ if matcher.arity == 1
67
+ !!action.instance_exec(exception, &matcher)
68
+ else
69
+ !!action.instance_exec(&matcher)
70
+ end
71
+ elsif matcher.is_a?(String) || matcher.is_a?(Symbol)
72
+ klass = Object.const_get(matcher.to_s)
73
+ klass && exception.is_a?(klass)
74
+ elsif matcher < Exception
75
+ exception.is_a?(matcher)
76
+ else
77
+ action.warn("Ignoring matcher #{matcher.inspect} in rescues command")
78
+ end
79
+
80
+ return stringified(value, exception:) if matches
81
+ end
82
+
83
+ nil
84
+ end
85
+
86
+ # Allow for callable OR string messages
87
+ def stringified(msg, exception: nil)
88
+ return msg.presence unless msg.respond_to?(:call)
89
+
90
+ # The error message callable can take the exception as an argument
91
+ if exception && msg.arity == 1
92
+ action.instance_exec(exception, &msg)
93
+ else
94
+ action.instance_exec(&msg)
95
+ end
96
+ rescue StandardError => e
97
+ action.warn("Ignoring #{e.class.name} raised while determining message callable: #{e.message}")
98
+ nil
99
+ end
100
+ end
101
+
102
+ # Inbound / Internal ContextFacade
103
+ class InternalContext < ContextFacade
104
+ # So can be referenced from within e.g. rescues callables
105
+ def default_error
106
+ [@context.error_prefix, determine_error_message(only_default: true)].compact.join(" ").squeeze(" ")
107
+ end
108
+
109
+ private
110
+
111
+ def exposure_method_name = :gets
112
+ end
113
+
114
+ # Outbound / External ContextFacade
115
+ class Result < ContextFacade
116
+ # Poke some holes for necessary internal control methods
117
+ delegate :called!, :rollback!, :each_pair, to: :context
118
+
119
+ # External interface
120
+ delegate :success?, :failure?, :exception, to: :context
121
+ def ok? = success?
122
+
123
+ def error
124
+ return if ok?
125
+
126
+ [@context.error_prefix, determine_error_message].compact.join(" ").squeeze(" ")
127
+ end
128
+
129
+ def success
130
+ return unless ok?
131
+
132
+ stringified(action._success_msg).presence || "Action completed successfully"
133
+ end
134
+
135
+ def ok = success
136
+
137
+ def message = error || success
138
+
139
+ private
140
+
141
+ def exposure_method_name = :sets
142
+ end
143
+
144
+ class Inspector
145
+ def initialize(action:, facade:, context:)
146
+ @action = action
147
+ @facade = facade
148
+ @context = context
149
+ end
150
+
151
+ def call
152
+ str = [status, visible_fields].compact_blank.join(" ")
153
+
154
+ "#<#{class_name} #{str}>"
155
+ end
156
+
157
+ private
158
+
159
+ attr_reader :action, :facade, :context
160
+
161
+ def status
162
+ return unless facade.is_a?(Action::Result)
163
+
164
+ return "[OK]" if context.success?
165
+ return "[failed with '#{context.error_from_user}']" unless context.exception
166
+
167
+ %([failed with #{context.exception.class.name}: '#{context.exception.message}'])
168
+ end
169
+
170
+ def visible_fields
171
+ declared_fields.map do |field|
172
+ value = facade.public_send(field)
173
+
174
+ "#{field}: #{format_for_inspect(field, value)}"
175
+ end.join(", ")
176
+ end
177
+
178
+ def class_name = facade.class.name
179
+ def declared_fields = facade.send(:declared_fields)
180
+
181
+ def format_for_inspect(field, value)
182
+ return value.inspect if value.nil?
183
+
184
+ # Initially based on https://github.com/rails/rails/blob/800976975253be2912d09a80757ee70a2bb1e984/activerecord/lib/active_record/attribute_methods.rb#L527
185
+ inspected_value = if value.is_a?(String) && value.length > 50
186
+ "#{value[0, 50]}...".inspect
187
+ elsif value.is_a?(Date) || value.is_a?(Time)
188
+ %("#{value.to_fs(:inspect)}")
189
+ elsif defined?(::ActiveRecord::Relation) && value.instance_of?(::ActiveRecord::Relation)
190
+ # Avoid hydrating full AR relation (i.e. avoid loading records just to report an error)
191
+ "#{value.name}::ActiveRecord_Relation"
192
+ else
193
+ value.inspect
194
+ end
195
+
196
+ inspection_filter.filter_param(field, inspected_value)
197
+ end
198
+
199
+ def inspection_filter = action.send(:inspection_filter)
200
+ end
201
+ end