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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NextStation
4
+ VERSION = '0.1.0'
5
+ end
@@ -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'