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.
@@ -0,0 +1,521 @@
1
+ # NextStation Plugin System Guide
2
+
3
+ NextStation's Plugin System allows you to extend the core functionality of operations without modifying the gem itself. This guide explains how the system works and how to design and implement your own plugins.
4
+
5
+ ## Architecture Overview
6
+
7
+ A plugin in NextStation is a standard Ruby module. When you call `plugin :my_plugin` in an `Operation`, NextStation:
8
+ 1. Loads the module registered as `:my_plugin`.
9
+ 2. Extends the `Operation` class with methods from `MyPlugin::ClassMethods`.
10
+ 3. Includes `MyPlugin::InstanceMethods` into the `Operation` instance.
11
+ 4. Includes `MyPlugin::DSL` into `NextStation::Operation::Node`, making new methods available in the `process` block.
12
+ 5. Extends the `NextStation::State` instance with `MyPlugin::State`.
13
+ 6. Automatically registers errors defined in `MyPlugin::Errors`.
14
+ 7. Calls `MyPlugin.configure(operation_class)` for any additional setup.
15
+ 8. Registers lifecycle hooks if the plugin module responds to them.
16
+
17
+ ## Designing a Plugin
18
+
19
+ ### 1. Structure
20
+
21
+ Your plugin should follow this structure:
22
+
23
+ ```ruby
24
+ module MyPlugin
25
+ # Extends the Operation class
26
+ module ClassMethods
27
+ # We use the extended hook to add configuration via Dry::Configurable
28
+ def self.extended(base)
29
+ base.extend Dry::Configurable
30
+
31
+ base.instance_eval do
32
+ setting :my_plugin do
33
+ setting :api_key
34
+ setting :timeout, default: 30
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ # Included in the Operation instance
41
+ module InstanceMethods
42
+ def my_instance_helper
43
+ "Hello from instance"
44
+ end
45
+
46
+ # Example of an InstanceMethod that enriches data
47
+ def enrich_user_data(state)
48
+ user_id = state[:user_id]
49
+ # Logic to fetch additional user data
50
+ state[:user_full_name] = "John Doe" # Fetch from DB or external service
51
+ state
52
+ end
53
+
54
+ # Example of an InstanceMethod that interacts with an external service
55
+ def notify_external_system(state, payload)
56
+ # Logic to send a notification to an external system
57
+ # MyExternalService.send(payload)
58
+ state
59
+ end
60
+
61
+ # Handler for DSL wrappers
62
+ def run_my_wrapper(node, state)
63
+ # Custom logic before
64
+ result = execute_nodes(node.children, state)
65
+ # Custom logic after
66
+ result
67
+ end
68
+ end
69
+
70
+ # Extended into the Node class (DSL in process block)
71
+ module DSL
72
+ def my_wrapper(&block)
73
+ add_child(NextStation::Operation::Node.new(:wrapper, nil, { handler: :run_wrapper }, &block))
74
+ end
75
+ end
76
+
77
+ # Mixed into the State instance during initialization
78
+ module State
79
+ def plugin_state_helper
80
+ "useful data"
81
+ end
82
+ end
83
+
84
+ # Automatically registers errors
85
+ module Errors
86
+ def self.definitions
87
+ {
88
+ my_plugin_error: {
89
+ message: { en: "Something went wrong in the plugin" }
90
+ }
91
+ }
92
+ end
93
+ end
94
+
95
+ # Lifecycle Hooks
96
+ def self.on_operation_start(operation, state)
97
+ # logic
98
+ end
99
+
100
+ def self.on_operation_stop(operation, result)
101
+ # logic
102
+ end
103
+
104
+ def self.around_step(operation, node, state)
105
+ # logic before
106
+ result = yield
107
+ # logic after
108
+ result
109
+ end
110
+
111
+ # Configuration Hook
112
+ def self.configure(operation_class)
113
+ # Called when the plugin is loaded into a class
114
+ end
115
+ end
116
+
117
+ # Register it
118
+ NextStation::Plugins.register(:my_plugin, MyPlugin)
119
+ ```
120
+
121
+ ### 2. Lifecycle Hooks
122
+
123
+ NextStation provides several hooks that your plugin can implement to intercept different stages of an operation's execution.
124
+
125
+ #### `on_operation_start(operation, state)`
126
+ Called once at the very beginning of the `call` method, before any steps are executed. Useful for initializing plugin-specific state or logging.
127
+
128
+ **Example: Performance Monitoring**
129
+ ```ruby
130
+ def self.on_operation_start(operation, state)
131
+ state[:_start_time] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
132
+ end
133
+ ```
134
+
135
+ #### `on_operation_stop(operation, result)`
136
+ Called once after the operation completes, whether it succeeded, failed, or was halted.
137
+
138
+ **Example: Log Operation Result**
139
+ ```ruby
140
+ def self.on_operation_stop(operation, result)
141
+ status = result.success? ? "SUCCESS" : "FAILURE"
142
+ operation.publish_log(:info, "Operation #{operation.class.name} finished with status: #{status}")
143
+ end
144
+ ```
145
+
146
+ #### `around_step(operation, node, state)`
147
+ Wraps the execution of every single step. **Important:** You must `yield` to allow the step (and other plugins) to execute. It receives the `node`, which contains the step `name` and `options`.
148
+
149
+ **Example: Step Execution Logging**
150
+ ```ruby
151
+ def self.around_step(operation, node, state)
152
+ operation.publish_log(:debug, "Starting step: #{node.name}")
153
+ result = yield
154
+ operation.publish_log(:debug, "Finished step: #{node.name}")
155
+ result
156
+ end
157
+ ```
158
+
159
+ #### `on_step_success(operation, node, state)`
160
+ Called after a step method returns successfully (and returns a `State` object).
161
+
162
+ **Example: Audit Trail**
163
+ ```ruby
164
+ def self.on_step_success(operation, node, state)
165
+ state[:audit_trail] ||= []
166
+ state[:audit_trail] << { step: node.name, timestamp: Time.now }
167
+ end
168
+ ```
169
+
170
+ #### `on_step_failure(operation, node, state, error)`
171
+ Called when a step raises an exception. This is called *before* the exception is re-raised or handled by retry logic.
172
+
173
+ **Example: Error Reporting**
174
+ ```ruby
175
+ def self.on_step_failure(operation, node, state, error)
176
+ # Sentry.capture_exception(error, extra: { step: node.name, state: state.to_h })
177
+ end
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Advanced Plugin Design
183
+
184
+ To create truly powerful plugins, you can leverage state extensions, custom errors, and configuration parameters.
185
+
186
+ ### 1. Extending State
187
+ The `State` module in your plugin is mixed into every `NextStation::State` instance created for the operation. Use this to provide helper methods that simplify step logic.
188
+
189
+ ```ruby
190
+ module MyPlugin
191
+ module State
192
+ def authenticated?
193
+ context[:current_user].present?
194
+ end
195
+
196
+ def admin?
197
+ authenticated? && context[:current_user].admin?
198
+ end
199
+ end
200
+ end
201
+ ```
202
+ *Usage in a step:*
203
+ ```ruby
204
+ def check_permission(state)
205
+ error!(:unauthorized) unless state.admin?
206
+ state
207
+ end
208
+ ```
209
+
210
+ ### 2. Custom Errors and Parameters
211
+ Plugins can register errors and even allow users to configure them.
212
+
213
+ ```ruby
214
+ module MyPlugin
215
+ module Errors
216
+ def self.definitions
217
+ {
218
+ plugin_error: {
219
+ message: { en: "Operation failed in %{plugin_name}: %{reason}" }
220
+ }
221
+ }
222
+ end
223
+ end
224
+ end
225
+ ```
226
+ *Usage in a plugin's InstanceMethods:*
227
+ ```ruby
228
+ module InstanceMethods
229
+ def fail_with_reason(reason)
230
+ error!(type: :plugin_error, msg_keys: { plugin_name: "MyPlugin", reason: reason })
231
+ end
232
+ end
233
+ ```
234
+
235
+ ### 3. Instance Methods
236
+ The `InstanceMethods` module in your plugin is included in every `Operation` instance that uses the plugin. This is the place for shared logic that doesn't belong to a specific step, as well as for implementing **wrapper handlers**.
237
+
238
+ #### Helper Methods
239
+ You can define methods that simplify common tasks across different operations or steps. These methods have access to all the internals of the `Operation` class.
240
+
241
+ ```ruby
242
+ module MyPlugin
243
+ module InstanceMethods
244
+ # An instance helper that can be called from any step
245
+ def build_payload(state, user_id)
246
+ {
247
+ user_id: user_id,
248
+ timestamp: Time.now.iso8601,
249
+ source: self.class.name
250
+ }
251
+ end
252
+
253
+ # A helper to handle common error conditions
254
+ def fail_gracefully!(reason)
255
+ error!(type: :plugin_error, msg_keys: { reason: reason })
256
+ end
257
+ end
258
+ end
259
+ ```
260
+ *Usage in a step:*
261
+ ```ruby
262
+ def some_step(state)
263
+ payload = build_payload(state, state[:user_id])
264
+ # ExternalService.send(payload)
265
+ state
266
+ rescue => e
267
+ fail_gracefully!(e.message)
268
+ end
269
+ ```
270
+
271
+ #### Wrapper Handlers
272
+ If your plugin provides a custom DSL (like a `transaction` block), the `InstanceMethods` module is where you implement the logic to handle that wrapper.
273
+
274
+ ```ruby
275
+ module MyPlugin
276
+ module InstanceMethods
277
+ def run_my_wrapper(node, state)
278
+ # 1. Access node options provided in the DSL
279
+ timeout = node.options[:timeout] || 10
280
+
281
+ # 2. Perform custom logic before executing inner steps
282
+ # MyService.start_timer(timeout)
283
+
284
+ # 3. Execute the children steps within this block
285
+ result_state = execute_nodes(node.children, state)
286
+
287
+ # 4. Perform custom logic after execution
288
+ # MyService.stop_timer
289
+
290
+ result_state
291
+ end
292
+ end
293
+ end
294
+ ```
295
+
296
+ ### 4. Plugin Configuration
297
+ The recommended way to handle plugin configuration is using `Dry::Configurable`. NextStation ensures that the `config` object is available for your plugin to extend.
298
+
299
+ Using `Dry::Configurable` provides a standardized, thread-safe way to define settings with defaults and nested namespaces.
300
+
301
+ ```ruby
302
+ module MyPlugin
303
+ module ClassMethods
304
+ def self.extended(base)
305
+ # Extend the operation class with configuration
306
+ base.extend Dry::Configurable
307
+
308
+ base.instance_eval do
309
+ # It is highly recommended to namespace your plugin settings
310
+ setting :my_plugin do
311
+ setting :timeout, default: 30
312
+ setting :retries, default: 3
313
+ end
314
+ end
315
+ end
316
+ end
317
+ end
318
+ ```
319
+ *Usage in an Operation:*
320
+ ```ruby
321
+ class MyOperation < NextStation::Operation
322
+ plugin :my_plugin
323
+
324
+ # Configure your plugin using the standardized DSL
325
+ config.my_plugin.timeout = 60
326
+ end
327
+ ```
328
+
329
+ While you can still use plain Ruby class methods and instance variables for configuration, `Dry::Configurable` is the preferred approach for consistency and robustness.
330
+
331
+ ### 4. Designing instructions best practices:
332
+ When designing a plugin:
333
+ 1. **Be Explicit**: Use clear naming conventions for hooks and methods.
334
+ 2. **Document Options**: If your DSL methods take options, document them clearly in the `DSL` module.
335
+ 3. **Use Namespace**: Prefix plugin-specific state keys (e.g., `state[:_my_plugin_data]`) to avoid collisions.
336
+ 4. **Leverage Context**: Use `state.context` for read-only global data and `state` for mutable operation-specific data.
337
+
338
+ ---
339
+
340
+ ## Using Plugin Features (End-User Perspective)
341
+
342
+ When a plugin is loaded into an operation, its features are seamlessly integrated. Here is how a developer using your plugin will interact with its components.
343
+
344
+ ### 1. Calling Instance Helpers from Steps
345
+
346
+ The methods defined in the plugin's `InstanceMethods` are directly available within your operation instance. You can call them from any step method.
347
+
348
+ ```ruby
349
+ class RegisterUser < NextStation::Operation
350
+ plugin :my_plugin # This plugin provides `build_payload` and `fail_gracefully!`
351
+
352
+ process do
353
+ step :process_user
354
+ end
355
+
356
+ def process_user(state)
357
+ # 1. Using a helper method from the plugin
358
+ payload = build_payload(state, state[:user_id])
359
+
360
+ # ... logic to use the payload ...
361
+
362
+ state
363
+ rescue => e
364
+ # 2. Using another helper to handle errors consistently
365
+ fail_gracefully!(e.message)
366
+ end
367
+ end
368
+ ```
369
+
370
+ ### 2. Using State Extensions
371
+
372
+ Methods from the plugin's `State` module are mixed into the `state` object. This makes your steps much more readable by moving complex logic into the state itself.
373
+
374
+ ```ruby
375
+ class DeleteArticle < NextStation::Operation
376
+ plugin :auth_plugin # This plugin provides `admin?` on the state object
377
+
378
+ process do
379
+ step :authorize
380
+ step :delete
381
+ end
382
+
383
+ def authorize(state)
384
+ # The `admin?` method is provided by the auth_plugin's State module
385
+ unless state.admin?
386
+ error!(:unauthorized)
387
+ end
388
+ state
389
+ end
390
+
391
+ def delete(state)
392
+ # ... delete logic ...
393
+ state
394
+ end
395
+ end
396
+ ```
397
+
398
+ ### 3. Using DSL Wrappers in the Process Block
399
+
400
+ DSL methods provided by the plugin's `DSL` module allow you to wrap steps or groups of steps, providing powerful flow control or automatic behavior (like transactions or logging).
401
+
402
+ ```ruby
403
+ class UpdateOrder < NextStation::Operation
404
+ plugin :transactional # This plugin provides the `transaction` block
405
+
406
+ process do
407
+ step :validate
408
+
409
+ # Everything inside this block is wrapped in a DB transaction
410
+ transaction do
411
+ step :update_stock
412
+ step :capture_payment
413
+ end
414
+
415
+ step :notify_user
416
+ end
417
+ end
418
+ ```
419
+
420
+ ### 4. Configuring the Plugin
421
+
422
+ As an end-user, you configure the plugin at the class level using the `config` object. These settings are then used by the plugin's internal logic.
423
+
424
+ ```ruby
425
+ class ImportData < NextStation::Operation
426
+ plugin :api_client_plugin
427
+
428
+ # Configure plugin-specific settings
429
+ config.api_client.timeout = 60
430
+ config.api_client.retries = 5
431
+
432
+ process do
433
+ step :fetch_remote_data
434
+ end
435
+ end
436
+ ```
437
+
438
+ ---
439
+
440
+ ## Example: ActiveRecord Transaction Plugin
441
+
442
+ This plugin wraps a group of steps in a database transaction.
443
+
444
+ ```ruby
445
+ module NextStation
446
+ module Plugins
447
+ module Transactional
448
+ module DSL
449
+ def transaction(&block)
450
+ add_child(
451
+ NextStation::Operation::Node.new(
452
+ :wrapper,
453
+ nil,
454
+ { handler: :run_transaction },
455
+ &block
456
+ )
457
+ )
458
+ end
459
+ end
460
+
461
+ module InstanceMethods
462
+ def run_transaction(node, state)
463
+ ActiveRecord::Base.transaction do
464
+ execute_nodes(node.children, state)
465
+ end
466
+ rescue ActiveRecord::Rollback
467
+ # The operation flow will stop because steps inside
468
+ # are executed within execute_nodes.
469
+ state
470
+ end
471
+ end
472
+
473
+ module State
474
+ def in_transaction?
475
+ # You could implement logic to check transaction depth
476
+ true
477
+ end
478
+ end
479
+
480
+ module Errors
481
+ def self.definitions
482
+ {
483
+ transaction_error: {
484
+ message: { en: "Transaction failed: %{message}" }
485
+ }
486
+ }
487
+ end
488
+ end
489
+ end
490
+
491
+ register(:transactional, Transactional)
492
+ end
493
+ end
494
+ ```
495
+
496
+ ### Usage:
497
+
498
+ ```ruby
499
+ class CreateUser < NextStation::Operation
500
+ plugin :transactional
501
+
502
+ process do
503
+ step :validate
504
+ transaction do
505
+ step :create_user
506
+ step :create_profile
507
+ end
508
+ step :send_welcome_email
509
+ end
510
+
511
+ # ... step definitions ...
512
+ end
513
+ ```
514
+
515
+ ## Tips for Plugin Developers
516
+
517
+ 1. **Thread Safety**: Ensure your plugin does not use mutable global state.
518
+ 2. **Inheritance**: NextStation handles plugin inheritance. If a parent class uses a plugin, the subclass will also have it.
519
+ 3. **Namespace**: Use a unique namespace for your plugin methods to avoid collisions with other plugins or core NextStation methods.
520
+ 4. **Error Handling**: Use the `Errors` module to register custom errors. This allows users to customize the messages in their operations.
521
+ 5. **State Extension**: Be careful when extending `State`. Only add methods that are genuinely useful for steps across many operations.