operandi 5.0.0
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/.cursor/rules/services/RULE.md +269 -0
- data/.cursor/rules/services-rspec/RULE.md +354 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +25 -0
- data/.rspec +3 -0
- data/.rubocop.yml +134 -0
- data/.ruby-version +1 -0
- data/.vscode/cspell.json +18 -0
- data/.vscode/project-words.txt +12 -0
- data/AGENTS.md +139 -0
- data/CHANGELOG.md +111 -0
- data/CLAUDE.md +139 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +28 -0
- data/Gemfile.lock +149 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/config/default.yml +57 -0
- data/docs/README.md +105 -0
- data/docs/SUMMARY.md +31 -0
- data/docs/arguments.md +275 -0
- data/docs/best-practices.md +153 -0
- data/docs/callbacks.md +475 -0
- data/docs/concepts.md +79 -0
- data/docs/configuration.md +218 -0
- data/docs/context.md +128 -0
- data/docs/crud.md +525 -0
- data/docs/errors.md +331 -0
- data/docs/generators.md +250 -0
- data/docs/outputs.md +150 -0
- data/docs/pundit-authorization.md +320 -0
- data/docs/quickstart.md +133 -0
- data/docs/recipes.md +14 -0
- data/docs/rubocop.md +430 -0
- data/docs/ruby-lsp.md +121 -0
- data/docs/service-rendering.md +222 -0
- data/docs/sorbet-runtime.md +283 -0
- data/docs/steps.md +438 -0
- data/docs/tapioca.md +188 -0
- data/docs/testing.md +548 -0
- data/lib/generators/operandi/install/USAGE +15 -0
- data/lib/generators/operandi/install/install_generator.rb +45 -0
- data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
- data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
- data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
- data/lib/generators/operandi/service/USAGE +21 -0
- data/lib/generators/operandi/service/service_generator.rb +80 -0
- data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
- data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
- data/lib/operandi/base.rb +230 -0
- data/lib/operandi/base_with_context.rb +57 -0
- data/lib/operandi/callbacks.rb +353 -0
- data/lib/operandi/collection.rb +166 -0
- data/lib/operandi/concerns/execution.rb +80 -0
- data/lib/operandi/concerns/parent_service.rb +32 -0
- data/lib/operandi/concerns/state_management.rb +34 -0
- data/lib/operandi/config.rb +142 -0
- data/lib/operandi/constants.rb +96 -0
- data/lib/operandi/dsl/arguments_dsl.rb +83 -0
- data/lib/operandi/dsl/outputs_dsl.rb +79 -0
- data/lib/operandi/dsl/steps_dsl.rb +206 -0
- data/lib/operandi/dsl/validation.rb +171 -0
- data/lib/operandi/exceptions.rb +66 -0
- data/lib/operandi/message.rb +52 -0
- data/lib/operandi/messages.rb +185 -0
- data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
- data/lib/operandi/rspec/matchers/define_output.rb +145 -0
- data/lib/operandi/rspec/matchers/define_step.rb +223 -0
- data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
- data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
- data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
- data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
- data/lib/operandi/rspec.rb +15 -0
- data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
- data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
- data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
- data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
- data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
- data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
- data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
- data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
- data/lib/operandi/rubocop.rb +22 -0
- data/lib/operandi/settings/field.rb +147 -0
- data/lib/operandi/settings/step.rb +105 -0
- data/lib/operandi/utils.rb +36 -0
- data/lib/operandi/version.rb +5 -0
- data/lib/operandi.rb +11 -0
- data/lib/ruby_lsp/operandi/addon.rb +37 -0
- data/lib/ruby_lsp/operandi/definition.rb +134 -0
- data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
- data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
- data/operandi.gemspec +33 -0
- data/rbi/operandi.rbi +197 -0
- data/sorbet/cache/data.mdb +0 -0
- data/sorbet/cache/lock.mdb +0 -0
- metadata +151 -0
data/docs/errors.md
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
Errors are a natural part of every application. This guide explores how to handle errors within Operandi, drawing parallels to ActiveModel errors.
|
|
4
|
+
|
|
5
|
+
## Error Structure
|
|
6
|
+
|
|
7
|
+
Operandi errors follow a structure similar to ActiveModel errors. Here's a simplified example:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
{
|
|
11
|
+
email: ["must be a valid email"],
|
|
12
|
+
password: ["is too short", "must contain at least one number"]
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Adding Errors
|
|
17
|
+
|
|
18
|
+
To add an error to your service, use the `errors.add` method.
|
|
19
|
+
|
|
20
|
+
{% hint style="info" %}
|
|
21
|
+
By default, adding an error marks the service as failed, preventing subsequent steps from executing. This behavior can be customized in the configuration for individual services and errors.
|
|
22
|
+
{% endhint %}
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
class ParsePage < ApplicationService
|
|
26
|
+
# Arguments
|
|
27
|
+
arg :url, type: String
|
|
28
|
+
# ...
|
|
29
|
+
|
|
30
|
+
# Steps
|
|
31
|
+
step :validate
|
|
32
|
+
step :parse
|
|
33
|
+
# ...
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def validate
|
|
38
|
+
# Multiple errors can be added with the same key
|
|
39
|
+
errors.add(:url, "must be a valid URL") unless url.match?(URI::DEFAULT_PARSER.make_regexp)
|
|
40
|
+
errors.add(:url, "must be a secure link") unless url.start_with?("https")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# ...
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick Error with `fail!`
|
|
48
|
+
|
|
49
|
+
The `fail!` method is a shortcut for adding an error to the `:base` key:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
class ParsePage < ApplicationService
|
|
53
|
+
def validate
|
|
54
|
+
fail!("URL is required") if url.blank?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This is equivalent to:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
errors.add(:base, "URL is required")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Reading Errors
|
|
66
|
+
|
|
67
|
+
To check if a service has errors, you can use the `#failed?` method. You can also use methods like `errors.any?` to inspect errors.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
class ParsePage < ApplicationService
|
|
71
|
+
def parse
|
|
72
|
+
nodes.each do |node|
|
|
73
|
+
if node.nil? || (node.respond_to?(:empty?) && node.empty?)
|
|
74
|
+
errors.add(:base, "Node #{node} is blank")
|
|
75
|
+
else
|
|
76
|
+
parse_node(node)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if failed? # or errors.any?
|
|
81
|
+
puts "Not all nodes were parsed"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
You can access errors outside the service using the `#errors` method.
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
service = ParsePage.run(url: "rubygems")
|
|
91
|
+
|
|
92
|
+
if service.failed?
|
|
93
|
+
puts service.errors
|
|
94
|
+
puts service.errors[:url]
|
|
95
|
+
puts service.errors.to_h # Returns errors as a hash
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Adding Warnings
|
|
100
|
+
|
|
101
|
+
Sometimes, you may want to add a warning instead of an error. Warnings are similar to errors but they do not mark the service as failed. By default they also do not stop execution and do not roll back the transaction (both behaviors can be configured globally or per-message).
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
class ParsePage < ApplicationService
|
|
105
|
+
def validate
|
|
106
|
+
errors.add(:url, "must be a valid URL") unless url.match?(URI::DEFAULT_PARSER.make_regexp)
|
|
107
|
+
warnings.add(:url, "should be a secure link") unless url.start_with?("https")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
service = ParsePage.run(url: "http://rubygems.org")
|
|
114
|
+
|
|
115
|
+
if service.warnings.any?
|
|
116
|
+
puts service.warnings
|
|
117
|
+
puts service.warnings[:url]
|
|
118
|
+
puts service.warnings.to_h # Returns warnings as a hash
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Copying Errors
|
|
123
|
+
|
|
124
|
+
### From ActiveRecord Models
|
|
125
|
+
|
|
126
|
+
Use `errors.copy_from` (or its alias `errors.from_record`) to copy errors from an ActiveRecord model:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
class User::Create < ApplicationService
|
|
130
|
+
def create_user
|
|
131
|
+
self.user = User.new(attributes)
|
|
132
|
+
|
|
133
|
+
unless user.save
|
|
134
|
+
errors.copy_from(user) # Copies all validation errors from the user model
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### From Another Service
|
|
141
|
+
|
|
142
|
+
Copy errors from a child service that wasn't run in the same context:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
class Order::Process < ApplicationService
|
|
146
|
+
def process_payment
|
|
147
|
+
payment_service = Payment::Charge.run(amount:, card:)
|
|
148
|
+
|
|
149
|
+
if payment_service.failed?
|
|
150
|
+
errors.copy_from(payment_service)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Converting Errors to Hash
|
|
157
|
+
|
|
158
|
+
Use `errors.to_h` to get a hash representation of all errors:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
service = User::Create.run(email: "invalid")
|
|
162
|
+
|
|
163
|
+
if service.failed?
|
|
164
|
+
service.errors.to_h
|
|
165
|
+
# => { email: ["is invalid"], password: ["can't be blank"] }
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Per-Message Options
|
|
170
|
+
|
|
171
|
+
When adding errors, you can control behavior on a per-message basis:
|
|
172
|
+
|
|
173
|
+
### Control Break Behavior
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
def validate
|
|
177
|
+
# This error won't stop subsequent steps from running
|
|
178
|
+
errors.add(:warning_field, "has a minor issue", break: false)
|
|
179
|
+
|
|
180
|
+
# This error WILL stop execution (default behavior)
|
|
181
|
+
errors.add(:critical_field, "is completely invalid")
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Control Rollback Behavior
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
def process
|
|
189
|
+
# This error won't trigger a transaction rollback
|
|
190
|
+
errors.add(:notification, "failed to send", rollback: false)
|
|
191
|
+
|
|
192
|
+
# This error WILL rollback (default behavior when use_transactions is true)
|
|
193
|
+
errors.add(:payment, "failed to process")
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Checking for Errors and Warnings
|
|
198
|
+
|
|
199
|
+
Operandi provides convenient methods to check error/warning states:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
service = MyService.run(args)
|
|
203
|
+
|
|
204
|
+
# Check if service has any errors
|
|
205
|
+
service.failed? # => true/false
|
|
206
|
+
service.success? # => true/false (opposite of failed?)
|
|
207
|
+
service.errors? # => true/false (same as errors.any?)
|
|
208
|
+
|
|
209
|
+
# Check if service has any warnings
|
|
210
|
+
service.warnings? # => true/false (same as warnings.any?)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
By following these guidelines, you can effectively manage errors and warnings in Operandi, ensuring a smoother and more robust application experience.
|
|
214
|
+
|
|
215
|
+
## Exception Classes
|
|
216
|
+
|
|
217
|
+
Operandi defines several exception classes for different error scenarios:
|
|
218
|
+
|
|
219
|
+
| Exception | Description |
|
|
220
|
+
|-----------|-------------|
|
|
221
|
+
| `Operandi::Error` | Base exception class for all Operandi errors |
|
|
222
|
+
| `Operandi::RuntimeError` | Raised while a service is running; exposes the service instance through `service` |
|
|
223
|
+
| `Operandi::ArgTypeError` | Raised when type validation fails; exposes the associated service class through `service_class` |
|
|
224
|
+
| `Operandi::ReservedNameError` | Raised when using a reserved name for arguments, outputs, or steps |
|
|
225
|
+
| `Operandi::InvalidNameError` | Raised when using an invalid name format |
|
|
226
|
+
| `Operandi::NoStepsError` | Raised when a service has no steps defined and no `run` method |
|
|
227
|
+
| `Operandi::MissingTypeError` | Raised when defining an argument or output without a `type` option when `require_arg_type` or `require_output_type` is enabled |
|
|
228
|
+
| `Operandi::StopExecution` | Control flow exception raised by `stop_immediately!` to halt execution without rollback |
|
|
229
|
+
| `Operandi::FailExecution` | Control flow exception raised by `fail_immediately!` to halt execution and rollback transactions |
|
|
230
|
+
|
|
231
|
+
### RuntimeError
|
|
232
|
+
|
|
233
|
+
Runtime failures caused by service errors, warnings, or invalid step execution raise
|
|
234
|
+
`Operandi::RuntimeError`. The exception inherits from `Operandi::Error` and provides
|
|
235
|
+
the concrete service instance that failed:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
begin
|
|
239
|
+
MyService.run!(name: "John")
|
|
240
|
+
rescue Operandi::RuntimeError => error
|
|
241
|
+
error.service # => the MyService instance
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Errors raised while defining a service continue to use `Operandi::Error`.
|
|
246
|
+
|
|
247
|
+
### ArgTypeError
|
|
248
|
+
|
|
249
|
+
Argument and output type failures raise `Operandi::ArgTypeError`. It inherits from
|
|
250
|
+
`Operandi::Error` and identifies the associated service class without requiring a
|
|
251
|
+
service instance:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
begin
|
|
255
|
+
MyService.run(name: 123)
|
|
256
|
+
rescue Operandi::ArgTypeError => error
|
|
257
|
+
error.service_class # => MyService
|
|
258
|
+
end
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### MissingTypeError
|
|
262
|
+
|
|
263
|
+
This exception is raised when you define an argument or output without a `type` option. Since `require_arg_type` and `require_output_type` are enabled by default, all arguments and outputs must have a type.
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
class MyService < ApplicationService
|
|
267
|
+
arg :name # => raises Operandi::MissingTypeError
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
To fix this, add a `type` option to all arguments and outputs:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
class MyService < ApplicationService
|
|
275
|
+
arg :name, type: String
|
|
276
|
+
output :result, type: Hash
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
If you need to disable type enforcement for legacy services, you can use the `config` method:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
class LegacyService < ApplicationService
|
|
284
|
+
config require_arg_type: false, require_output_type: false
|
|
285
|
+
|
|
286
|
+
arg :data # Allowed when require_arg_type is disabled
|
|
287
|
+
output :result # Allowed when require_output_type is disabled
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### NoStepsError
|
|
292
|
+
|
|
293
|
+
This exception is raised when you attempt to execute a service that has no steps defined and no `run` method as a fallback:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
class EmptyService < ApplicationService
|
|
297
|
+
# No steps defined and no run method
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
EmptyService.run # => raises Operandi::NoStepsError
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
To fix this, either define at least one step or implement a `run` method:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
# Option 1: Define steps
|
|
307
|
+
class MyService < ApplicationService
|
|
308
|
+
step :do_work
|
|
309
|
+
|
|
310
|
+
private
|
|
311
|
+
|
|
312
|
+
def do_work
|
|
313
|
+
# ...
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Option 2: Use run method
|
|
318
|
+
class MyService < ApplicationService
|
|
319
|
+
private
|
|
320
|
+
|
|
321
|
+
def run
|
|
322
|
+
# ...
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## What's next?
|
|
328
|
+
|
|
329
|
+
Learn about callbacks to add logging, benchmarking, and other cross-cutting concerns to your services.
|
|
330
|
+
|
|
331
|
+
[Next: Callbacks](callbacks.md)
|
data/docs/generators.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Rails Generators
|
|
2
|
+
|
|
3
|
+
Operandi includes Rails generators to help you quickly set up and create services in your Rails application. These generators follow Rails conventions and integrate seamlessly with your Rails workflow.
|
|
4
|
+
|
|
5
|
+
## Install Generator
|
|
6
|
+
|
|
7
|
+
The install generator sets up Operandi in your Rails application by creating the base `ApplicationService` class and configuration files.
|
|
8
|
+
|
|
9
|
+
### Usage
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bin/rails generate operandi:install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### What It Creates
|
|
16
|
+
|
|
17
|
+
The install generator creates the following files:
|
|
18
|
+
|
|
19
|
+
1. **`app/services/application_service.rb`** - Base service class for your application
|
|
20
|
+
```ruby
|
|
21
|
+
class ApplicationService < Operandi::Base
|
|
22
|
+
# Add common arguments, callbacks, or helpers shared across all services.
|
|
23
|
+
#
|
|
24
|
+
# Example: Add a context argument for the current user
|
|
25
|
+
# arg :current_user, type: User, optional: true, context: true
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
2. **`config/initializers/operandi.rb`** - Configuration file (unless `--skip-initializer` is used)
|
|
30
|
+
This file contains the global configuration for Operandi in your Rails application.
|
|
31
|
+
|
|
32
|
+
3. **`spec/services/application_service_spec.rb`** - RSpec test file (if RSpec is detected and `--skip-spec` is not used)
|
|
33
|
+
|
|
34
|
+
### Options
|
|
35
|
+
|
|
36
|
+
- `--skip-initializer` - Skip creating the initializer file
|
|
37
|
+
- `--skip-spec` - Skip creating the spec file
|
|
38
|
+
|
|
39
|
+
### Examples
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Standard installation
|
|
43
|
+
bin/rails generate operandi:install
|
|
44
|
+
|
|
45
|
+
# Skip initializer
|
|
46
|
+
bin/rails generate operandi:install --skip-initializer
|
|
47
|
+
|
|
48
|
+
# Skip spec file
|
|
49
|
+
bin/rails generate operandi:install --skip-spec
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Service Generator
|
|
53
|
+
|
|
54
|
+
The service generator creates a new service class that inherits from `ApplicationService`. It supports namespaced services and can pre-populate arguments, steps, and outputs.
|
|
55
|
+
|
|
56
|
+
### Usage
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
bin/rails generate operandi:service NAME [options]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### What It Creates
|
|
63
|
+
|
|
64
|
+
The service generator creates:
|
|
65
|
+
|
|
66
|
+
1. **Service file** - `app/services/{name}.rb`
|
|
67
|
+
2. **Spec file** - `spec/services/{name}_spec.rb` (if RSpec is detected and `--skip-spec` is not used)
|
|
68
|
+
|
|
69
|
+
### Options
|
|
70
|
+
|
|
71
|
+
- `--args` - List of arguments for the service (space-separated)
|
|
72
|
+
- `--steps` - List of steps for the service (space-separated)
|
|
73
|
+
- `--outputs` - List of outputs for the service (space-separated)
|
|
74
|
+
- `--skip-spec` - Skip creating the spec file
|
|
75
|
+
- `--parent` - Parent class (default: `ApplicationService`)
|
|
76
|
+
|
|
77
|
+
### Examples
|
|
78
|
+
|
|
79
|
+
#### Basic Service
|
|
80
|
+
|
|
81
|
+
Create a simple service without any predefined structure:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bin/rails generate operandi:service user/create
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This creates:
|
|
88
|
+
```ruby
|
|
89
|
+
# app/services/user/create.rb
|
|
90
|
+
class User::Create < ApplicationService
|
|
91
|
+
# step :step_a
|
|
92
|
+
# step :step_b
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# def step_a
|
|
97
|
+
# # TODO: Implement service logic
|
|
98
|
+
# end
|
|
99
|
+
|
|
100
|
+
# def step_b
|
|
101
|
+
# # TODO: Implement service logic
|
|
102
|
+
# end
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
#### Service with Arguments, Steps, and Outputs
|
|
107
|
+
|
|
108
|
+
Create a fully structured service:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
bin/rails generate operandi:service CreateOrder \
|
|
112
|
+
--args=user product quantity \
|
|
113
|
+
--steps=validate_stock create_order send_confirmation \
|
|
114
|
+
--outputs=order
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
This creates:
|
|
118
|
+
```ruby
|
|
119
|
+
# app/services/create_order.rb
|
|
120
|
+
class CreateOrder < ApplicationService
|
|
121
|
+
# Arguments
|
|
122
|
+
arg :user
|
|
123
|
+
arg :product
|
|
124
|
+
arg :quantity
|
|
125
|
+
|
|
126
|
+
# Steps
|
|
127
|
+
step :validate_stock
|
|
128
|
+
step :create_order
|
|
129
|
+
step :send_confirmation
|
|
130
|
+
|
|
131
|
+
# Outputs
|
|
132
|
+
output :order
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def validate_stock
|
|
137
|
+
# TODO: Implement validate_stock
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def create_order
|
|
141
|
+
# TODO: Implement create_order
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def send_confirmation
|
|
145
|
+
# TODO: Implement send_confirmation
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
#### Namespaced Service
|
|
151
|
+
|
|
152
|
+
Create a service within a namespace:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
bin/rails generate operandi:service payment/process \
|
|
156
|
+
--args=order payment_method \
|
|
157
|
+
--steps=validate_payment charge_card update_order \
|
|
158
|
+
--outputs=transaction
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
This creates:
|
|
162
|
+
```ruby
|
|
163
|
+
# app/services/payment/process.rb
|
|
164
|
+
class Payment::Process < ApplicationService
|
|
165
|
+
# Arguments
|
|
166
|
+
arg :order
|
|
167
|
+
arg :payment_method
|
|
168
|
+
|
|
169
|
+
# Steps
|
|
170
|
+
step :validate_payment
|
|
171
|
+
step :charge_card
|
|
172
|
+
step :update_order
|
|
173
|
+
|
|
174
|
+
# Outputs
|
|
175
|
+
output :transaction
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def validate_payment
|
|
180
|
+
# TODO: Implement validate_payment
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def charge_card
|
|
184
|
+
# TODO: Implement charge_card
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def update_order
|
|
188
|
+
# TODO: Implement update_order
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
#### Custom Parent Class
|
|
194
|
+
|
|
195
|
+
Create a service that inherits from a custom parent class:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
bin/rails generate operandi:service admin/reports/generate \
|
|
199
|
+
--parent=AdminService \
|
|
200
|
+
--args=start_date end_date \
|
|
201
|
+
--steps=fetch_data generate_report \
|
|
202
|
+
--outputs=report
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## RSpec Integration
|
|
206
|
+
|
|
207
|
+
Both generators automatically detect if RSpec is installed in your Rails application by checking for the presence of the `spec/` directory. If RSpec is detected, the generators will create corresponding spec files with basic test structure.
|
|
208
|
+
|
|
209
|
+
### Example Spec File
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
# spec/services/user/create_spec.rb
|
|
213
|
+
require "rails_helper"
|
|
214
|
+
|
|
215
|
+
RSpec.describe User::Create do
|
|
216
|
+
describe ".run" do
|
|
217
|
+
it "creates a user" do
|
|
218
|
+
service = described_class.run(...)
|
|
219
|
+
expect(service).to be_successful
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
You can skip spec file generation with the `--skip-spec` option:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
bin/rails generate operandi:service user/create --skip-spec
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Best Practices
|
|
232
|
+
|
|
233
|
+
1. **Run the install generator first** - Always run `operandi:install` before creating individual services to set up the base `ApplicationService` class.
|
|
234
|
+
|
|
235
|
+
2. **Use namespaces** - Organize related services under namespaces (e.g., `User::Create`, `Payment::Process`) to keep your services organized.
|
|
236
|
+
|
|
237
|
+
3. **Start with structure** - Use `--args`, `--steps`, and `--outputs` options to create a skeleton for your service, then fill in the implementation.
|
|
238
|
+
|
|
239
|
+
4. **Keep it simple** - Don't over-specify. If you're not sure about the exact steps, create a basic service and add them as you develop.
|
|
240
|
+
|
|
241
|
+
5. **Follow conventions** - Use descriptive names for services that indicate the action being performed (e.g., `CreateOrder`, `User::Authenticate`, `Payment::Refund`).
|
|
242
|
+
|
|
243
|
+
## Next Steps
|
|
244
|
+
|
|
245
|
+
After generating your services, learn more about:
|
|
246
|
+
|
|
247
|
+
- [Arguments](arguments.md) - Define and validate service inputs
|
|
248
|
+
- [Steps](steps.md) - Organize service logic into steps
|
|
249
|
+
- [Outputs](outputs.md) - Define service outputs
|
|
250
|
+
- [Testing](testing.md) - Write comprehensive tests for your services
|
data/docs/outputs.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Outputs
|
|
2
|
+
|
|
3
|
+
Outputs are the results of a service.
|
|
4
|
+
|
|
5
|
+
## TL;DR
|
|
6
|
+
|
|
7
|
+
- Define outputs using the `output` keyword in the service class
|
|
8
|
+
- Outputs can have default values
|
|
9
|
+
- Outputs can be validated by type (validated when the service succeeds)
|
|
10
|
+
|
|
11
|
+
## Define Outputs
|
|
12
|
+
|
|
13
|
+
You define outputs using the `output` keyword in the service class.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
class AI::Chat < ApplicationService
|
|
17
|
+
output :messages
|
|
18
|
+
output :cost
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Write Outputs
|
|
23
|
+
|
|
24
|
+
Outputs function similarly to instance variables created with `attr_accessor`.
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
class AI::Chat < ApplicationService
|
|
28
|
+
# Steps
|
|
29
|
+
step :chat
|
|
30
|
+
|
|
31
|
+
# Outputs
|
|
32
|
+
output :messages
|
|
33
|
+
output :cost
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def chat
|
|
38
|
+
self.messages = ["Hello!", "Hi, how are you?"]
|
|
39
|
+
self.cost = 0.0013
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
To set outputs programmatically, use the `output.set` method or hash syntax.
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
class AI::Chat < ApplicationService
|
|
48
|
+
# ...
|
|
49
|
+
|
|
50
|
+
def chat
|
|
51
|
+
output.set(:messages, ["Hello!", "Hi, how are you?"])
|
|
52
|
+
output.set(:cost, 0.0013)
|
|
53
|
+
|
|
54
|
+
# Or use hash syntax
|
|
55
|
+
|
|
56
|
+
output[:messages] = ["Hello!", "Hi, how are you?"]
|
|
57
|
+
output[:cost] = 0.0013
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Type Validation
|
|
63
|
+
|
|
64
|
+
You can specify the type of output using the `type` option. The output type will be validated when the service successfully completes.
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
class AI::Chat < ApplicationService
|
|
68
|
+
output :messages, type: Array
|
|
69
|
+
output :cost, type: Float
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
You can specify multiple allowed types using an array.
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
class AI::Chat < ApplicationService
|
|
77
|
+
output :result, type: [String, Hash]
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Type Enforcement (Enabled by Default)
|
|
82
|
+
|
|
83
|
+
By default, all outputs must have a `type` option. This helps catch type-related bugs early and makes your services self-documenting.
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class MyService < ApplicationService
|
|
87
|
+
output :result, type: Hash # ✓ Valid
|
|
88
|
+
output :data # ✗ Raises MissingTypeError
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
To disable type enforcement for outputs in a specific service:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class LegacyService < ApplicationService
|
|
96
|
+
config require_output_type: false
|
|
97
|
+
|
|
98
|
+
output :data # Allowed when require_output_type is disabled
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
See the [Configuration documentation](configuration.md) for more details.
|
|
103
|
+
|
|
104
|
+
### Sorbet Runtime Types
|
|
105
|
+
|
|
106
|
+
Outputs support [Sorbet runtime types](https://sorbet.org/docs/runtime) for type validation:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
require "sorbet-runtime"
|
|
110
|
+
|
|
111
|
+
class AI::Chat < ApplicationService
|
|
112
|
+
output :messages, type: T::Array[Hash]
|
|
113
|
+
output :total_tokens, type: T::Utils.coerce(Integer)
|
|
114
|
+
output :metadata, type: T.nilable(Hash), optional: true
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
See the [Sorbet Runtime Types documentation](sorbet-runtime.md) for more details.
|
|
119
|
+
|
|
120
|
+
## Default Values
|
|
121
|
+
|
|
122
|
+
Set default values for outputs using the `default` option. The default value will be automatically set before the execution of steps.
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class AI::Chat < ApplicationService
|
|
126
|
+
output :cost, default: 0.0
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Removing Inherited Outputs
|
|
131
|
+
|
|
132
|
+
When inheriting from a parent service, you can remove outputs using `remove_output`:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
class BaseReportService < ApplicationService
|
|
136
|
+
output :report
|
|
137
|
+
output :debug_info
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
class ProductionReportService < BaseReportService
|
|
141
|
+
# Don't expose debug info in production
|
|
142
|
+
remove_output :debug_info
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## What's Next?
|
|
147
|
+
|
|
148
|
+
Next, learn about context.
|
|
149
|
+
|
|
150
|
+
[Next: Context](context.md)
|