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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ef826e80d884d02a1ed34f2bea7adec8d588c10b9205530491dfdd3f7f0b49b6
4
+ data.tar.gz: 11ea30c7aa7648962a5c0f90e8e6de872e6a28aaedb5603982d762013260e5ed
5
+ SHA512:
6
+ metadata.gz: 6ab30a1f36da77f01eb19e1de1fcdeebac6662246bc30b652917b6e86af69e345a1075370de4a9cd2f197e93db77264fe1e369a48c4d994c1246f57a347335e8
7
+ data.tar.gz: d9e72527cb17dac4eb2f85d2b607919996d50d59797122049f42a3c92a44c44a60ca83660ceaf446588013b355a053162698a94aed75424f16e357de56ba7c70
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,54 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1
3
+ SuggestExtensions: false
4
+ NewCops: enable
5
+
6
+ Style/StringLiterals:
7
+ Enabled: true
8
+ EnforcedStyle: double_quotes
9
+
10
+ Style/StringLiteralsInInterpolation:
11
+ Enabled: true
12
+ EnforcedStyle: double_quotes
13
+
14
+ Style/Documentation:
15
+ Enabled: false
16
+
17
+ Style/TrailingCommaInArguments:
18
+ EnforcedStyleForMultiline: comma
19
+
20
+ Style/TrailingCommaInArrayLiteral:
21
+ EnforcedStyleForMultiline: comma
22
+
23
+ Style/TrailingCommaInHashLiteral:
24
+ EnforcedStyleForMultiline: comma
25
+
26
+ Style/DoubleNegation:
27
+ Enabled: false
28
+
29
+ Metrics/BlockLength:
30
+ Enabled: false
31
+
32
+ Metrics/MethodLength:
33
+ Max: 60
34
+
35
+ Metrics/PerceivedComplexity:
36
+ Max: 15
37
+
38
+ Metrics/AbcSize:
39
+ Max: 51
40
+
41
+ Metrics/CyclomaticComplexity:
42
+ Max: 12
43
+
44
+ Lint/EmptyBlock:
45
+ Enabled: false
46
+
47
+ Naming/MethodParameterName:
48
+ AllowedNames: e
49
+
50
+ Metrics/ParameterLists:
51
+ Max: 6
52
+
53
+ Layout/LineLength:
54
+ Max: 160
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## UNRELEASED
4
+
5
+ * N/A
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,37 @@
1
+ # Contributing to Axn
2
+
3
+ Axn is open source and contributions from the community are encouraged!
4
+ No contribution is too small.
5
+
6
+ Please consider:
7
+
8
+ * adding a feature
9
+ * squashing a bug
10
+ * writing documentation
11
+ * reporting an issue
12
+ * fixing a typo
13
+
14
+ ## How do I contribute?
15
+
16
+ For the best chance of having your changes merged, please:
17
+
18
+ 1. [Fork](https://github.com/teamshares/axn/fork) the project.
19
+ 2. [Write](http://en.wikipedia.org/wiki/Test-driven_development) a failing test.
20
+ 3. [Commit](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) changes that fix the tests.
21
+ 4. [Submit](https://github.com/teamshares/axn/pulls) a pull request with *at least* one animated GIF.
22
+ 5. Be patient.
23
+
24
+ ## Bug Reports
25
+
26
+ If you are experiencing unexpected behavior and, after having read [our documentation](https://teamshares.github.io/axn/guide/), are convinced this behavior is a bug, please:
27
+
28
+ 1. [Search](https://github.com/teamshares/axn/issues) existing issues.
29
+ 2. Collect enough information to reproduce the issue:
30
+ * Axn version
31
+ * Ruby version
32
+ * Rails version (if applicable)
33
+ * Specific setup conditions
34
+ * Description of expected behavior
35
+ * Description of actual behavior
36
+ 3. [Submit](https://github.com/teamshares/axn/issues/new) an issue.
37
+ 4. Be patient.
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2025 Teamshares
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # Axn ("Action")
2
+
3
+ Just spinning this up -- not yet released (i.e. doc updates in flight).
4
+
5
+ ## Installation & Usage
6
+
7
+ See our [User Guide](https://teamshares.github.io/axn/guide/) for details.
8
+
9
+ ## [!!] Inheritance Support
10
+
11
+ Out of the box Axn only supports a direct style (every action must `include Action`).
12
+
13
+ If you want to support inheritance, you'll need to add this line to your `Gemfile` (we're layered over Interactor, and their released version doesn't yet support inheritance):
14
+
15
+ gem "interactor", github: "kaspermeyer/interactor", branch: "fix-hook-inheritance"
16
+
17
+
18
+ ## Development
19
+
20
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
21
+
22
+ ## Contributions
23
+
24
+ Axn is open source and contributions from the community are encouraged! No contribution is too small.
25
+
26
+ See our [contribution guidelines](CONTRIBUTING.md) for more information.
27
+
28
+ ## Thank You
29
+
30
+ A very special thank you to [Collective Idea](https://collectiveidea.com/)'s fantastic [Interactor](https://github.com/collectiveidea/interactor?tab=readme-ov-file#interactor) library, which [we](https://www.teamshares.com/) used successfully for a number of years and which still forms the basis of this library today.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/axn.gemspec ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/axn/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "axn"
7
+ spec.version = Axn::VERSION
8
+ spec.authors = ["Kali Donovan"]
9
+ spec.email = ["kali@teamshares.com"]
10
+
11
+ spec.summary = "A terse convention for business logic"
12
+ spec.description = "Pattern for writing callable service objects with contract validation and error swallowing"
13
+ spec.homepage = "https://github.com/teamshares/axn"
14
+ spec.license = "MIT"
15
+
16
+ # NOTE: uses endless methods from 3, literal value omission from 3.1
17
+ spec.required_ruby_version = ">= 3.1.0"
18
+
19
+ # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"
20
+ # spec.metadata["rubygems_mfa_required"] = "true"
21
+
22
+ spec.metadata["homepage_uri"] = spec.homepage
23
+ spec.metadata["source_code_uri"] = spec.homepage
24
+ spec.metadata["changelog_uri"] = "https://github.com/teamshares/axn/blob/main/CHANGELOG.md"
25
+
26
+ # Specify which files should be added to the gem when it is released.
27
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
28
+ spec.files = Dir.chdir(__dir__) do
29
+ `git ls-files -z`.split("\x0").reject do |f|
30
+ (File.expand_path(f) == __FILE__) ||
31
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
32
+ end
33
+ end
34
+ spec.bindir = "exe"
35
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
36
+ spec.require_paths = ["lib"]
37
+
38
+ # Core dependencies
39
+ spec.add_dependency "activemodel", "> 7.0" # For contract validation
40
+ spec.add_dependency "activesupport", "> 7.0" # For compact_blank and friends
41
+
42
+ # NOTE: for inheritance support, need to specify a fork in consuming applications' Gemfile (see Gemfile here for syntax)
43
+ spec.add_dependency "interactor", "3.1.2" # We're building on this scaffolding for organizing business logic
44
+ spec.metadata["rubygems_mfa_required"] = "true"
45
+ end
@@ -0,0 +1,55 @@
1
+ import { defineConfig } from 'vitepress'
2
+
3
+ // https://vitepress.dev/reference/site-config
4
+ export default defineConfig({
5
+ title: "Axn",
6
+ description: "A terse convention for business logic",
7
+ base: "/axn/",
8
+ themeConfig: {
9
+ // https://vitepress.dev/reference/default-theme-config
10
+ nav: [
11
+ { text: 'Home', link: '/' },
12
+ { text: 'User Guide', link: '/guide' }
13
+ ],
14
+
15
+ sidebar: [
16
+ {
17
+ text: 'Introduction',
18
+ items: [
19
+ { text: 'About', link: '/about/' },
20
+ { text: 'Summary Overview', link: '/guide/' },
21
+ ]
22
+ },
23
+ {
24
+ text: 'Getting Started',
25
+ items: [
26
+ { text: 'Setup', link: '/usage/setup' },
27
+ { text: 'Writing Actions', link: '/usage/writing' },
28
+ { text: 'Using Actions', link: '/usage/using' },
29
+ { text: 'Testing Actions', link: '/usage/testing' },
30
+ { text: 'Conventions', link: '/usage/conventions' },
31
+ ]
32
+ },
33
+ {
34
+ text: 'Reference',
35
+ items: [
36
+ { text: 'Configuration', link: '/reference/configuration' },
37
+ { text: 'Class Interface', link: '/reference/class' },
38
+ { text: 'Instance Interface', link: '/reference/instance' },
39
+ { text: 'Result Interface', link: '/reference/action-result' },
40
+ ]
41
+ },
42
+ {
43
+ text: 'Advanced Usage',
44
+ items: [
45
+ { text: 'ROUGH NOTES', link: '/advanced/rough' },
46
+ { text: 'Validating User Input', link: '/advanced/validating-user-input' },
47
+ ]
48
+ },
49
+ ],
50
+
51
+ socialLinks: [
52
+ { icon: 'github', link: 'https://github.com/teamshares/axn' }
53
+ ]
54
+ }
55
+ })
@@ -0,0 +1,46 @@
1
+ ## History
2
+
3
+ The need to consistently organize your business logic somewhere within the MVC Rails stack is a perennial topic of discussion, with many approaches in the community. Over the course of a few years, [we at Teamshares](https://github.com/teamshares) had three teams building three separate apps, each of which chose a different approach.
4
+
5
+ After observing the challenges that emerged from each approach, we extracted a list of explicit design goals and then set out to build a library that would implement them.
6
+
7
+ ## Design Goals
8
+
9
+ ::: tip Overall Focus
10
+ A simple, declarative core API. Concise enough to pick up quickly, but sufficiently powerful to manage real-world complexity.
11
+ :::
12
+
13
+ **Core needs:**
14
+
15
+ - Consistent, DRY pattern to reach for when building services (`FooService.call`)
16
+ - Ability to declaratively specify pre- and post- conditions
17
+ - Consistent return interface (including exception swallowing)
18
+ - Clear distinction between user-facing and internal errors
19
+ - Minimal boilerplate
20
+ - Easy backgrounding (no need for a separate Worker class just to wrap a service call)
21
+
22
+ **Additional benefits devs get for free:**
23
+
24
+ - Integrated metrics
25
+ - Integrated debug logging
26
+ - Automatic error reporting
27
+
28
+ ## Orchestration
29
+
30
+ We found that many of our existing solutions were _also_ pretty solid for individual services, but started to break down when complex use-cases required nesting service calls within each other (hard to tell at a glance how exceptions bubble up, what the end-user ends up seeing in various failure modes, which parts get unwound by DB transactions, etc.).
31
+
32
+ The core library provides many benefits for individual action calls, but also aims to establish a few clear usage patterns to make it easy to reason about nested services.
33
+
34
+ ### "Blessed" patterns:
35
+ * Single action
36
+ * Linear flow
37
+ * A list of actions to execute in series
38
+ * Each layer `expects` and `exposes` its own accessor set, but internally all the values are passed down the chain (i.e. actor C can accept something A exposed that B didn’t touch and knows nothing about).
39
+ * The top-level action must `expose` it’s own layer (effectively documenting public vs private exposures, which drastically eases refactoring)
40
+ * Ad hoc (called arbitrarily from within other actions)
41
+ * `hoist_errors` (usage: `hoist_errors { Nested::Action.call }`) ensures any failure from a nested service is bubbled up to the top level (by default, as if the failure had happened there directly).
42
+ * Allows configurable handling at call site (e.g. setting `prefix`, so identical failures from different nested calls are distinguishable)
43
+
44
+ ::: danger ALPHA
45
+ * TODO: add links to sections showing usage guides/examples for the more complex flows
46
+ :::
@@ -0,0 +1,12 @@
1
+ ::: danger ALPHA
2
+ * TODO: convert rough notes into actual documentation
3
+ :::
4
+
5
+ ## Rough Notes
6
+
7
+ * General note: the inbound/outbound contexts are views into an underlying shared object (passed down through organize calls) -- modifications of one will affect the other (e.g. preprocessing inbound args implicitly transforms them on the underlying context, which is echoed if you also expose it on outbound).
8
+
9
+ * Configuring logging (will default to Rails.logger if available, else fall back to basic Logger (but can explicitly set via e.g. `Action.config.logger = Logger.new($stdout`))
10
+
11
+ * Note `context_for_logging` is available (filtered to accessible attrs, filtering out sensitive values). Automatically passed into `on_exception` hook.
12
+
@@ -0,0 +1,11 @@
1
+ # Validating _user_ input
2
+
3
+ ::: danger ALPHA
4
+ This has not yet been fully fleshed out. For now, the general idea is that user-facing validation is a _separate layer_ from the declarative expectations about what inputs your Action takes (e.g. `expects :params` and pass to a form object, rather than accepting field-level params directly).
5
+ :::
6
+
7
+
8
+ The `expects`/`exposes` validations are for confirming that you're fulfilling your contract with yourself to call your service correctly. Any failures are _not_ user facing (and in fact, at some point may optionally raise in development)
9
+
10
+ If you want to run validations on user-provided data (i.e. individual form elements), there's a Form Object pattern for that.
11
+
@@ -0,0 +1,157 @@
1
+ ---
2
+ outline: deep
3
+ ---
4
+
5
+ # Introduction
6
+
7
+ This library provides a set of conventions for writing business logic in Rails (or other Ruby) applications with:
8
+
9
+ * Clear calling semantics: `Foo.call`
10
+ * A declarative interface
11
+ * A [consistent return interface](./#return-interface)
12
+ * Exception swallowing + clear distinction between internal and user-facing errors
13
+
14
+ ### Minimal example
15
+
16
+ Your logic goes in a <abbr title="Plain Old Ruby Object">PORO</abbr>. The only requirements are to `include Action` and a `call` method, meaning the basic skeleton looks something like this:
17
+
18
+ ```ruby
19
+ class Foo
20
+ include Action
21
+
22
+ def call
23
+ log "Doesn't do much, but this technically works..."
24
+ end
25
+ end
26
+ ```
27
+
28
+ ## Inputs and Outflows
29
+
30
+ ### Overview
31
+
32
+ Most actions require inputs, and many return values to the caller; no need for any `def initialize` boilerplate, just add:
33
+
34
+ * `expects :foo` to declare inputs the class expects to receive.
35
+
36
+ You pass the `expect`ed keyword arguments to `call`, then reference their values as local `attr_reader`s.
37
+
38
+ * `exposes :bar` to declare any outputs the class will expose.
39
+
40
+ Within your action, use `expose :bar, <value>` to set a value that will be available on the return interface.
41
+
42
+ ::: info
43
+ By design you _cannot access anything you do not explicitly `expose` from outside the action itself_. Making the external interface explicit helps maintainability by ensuring you can refactor internals without breaking existing callsites.
44
+ :::
45
+
46
+ ### Details
47
+ Both `expects` and `exposes` support a variety of options:
48
+
49
+ | Option | Example (same for `exposes`) | Meaning |
50
+ | -- | -- | -- |
51
+ | `sensitive` | `expects :password, sensitive: true` | Filters the fields value when logging, reporting errors, or calling `inspect`
52
+ | `default` | `expects :foo, default: 123` | If `foo` isn't provided, it'll default to this value
53
+ | `allow_blank` | `expects :foo, allow_blank: true` | Don't fail if the value is blank
54
+ | `type` | `expects :foo, type: String` | Custom type validation -- fail unless `foo.is_a?(String)`
55
+ | 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 `validate :foo, <...>` on an ActiveRecord model)
56
+
57
+ ::: warning
58
+ The declarative interface (`expects` and `exposes`) constitutes a contract you are making _with yourself_ (and your fellow developers). **This is _not_ for validating user input** -- [there's a Form Object pattern for that](/advanced/validating-user-input).
59
+ :::
60
+
61
+ If any expectations fail, the action will fail early and set `error` to a generic error message (because a failed validation means _you_ called _your own_ service wrong; there's nothing the end user can do about that).
62
+
63
+
64
+ ### Putting it together
65
+
66
+ ```ruby
67
+ class Actions::Slack::Post
68
+ include Action
69
+ VALID_CHANNELS = [ ... ]
70
+
71
+ expects :channel, default: VALID_CHANNELS.first, inclusion: { in: VALID_CHANNELS } # [!code focus:4]
72
+ expects :message, type: String
73
+
74
+ exposes :thread_id, type: String
75
+
76
+ def call
77
+ response = client.chat_postMessage(channel:, text: message)
78
+ the_thread_id = response["ts"]
79
+
80
+ expose :thread_id, the_thread_id # [!code focus]
81
+ end
82
+
83
+ private
84
+
85
+ def client = Slack::Web::Client.new
86
+ end
87
+ ```
88
+
89
+ ## Return interface {#return-interface}
90
+
91
+ ### Overview
92
+
93
+ The return value of an Action call is always an `Action::Result`, which provides a consistent interface:
94
+
95
+ | Method | Description |
96
+ | -- | -- |
97
+ | `ok?` | `true` if the call succeeded, `false` if not.
98
+ | `error` | Will _always_ be set to a safe-to-show-users string if not `ok?`
99
+ | 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)
100
+
101
+ ### Details
102
+
103
+ ::: danger ALPHA
104
+ * TODO: link to a reference page for the full interface.
105
+ :::
106
+
107
+ ### Putting it together
108
+
109
+ This interface yields a common usage pattern:
110
+
111
+
112
+ ```ruby
113
+ class MessagesController < ApplicationController
114
+ def create
115
+ result = Actions::Slack::Post.call( # [!code focus]
116
+ channel: "#engineering",
117
+ message: params[:message],
118
+ )
119
+
120
+ if result.ok? # [!code focus:2]
121
+ @thread_id = result.thread_id # Because `thread_id` was explicitly exposed
122
+ flash.now[:success] = "Sent the Slack message"
123
+ else
124
+ flash[:alert] = result.error # [!code focus]
125
+ redirect_to action: :new
126
+ end
127
+ end
128
+ end
129
+ ```
130
+
131
+ Note this simple pattern handles multiple levels of "failure" ([details below](#error-handling)):
132
+ * Showing specific user-facing flash messages for any arbitrary logic you want in your action (from `fail!`)
133
+ * Showing generic error message if anything went wrong internally (e.g. the Slack client raised an exception -- it's been logged for the team to investigate, but the user doesn't need to care _what_ went wrong)
134
+ * Showing generic error message if any of your declared interface expectations fail (e.g. if the exposed `thread_id`, which we pulled from Slack's API response, somehow _isn't_ a String)
135
+
136
+
137
+ ## Error handling {#error-handling}
138
+
139
+ ::: tip BIG IDEA
140
+ By design, `result.error` is always safe to show to the user.
141
+
142
+ :star_struck: The calling code usually only cares about `ok?` and `error` -- no complex error handling needed.
143
+ :::
144
+
145
+
146
+ We make a clear distinction between user-facing and internal errors.
147
+
148
+ ### User-facing errors (`fail!`)
149
+
150
+ For _known_ failure modes, you can call `fail!("Some user-facing explanation")` at any time to abort execution and set `result.error` to your custom message.
151
+
152
+ ### Internal errors (uncaught `raise`)
153
+
154
+ Any exceptions will be swallowed and the action failed (i.e. _not_ `ok?`). `result.error` will be set to a generic error message ("Something went wrong" by default, but highly configurable).
155
+ <!-- TODO: link to messaging configs -->
156
+
157
+ The swallowed exception will be available on `result.exception` for your introspection, but it'll also be passed to your `on_exception` handler so, [with a bit of configuration](/usage/setup), you can trust that any exceptions have been logged to your error tracking service automatically (one more thing the dev doesn't need to think about).
data/docs/index.md ADDED
@@ -0,0 +1,26 @@
1
+ ---
2
+ # https://vitepress.dev/reference/default-theme-home-page
3
+ layout: home
4
+
5
+ hero:
6
+ name: "Axn"
7
+ text: "A terse convention for business logic"
8
+ tagline: "**ALPHA release -- everything subject to change**"
9
+ actions:
10
+ - theme: brand
11
+ text: User Guide
12
+ link: /guide
13
+ # - theme: alt
14
+ # text: API Examples
15
+ # link: /api-examples
16
+
17
+ features:
18
+ - title: Declarative interface
19
+ details: Lorem ipsum dolor sit amet, consectetur adipiscing elit
20
+ - title: Exception swallowing
21
+ details: Lorem ipsum dolor sit amet, consectetur adipiscing elit
22
+ - title: Default Observability
23
+ details: Lorem ipsum dolor sit amet, consectetur adipiscing elit
24
+ ---
25
+
26
+
@@ -0,0 +1,12 @@
1
+ ::: danger ALPHA
2
+ * TODO: convert this rough outline into actual documentation
3
+ :::
4
+
5
+
6
+ `Action::Result`
7
+
8
+ * ok?
9
+ * error
10
+ * exception
11
+ * success
12
+ * message
@@ -0,0 +1,18 @@
1
+ ::: danger ALPHA
2
+ * TODO: convert this rough outline into actual documentation
3
+ :::
4
+
5
+ ## Class-level interface
6
+
7
+ * `expects`
8
+ * `exposes`
9
+ * `messages`
10
+
11
+ ### `expects` and `exposes`
12
+ * setting `sensitive: true` on any param will filter that value out when inspecting or passing to on_exception
13
+ * Note we have two custom validations: boolean: true and the implicit type: foo. (maybe with array of types?)
14
+ Note a third allows custom validations: `expects :foo, validate: ->(value) { "must be pretty big" unless value > 10 }` (error raised if any string returned OR if it raises an exception)
15
+
16
+ ### #call and #rollback
17
+
18
+ ### hooks
@@ -0,0 +1,90 @@
1
+ # Configuration
2
+
3
+ Somewhere at boot (e.g. `config/initializers/actions.rb` in Rails), you can call `Action.configure` to adjust a few global settings.
4
+
5
+
6
+ ```ruby
7
+ Action.configure do |c|
8
+ c.global_debug_logging = false
9
+
10
+ c.on_exception = ...
11
+
12
+ c.top_level_around_hook = ...
13
+
14
+ c.additional_includes = []
15
+ end
16
+ ```
17
+
18
+ ## `global_debug_logging`
19
+
20
+ By default, every `action.call` will emit _debug_ log lines when it is called (including the action class and any arguments it was provided) and after it completes (including the execution time and the outcome).
21
+
22
+ You can bump the log level from `debug` to `info` for specific actions by including their class name (comma separated, if multiple) in a `SA_DEBUG_TARGETS` ENV variable.
23
+
24
+ You can also turn this on _globally_ by setting `global_debug_logging = true`.
25
+
26
+ ```ruby
27
+ Action.configure do |c|
28
+ c.global_debug_logging = true
29
+ end
30
+ ```
31
+
32
+ ## `on_exception`
33
+
34
+ By default any swallowed errors are noted in the logs, but it's _highly recommended_ to wire up an `on_exception` handler so those get reported to your error tracking service.
35
+
36
+ For example, if you're using Honeybadger this could look something like:
37
+
38
+
39
+ ```ruby
40
+ Action.configure do |c|
41
+ c.on_exception = proc do |e, action:, context:|
42
+ message = "[#{action.class.name}] Failing due to #{e.class.name}: #{e.message}"
43
+
44
+ Rails.logger.warn(message)
45
+ Honeybadger.notify(message, context:)
46
+ end
47
+ end
48
+ ```
49
+
50
+ A couple notes:
51
+
52
+ * `context` will contain the arguments passed to the `action`, _but_ any marked as sensitive (e.g. `expects :foo, sensitive: true`) will be filtered out in the logs.
53
+ * If your handler raises, the failure will _also_ be swallowed and logged
54
+
55
+
56
+ ## `top_level_around_hook`
57
+
58
+ If you're using an APM provider, observability can be greatly enhanced by adding automatic _tracing_ of Action calls and/or emitting count metrics after each call completes.
59
+
60
+ For example, to wire up Datadog:
61
+
62
+ ```ruby
63
+ Action.configure do |c|
64
+ c.top_level_around_hook = proc do |resource, &action|
65
+ Datadog::Tracing.trace("Action", resource:) do
66
+ (outcome, _exception) = action.call
67
+
68
+ TS::Metrics.increment("action.#{resource.underscore}", tags: { outcome:, resource: })
69
+ end
70
+ end
71
+ end
72
+ ```
73
+
74
+ A couple notes:
75
+
76
+ * `Datadog::Tracing` is provided by [the datadog gem](https://rubygems.org/gems/datadog)
77
+ * `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.
78
+
79
+
80
+ ## `additional_includes`
81
+
82
+ This is much less critical than the preceding options, but on the off chance you want to add additional customization to _all_ your actions you can set additional modules to be included alongside `include Action`.
83
+
84
+ For example:
85
+
86
+ ```ruby
87
+ Action.configure do |c|
88
+ c.additional_includes = [SomeFancyCustomModule]
89
+ end
90
+ ```