assistant 0.1.0 → 1.0.0.rc1
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/.github/PULL_REQUEST_TEMPLATE.md +39 -0
- data/.github/workflows/ci.yml +99 -0
- data/.github/workflows/docs.yml +64 -0
- data/.github/workflows/release.yml +1 -1
- data/.gitignore +5 -1
- data/.opencode/.gitignore +4 -0
- data/.opencode/opencode.json +13 -0
- data/.opencode/skills/create-pr/SKILL.md +138 -0
- data/.opencode/skills/ruby-services/SKILL.md +81 -0
- data/.rubocop.yml +14 -4
- data/.yardopts +17 -0
- data/CHANGELOG.md +378 -0
- data/CONTRIBUTING.md +131 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +196 -29
- data/README.md +125 -16
- data/Rakefile +45 -0
- data/SECURITY.md +50 -0
- data/Steepfile +49 -0
- data/_config.yml +87 -0
- data/assistant.gemspec +24 -7
- data/docs/api-reference.md +264 -0
- data/docs/changelog.md +26 -0
- data/docs/deprecations.md +86 -0
- data/docs/examples/cli-handler.md +17 -0
- data/docs/examples/composing-services.md +17 -0
- data/docs/examples/execute-callbacks.md +17 -0
- data/docs/examples/index.md +29 -0
- data/docs/examples/instrumentation-notifier.md +17 -0
- data/docs/examples/rails-service.md +17 -0
- data/docs/examples/rbs-generator.md +17 -0
- data/docs/examples/sidekiq-worker.md +17 -0
- data/docs/getting-started.md +136 -0
- data/docs/guides/composing-services.md +222 -0
- data/docs/guides/index.md +25 -0
- data/docs/guides/inputs.md +333 -0
- data/docs/guides/logging-and-results.md +202 -0
- data/docs/guides/rbs-and-types.md +16 -0
- data/docs/guides/validation.md +180 -0
- data/docs/index.md +69 -0
- data/docs/roadmap.md +33 -0
- data/exe/assistant-rbs +7 -0
- data/lib/assistant/execute_callbacks.rb +103 -0
- data/lib/assistant/execute_callbacks.rbs +30 -0
- data/lib/assistant/input_builder/accessors.rb +36 -0
- data/lib/assistant/input_builder/accessors.rbs +10 -0
- data/lib/assistant/input_builder/default_option.rb +41 -0
- data/lib/assistant/input_builder/default_option.rbs +11 -0
- data/lib/assistant/input_builder/dsl.rb +37 -0
- data/lib/assistant/input_builder/dsl.rbs +12 -0
- data/lib/assistant/input_builder/optional_option.rb +45 -0
- data/lib/assistant/input_builder/optional_option.rbs +10 -0
- data/lib/assistant/input_builder/registry.rb +27 -0
- data/lib/assistant/input_builder/registry.rbs +13 -0
- data/lib/assistant/input_builder/require_validator.rb +104 -0
- data/lib/assistant/input_builder/require_validator.rbs +24 -0
- data/lib/assistant/input_builder/type_validator.rb +47 -0
- data/lib/assistant/input_builder/type_validator.rbs +18 -0
- data/lib/assistant/input_builder.rb +25 -81
- data/lib/assistant/input_builder.rbs +15 -0
- data/lib/assistant/log_item.rb +74 -16
- data/lib/assistant/log_item.rbs +40 -0
- data/lib/assistant/log_list.rb +43 -17
- data/lib/assistant/log_list.rbs +48 -0
- data/lib/assistant/rbs_generator/cli.rb +109 -0
- data/lib/assistant/rbs_generator/cli.rbs +24 -0
- data/lib/assistant/rbs_generator/renderer.rb +67 -0
- data/lib/assistant/rbs_generator/renderer.rbs +11 -0
- data/lib/assistant/rbs_generator/writer.rb +65 -0
- data/lib/assistant/rbs_generator/writer.rbs +24 -0
- data/lib/assistant/rbs_generator.rb +38 -0
- data/lib/assistant/rbs_generator.rbs +5 -0
- data/lib/assistant/refinements/string_blankness.rb +9 -13
- data/lib/assistant/refinements/string_blankness.rbs +6 -0
- data/lib/assistant/service.rb +300 -11
- data/lib/assistant/service.rbs +82 -1
- data/lib/assistant/version.rb +5 -1
- data/lib/assistant/version.rbs +5 -0
- data/lib/assistant.rb +54 -4
- data/lib/assistant.rbs +25 -0
- data/mise.toml +2 -0
- data/sig/examples/greeter.rbs +14 -0
- metadata +142 -38
- data/.fasterer.yml +0 -19
- data/.rubocop_todo.yml +0 -7
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Validation
|
|
3
|
+
parent: Guides
|
|
4
|
+
nav_order: 2
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!-- markdownlint-disable MD013 MD024 -->
|
|
8
|
+
# Validation
|
|
9
|
+
|
|
10
|
+
> **TL;DR** — Declarative `input` checks (`type:`, `required:`,
|
|
11
|
+
> `if:`, etc.) run automatically before `#execute`. For everything
|
|
12
|
+
> else, override `#validate` and call `log_item_error(...)` to
|
|
13
|
+
> short-circuit, or `log_item_warning(...)` to flag a recoverable
|
|
14
|
+
> issue. `LogItem.new` raises `ArgumentError` for invalid attributes
|
|
15
|
+
> in 1.0 — use the helpers, not `LogItem.new` directly.
|
|
16
|
+
|
|
17
|
+
This guide covers the validation surface beyond the declarative
|
|
18
|
+
options on [`input`](./inputs.md): the `validate` hook, the
|
|
19
|
+
warning-vs-error decision, the strict `LogItem` constructor, and
|
|
20
|
+
conditional patterns.
|
|
21
|
+
|
|
22
|
+
## What runs automatically
|
|
23
|
+
|
|
24
|
+
For every `Service.input :name, type: T, required: ..., if: ...`,
|
|
25
|
+
the gem generates and runs:
|
|
26
|
+
|
|
27
|
+
- `#valid_type_name?` — type check (or multi-type with M3 union).
|
|
28
|
+
- `#valid_required_name?` — presence check, when `required: true`.
|
|
29
|
+
- `#valid_required_conditional_name?` — presence + predicate, when
|
|
30
|
+
`required: true` *and* `if:` are both supplied.
|
|
31
|
+
|
|
32
|
+
`#run` calls every `valid_required_*?`, `valid_required_conditional_*?`,
|
|
33
|
+
and `valid_type_*?` method that matches by naming convention before
|
|
34
|
+
calling your `#validate`. Failures are logged as error-level
|
|
35
|
+
`LogItem`s and short-circuit `#execute`.
|
|
36
|
+
|
|
37
|
+
## Adding your own checks with `#validate`
|
|
38
|
+
|
|
39
|
+
Override `#validate` to log domain-specific errors:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
class CreateUser < Assistant::Service
|
|
43
|
+
input :email, type: String, required: true
|
|
44
|
+
|
|
45
|
+
def validate
|
|
46
|
+
return if email.include?('@')
|
|
47
|
+
|
|
48
|
+
log_item_error(source: :validate, detail: :email, message: 'must contain @')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def execute
|
|
52
|
+
{ email: }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
CreateUser.run(email: 'a@b.com').fetch(:status) # => :ok
|
|
57
|
+
CreateUser.run(email: 'oops').fetch(:status) # => :with_errors
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`#validate` runs **after** the declarative checks. If a declarative
|
|
61
|
+
check already added an error, your `#validate` still runs (it has the
|
|
62
|
+
chance to surface additional context), but `#execute` is skipped.
|
|
63
|
+
|
|
64
|
+
## Warning vs. error: how to choose
|
|
65
|
+
|
|
66
|
+
| Level | Helper | Effect |
|
|
67
|
+
|-----------|-------------------------|---------------------------------------------------------------------------------|
|
|
68
|
+
| `:info` | `log_item_info(...)` | Recorded on `#logs`; does not affect `#status`. |
|
|
69
|
+
| `:warning`| `log_item_warning(...)` | Flips `#status` from `:ok` to `:with_warnings`; `#execute` still runs. |
|
|
70
|
+
| `:error` | `log_item_error(...)` | Flips `#status` to `:with_errors`; `#execute` is **skipped**, `#result` is nil. |
|
|
71
|
+
|
|
72
|
+
Rule of thumb:
|
|
73
|
+
|
|
74
|
+
- **Use an error** when continuing would produce an invalid or
|
|
75
|
+
misleading result (`#execute` would have to handle the bad state).
|
|
76
|
+
- **Use a warning** when the result is still meaningful but the
|
|
77
|
+
caller should know something is off (a missing optional input, an
|
|
78
|
+
in-progress migration shape, a deprecated value).
|
|
79
|
+
|
|
80
|
+
A worked example:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
class CreateUser < Assistant::Service
|
|
84
|
+
input :email, type: String, required: true
|
|
85
|
+
input :age, type: Integer, allow_nil: true, default: nil
|
|
86
|
+
|
|
87
|
+
def validate
|
|
88
|
+
log_item_error(source: :validate, detail: :email, message: 'invalid email') unless email.include?('@')
|
|
89
|
+
log_item_warning(source: :validate, detail: :age, message: 'age missing') if age.nil?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def execute
|
|
93
|
+
{ email:, age: }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
CreateUser.run(email: 'a@b.com').fetch(:status)
|
|
98
|
+
# => :with_warnings — age is missing, but we still build the result
|
|
99
|
+
|
|
100
|
+
CreateUser.run(email: 'oops').fetch(:status)
|
|
101
|
+
# => :with_errors — execute is skipped
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Conditional requirements
|
|
105
|
+
|
|
106
|
+
When a presence check should fire only sometimes, combine
|
|
107
|
+
`required: true` with `if:`:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
class UpdateUser < Assistant::Service
|
|
111
|
+
input :role, type: Symbol, default: :member
|
|
112
|
+
input :reason, type: String, required: true, if: ->(_value) { true }
|
|
113
|
+
|
|
114
|
+
def execute
|
|
115
|
+
{ role:, reason: }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
UpdateUser.run(role: :member).fetch(:status)
|
|
120
|
+
# => :with_errors — predicate is truthy, so :reason is required
|
|
121
|
+
|
|
122
|
+
UpdateUser.run(role: :member, reason: 'audit cleanup').fetch(:status)
|
|
123
|
+
# => :ok
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The `if:` predicate is called with the *input's own value*. The
|
|
127
|
+
validator requires the input to be present **and** the predicate to
|
|
128
|
+
be truthy — so the canonical use is "I need this to be present
|
|
129
|
+
*when* some other condition holds". See
|
|
130
|
+
[`inputs.md`](./inputs.md#if-conditional-requirement) for the
|
|
131
|
+
inverse pattern.
|
|
132
|
+
|
|
133
|
+
## `LogItem.new` raises in 1.0 (M10)
|
|
134
|
+
|
|
135
|
+
Constructing a `LogItem` directly with invalid attributes now raises
|
|
136
|
+
`ArgumentError`. The `#valid?` family is kept for introspection but
|
|
137
|
+
always returns `true` after a successful `new`:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
Assistant::LogItem.new(level: :info, source: :a, detail: :b, message: 'ok').valid?
|
|
141
|
+
# => true
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
Assistant::LogItem.new(level: :info, source: :a, detail: :b, message: '')
|
|
145
|
+
rescue ArgumentError => e
|
|
146
|
+
e.message # => "invalid LogItem: message must be present"
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Inside a `Service`, you almost never need `LogItem.new` directly:
|
|
151
|
+
`log_item_info(...)`, `log_item_warning(...)`, `log_item_error(...)`,
|
|
152
|
+
and `add_log(level:, source:, detail:, message:)` build the item and
|
|
153
|
+
append it to `#logs` for you. See
|
|
154
|
+
[`logging-and-results.md`](./logging-and-results.md) for the full
|
|
155
|
+
catalogue.
|
|
156
|
+
|
|
157
|
+
## Common pitfalls
|
|
158
|
+
|
|
159
|
+
- **Returning `false` from `#validate` to signal failure.** The hook's
|
|
160
|
+
return value is ignored. The only way to fail is to log an
|
|
161
|
+
error-level `LogItem`.
|
|
162
|
+
- **Calling `raise` inside `#validate` or `#execute`.** Don't —
|
|
163
|
+
`assistant` is soft-fail. Convert expected failures into log items.
|
|
164
|
+
Unexpected exceptions propagate (the gem catches exceptions only
|
|
165
|
+
in `before_execute` / `around_execute` / `after_execute` hooks).
|
|
166
|
+
- **Building `LogItem.new(...)` and pushing it onto `#logs`.** Use the
|
|
167
|
+
helpers; they apply the same M10 strict construction and keep your
|
|
168
|
+
call sites readable.
|
|
169
|
+
- **Forgetting that `#validate` runs even when a declarative check
|
|
170
|
+
already failed.** Either guard `#validate` with `return if
|
|
171
|
+
errors.any?`, or design it to add complementary errors.
|
|
172
|
+
|
|
173
|
+
## See also
|
|
174
|
+
|
|
175
|
+
- [Inputs guide](./inputs.md) — `required:`, `if:`, multi-type, the
|
|
176
|
+
generated `valid_*` predicates.
|
|
177
|
+
- [Logging and results](./logging-and-results.md) — the helpers, the
|
|
178
|
+
full result hash, log filtering.
|
|
179
|
+
- [API reference: LogItem](../api-reference.md#assistantlogitem).
|
|
180
|
+
- [API reference: LogList](../api-reference.md#assistantloglist).
|
data/docs/index.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Home
|
|
3
|
+
layout: home
|
|
4
|
+
nav_order: 0
|
|
5
|
+
permalink: /
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Assistant
|
|
9
|
+
|
|
10
|
+
**Tiny, dependency-free soft-fail service objects for Ruby.**
|
|
11
|
+
|
|
12
|
+
[](https://rubygems.org/gems/assistant)
|
|
13
|
+
[](https://github.com/ramongr/assistant/actions/workflows/ci.yml)
|
|
14
|
+
|
|
15
|
+
Assistant lets you write service objects that **never raise for expected
|
|
16
|
+
failures**. A service declares its inputs, validates them, runs its body, and
|
|
17
|
+
returns a uniform result hash that always carries either a value plus
|
|
18
|
+
warnings or a list of errors. Ships with RBS signatures, a 1.0-frozen public
|
|
19
|
+
API, and zero runtime gem dependencies.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# Gemfile
|
|
25
|
+
gem 'assistant', '~> 1.0'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
bundle install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Ruby 3.4 or newer is required.
|
|
33
|
+
|
|
34
|
+
## The 60-second example
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
require 'assistant'
|
|
38
|
+
|
|
39
|
+
class CreateUser < Assistant::Service
|
|
40
|
+
input :email, type: String, required: true
|
|
41
|
+
input :name, type: String, default: 'Anonymous'
|
|
42
|
+
|
|
43
|
+
def execute
|
|
44
|
+
log_item_info(source: :create_user, detail: :persisted, message: "saved #{email}")
|
|
45
|
+
User.create!(email: email, name: name)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
case CreateUser.run(email: 'me@example.com')
|
|
50
|
+
in { result:, status: :ok }
|
|
51
|
+
result
|
|
52
|
+
in { errors:, status: :with_errors }
|
|
53
|
+
errors.map(&:item)
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Where to next
|
|
58
|
+
|
|
59
|
+
- **[Getting started](getting-started.md)** — walk through your first
|
|
60
|
+
service end-to-end.
|
|
61
|
+
- **[Guides](guides/inputs.md)** — DSL deep-dives, one page per concern.
|
|
62
|
+
- **[API reference](api-reference.md)** — every Frozen symbol, deep-link
|
|
63
|
+
friendly.
|
|
64
|
+
- **[Examples](examples/index.md)** — runnable patterns (Rails, CLI,
|
|
65
|
+
Sidekiq, composition, callbacks, instrumentation, RBS).
|
|
66
|
+
- **[Roadmap](roadmap.md)** — what's planned, what shipped.
|
|
67
|
+
- **[Changelog](changelog.md)** — full release history.
|
|
68
|
+
|
|
69
|
+
Source on [GitHub](https://github.com/ramongr/assistant).
|
data/docs/roadmap.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Roadmap
|
|
3
|
+
nav_order: 6
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Roadmap
|
|
7
|
+
|
|
8
|
+
The full 1.0 plan lives in
|
|
9
|
+
[`docs/v1/`](https://github.com/ramongr/assistant/tree/main/docs/v1) in
|
|
10
|
+
the repository. Each file below is a focused planning document; the
|
|
11
|
+
index ties them together.
|
|
12
|
+
|
|
13
|
+
| Doc | What it covers |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| [`README.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/README.md) | Plan index and reading order. |
|
|
16
|
+
| [`00-overview.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/00-overview.md) | 1.0 goals, non-goals, acceptance criteria. |
|
|
17
|
+
| [`01-api-surface.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/01-api-surface.md) | Frozen vs Experimental symbols, stability labels. |
|
|
18
|
+
| [`02-features.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/02-features.md) | M1–M13 milestones and the promoted M-S* set. |
|
|
19
|
+
| [`03-documentation.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/03-documentation.md) | D1–D5 documentation deliverables. |
|
|
20
|
+
| [`04-release-checklist.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/04-release-checklist.md) | Pre-release, RC, release, and post-release steps. |
|
|
21
|
+
| [`05-quality-and-tooling.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/05-quality-and-tooling.md) | SimpleCov, RuboCop, Steep, CI matrix. |
|
|
22
|
+
| [`06-migration-0x-to-1.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/06-migration-0x-to-1.md) | The three mechanical rewrites required to upgrade. |
|
|
23
|
+
| [`07-risks-and-open-questions.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/07-risks-and-open-questions.md) | Known constraints (e.g. R1 RBS limitation) and resolutions. |
|
|
24
|
+
| [`08-github-pages.md`](https://github.com/ramongr/assistant/blob/main/docs/v1/08-github-pages.md) | The plan for **this site** (parallel deliverable, not a 1.0 gate). |
|
|
25
|
+
|
|
26
|
+
## Current snapshot
|
|
27
|
+
|
|
28
|
+
- 1.0 release plumbing is in flight — the gem is at
|
|
29
|
+
[`1.0.0.rc1`](changelog.md) and the migration recipe is finalised.
|
|
30
|
+
- Every "Must" milestone (M1–M13) and every promoted "Should"
|
|
31
|
+
(M-S1–M-S4) has shipped to `main`.
|
|
32
|
+
- The Jekyll site you're reading now is P2 of the GitHub Pages plan;
|
|
33
|
+
later phases land the remaining guide / example content.
|
data/exe/assistant-rbs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Class-level DSL for registering `before_execute` / `after_execute` /
|
|
4
|
+
# `around_execute` hooks on `Assistant::Service` subclasses (M-S1).
|
|
5
|
+
#
|
|
6
|
+
# Mixed into `Service.singleton_class` so the DSL is available at class
|
|
7
|
+
# definition time. Hooks are stored as `UnboundMethod`s on private
|
|
8
|
+
# anonymous Modules so we can bind `self` to the service instance and
|
|
9
|
+
# still pass a block (the continuation) into `around_execute` hooks.
|
|
10
|
+
#
|
|
11
|
+
# Hooks are inherited at subclass-definition time: a subclass receives
|
|
12
|
+
# a duplicate of each registered hook array. Adding more hooks to the
|
|
13
|
+
# subclass does not affect the parent, and vice-versa.
|
|
14
|
+
#
|
|
15
|
+
# See docs/v1/02-features.md M-S1 and docs/v1/01-api-surface.md.
|
|
16
|
+
module Assistant::ExecuteCallbacks
|
|
17
|
+
# The exhaustive set of hook types this module manages.
|
|
18
|
+
# @return [Array<Symbol>]
|
|
19
|
+
HOOK_TYPES = %i[before_execute after_execute around_execute].freeze
|
|
20
|
+
|
|
21
|
+
# @return [Array<UnboundMethod>] hooks registered via {#before_execute}, in declaration order
|
|
22
|
+
def before_execute_hooks
|
|
23
|
+
@before_execute_hooks ||= []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Array<UnboundMethod>] hooks registered via {#after_execute}, in declaration order
|
|
27
|
+
def after_execute_hooks
|
|
28
|
+
@after_execute_hooks ||= []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Array<UnboundMethod>] hooks registered via {#around_execute}, in declaration order
|
|
32
|
+
def around_execute_hooks
|
|
33
|
+
@around_execute_hooks ||= []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Register a block to run after validation and before `#execute`.
|
|
37
|
+
# `self` inside the block is the service instance.
|
|
38
|
+
#
|
|
39
|
+
# @yield runs in the context of the service instance after validation, before `#execute`
|
|
40
|
+
# @raise [ArgumentError] when no block is given
|
|
41
|
+
# @return [Array<UnboundMethod>] the updated {#before_execute_hooks} chain
|
|
42
|
+
def before_execute(&block)
|
|
43
|
+
raise ArgumentError, 'before_execute requires a block' unless block
|
|
44
|
+
|
|
45
|
+
before_execute_hooks << build_hook(block)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Register a block to run after `#execute` returns. `self` inside the
|
|
49
|
+
# block is the service instance; the execute result is passed as the
|
|
50
|
+
# single positional argument.
|
|
51
|
+
#
|
|
52
|
+
# @yieldparam execute_result [Object] return value of `#execute`
|
|
53
|
+
# @raise [ArgumentError] when no block is given
|
|
54
|
+
# @return [Array<UnboundMethod>] the updated {#after_execute_hooks} chain
|
|
55
|
+
def after_execute(&block)
|
|
56
|
+
raise ArgumentError, 'after_execute requires a block' unless block
|
|
57
|
+
|
|
58
|
+
after_execute_hooks << build_hook(block)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Register a block that wraps `#execute`. `self` inside the block is
|
|
62
|
+
# the service instance; the `&blk` block argument yields to the
|
|
63
|
+
# inner stack (the next around hook, or `#execute` for the innermost
|
|
64
|
+
# layer). Declaration order wraps: the first declared hook is the
|
|
65
|
+
# outermost layer.
|
|
66
|
+
#
|
|
67
|
+
# @yield runs in the context of the service instance, wrapping the inner stack
|
|
68
|
+
# @yieldparam blk [Proc] the continuation; call `yield` (or `blk.call`) to invoke the inner layer
|
|
69
|
+
# @raise [ArgumentError] when no block is given
|
|
70
|
+
# @return [Array<UnboundMethod>] the updated {#around_execute_hooks} chain
|
|
71
|
+
def around_execute(&block)
|
|
72
|
+
raise ArgumentError, 'around_execute requires a block' unless block
|
|
73
|
+
|
|
74
|
+
around_execute_hooks << build_hook(block)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Snapshot parent hooks into the subclass at definition time. The
|
|
78
|
+
# snapshot is a `dup` so the subclass owns its own array and further
|
|
79
|
+
# additions on either side never bleed across the hierarchy.
|
|
80
|
+
#
|
|
81
|
+
# @param subclass [Class] freshly defined subclass
|
|
82
|
+
# @return [void]
|
|
83
|
+
def inherited(subclass)
|
|
84
|
+
super
|
|
85
|
+
subclass.instance_variable_set(:@before_execute_hooks, before_execute_hooks.dup)
|
|
86
|
+
subclass.instance_variable_set(:@after_execute_hooks, after_execute_hooks.dup)
|
|
87
|
+
subclass.instance_variable_set(:@around_execute_hooks, around_execute_hooks.dup)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Wrap the user's block in an anonymous Module so we can convert it
|
|
93
|
+
# to an `UnboundMethod`. `um.bind_call(service, *args, &cont)` then
|
|
94
|
+
# runs the user's block with `self` == the service instance AND
|
|
95
|
+
# passes a block argument through to the block's `&blk` parameter,
|
|
96
|
+
# which is essential for `around_execute` continuations and cannot
|
|
97
|
+
# be expressed with `instance_exec` alone.
|
|
98
|
+
def build_hook(block)
|
|
99
|
+
mod = Module.new
|
|
100
|
+
mod.send(:define_method, :__assistant_execute_hook__, &block)
|
|
101
|
+
mod.instance_method(:__assistant_execute_hook__)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Type signatures for `lib/assistant/execute_callbacks.rb`. See
|
|
2
|
+
# docs/v1/01-api-surface.md for the frozen 1.0 surface. The block types
|
|
3
|
+
# for `before_execute` / `after_execute` / `around_execute` are loosely
|
|
4
|
+
# typed because the user's block can take a `&blk` continuation, varied
|
|
5
|
+
# `result` shapes, or no arguments at all — RBS cannot model the
|
|
6
|
+
# `instance_exec`-like rebinding precisely.
|
|
7
|
+
|
|
8
|
+
module Assistant
|
|
9
|
+
module ExecuteCallbacks
|
|
10
|
+
HOOK_TYPES: Array[Symbol]
|
|
11
|
+
|
|
12
|
+
@before_execute_hooks: Array[UnboundMethod]
|
|
13
|
+
@after_execute_hooks: Array[UnboundMethod]
|
|
14
|
+
@around_execute_hooks: Array[UnboundMethod]
|
|
15
|
+
|
|
16
|
+
def before_execute_hooks: () -> Array[UnboundMethod]
|
|
17
|
+
def after_execute_hooks: () -> Array[UnboundMethod]
|
|
18
|
+
def around_execute_hooks: () -> Array[UnboundMethod]
|
|
19
|
+
|
|
20
|
+
def before_execute: () { (*untyped) -> untyped } -> Array[UnboundMethod]
|
|
21
|
+
def after_execute: () { (*untyped) -> untyped } -> Array[UnboundMethod]
|
|
22
|
+
def around_execute: () { (*untyped) -> untyped } -> Array[UnboundMethod]
|
|
23
|
+
|
|
24
|
+
def inherited: (Class subclass) -> void
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def build_hook: (Proc block) -> UnboundMethod
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'assistant/refinements/string_blankness'
|
|
4
|
+
|
|
5
|
+
# Generators for the per-input reader and `?`-checker instance methods.
|
|
6
|
+
# The lexical refinement `Assistant::Refinements::StringBlankness` is
|
|
7
|
+
# activated here only (narrower than the pre-M13 module-wide `using`).
|
|
8
|
+
module Assistant::InputBuilder::Accessors
|
|
9
|
+
using Assistant::Refinements::StringBlankness
|
|
10
|
+
|
|
11
|
+
# Define `#name` reader on the host class. Returns the raw value
|
|
12
|
+
# stored under `@inputs[name]`.
|
|
13
|
+
#
|
|
14
|
+
# @param name [Symbol] input name
|
|
15
|
+
# @return [void]
|
|
16
|
+
def input_getter_meth(name:)
|
|
17
|
+
define_method(name) do
|
|
18
|
+
@inputs[name]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Define `#name?` predicate on the host class. Treats `nil`, `false`,
|
|
23
|
+
# whitespace-only strings, and `#empty?` collections as missing.
|
|
24
|
+
#
|
|
25
|
+
# @param name [Symbol] input name
|
|
26
|
+
# @return [void]
|
|
27
|
+
def input_checker_meth(name:)
|
|
28
|
+
define_method("#{name}?") do
|
|
29
|
+
val = @inputs[name]
|
|
30
|
+
return false if val.nil? || val == false
|
|
31
|
+
return !val.whitespace? if val.is_a?(String)
|
|
32
|
+
|
|
33
|
+
val.respond_to?(:empty?) ? !val.empty? : true
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Type signatures for `lib/assistant/input_builder/accessors.rb`. Each
|
|
2
|
+
# helper defines a per-input instance method on the host Service
|
|
3
|
+
# subclass via `define_method`; from RBS's perspective the helpers
|
|
4
|
+
# return whatever `define_method` returns (a Symbol).
|
|
5
|
+
|
|
6
|
+
module Assistant::InputBuilder::Accessors
|
|
7
|
+
def input_getter_meth: (name: Symbol) -> Symbol
|
|
8
|
+
|
|
9
|
+
def input_checker_meth: (name: Symbol) -> Symbol
|
|
10
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# M1: class-time gate for the `default:` option — fail fast on illegal
|
|
4
|
+
# providers, warn on shared mutable literals. Pure side-effects, no
|
|
5
|
+
# interaction with the per-class definitions registry.
|
|
6
|
+
module Assistant::InputBuilder::DefaultOption
|
|
7
|
+
# Run the class-time gate for an input's `default:` provider:
|
|
8
|
+
# reject illegal providers, warn on shared mutable literals.
|
|
9
|
+
#
|
|
10
|
+
# @param name [Symbol] input name
|
|
11
|
+
# @param default [Object, Proc] the literal value or zero-arity Proc
|
|
12
|
+
# @return [void]
|
|
13
|
+
# @raise [ArgumentError] when `default` is callable but not a zero-arity Proc
|
|
14
|
+
def process_default_option(name:, default:)
|
|
15
|
+
validate_default!(name:, default:)
|
|
16
|
+
warn_on_mutable_default(name:, default:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# M1: a default: provider must be either a literal value or a
|
|
20
|
+
# zero-arity Proc/Lambda. Anything else that responds to #call (a
|
|
21
|
+
# Method object, a custom callable) is rejected at class-definition
|
|
22
|
+
# time.
|
|
23
|
+
def validate_default!(name:, default:)
|
|
24
|
+
if default.is_a?(Proc) && !default.arity.zero? && default.arity != -1
|
|
25
|
+
raise ArgumentError, "default: for input :#{name} must be a zero-arity Proc, got arity #{default.arity}"
|
|
26
|
+
elsif !default.is_a?(Proc) && default.respond_to?(:call)
|
|
27
|
+
raise ArgumentError, "default: for input :#{name} must be a literal or a zero-arity Proc, not a #{default.class}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# M1: warn when a mutable literal default (unfrozen Array/Hash) is
|
|
32
|
+
# used — such a default is shared across every instance of the
|
|
33
|
+
# Service subclass and almost never what the author wants. Frozen
|
|
34
|
+
# literals and Procs are safe and pass silently.
|
|
35
|
+
def warn_on_mutable_default(name:, default:)
|
|
36
|
+
return unless (default.is_a?(Array) || default.is_a?(Hash)) && !default.frozen?
|
|
37
|
+
|
|
38
|
+
Kernel.warn("assistant: input :#{name} has a mutable #{default.class} default; " \
|
|
39
|
+
'use `default: -> { ... }` to avoid sharing state across instances')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Type signatures for `lib/assistant/input_builder/default_option.rb`
|
|
2
|
+
# (M1). Raises `ArgumentError` at class-definition time for illegal
|
|
3
|
+
# default providers; warns on shared mutable literals.
|
|
4
|
+
|
|
5
|
+
module Assistant::InputBuilder::DefaultOption
|
|
6
|
+
def process_default_option: (name: Symbol, default: untyped) -> void
|
|
7
|
+
|
|
8
|
+
def validate_default!: (name: Symbol, default: untyped) -> void
|
|
9
|
+
|
|
10
|
+
def warn_on_mutable_default: (name: Symbol, default: untyped) -> void
|
|
11
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Public DSL surface: declarative `#input`/`#inputs` that register an
|
|
4
|
+
# input definition and generate the per-input accessor + validator
|
|
5
|
+
# instance methods on the host Service subclass. Calls into every
|
|
6
|
+
# other InputBuilder submodule.
|
|
7
|
+
#
|
|
8
|
+
# These two methods deliberately keep their leading positional `name`
|
|
9
|
+
# parameter even though every other M12 helper is keyword-only --
|
|
10
|
+
# `input :foo, type: String` reads better as a class-body declaration
|
|
11
|
+
# than `input name: :foo, type: String`. The internal helpers we call
|
|
12
|
+
# below are still keyword-only; we just map the positional `attr_name`
|
|
13
|
+
# /`attr_names` here to `name:` / `names:` on the way down.
|
|
14
|
+
module Assistant::InputBuilder::Dsl
|
|
15
|
+
# Lists all inputs that have the same type and options.
|
|
16
|
+
def inputs(attr_names, type:, **)
|
|
17
|
+
attr_names.each do |attr_name|
|
|
18
|
+
input(attr_name, type:, **)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Individual input with a specific type or options.
|
|
23
|
+
def input(attr_name, type:, **options)
|
|
24
|
+
process_default_option(name: attr_name, default: options[:default]) if options.key?(:default)
|
|
25
|
+
options = process_optional_option(name: attr_name, options:) if options.key?(:optional)
|
|
26
|
+
register_input_definition(name: attr_name, type:, options:)
|
|
27
|
+
|
|
28
|
+
# Base Methods
|
|
29
|
+
input_getter_meth(name: attr_name)
|
|
30
|
+
input_checker_meth(name: attr_name)
|
|
31
|
+
|
|
32
|
+
# Input type validation method, simple and conditional requirement validation methods
|
|
33
|
+
input_type_validator_meth(name: attr_name, type:, **options)
|
|
34
|
+
input_require_validator_meth(name: attr_name, **options) if options[:required] == true
|
|
35
|
+
input_require_conditional_meth(name: attr_name, **options) if options[:required] == true && options[:if]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Type signatures for `lib/assistant/input_builder/dsl.rb`. The public
|
|
2
|
+
# `#input`/`#inputs` DSL surface; see docs/v1/01-api-surface.md.
|
|
3
|
+
#
|
|
4
|
+
# Note: `#input` and `#inputs` deliberately keep their leading
|
|
5
|
+
# positional `attr_name`/`attr_names` parameter; every other M12 helper
|
|
6
|
+
# is keyword-only.
|
|
7
|
+
|
|
8
|
+
module Assistant::InputBuilder::Dsl
|
|
9
|
+
def inputs: (Array[Symbol] attr_names, type: untyped, **untyped) -> Array[Symbol]
|
|
10
|
+
|
|
11
|
+
def input: (Symbol attr_name, type: untyped, **untyped) -> untyped
|
|
12
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# M7: explicit `optional:` flag handling. Validates the value and
|
|
4
|
+
# returns the canonical option hash (with `:required` derived from
|
|
5
|
+
# `optional: false`). Mirrors `DefaultOption`'s shape so the `#input`
|
|
6
|
+
# call site stays one line per option family.
|
|
7
|
+
module Assistant::InputBuilder::OptionalOption
|
|
8
|
+
# Validate the `optional:` keyword for an input and return the
|
|
9
|
+
# canonical option hash. `optional: false` is translated into
|
|
10
|
+
# `required: true` so downstream validators see a single flag.
|
|
11
|
+
#
|
|
12
|
+
# @param name [Symbol] input name
|
|
13
|
+
# @param options [Hash] options hash from the `#input` call
|
|
14
|
+
# @return [Hash] the (possibly translated) options hash
|
|
15
|
+
# @raise [ArgumentError] when `optional:` is non-boolean or contradicts `required: true`
|
|
16
|
+
def process_optional_option(name:, options:)
|
|
17
|
+
validate_optional!(name:, options:)
|
|
18
|
+
apply_optional_option(options)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# M7: `optional:` must be a boolean. `optional: true` together with
|
|
22
|
+
# `required: true` is a contradiction. Both rules raise
|
|
23
|
+
# `ArgumentError` at class-definition time, before any method is
|
|
24
|
+
# generated.
|
|
25
|
+
def validate_optional!(name:, options:)
|
|
26
|
+
optional = options[:optional]
|
|
27
|
+
unless [true, false].include?(optional)
|
|
28
|
+
raise ArgumentError, "optional: for input :#{name} must be true or false, got #{optional.inspect}"
|
|
29
|
+
end
|
|
30
|
+
return unless optional == true && options[:required] == true
|
|
31
|
+
|
|
32
|
+
raise ArgumentError, "input :#{name} cannot be both required: true and optional: true"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# M7: pure translation of the validated `optional:` value into the
|
|
36
|
+
# canonical `required:` flag used by downstream validator helpers.
|
|
37
|
+
# `optional: false` -> `required: true`; `optional: true` is left
|
|
38
|
+
# alone (no `valid_require_<name>?` is generated, matching the
|
|
39
|
+
# default). The original `:optional` key is retained in
|
|
40
|
+
# `input_definitions` for introspection. Non-mutating: callers
|
|
41
|
+
# receive a new hash when a translation is applied.
|
|
42
|
+
def apply_optional_option(options)
|
|
43
|
+
options[:optional] == false ? options.merge(required: true) : options
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Type signatures for `lib/assistant/input_builder/optional_option.rb`
|
|
2
|
+
# (M7). Validates the `optional:` flag and normalises the option hash.
|
|
3
|
+
|
|
4
|
+
module Assistant::InputBuilder::OptionalOption
|
|
5
|
+
def process_optional_option: (name: Symbol, options: Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
|
|
6
|
+
|
|
7
|
+
def validate_optional!: (name: Symbol, options: Hash[Symbol, untyped]) -> void
|
|
8
|
+
|
|
9
|
+
def apply_optional_option: (Hash[Symbol, untyped] options) -> Hash[Symbol, untyped]
|
|
10
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Per-class registry of input definitions. Each Service subclass gets its
|
|
4
|
+
# own hash keyed by attribute name with the original keyword options
|
|
5
|
+
# frozen for introspection (used by Service#initialize for M1 defaulting
|
|
6
|
+
# and by the M11 RBS generator).
|
|
7
|
+
module Assistant::InputBuilder::Registry
|
|
8
|
+
# Per-class hash of input definitions, keyed by attribute name.
|
|
9
|
+
# Values are frozen `{ type:, **options }` hashes. Read by
|
|
10
|
+
# `Service#initialize` (M1 defaulting), by `Service#input_snapshot`
|
|
11
|
+
# (M-S4), and by the M11 RBS generator.
|
|
12
|
+
#
|
|
13
|
+
# @return [Hash{Symbol => Hash}]
|
|
14
|
+
def input_definitions
|
|
15
|
+
@input_definitions ||= {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Register or replace an input definition.
|
|
19
|
+
#
|
|
20
|
+
# @param name [Symbol] input name
|
|
21
|
+
# @param type [Class, Array<Class>] declared type(s)
|
|
22
|
+
# @param options [Hash] remaining `#input` keyword options
|
|
23
|
+
# @return [Hash] the frozen definition entry just stored
|
|
24
|
+
def register_input_definition(name:, type:, options:)
|
|
25
|
+
input_definitions[name] = { type:, **options }.freeze
|
|
26
|
+
end
|
|
27
|
+
end
|