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
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'operation/errors'
|
|
4
|
+
require_relative 'operation/node'
|
|
5
|
+
require_relative 'operation/class_methods'
|
|
6
|
+
|
|
7
|
+
module NextStation
|
|
8
|
+
# The core class for defining operations.
|
|
9
|
+
#
|
|
10
|
+
# Operations are composed of steps and branches, and they return a NextStation::Result.
|
|
11
|
+
class Operation
|
|
12
|
+
extend ClassMethods
|
|
13
|
+
|
|
14
|
+
errors do
|
|
15
|
+
error_type :validation do
|
|
16
|
+
message en: 'One or more parameters are invalid. See validation details.',
|
|
17
|
+
sp: 'Uno o más parámetros son inválidos. Ver detalles de validación.'
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @example Example usage, simple initialization:
|
|
22
|
+
# CreateUser.new
|
|
23
|
+
# Use ` .new(deps: ...) ` to inject dependencies (e.g. test doubles).
|
|
24
|
+
# @example Example usage, `.new` with override of dependencies:
|
|
25
|
+
# # Assuming Mailer is a class that sends emails:
|
|
26
|
+
# # class CreateUser < NextStation::Operation
|
|
27
|
+
# # depends mailer: -> { Mailer.new }
|
|
28
|
+
# # # rest of the class definition
|
|
29
|
+
# # end
|
|
30
|
+
# #
|
|
31
|
+
# # You can then inject the mock mailer in tests:
|
|
32
|
+
# mock_mailer = mock('mailer')
|
|
33
|
+
# CreateUser.new(deps: { mailer: mock_mailer })
|
|
34
|
+
# @param deps [Hash] Allows to override the default dependencies.
|
|
35
|
+
def initialize(deps: {})
|
|
36
|
+
@injected_deps = deps
|
|
37
|
+
@resolved_deps = {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Resolves a dependency by name.
|
|
41
|
+
# @param name [Symbol]
|
|
42
|
+
# @return [Object] The resolved dependency.
|
|
43
|
+
def dependency(name)
|
|
44
|
+
return @resolved_deps[name] if @resolved_deps.key?(name)
|
|
45
|
+
|
|
46
|
+
if @injected_deps.key?(name)
|
|
47
|
+
@resolved_deps[name] = @injected_deps[name]
|
|
48
|
+
else
|
|
49
|
+
default = self.class.dependencies.fetch(name)
|
|
50
|
+
@resolved_deps[name] = default.is_a?(Proc) ? default.call : default
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Executes the operation.
|
|
55
|
+
# @param params [Hash] Input parameters.
|
|
56
|
+
# @param context [Hash] Execution context (e.g. :lang).
|
|
57
|
+
# @example operation.call(name: 'john', age: 25)
|
|
58
|
+
# @example operation.call(params: { name: 'john', age: 25 }, context: { lang: :en })
|
|
59
|
+
# @return [NextStation::Result]
|
|
60
|
+
def call(params = {}, context = {})
|
|
61
|
+
monitor = NextStation.config.monitor
|
|
62
|
+
|
|
63
|
+
monitor.publish('operation.start', operation: self.class.name, params: params, context: context)
|
|
64
|
+
|
|
65
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
+
|
|
67
|
+
if self.class.validation_enforced? && !self.class.has_step?(:validation)
|
|
68
|
+
raise ValidationError, 'Validation is enforced but step :validation is missing from process block'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@state = State.new(params, context, self.class.loaded_plugins)
|
|
72
|
+
lang = context[:lang] || :en
|
|
73
|
+
|
|
74
|
+
self.class.loaded_plugins.each do |mod|
|
|
75
|
+
mod.on_operation_start(self, @state) if mod.respond_to?(:on_operation_start)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
@state = execute_nodes(self.class.steps, @state)
|
|
80
|
+
rescue Halt => e
|
|
81
|
+
result = if e.error
|
|
82
|
+
Result::Failure.new(e.error)
|
|
83
|
+
else
|
|
84
|
+
definition = self.class.error_definitions[e.type]
|
|
85
|
+
raise "Undeclared error type: #{e.type}" unless definition
|
|
86
|
+
|
|
87
|
+
message = definition.resolve_message(lang, e.msg_keys)
|
|
88
|
+
Result::Failure.new(
|
|
89
|
+
Result::Error.new(
|
|
90
|
+
type: e.type,
|
|
91
|
+
message: message,
|
|
92
|
+
help_url: definition.help_url,
|
|
93
|
+
details: e.details,
|
|
94
|
+
msg_keys: e.msg_keys
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
self.class.loaded_plugins.each do |mod|
|
|
100
|
+
mod.on_operation_stop(self, result) if mod.respond_to?(:on_operation_stop)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
monitor.publish('operation.stop',
|
|
104
|
+
operation: self.class.name,
|
|
105
|
+
duration: duration(start_time),
|
|
106
|
+
result: result,
|
|
107
|
+
state: @state)
|
|
108
|
+
return result
|
|
109
|
+
rescue NextStation::ValidationError => e
|
|
110
|
+
raise e
|
|
111
|
+
rescue NextStation::Error => e
|
|
112
|
+
raise e
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
result = Result::Failure.new(
|
|
115
|
+
Result::Error.new(
|
|
116
|
+
type: :exception,
|
|
117
|
+
message: e.message,
|
|
118
|
+
details: { backtrace: e.backtrace }
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
self.class.loaded_plugins.each do |mod|
|
|
123
|
+
mod.on_operation_stop(self, result) if mod.respond_to?(:on_operation_stop)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
monitor.publish('operation.stop',
|
|
127
|
+
operation: self.class.name,
|
|
128
|
+
duration: duration(start_time),
|
|
129
|
+
result: result,
|
|
130
|
+
state: @state)
|
|
131
|
+
return result
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
key = self.class.result_key || :result
|
|
135
|
+
unless @state.key?(key)
|
|
136
|
+
raise NextStation::MissingResultKeyError, "Missing result key #{key.inspect} in state. " \
|
|
137
|
+
'Operations must set this key or use result_at to specify another one.'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
result = Result::Success.new(
|
|
141
|
+
@state[key],
|
|
142
|
+
schema: self.class.result_class,
|
|
143
|
+
enforced: self.class.schema_enforced?
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
self.class.loaded_plugins.each do |mod|
|
|
147
|
+
mod.on_operation_stop(self, result) if mod.respond_to?(:on_operation_stop)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
monitor.publish('operation.stop',
|
|
151
|
+
operation: self.class.name,
|
|
152
|
+
duration: duration(start_time),
|
|
153
|
+
result: result,
|
|
154
|
+
state: @state)
|
|
155
|
+
result
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Built-in step for performing validation.
|
|
159
|
+
# @param state [NextStation::State]
|
|
160
|
+
# @return [NextStation::State]
|
|
161
|
+
def validation(state)
|
|
162
|
+
contract_class = self.class.validation_contract_class
|
|
163
|
+
raise ValidationError, 'Step :validation called but no contract defined via validate_with' unless contract_class
|
|
164
|
+
|
|
165
|
+
return state unless self.class.validation_enforced?
|
|
166
|
+
|
|
167
|
+
lang = state.context[:lang] || :en
|
|
168
|
+
contract = self.class.validation_contract_instance
|
|
169
|
+
result = contract.call(state.params)
|
|
170
|
+
|
|
171
|
+
if result.success?
|
|
172
|
+
state[:params] = result.to_h
|
|
173
|
+
state
|
|
174
|
+
else
|
|
175
|
+
# Attempt to get localized errors from dry-validation, fallback to default if it fails
|
|
176
|
+
# (e.g. if I18n is not configured for that language in dry-validation)
|
|
177
|
+
validation_errors = begin
|
|
178
|
+
result.errors(locale: lang).to_h
|
|
179
|
+
rescue StandardError
|
|
180
|
+
result.errors.to_h
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
error!(
|
|
184
|
+
type: :validation,
|
|
185
|
+
msg_keys: { errors: validation_errors }.merge(state.params),
|
|
186
|
+
details: validation_errors
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Halts the operation and returns a failure result.
|
|
192
|
+
# @param type [Symbol]
|
|
193
|
+
# @param msg_keys [Hash]
|
|
194
|
+
# @param details [Hash]
|
|
195
|
+
def error!(type:, msg_keys: {}, details: {})
|
|
196
|
+
raise Halt.new(type: type, msg_keys: msg_keys, details: details)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Publishes a log event to the default monitor.
|
|
200
|
+
#
|
|
201
|
+
# NextStation provides a built-in event system powered by dry-monitor to track user-defined logs.
|
|
202
|
+
# Inside your operation steps, you can use publish_log to broadcast custom events.
|
|
203
|
+
# These are automatically routed to the configured logger by default.
|
|
204
|
+
#
|
|
205
|
+
# By default, NextStation logs to STDOUT using the standard Ruby Logger.
|
|
206
|
+
# @param [Symbol] level The log level.
|
|
207
|
+
# @option level [Symbol] :info Informational message
|
|
208
|
+
# @option level [Symbol] :warn Warning condition
|
|
209
|
+
# @option level [Symbol] :error Error condition
|
|
210
|
+
# @option level [Symbol] :fatal Fatal error
|
|
211
|
+
# @option level [Symbol] :debug Debugging message
|
|
212
|
+
# @param message [String] The log message.
|
|
213
|
+
# @param payload [Hash] Additional metadata for the log.
|
|
214
|
+
# @example publish_log :info, 'simple log message'
|
|
215
|
+
# @example publish_log :info, 'log with custom payload', user_id: 5, hello: 'world'
|
|
216
|
+
def publish_log(level, message, payload = {})
|
|
217
|
+
NextStation.config.monitor.publish(
|
|
218
|
+
'log.custom',
|
|
219
|
+
level: level,
|
|
220
|
+
message: message,
|
|
221
|
+
operation: self.class.name,
|
|
222
|
+
step_name: @state&.current_step,
|
|
223
|
+
payload: payload
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Calls another operation and integrates its result into the current state.
|
|
228
|
+
# @param state [NextStation::State]
|
|
229
|
+
# @param operation_class [Class, Object] The operation to call.
|
|
230
|
+
# @param with_params [Hash, Proc] Params for the child operation.
|
|
231
|
+
# @param store_result_in_key [Symbol, nil] Where to store the child's result value.
|
|
232
|
+
# @return [NextStation::State]
|
|
233
|
+
def call_operation(state, operation_class, with_params:, store_result_in_key: nil)
|
|
234
|
+
params = with_params.is_a?(Proc) ? with_params.call(state) : with_params
|
|
235
|
+
|
|
236
|
+
operation = if operation_class.is_a?(Class)
|
|
237
|
+
operation_class.new(deps: @injected_deps)
|
|
238
|
+
else
|
|
239
|
+
operation_class
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
result = operation.call(params, state.context)
|
|
243
|
+
|
|
244
|
+
if result.success?
|
|
245
|
+
state[store_result_in_key] = result.value if store_result_in_key
|
|
246
|
+
state
|
|
247
|
+
else
|
|
248
|
+
child_error = result.error
|
|
249
|
+
raise Halt.new(error: child_error) unless self.class.error_definitions.key?(child_error.type)
|
|
250
|
+
|
|
251
|
+
error!(
|
|
252
|
+
type: child_error.type,
|
|
253
|
+
msg_keys: child_error.msg_keys,
|
|
254
|
+
details: child_error.details
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
|
|
262
|
+
def duration(start_time)
|
|
263
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def execute_nodes(nodes, state)
|
|
267
|
+
nodes.reduce(state) do |current_state, node|
|
|
268
|
+
execute_node(node, current_state)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def execute_node(node, state)
|
|
273
|
+
case node.type
|
|
274
|
+
when :step
|
|
275
|
+
execute_step(node, state)
|
|
276
|
+
when :branch
|
|
277
|
+
execute_branch(node, state)
|
|
278
|
+
when :wrapper
|
|
279
|
+
handler = node.options[:handler]
|
|
280
|
+
send(handler, node, state)
|
|
281
|
+
else
|
|
282
|
+
state
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def execute_step(node, state)
|
|
287
|
+
skip_condition = node.options[:skip_if]
|
|
288
|
+
return state if skip_condition&.call(state)
|
|
289
|
+
|
|
290
|
+
state.set_current_step(node.name)
|
|
291
|
+
|
|
292
|
+
retry_if = node.options[:retry_if]
|
|
293
|
+
max_attempts = node.options[:attempts] || 1
|
|
294
|
+
delay = node.options[:delay] || 0
|
|
295
|
+
attempts = 0
|
|
296
|
+
monitor = NextStation.config.monitor
|
|
297
|
+
|
|
298
|
+
loop do
|
|
299
|
+
attempts += 1
|
|
300
|
+
state.set_step_attempt(attempts)
|
|
301
|
+
|
|
302
|
+
monitor.publish('step.start', operation: self.class.name, step: node.name, state: state, attempt: attempts)
|
|
303
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
304
|
+
|
|
305
|
+
begin
|
|
306
|
+
result = wrap_in_hooks(node, state) do |current_state|
|
|
307
|
+
send(node.name, current_state)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
unless result.is_a?(NextStation::State)
|
|
311
|
+
class_name = self.class.name || 'AnonymousOperation'
|
|
312
|
+
raise NextStation::StepReturnValueError,
|
|
313
|
+
"Step '#{node.name}' in #{class_name} must return a NextStation::State object, but it returned #{result.class} (#{result.inspect})."
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
if retry_if && attempts < max_attempts && retry_if.call(result, nil)
|
|
317
|
+
monitor.publish('step.retry',
|
|
318
|
+
operation: self.class.name,
|
|
319
|
+
step: node.name,
|
|
320
|
+
state: result,
|
|
321
|
+
attempt: attempts,
|
|
322
|
+
duration: duration(start_time))
|
|
323
|
+
sleep(delay) if delay.positive?
|
|
324
|
+
next
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
self.class.loaded_plugins.each do |mod|
|
|
328
|
+
mod.on_step_success(self, node, result) if mod.respond_to?(:on_step_success)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
monitor.publish('step.stop',
|
|
332
|
+
operation: self.class.name,
|
|
333
|
+
step: node.name,
|
|
334
|
+
state: result,
|
|
335
|
+
attempt: attempts,
|
|
336
|
+
duration: duration(start_time))
|
|
337
|
+
return result
|
|
338
|
+
rescue StandardError => e
|
|
339
|
+
if retry_if && attempts < max_attempts && retry_if.call(state, e)
|
|
340
|
+
monitor.publish('step.retry',
|
|
341
|
+
operation: self.class.name,
|
|
342
|
+
step: node.name,
|
|
343
|
+
state: state,
|
|
344
|
+
attempt: attempts,
|
|
345
|
+
error: e,
|
|
346
|
+
duration: duration(start_time))
|
|
347
|
+
sleep(delay) if delay.positive?
|
|
348
|
+
next
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
self.class.loaded_plugins.each do |mod|
|
|
352
|
+
mod.on_step_failure(self, node, state, e) if mod.respond_to?(:on_step_failure)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
monitor.publish('step.stop',
|
|
356
|
+
operation: self.class.name,
|
|
357
|
+
step: node.name,
|
|
358
|
+
state: state,
|
|
359
|
+
attempt: attempts,
|
|
360
|
+
error: e,
|
|
361
|
+
duration: duration(start_time))
|
|
362
|
+
raise e
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def execute_branch(node, state)
|
|
368
|
+
condition = node.options[:condition]
|
|
369
|
+
if condition.call(state)
|
|
370
|
+
execute_nodes(node.children, state)
|
|
371
|
+
else
|
|
372
|
+
state
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def wrap_in_hooks(node, state, &block)
|
|
377
|
+
around_hooks = self.class.loaded_plugins.select { |p| p.respond_to?(:around_step) }
|
|
378
|
+
run_around_hooks(around_hooks, node, state, &block)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def run_around_hooks(hooks, node, state, &block)
|
|
382
|
+
if hooks.empty?
|
|
383
|
+
yield state
|
|
384
|
+
else
|
|
385
|
+
hook = hooks.first
|
|
386
|
+
remaining = hooks[1..]
|
|
387
|
+
hook.around_step(self, node, state) do
|
|
388
|
+
run_around_hooks(remaining, node, state, &block)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NextStation
|
|
4
|
+
# Central registry for NextStation plugins.
|
|
5
|
+
module Plugins
|
|
6
|
+
@registry = {}
|
|
7
|
+
|
|
8
|
+
# Registers a plugin.
|
|
9
|
+
# @param name [Symbol] The plugin name.
|
|
10
|
+
# @param mod [Module] The plugin module.
|
|
11
|
+
def self.register(name, mod)
|
|
12
|
+
@registry[name] = mod
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Loads a plugin by name.
|
|
16
|
+
# @param name [Symbol]
|
|
17
|
+
# @return [Module]
|
|
18
|
+
# @raise [KeyError] If the plugin is not registered.
|
|
19
|
+
def self.load_plugin(name)
|
|
20
|
+
@registry.fetch(name)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NextStation
|
|
4
|
+
# Represents the result of an operation.
|
|
5
|
+
class Result
|
|
6
|
+
# @example result.success? => true
|
|
7
|
+
# @example result.success? => false
|
|
8
|
+
# @return [Boolean] true if the result is a success.
|
|
9
|
+
def success?
|
|
10
|
+
false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @example result.failure? => true
|
|
14
|
+
# @example result.failure? => false
|
|
15
|
+
# @return [Boolean] true if the result is a failure.
|
|
16
|
+
def failure?
|
|
17
|
+
false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Object, nil] The value of a successful result.
|
|
21
|
+
def value
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @see NextStation::Result::Error
|
|
26
|
+
# @example result.error => #<NextStation::Result::Error: ...>
|
|
27
|
+
# @example Example methods inside of an NextStation::Result::Error
|
|
28
|
+
# result.error.type
|
|
29
|
+
# result.error.message
|
|
30
|
+
# result.error.details
|
|
31
|
+
# @return [NextStation::Result::Error, nil] The error object if it's a failure.
|
|
32
|
+
def error
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Represents a successful operation result.
|
|
37
|
+
class Success < Result
|
|
38
|
+
# @param value [Object] The result value.
|
|
39
|
+
# @param schema [Class, nil] The Dry::Struct schema to validate against.
|
|
40
|
+
# @param enforced [Boolean] Whether schema validation is enforced.
|
|
41
|
+
def initialize(value, schema: nil, enforced: false)
|
|
42
|
+
@raw_value = value
|
|
43
|
+
@schema = schema
|
|
44
|
+
@enforced = enforced
|
|
45
|
+
@validated_value = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def value
|
|
49
|
+
if @enforced && @schema.nil?
|
|
50
|
+
raise NextStation::Error, 'Result schema enforcement is enabled but no result_schema is defined.'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
return @raw_value unless @enforced && @schema
|
|
54
|
+
|
|
55
|
+
@value ||= begin
|
|
56
|
+
@schema.new(@raw_value)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
raise NextStation::ResultShapeError, e.message
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def success?
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Represents a failed operation result.
|
|
68
|
+
class Failure < Result
|
|
69
|
+
attr_reader :error
|
|
70
|
+
|
|
71
|
+
# @param error [NextStation::Result::Error]
|
|
72
|
+
def initialize(error)
|
|
73
|
+
@error = error
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def failure?
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Structured error information.
|
|
82
|
+
class Error
|
|
83
|
+
# The error type.
|
|
84
|
+
# @example :invalid_input
|
|
85
|
+
# @example :not_found
|
|
86
|
+
# @example :email_taken
|
|
87
|
+
# @return [Symbol]
|
|
88
|
+
attr_reader :type
|
|
89
|
+
|
|
90
|
+
# A human-readable message describing the error.
|
|
91
|
+
# @example "Email is already taken"
|
|
92
|
+
# @example "User not found"
|
|
93
|
+
# @example "Something went wrong, please try again."
|
|
94
|
+
# @return [String, nil]
|
|
95
|
+
attr_reader :message
|
|
96
|
+
|
|
97
|
+
# An optional URL to help the end user resolve the error.
|
|
98
|
+
# @example "https://example.com/help/invalid_input"
|
|
99
|
+
# @return [String, nil]
|
|
100
|
+
attr_reader :help_url
|
|
101
|
+
|
|
102
|
+
# Additional error details.
|
|
103
|
+
# @example { age: ["must be greater than 18"] }
|
|
104
|
+
# @example { existing_email: true }
|
|
105
|
+
# @return [Hash]
|
|
106
|
+
attr_reader :details
|
|
107
|
+
# @return [Hash]
|
|
108
|
+
attr_reader :msg_keys
|
|
109
|
+
|
|
110
|
+
# @param type [Symbol]
|
|
111
|
+
# @param message [String, nil]
|
|
112
|
+
# @param help_url [String, nil]
|
|
113
|
+
# @param details [Hash]
|
|
114
|
+
# @param msg_keys [Hash]
|
|
115
|
+
def initialize(type:, message: nil, help_url: nil, details: {}, msg_keys: {})
|
|
116
|
+
@type = type
|
|
117
|
+
@message = message
|
|
118
|
+
@help_url = help_url
|
|
119
|
+
@details = details
|
|
120
|
+
@msg_keys = msg_keys
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'forwardable'
|
|
4
|
+
|
|
5
|
+
module NextStation
|
|
6
|
+
# Holds the mutable state during operation execution.
|
|
7
|
+
#
|
|
8
|
+
# It wraps a data hash and provides access to params and context.
|
|
9
|
+
class State
|
|
10
|
+
extend Forwardable
|
|
11
|
+
def_delegators :@data, :[], :[]=, :fetch, :key?, :has_key?, :to_h, :merge, :merge!
|
|
12
|
+
|
|
13
|
+
# @return [Hash] The execution context.
|
|
14
|
+
attr_reader :context
|
|
15
|
+
# @return [Integer] The attempt number of the current step.
|
|
16
|
+
attr_reader :step_attempt
|
|
17
|
+
# @return [Symbol, nil] The name of the current step being executed.
|
|
18
|
+
attr_reader :current_step
|
|
19
|
+
|
|
20
|
+
# @param params [Hash] Initial parameters.
|
|
21
|
+
# @param context [Hash] Shared context (immutable).
|
|
22
|
+
def initialize(params = {}, context = {}, plugins = [])
|
|
23
|
+
@context = context.dup.freeze
|
|
24
|
+
@data = { params: unwrap_params(params).dup }
|
|
25
|
+
@step_attempt = 1
|
|
26
|
+
@current_step = nil
|
|
27
|
+
extend_with_plugins(plugins)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Sets the current attempt number for the active step.
|
|
31
|
+
# @param value [Integer]
|
|
32
|
+
def set_step_attempt(value)
|
|
33
|
+
@step_attempt = value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Sets the name of the current step.
|
|
37
|
+
# @param value [Symbol, nil]
|
|
38
|
+
def set_current_step(value)
|
|
39
|
+
@current_step = value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the input parameters.
|
|
43
|
+
# @return [Hash]
|
|
44
|
+
def params
|
|
45
|
+
@data[:params]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def unwrap_params(params)
|
|
51
|
+
if params.is_a?(Hash) && params.key?(:params) && params.keys.size == 1
|
|
52
|
+
params[:params]
|
|
53
|
+
else
|
|
54
|
+
params
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def extend_with_plugins(plugins)
|
|
59
|
+
plugins.each do |mod|
|
|
60
|
+
extend mod::State if mod.const_defined?(:State)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry-types'
|
|
4
|
+
|
|
5
|
+
module NextStation
|
|
6
|
+
module Types
|
|
7
|
+
include Dry.Types
|
|
8
|
+
StrippedString = Types::String.constructor(&:strip)
|
|
9
|
+
Email = Types::String.constructor { |v| v.strip.downcase }.constrained(format: /\A[^@\s]+@[^@\s]+\z/)
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/next_station.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'next_station/config'
|
|
4
|
+
|
|
5
|
+
# NextStation is a lightweight, service-object like framework for Ruby
|
|
6
|
+
# that emphasizes structured operations, railway-oriented programming,
|
|
7
|
+
# and strong validation.
|
|
8
|
+
module NextStation
|
|
9
|
+
|
|
10
|
+
# Base error class for all NextStation errors
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Raised when a step method returns something other than a NextStation::State object.
|
|
14
|
+
# This ensures that the Railway flow is maintained throughout the operation.
|
|
15
|
+
class StepReturnValueError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised when the operation finishes but the expected result key is missing from the state.
|
|
18
|
+
class MissingResultKeyError < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised when the result does not match the defined schema.
|
|
21
|
+
class ResultShapeError < Error; end
|
|
22
|
+
|
|
23
|
+
# Raised when both a Dry::Struct class and a block are provided to result_schema.
|
|
24
|
+
class DoubleResultSchemaError < Error; end
|
|
25
|
+
|
|
26
|
+
# Raised when there is a configuration error related to validations.
|
|
27
|
+
class ValidationError < Error; end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
require_relative 'next_station/version'
|
|
31
|
+
require_relative 'next_station/types'
|
|
32
|
+
require_relative 'next_station/errors'
|
|
33
|
+
require_relative 'next_station/plugins'
|
|
34
|
+
require_relative 'next_station/state'
|
|
35
|
+
require_relative 'next_station/result'
|
|
36
|
+
require_relative 'next_station/operation'
|