next_station 0.1.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/.aiignore +36 -0
- data/.idea/.gitignore +10 -0
- data/.idea/inspectionProfiles/Project_Default.xml +8 -0
- data/.idea/junie.xml +6 -0
- data/.idea/modules.xml +8 -0
- data/.idea/next_station.iml +54 -0
- data/.idea/vcs.xml +6 -0
- data/AGENTS.md +157 -0
- data/Gemfile +11 -0
- data/PLUGIN_SYSTEM_GUIDE.md +521 -0
- data/README.md +790 -0
- data/TODO.txt +6 -0
- data/examples/plugin_http_example.rb +102 -0
- data/lib/next_station/config/errors.yml +149 -0
- data/lib/next_station/config.rb +49 -0
- data/lib/next_station/environment.rb +42 -0
- data/lib/next_station/errors.rb +21 -0
- data/lib/next_station/logging/formatters/console.rb +38 -0
- data/lib/next_station/logging/formatters/json.rb +80 -0
- data/lib/next_station/logging/subscribers/base.rb +70 -0
- data/lib/next_station/logging/subscribers/custom.rb +25 -0
- data/lib/next_station/logging/subscribers/operation.rb +41 -0
- data/lib/next_station/logging/subscribers/step.rb +54 -0
- data/lib/next_station/logging.rb +35 -0
- data/lib/next_station/operation/class_methods.rb +299 -0
- data/lib/next_station/operation/errors.rb +97 -0
- data/lib/next_station/operation/node.rb +49 -0
- data/lib/next_station/operation.rb +393 -0
- data/lib/next_station/plugins.rb +23 -0
- data/lib/next_station/result.rb +124 -0
- data/lib/next_station/state.rb +64 -0
- data/lib/next_station/types.rb +11 -0
- data/lib/next_station/version.rb +5 -0
- data/lib/next_station.rb +36 -0
- metadata +203 -0
data/README.md
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
# NextStation
|
|
2
|
+
|
|
3
|
+
NextStation is a lightweight, flexible framework for building service objects (Operations) in Ruby. It provides a clean DSL to define business processes, manage state, and handle flow control.
|
|
4
|
+
|
|
5
|
+
## Index
|
|
6
|
+
|
|
7
|
+
- [Installation](#installation)
|
|
8
|
+
- [Getting Started](#getting-started)
|
|
9
|
+
- [Core Concepts](#core-concepts)
|
|
10
|
+
- [Flow Control](#flow-control)
|
|
11
|
+
- [Railway Pattern & Errors](#railway-pattern--errors)
|
|
12
|
+
- [Input Validation (dry-validation)](#input-validation-dry-validation)
|
|
13
|
+
- [Logging and Monitoring](#logging-and-monitoring)
|
|
14
|
+
- [Dependency Injection](#dependency-injection)
|
|
15
|
+
- [Nested Operations (Operation Composition)](#nested-operations-operation-composition)
|
|
16
|
+
- [Plugin System](#plugin-system)
|
|
17
|
+
- [Advanced Usage](#advanced-usage)
|
|
18
|
+
- [License](#license)
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Add this line to your application's Gemfile:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem 'next_station'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
And then execute:
|
|
29
|
+
|
|
30
|
+
$ bundle install
|
|
31
|
+
|
|
32
|
+
Or install it yourself as:
|
|
33
|
+
|
|
34
|
+
$ gem install next_station
|
|
35
|
+
|
|
36
|
+
## Getting Started
|
|
37
|
+
|
|
38
|
+
Define an operation by inheriting from `NextStation::Operation` and using the `process` block. You can use `result_at` to specify which key from the state should be returned as the result value:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
class CreateUser < NextStation::Operation
|
|
42
|
+
result_at :user_id
|
|
43
|
+
|
|
44
|
+
process do
|
|
45
|
+
step :validate_params
|
|
46
|
+
step :persist_user
|
|
47
|
+
step :send_welcome_email
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_params(state)
|
|
51
|
+
raise "Invalid email" unless state.params[:email].include?("@")
|
|
52
|
+
state
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def persist_user(state)
|
|
56
|
+
# state[:params] contains the initial input
|
|
57
|
+
user = User.create(state.params)
|
|
58
|
+
state[:user_id] = user.id
|
|
59
|
+
state
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def send_welcome_email(state)
|
|
63
|
+
# Logic to send email
|
|
64
|
+
state
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Usage
|
|
69
|
+
result = CreateUser.new.call(email: "user@example.com", name: "John Doe")
|
|
70
|
+
|
|
71
|
+
if result.success?
|
|
72
|
+
puts "User created with ID: #{result.value}"
|
|
73
|
+
else
|
|
74
|
+
puts "Error: #{result.error.message}"
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Core Concepts
|
|
79
|
+
|
|
80
|
+
### State
|
|
81
|
+
|
|
82
|
+
Every operation execution revolves around a `State` object. It holds:
|
|
83
|
+
|
|
84
|
+
- **params**: The initial input passed to `.call(params, context)`.
|
|
85
|
+
- **context**: Read-only configuration or dependencies (e.g., current_user, repository).
|
|
86
|
+
- **data**: A hash-like storage where steps can read and write data. By default, it contains a reference to `params` under the `:params` key.
|
|
87
|
+
|
|
88
|
+
Steps always receive the `state` as their only argument and MUST return it. If a step returns something else (or `nil`), a `NextStation::StepReturnValueError` will be raised.
|
|
89
|
+
|
|
90
|
+
Inside a step, you can access params in two ways:
|
|
91
|
+
```ruby
|
|
92
|
+
state.params[:email] # Recommended
|
|
93
|
+
state[:params][:email] # Also valid
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Direct access to params via top-level state keys (e.g., `state[:email]`) is NOT supported to avoid confusion between initial input and operation data.
|
|
97
|
+
|
|
98
|
+
### Result
|
|
99
|
+
|
|
100
|
+
Operations return a `NextStation::Result` object (either a `Success` or `Failure`) which provides:
|
|
101
|
+
|
|
102
|
+
- `success?`: Boolean indicating if the operation finished successfully.
|
|
103
|
+
- `failure?`: Boolean indicating if the operation failed or was halted.
|
|
104
|
+
- `value`: The data returned by the operation (for `Success`).
|
|
105
|
+
- `error`: A `Result::Error` object containing `type`, `message`, `help_url`, and `details`.
|
|
106
|
+
|
|
107
|
+
## Flow Control
|
|
108
|
+
|
|
109
|
+
NextStation provides powerful tools to manage complex business logic.
|
|
110
|
+
|
|
111
|
+
### Step Skips
|
|
112
|
+
|
|
113
|
+
You can skip a step conditionally using `skip_if`:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
step :send_notification, skip_if: ->(state) { state.params[:do_not_contact] }
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Branching
|
|
120
|
+
|
|
121
|
+
Use `branch` to execute a group of steps only when a condition is met:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
branch ->(state) { state.params[:is_admin] } do
|
|
125
|
+
step :grant_admin_privileges
|
|
126
|
+
step :log_admin_action
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Branches can be nested for complex flows.
|
|
131
|
+
|
|
132
|
+
### Resilience (Retry Logic)
|
|
133
|
+
|
|
134
|
+
Add resilience to flaky steps using `retry_if`, `attempts`, and `delay`:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
process do
|
|
138
|
+
step :call_external_api,
|
|
139
|
+
retry_if: ->(state, exception) { exception.is_a?(Timeout::Error) },
|
|
140
|
+
attempts: 3,
|
|
141
|
+
delay: 1
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The `retry_if` lambda receives both the current `state` and the `exception` (if any). It should return `true` if the step should be retried.
|
|
146
|
+
|
|
147
|
+
You can also retry based on the state result even if no exception was raised:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
step :check_job_status,
|
|
151
|
+
retry_if: ->(state, _exception) { state[:job_status] == "pending" },
|
|
152
|
+
attempts: 5,
|
|
153
|
+
delay: 2
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Inside a step, you can check the current attempt number using `state.step_attempt`:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
def call_external_api(state)
|
|
160
|
+
puts "Executing attempt number: #{state.step_attempt}"
|
|
161
|
+
# ...
|
|
162
|
+
state
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Railway Pattern & Errors
|
|
167
|
+
|
|
168
|
+
NextStation supports the Railway pattern, allowing you to explicitly handle success and failure paths using a structured error DSL.
|
|
169
|
+
|
|
170
|
+
### Defining Errors
|
|
171
|
+
|
|
172
|
+
Use the `errors` block to define possible error types:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
class CreateUser < NextStation::Operation
|
|
176
|
+
errors do
|
|
177
|
+
error_type :email_taken do
|
|
178
|
+
message en: "Email %{email} is taken"
|
|
179
|
+
message sp: "El correo %{email} ya existe"
|
|
180
|
+
help_url "http://example.com/support/email-taken"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### External Errors
|
|
187
|
+
|
|
188
|
+
You can also pass an existing `NextStation::Errors` class to `errors`.
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
|
|
192
|
+
class MyExternalErrors < NextStation::Errors
|
|
193
|
+
error_type :invalid_token do
|
|
194
|
+
message en: "Invalid token"
|
|
195
|
+
message sp: "Token inválido"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
class GetUser < NextStation::Operation
|
|
200
|
+
errors MyExternalErrors
|
|
201
|
+
# ...
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Shared Errors
|
|
206
|
+
|
|
207
|
+
You can define shared error collections by inheriting from `NextStation::Errors`. This allows you to reuse common error
|
|
208
|
+
definitions across multiple operations.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
|
|
212
|
+
class MySharedErrors < NextStation::Errors
|
|
213
|
+
error_type :not_found do
|
|
214
|
+
message en: "Resource not found", sp: "Recurso no encontrado"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
error_type :unauthorized do
|
|
218
|
+
message en: "You are not authorized to perform this action"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
class GetUser < NextStation::Operation
|
|
223
|
+
# Pass the class directly to errors
|
|
224
|
+
errors MySharedErrors
|
|
225
|
+
|
|
226
|
+
# You can still add operation-specific errors or override shared ones
|
|
227
|
+
errors do
|
|
228
|
+
error_type :user_inactive do
|
|
229
|
+
message en: "User is inactive"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
error_type :not_found do
|
|
233
|
+
message en: "User with ID %{id} not found"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Halting Execution
|
|
240
|
+
|
|
241
|
+
Use `error!` within a step to stop the operation immediately and return a failure result:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
def check_email(state)
|
|
245
|
+
if User.exists?(state.params[:email])
|
|
246
|
+
error!(
|
|
247
|
+
type: :email_taken,
|
|
248
|
+
msg_keys: { email: state.params[:email] },
|
|
249
|
+
details: { timestamp: Time.now }
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
state
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Multi-language Support
|
|
257
|
+
|
|
258
|
+
You can specify the desired language when calling the operation via the context:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
result = CreateUser.new.call({ email: "taken@example.com" }, { lang: :sp })
|
|
262
|
+
result.error.message # => "El correo taken@example.com ya existe"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
If the requested language is not defined, it defaults to `:en`.
|
|
266
|
+
|
|
267
|
+
## Input Validation (dry-validation)
|
|
268
|
+
|
|
269
|
+
NextStation integrates with `dry-validation` to provide powerful input guarding and coercion.
|
|
270
|
+
|
|
271
|
+
### Defining a Contract
|
|
272
|
+
|
|
273
|
+
Use `validate_with` to define your validation rules. You can use a block to define the contract inline, or pass an
|
|
274
|
+
existing contract class.
|
|
275
|
+
|
|
276
|
+
#### Inline Contract
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
class CreateUser < NextStation::Operation
|
|
280
|
+
# Define the contract inline
|
|
281
|
+
validate_with do
|
|
282
|
+
params do
|
|
283
|
+
required(:email).filled(:string, format?: /@/)
|
|
284
|
+
required(:age).filled(:integer, gteq?: 18)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
process do
|
|
289
|
+
step :validation # Explicitly run the validation
|
|
290
|
+
step :persist
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def persist(state)
|
|
294
|
+
# state.params now contains COERCED values (e.g., age is an Integer)
|
|
295
|
+
User.create!(state.params)
|
|
296
|
+
state
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
#### External Contract
|
|
302
|
+
|
|
303
|
+
You can also pass an existing `Dry::Validation::Contract` class.
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
|
|
307
|
+
class MyExternalContract < Dry::Validation::Contract
|
|
308
|
+
params do
|
|
309
|
+
required(:token).filled(:string)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
class Authenticate < NextStation::Operation
|
|
314
|
+
validate_with MyExternalContract
|
|
315
|
+
|
|
316
|
+
process do
|
|
317
|
+
step :validation
|
|
318
|
+
step :authorize
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def authorize(state)
|
|
322
|
+
# state.params[:token] is available here
|
|
323
|
+
state
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### The :validation Step
|
|
329
|
+
|
|
330
|
+
Validation is NOT automatic. You must explicitly add `step :validation` in your `process` block.
|
|
331
|
+
|
|
332
|
+
- **Failure**: If validation fails, the operation halts immediately and returns a `Result::Failure` with type
|
|
333
|
+
`:validation`.
|
|
334
|
+
- **Details**: `result.error.details` contains the raw error hash from `dry-validation`.
|
|
335
|
+
- **Coercion**: On success, `state.params` is updated with the coerced and filtered values from the validation result.
|
|
336
|
+
|
|
337
|
+
### Customizing Validation Errors
|
|
338
|
+
|
|
339
|
+
You can override the default validation error message using the `errors` DSL:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
|
|
343
|
+
class UpdateProfile < NextStation::Operation
|
|
344
|
+
errors do
|
|
345
|
+
error_type :validation do
|
|
346
|
+
message en: "The provided data is invalid: %{errors}",
|
|
347
|
+
sp: "Los datos son inválidos: %{errors}"
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
validate_with do
|
|
352
|
+
# ...
|
|
353
|
+
end
|
|
354
|
+
process { step :validation }
|
|
355
|
+
end
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
If no custom message is defined, NextStation uses a default message: "One or more parameters are invalid. See validation
|
|
359
|
+
details." (available in English and Spanish).
|
|
360
|
+
|
|
361
|
+
### Localization
|
|
362
|
+
|
|
363
|
+
NextStation automatically handles localization for validation errors. It defaults to a "slim" approach using the `:yaml` backend, loading translations from its internal configuration.
|
|
364
|
+
For this gem, the locale yml file is located at `lib/next_station/config/errors.yml`.
|
|
365
|
+
|
|
366
|
+
The `lang` passed in the context (e.g., `call(params, { lang: :sp })`) is automatically respected.
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
class UpdateProfile < NextStation::Operation
|
|
370
|
+
validate_with do
|
|
371
|
+
params do
|
|
372
|
+
required(:name).filled(:string)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
process { step :validation }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Pass the desired language in the context
|
|
380
|
+
result = UpdateProfile.new.call({ name: "" }, { lang: :sp })
|
|
381
|
+
|
|
382
|
+
# result.error.details will contain the localized messages from dry-validation
|
|
383
|
+
# => { name: ["debe estar lleno"] }
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### Validation Enforcement
|
|
387
|
+
|
|
388
|
+
By default, if you define `validate_with`, the validation is considered enabled.
|
|
389
|
+
|
|
390
|
+
- **force_validation!**: Ensures that `step :validation` is present in the `process` block. If missing, calling the
|
|
391
|
+
operation will raise a `NextStation::ValidationError`.
|
|
392
|
+
- **skip_validation!**: Disables the validation check even if `step :validation` is present.
|
|
393
|
+
|
|
394
|
+
## Logging and Monitoring
|
|
395
|
+
|
|
396
|
+
NextStation provides a built-in event system powered by `dry-monitor` to track operation lifecycle and user-defined
|
|
397
|
+
logs.
|
|
398
|
+
|
|
399
|
+
### Bult-in Logging
|
|
400
|
+
|
|
401
|
+
Inside your operation steps, you can use `publish_log` to broadcast custom events. These are automatically routed to the
|
|
402
|
+
configured logger by default.
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
|
|
406
|
+
class CreateUser < NextStation::Operation
|
|
407
|
+
def persist(state)
|
|
408
|
+
# ... logic ...
|
|
409
|
+
publish_log(:info, "User persisted successfully", user_id: state[:user_id])
|
|
410
|
+
state
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
- The log will automatically include the fields `trace_id` and `span_id` if the OpenTelemetry SDK is detected,
|
|
416
|
+
|
|
417
|
+
NextStation features an environment-aware logging configuration that works out of the box.
|
|
418
|
+
|
|
419
|
+
- **In Development:** It defaults to the `Console` formatter, providing human-readable, colorized output to `STDOUT`.
|
|
420
|
+
Example:
|
|
421
|
+
|
|
422
|
+
```Text
|
|
423
|
+
[I][2026-03-01 20:32:54][CreateUser/persist] -- User persisted successfully {:user_id=>1}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
- **In Production (or any other environment):** It defaults to the `Json` formatter, which is ideal for structured
|
|
427
|
+
logging. Example:
|
|
428
|
+
```JSON
|
|
429
|
+
{
|
|
430
|
+
"level": "INFO",
|
|
431
|
+
"time": "2026-03-01T20:32:54.123456",
|
|
432
|
+
"pid": 92323,
|
|
433
|
+
"origin": {
|
|
434
|
+
"operation": "CreateUser",
|
|
435
|
+
"event": "log.custom",
|
|
436
|
+
"step_name": "persist"
|
|
437
|
+
},
|
|
438
|
+
"message": "User persisted successfully",
|
|
439
|
+
"payload": {
|
|
440
|
+
"user_id": 1
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### Configuration
|
|
446
|
+
|
|
447
|
+
You can customize the logger, logging level, and other options:
|
|
448
|
+
|
|
449
|
+
```ruby
|
|
450
|
+
NextStation.configure do |config|
|
|
451
|
+
# Use a different logger (e.g., Rails.logger)
|
|
452
|
+
config.logger = Rails.logger
|
|
453
|
+
|
|
454
|
+
# Manually override the formatter if needed
|
|
455
|
+
# config.logger.formatter = NextStation::Logging::Formatter::Json.new
|
|
456
|
+
|
|
457
|
+
# Set logging level (:debug, :info, :warn, :error, :fatal, :unknown).
|
|
458
|
+
# :info (default): logs everything except debug level.
|
|
459
|
+
# :warn: logs warn and above levels.
|
|
460
|
+
# :debug: logs everything including individual step start/stop events.
|
|
461
|
+
config.logging_level = :info
|
|
462
|
+
|
|
463
|
+
# To disable default logging subscribers:
|
|
464
|
+
# config.logging_enabled = false
|
|
465
|
+
# config.monitor = MyCustomMonitor.new
|
|
466
|
+
end
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Lifecycle Events
|
|
470
|
+
|
|
471
|
+
NextStation automatically broadcasts events for every operation and step execution. You can subscribe to these events to
|
|
472
|
+
integrate with external monitoring tools (Datadog, Prometheus, etc.):
|
|
473
|
+
|
|
474
|
+
```ruby
|
|
475
|
+
NextStation.config.monitor.subscribe("operation.stop") do |event|
|
|
476
|
+
puts "Operation #{event[:operation]} finished in #{event[:duration]}ms"
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
NextStation.config.monitor.subscribe("step.retry") do |event|
|
|
480
|
+
puts "Step #{event[:step]} failed (attempt #{event[:attempt]}) with: #{event[:error].message}"
|
|
481
|
+
end
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
**Available Events:**
|
|
485
|
+
|
|
486
|
+
- `operation.start`: Triggered when an operation starts.
|
|
487
|
+
- `operation.stop`: Triggered when an operation finishes (success or failure). Includes `duration` and `result`.
|
|
488
|
+
- `step.start`: Triggered before a step starts.
|
|
489
|
+
- `step.stop`: Triggered after a step finishes. Includes `duration` and `state`.
|
|
490
|
+
- `step.retry`: Triggered when a step fails and is about to be retried.
|
|
491
|
+
|
|
492
|
+
## Dependency Injection
|
|
493
|
+
|
|
494
|
+
NextStation includes a lightweight Dependency Injection (DI) system to help you decouple your operations from their external dependencies.
|
|
495
|
+
|
|
496
|
+
### Declaring Dependencies
|
|
497
|
+
|
|
498
|
+
Use the `depends` method to declare dependencies and their defaults. Defaults can be static values or lazy lambdas:
|
|
499
|
+
|
|
500
|
+
```ruby
|
|
501
|
+
class CreateUser < NextStation::Operation
|
|
502
|
+
depends mailer: -> { Mailer.new },
|
|
503
|
+
repository: UserRepository.new
|
|
504
|
+
|
|
505
|
+
process do
|
|
506
|
+
step :send_welcome_email
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def send_welcome_email(state)
|
|
510
|
+
# Access dependencies using the dependency() method
|
|
511
|
+
dependency(:mailer).send_welcome(state.params[:email])
|
|
512
|
+
state
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
### Injecting Dependencies
|
|
518
|
+
|
|
519
|
+
You can override the default dependencies when instantiating the operation by passing the `deps:` keyword argument:
|
|
520
|
+
|
|
521
|
+
```ruby
|
|
522
|
+
# In your tests
|
|
523
|
+
mock_mailer = double("Mailer")
|
|
524
|
+
operation = CreateUser.new(deps: { mailer: mock_mailer })
|
|
525
|
+
operation.call(email: "test@example.com")
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### Inheritance
|
|
529
|
+
|
|
530
|
+
Dependencies are inherited and can be overridden in subclasses:
|
|
531
|
+
|
|
532
|
+
```ruby
|
|
533
|
+
class BaseOp < NextStation::Operation
|
|
534
|
+
depends logger: Logger.new
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
class MyOp < BaseOp
|
|
538
|
+
depends logger: CustomLogger.new # Overrides parent dependency
|
|
539
|
+
end
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
## Nested Operations (Operation Composition)
|
|
543
|
+
|
|
544
|
+
Operations can invoke other operations using the `call_operation` helper. This maintains the Railway pattern, shares context (e.g., `current_user`, `lang`), and handles error propagation automatically.
|
|
545
|
+
|
|
546
|
+
```ruby
|
|
547
|
+
class SyncUser < NextStation::Operation
|
|
548
|
+
depends remote_op: -> { RemoteOp.new }
|
|
549
|
+
|
|
550
|
+
errors do
|
|
551
|
+
error_type :provider_error do
|
|
552
|
+
message en: "External Sync Failed: %{reason}"
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
process do
|
|
557
|
+
step :fetch_remote_data
|
|
558
|
+
step :other_step
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def fetch_remote_data(state)
|
|
562
|
+
# 1. Automatically shares context (state.context)
|
|
563
|
+
# 2. Dynamic params via Proc (or pass a Hash directly)
|
|
564
|
+
# 3. Results stored in state[:remote_profile]
|
|
565
|
+
# 4. If RemoteOp fails with :provider_error, this step halts and
|
|
566
|
+
# the parent returns its own template for :provider_error.
|
|
567
|
+
call_operation(
|
|
568
|
+
state,
|
|
569
|
+
dependency(:remote_op),
|
|
570
|
+
with_params: ->(s) { { uid: s.params[:id] } },
|
|
571
|
+
store_result_in_key: :remote_profile
|
|
572
|
+
)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def other_step(state)
|
|
576
|
+
state[:remote_profile] # Access the result from the child operation
|
|
577
|
+
state
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
### Error Propagation Rules
|
|
583
|
+
|
|
584
|
+
- **Mapped Error**: If the Parent Operation has a matching `error_type` defined, it "intercepts" the failure. The resulting error uses the Parent's message template but is populated with the Child's `msg_keys` and `details`.
|
|
585
|
+
- **Transparent Error**: If the Parent has NOT defined that error type, the child's `Error` object is propagated exactly as is (including its already resolved message).
|
|
586
|
+
|
|
587
|
+
The `call_operation` helper triggers the internal Halt mechanism, allowing parent step controls like `retry_if` to function as expected.
|
|
588
|
+
|
|
589
|
+
## Plugin System
|
|
590
|
+
|
|
591
|
+
NextStation features a modular **Plugin System** that allows extending core functionality without modifying the gem
|
|
592
|
+
itself.
|
|
593
|
+
|
|
594
|
+
### Using Plugins
|
|
595
|
+
|
|
596
|
+
Enable plugins using the `plugin` macro:
|
|
597
|
+
|
|
598
|
+
```ruby
|
|
599
|
+
|
|
600
|
+
class CreateUser < NextStation::Operation
|
|
601
|
+
plugin :transactional
|
|
602
|
+
|
|
603
|
+
process do
|
|
604
|
+
step :validate_inputs
|
|
605
|
+
transaction do
|
|
606
|
+
step :create_user_record
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
### Creating Plugins
|
|
613
|
+
|
|
614
|
+
You can create your own plugins to add lifecycle hooks, DSL methods, and state helpers.
|
|
615
|
+
|
|
616
|
+
For detailed information on how to design and build plugins, please refer to
|
|
617
|
+
the [Plugin System Guide](PLUGIN_SYSTEM_GUIDE.md).
|
|
618
|
+
|
|
619
|
+
## Advanced Usage
|
|
620
|
+
|
|
621
|
+
### Result Value and `result_at`
|
|
622
|
+
|
|
623
|
+
Operations return a value encapsulated in the `Result::Success` object. You have two ways to define what this value is:
|
|
624
|
+
|
|
625
|
+
#### 1. Default Result Key (`:result`)
|
|
626
|
+
If you don't specify anything, NextStation looks for the `:result` key in the state.
|
|
627
|
+
|
|
628
|
+
```ruby
|
|
629
|
+
class MyOperation < NextStation::Operation
|
|
630
|
+
process do
|
|
631
|
+
step :do_work
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def do_work(state)
|
|
635
|
+
state[:result] = { message: "All good!" }
|
|
636
|
+
state
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
result = MyOperation.new.call
|
|
641
|
+
result.value # => { message: "All good!" }
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
#### 2. Customizing with `result_at`
|
|
645
|
+
If you want to use a more descriptive key for your result, use `result_at`.
|
|
646
|
+
|
|
647
|
+
```ruby
|
|
648
|
+
class MyOperation < NextStation::Operation
|
|
649
|
+
result_at :user_record
|
|
650
|
+
|
|
651
|
+
process do
|
|
652
|
+
step :find_user
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def find_user(state)
|
|
656
|
+
state[:user_record] = User.find(state.params[:id])
|
|
657
|
+
state
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
result = MyOperation.new.call
|
|
662
|
+
result.value # => <User instance>
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
> **Note:** If the expected key (either `:result` or the one defined by `result_at`) is missing from the state at the end of the operation, a `NextStation::Error` will be raised. This ensures that you explicitly define the output of your operations.
|
|
666
|
+
|
|
667
|
+
### Output Shapes (dry-struct)
|
|
668
|
+
|
|
669
|
+
You can enforce the structure of the success result using the `result_schema` DSL, which leverages the `dry-struct` gem.
|
|
670
|
+
|
|
671
|
+
```ruby
|
|
672
|
+
class CreateUser < NextStation::Operation
|
|
673
|
+
result_at :user_data
|
|
674
|
+
|
|
675
|
+
result_schema do
|
|
676
|
+
attribute :id, NextStation::Types::Integer
|
|
677
|
+
attribute :email, NextStation::Types::String
|
|
678
|
+
attribute :address do
|
|
679
|
+
attribute :city, NextStation::Types::String
|
|
680
|
+
attribute :street, NextStation::Types::String
|
|
681
|
+
end
|
|
682
|
+
attribute :metadata, NextStation::Types::Any
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
process do
|
|
686
|
+
step :set_data
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def set_data(state)
|
|
690
|
+
state[:user_data] = {
|
|
691
|
+
id: 1,
|
|
692
|
+
email: "john@example.com",
|
|
693
|
+
address: { city: "NYC", street: "Main St" },
|
|
694
|
+
metadata: { foo: "bar" }
|
|
695
|
+
}
|
|
696
|
+
state
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
#### Lazy Validation
|
|
702
|
+
|
|
703
|
+
The result schema is applied **lazily**. Validation and coercion only occur when you call `result.value`.
|
|
704
|
+
|
|
705
|
+
```ruby
|
|
706
|
+
op = CreateUser.new.call(params)
|
|
707
|
+
op.success? # => true (Operation finished without errors)
|
|
708
|
+
|
|
709
|
+
# Validation happens now:
|
|
710
|
+
op.value
|
|
711
|
+
# => #<CreateUser::ResultSchema id=1 email="john@example.com" ...>
|
|
712
|
+
|
|
713
|
+
# If the data doesn't match the schema:
|
|
714
|
+
# => raises NextStation::ResultShapeError
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
#### External Schemas
|
|
718
|
+
|
|
719
|
+
You can also pass an existing `Dry::Struct` class to `result_schema`. This is useful for sharing schemas across multiple operations.
|
|
720
|
+
|
|
721
|
+
```ruby
|
|
722
|
+
class MySharedSchema < Dry::Struct
|
|
723
|
+
attribute :id, NextStation::Types::Integer
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
class CreateUser < NextStation::Operation
|
|
727
|
+
result_schema MySharedSchema
|
|
728
|
+
end
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
Note that `result_schema` accepts either a `Dry::Struct` class OR a block, but not both. Providing both will raise a `NextStation::DoubleSchemaError`.
|
|
732
|
+
|
|
733
|
+
#### Enabling/Disabling Enforcement
|
|
734
|
+
|
|
735
|
+
By default, enforcement is enabled if a `result_schema` is defined. You can explicitly control this behavior:
|
|
736
|
+
|
|
737
|
+
```ruby
|
|
738
|
+
class CreateUser < NextStation::Operation
|
|
739
|
+
result_schema do
|
|
740
|
+
# ...
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# Force enforcement (default if schema is present)
|
|
744
|
+
enforce_result_schema
|
|
745
|
+
|
|
746
|
+
# Disable enforcement (result.value will return the raw hash)
|
|
747
|
+
disable_result_schema
|
|
748
|
+
end
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
> **Note:** If `enforce_result_schema` is enabled but no `result_schema` is defined (either in the class or its ancestors), calling `result.value` will raise a `NextStation::Error`.
|
|
752
|
+
|
|
753
|
+
#### Types
|
|
754
|
+
|
|
755
|
+
You can use all standard dry-types via `NextStation::Types`.
|
|
756
|
+
|
|
757
|
+
### Environment Configuration
|
|
758
|
+
|
|
759
|
+
NextStation's behavior can be environment-aware.
|
|
760
|
+
|
|
761
|
+
By default, it automatically detects the environment by checking for `RAILS_ENV`, `RACK_ENV`, `APP_ENV`, and `RUBY_ENV`.
|
|
762
|
+
It considers `development` and `dev` as development environments, and `production`, `prod`, `prd` as production-like.
|
|
763
|
+
|
|
764
|
+
### Simple Configuration
|
|
765
|
+
|
|
766
|
+
You can set the environment name directly:
|
|
767
|
+
|
|
768
|
+
```ruby
|
|
769
|
+
NextStation.configure do |config|
|
|
770
|
+
config.environment = 'production'
|
|
771
|
+
# or
|
|
772
|
+
config.environment = ENV['MY_APP_ENV']
|
|
773
|
+
end
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
### Advanced Configuration
|
|
777
|
+
|
|
778
|
+
If you need to customize which names are considered "production" or "development", or which environment variables to
|
|
779
|
+
check, you can access the environment object properties:
|
|
780
|
+
|
|
781
|
+
```ruby
|
|
782
|
+
NextStation.configure do |config|
|
|
783
|
+
# Consider 'staging' as a production-like environment
|
|
784
|
+
config.environment.production_names << 'staging'
|
|
785
|
+
end
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
## License
|
|
789
|
+
|
|
790
|
+
TBD
|