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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +54 -0
- data/CHANGELOG.md +5 -0
- data/CONTRIBUTING.md +37 -0
- data/LICENSE.txt +22 -0
- data/README.md +30 -0
- data/Rakefile +12 -0
- data/axn.gemspec +45 -0
- data/docs/.vitepress/config.mjs +55 -0
- data/docs/about/index.md +46 -0
- data/docs/advanced/rough.md +12 -0
- data/docs/advanced/validating-user-input.md +11 -0
- data/docs/guide/index.md +157 -0
- data/docs/index.md +26 -0
- data/docs/reference/action-result.md +12 -0
- data/docs/reference/class.md +18 -0
- data/docs/reference/configuration.md +90 -0
- data/docs/reference/instance.md +17 -0
- data/docs/usage/conventions.md +38 -0
- data/docs/usage/setup.md +26 -0
- data/docs/usage/testing.md +13 -0
- data/docs/usage/using.md +65 -0
- data/docs/usage/writing.md +118 -0
- data/lib/action/configuration.rb +46 -0
- data/lib/action/context_facade.rb +201 -0
- data/lib/action/contract.rb +190 -0
- data/lib/action/contract_validator.rb +66 -0
- data/lib/action/enqueueable.rb +74 -0
- data/lib/action/exceptions.rb +65 -0
- data/lib/action/hoist_errors.rb +51 -0
- data/lib/action/logging.rb +41 -0
- data/lib/action/organizer.rb +41 -0
- data/lib/action/swallow_exceptions.rb +104 -0
- data/lib/action/top_level_around_hook.rb +63 -0
- data/lib/axn/version.rb +5 -0
- data/lib/axn.rb +54 -0
- data/package.json +10 -0
- data/yarn.lock +1166 -0
- metadata +128 -0
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
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/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
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
|
+
})
|
data/docs/about/index.md
ADDED
@@ -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
|
+
|
data/docs/guide/index.md
ADDED
@@ -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,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
|
+
```
|