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,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logging/subscribers/operation'
4
+ require_relative 'logging/subscribers/step'
5
+ require_relative 'logging/subscribers/custom'
6
+
7
+ module NextStation
8
+ # Entry point for logging configuration and setup.
9
+ module Logging
10
+ # Initializes the default logging subscribers.
11
+ # @param monitor [Dry::Monitor::Notifications] The monitor to subscribe to.
12
+ # @return [void]
13
+ def self.setup!(monitor = NextStation.config.monitor)
14
+ setup_formatter!
15
+ Subscribers::Operation.subscribe(monitor)
16
+ Subscribers::Step.subscribe(monitor)
17
+ Subscribers::Custom.subscribe(monitor)
18
+ end
19
+
20
+ # Selects the log formatter based on the current environment.
21
+ # It uses the Console formatter for development and the JSON formatter otherwise.
22
+ # @return [void]
23
+ def self.setup_formatter!
24
+ formatter = if NextStation.config.environment.development?
25
+ Formatter::Console.new
26
+ else
27
+ Formatter::Json.new
28
+ end
29
+ NextStation.config.logger.formatter = formatter
30
+ end
31
+
32
+ private_class_method :setup_formatter!
33
+ end
34
+ end
35
+
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NextStation
4
+ class Operation
5
+
6
+ # The `ClassMethods` module provides a set of class-level methods for
7
+ # defining the structure, validation, dependencies, and processing logic
8
+ # of an operation. It introduces a DSL for custom error handling, step
9
+ # management, schema enforcement, and parameter validation.
10
+ #
11
+ # === Error Handling
12
+ # - Allows defining custom error types for the operation using a DSL.
13
+ #
14
+ # === Result Management
15
+ # - Supports setting and retrieving the key where the operation result
16
+ # is stored.
17
+ # - Enables schema enforcement for result values using a Dry::Struct schema.
18
+ #
19
+ # === Steps and Branches
20
+ # - Facilitates defining execution steps and branches within an operation.
21
+ # - Allows querying the presence of specific steps within the operation.
22
+ #
23
+ # === Validation
24
+ # - Provides integration with Dry::Validation for validating operation parameters.
25
+ # - Enables enforcing or skipping validation on demand.
26
+ #
27
+ # === Dependencies
28
+ # - Supports defining and managing dependencies for the operation.
29
+ #
30
+ # === Methods
31
+ # - {#errors}: Defines custom error types using an error DSL.
32
+ # - {#error_definitions}: Returns all defined error mappings.
33
+ # - {#result_at}: Defines the key in the state for the operation result.
34
+ # - {#result_key}: Retrieves the key where the operation result is stored.
35
+ # - {#result_schema}: Defines a schema for the result using Dry::Struct.
36
+ # - {#result_class}: Retrieves the Dry::Struct class for the result schema.
37
+ # - {#enforce_result_schema}: Enables schema enforcement for the result.
38
+ # - {#disable_result_schema}: Disables schema enforcement for the result.
39
+ # - {#schema_enforced?}: Checks if schema enforcement is enabled.
40
+ # - {#process}: Defines the root execution block for the operation.
41
+ # - {#step}: Adds a single execution step to the operation.
42
+ # - {#branch}: Adds a conditional branch to the operation's execution path.
43
+ # - {#steps}: Returns the defined execution steps for the operation.
44
+ # - {#validate_with}: Defines validation rules using Dry::Validation.
45
+ # - {#validation_contract_class}: Retrieves the validation contract class.
46
+ # - {#validation_contract_instance}: Returns an instance of the
47
+ # validation contract.
48
+ # - {#force_validation!}: Forces validation to be applied, even if not part
49
+ # of the operation steps.
50
+ # - {#skip_validation!}: Skips validation for the operation.
51
+ # - {#validation_enforced?}: Checks if validation is enforced.
52
+ # - {#has_step?}: Checks the presence of a specific step in the operation.
53
+ # - {#depends}: Defines dependencies required by the operation.
54
+ # - {#dependencies}: Retrieves the defined dependencies.
55
+ module ClassMethods
56
+ # Defines error types for the operation.
57
+ # @param external_source [Class, Hash, nil] An external error collection class or a hash of definitions.
58
+ # @yield The block defining errors via ErrorsDSL.
59
+ def errors(external_source = nil, &block)
60
+ @error_definitions ||= {}
61
+
62
+ # 1. Handle external source (e.g., SharedErrors < NextStation::Errors)
63
+ if external_source.respond_to?(:definitions)
64
+ @error_definitions.merge!(external_source.definitions)
65
+ elsif external_source.is_a?(Hash)
66
+ external_source.each do |type, config|
67
+ definition = ErrorDefinition.new(type)
68
+ definition.message(config[:message]) if config[:message]
69
+ definition.help_url(config[:help_url]) if config[:help_url]
70
+ definition.validate!
71
+ @error_definitions[type] = definition
72
+ end
73
+ end
74
+
75
+ # 2. Handle inline block
76
+ if block_given?
77
+ dsl = ErrorsDSL.new
78
+ dsl.instance_eval(&block)
79
+ @error_definitions.merge!(dsl.definitions)
80
+ end
81
+ end
82
+
83
+ # @return [Hash] The registered error definitions.
84
+ def error_definitions
85
+ parent_defs = if superclass.respond_to?(:error_definitions)
86
+ superclass.error_definitions
87
+ else
88
+ {}
89
+ end
90
+ parent_defs.merge(@error_definitions || {})
91
+ end
92
+
93
+ # Defines the key in the state where the final result is stored.
94
+ # @param key [Symbol]
95
+ def result_at(key)
96
+ @result_key = key
97
+ end
98
+
99
+ # @return [Symbol, nil] The key where the result is stored.
100
+ def result_key
101
+ @result_key || (superclass.result_key if superclass.respond_to?(:result_key))
102
+ end
103
+
104
+ # Defines a Dry::Struct schema for the result value.
105
+ # @param struct_class [Class, nil] A Dry::Struct class or nil if a block is provided.
106
+ # @yield The block defining the schema.
107
+ def result_schema(struct_class = nil, &block)
108
+ require 'dry-struct'
109
+
110
+ if @result_class
111
+ raise NextStation::DoubleResultSchemaError, 'result_schema has already been defined'
112
+ end
113
+
114
+ if struct_class && block_given?
115
+ raise NextStation::DoubleResultSchemaError, 'result_schema accepts either a Dry::Struct class OR a block, but not both.'
116
+ end
117
+
118
+ if struct_class
119
+ if struct_class.is_a?(Class) && struct_class < Dry::Struct
120
+ @result_class = struct_class
121
+ else
122
+ raise ArgumentError, 'result_schema requires a subclass of Dry::Struct'
123
+ end
124
+ elsif block_given?
125
+ @result_class = Class.new(Dry::Struct, &block)
126
+ const_set(:ResultSchema, @result_class) unless const_defined?(:ResultSchema, false)
127
+ else
128
+ raise ArgumentError, 'result_schema requires either a Dry::Struct class or a block'
129
+ end
130
+
131
+ @schema_enforced = true
132
+ end
133
+
134
+ # @return [Class, nil] The Dry::Struct class for the result.
135
+ def result_class
136
+ @result_class || (superclass.result_class if superclass.respond_to?(:result_class))
137
+ end
138
+
139
+ # Enables result schema enforcement.
140
+ def enforce_result_schema
141
+ @schema_enforced = true
142
+ end
143
+
144
+ # Disables result schema enforcement.
145
+ def disable_result_schema
146
+ @schema_enforced = false
147
+ end
148
+
149
+ # @return [Boolean] Whether schema enforcement is enabled.
150
+ def schema_enforced?
151
+ return @schema_enforced unless @schema_enforced.nil?
152
+ return superclass.schema_enforced? if superclass.respond_to?(:schema_enforced?)
153
+
154
+ false
155
+ end
156
+
157
+ # Defines the root execution block for the operation.
158
+ # @yield The block defining steps and branches.
159
+ def process(&block)
160
+ @root = Node.new(:root, &block)
161
+ end
162
+
163
+ # Adds a step to the operation.
164
+ # @param method_name [Symbol]
165
+ # @param options [Hash]
166
+ def step(method_name, options = {})
167
+ @root ||= Node.new(:root)
168
+ @root.step(method_name, options)
169
+ end
170
+
171
+ # Adds a branch to the operation.
172
+ # @param condition [Proc]
173
+ # @yield
174
+ def branch(condition, &block)
175
+ @root ||= Node.new(:root)
176
+ @root.branch(condition, &block)
177
+ end
178
+
179
+ # @return [Array<Node>] The steps defined for the operation.
180
+ def steps
181
+ @root&.children || (superclass.steps if superclass.respond_to?(:steps)) || []
182
+ end
183
+
184
+ # Defines a Dry::Validation::Contract to validate the params.
185
+ #
186
+ # @param contract_or_block [Class, nil] A Contract class or nil if a block is provided.
187
+ # @yield The block defining the validation rules.
188
+ def validate_with(contract_or_block = nil, &block)
189
+ require 'dry-validation'
190
+ @validation_contract_class = if block_given?
191
+ Class.new(Dry::Validation::Contract) do
192
+ config.messages.backend = :yaml
193
+ config.messages.top_namespace = 'next_station_validations'
194
+ config.messages.load_paths << File.expand_path('../../config/errors.yml', __FILE__)
195
+ instance_eval(&block)
196
+ end
197
+ elsif contract_or_block.is_a?(Class) && contract_or_block < Dry::Validation::Contract
198
+ contract_or_block
199
+ else
200
+ raise ValidationError,
201
+ 'validate_with requires a block or a Dry::Validation::Contract class'
202
+ end
203
+
204
+ @validation_enforced = true
205
+ end
206
+
207
+ # @return [Class, nil] The validation contract class.
208
+ def validation_contract_class
209
+ @validation_contract_class || (if superclass.respond_to?(:validation_contract_class)
210
+ superclass.validation_contract_class
211
+ end)
212
+ end
213
+
214
+ # @return [Dry::Validation::Contract, nil] An instance of the validation contract.
215
+ def validation_contract_instance
216
+ @validation_contract_instance ||= validation_contract_class&.new
217
+ end
218
+
219
+ # Forces validation even if not explicitly defined in steps.
220
+ def force_validation!
221
+ @validation_enforced = true
222
+ end
223
+
224
+ # Skips validation even if defined.
225
+ def skip_validation!
226
+ @validation_enforced = false
227
+ end
228
+
229
+ # @return [Boolean] Whether validation is enforced.
230
+ def validation_enforced?
231
+ return @validation_enforced unless @validation_enforced.nil?
232
+
233
+ superclass.respond_to?(:validation_enforced?) ? superclass.validation_enforced? : false
234
+ end
235
+
236
+ # Checks if a step exists in the operation.
237
+ # @param name [Symbol]
238
+ # @param nodes [Array<Node>]
239
+ # @return [Boolean]
240
+ def has_step?(name, nodes = steps)
241
+ nodes.any? do |node|
242
+ node.name == name || (node.type == :branch && has_step?(name, node.children))
243
+ end
244
+ end
245
+
246
+ # Defines dependencies for the operation.
247
+ # @param deps [Hash] A mapping of dependency names to values or Procs.
248
+ # @example depends mailer: -> { Mailer.new }
249
+ # @example depends repository: UserRepository.new
250
+ # @example Usage inside a step:
251
+ # def send_email
252
+ # # Access dependencies using the dependency() method
253
+ # dependency(:mailer).send_welcome(state.params[:email])
254
+ # # rest of the step
255
+ # end
256
+ #
257
+ # @example You can override the dependencies when instantiating the operation by passing the deps: argument:
258
+ # mock_mailer = double("Mailer")
259
+ # operation = CreateUser.new(deps: { mailer: mock_mailer })
260
+ # operation.call(email: "test@example.com")
261
+ def depends(deps)
262
+ @dependencies = dependencies.merge(deps)
263
+ end
264
+
265
+ # @return [Hash] The defined dependencies.
266
+ def dependencies
267
+ @dependencies || (superclass.respond_to?(:dependencies) ? superclass.dependencies : {})
268
+ end
269
+
270
+ # Convenience method to instantiate and call the operation.
271
+ def call(params = {}, context = {}, deps: {})
272
+ new(deps: deps).call(params, context)
273
+ end
274
+
275
+ # Enables a plugin for the operation.
276
+ # @param name [Symbol] The registered plugin name.
277
+ def plugin(name)
278
+ require 'dry-configurable'
279
+ mod = NextStation::Plugins.load_plugin(name)
280
+ loaded_plugins << mod
281
+
282
+ extend mod::ClassMethods if mod.const_defined?(:ClassMethods)
283
+ include mod::InstanceMethods if mod.const_defined?(:InstanceMethods)
284
+ NextStation::Operation::Node.include mod::DSL if mod.const_defined?(:DSL)
285
+
286
+ if mod.const_defined?(:Errors) && mod::Errors.respond_to?(:definitions)
287
+ errors(mod::Errors.definitions)
288
+ end
289
+
290
+ mod.configure(self) if mod.respond_to?(:configure)
291
+ end
292
+
293
+ # @return [Array<Module>] The plugins loaded into this operation.
294
+ def loaded_plugins
295
+ @loaded_plugins ||= (superclass.respond_to?(:loaded_plugins) ? superclass.loaded_plugins.dup : [])
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NextStation
4
+ class Operation
5
+ # Raised internally to stop the operation flow and return a failure.
6
+ class Halt < StandardError
7
+ # @return [Symbol] The error type.
8
+ attr_reader :type
9
+ # @return [Hash] Keys for message interpolation.
10
+ attr_reader :msg_keys
11
+ # @return [Hash] Additional error details.
12
+ attr_reader :details
13
+ # @return [NextStation::Result::Error] An existing error object.
14
+ attr_reader :error
15
+
16
+ # @param type [Symbol] The error type.
17
+ # @param msg_keys [Hash] Keys for message interpolation.
18
+ # @param details [Hash] Additional error details.
19
+ # @param error [NextStation::Result::Error] An existing error object.
20
+ def initialize(type: nil, msg_keys: {}, details: {}, error: nil)
21
+ @type = type
22
+ @msg_keys = msg_keys
23
+ @details = details
24
+ @error = error
25
+ end
26
+ end
27
+
28
+ # Defines an error with its messages and optional help URL.
29
+ class ErrorDefinition
30
+ # @return [Symbol] The error type.
31
+ attr_reader :type
32
+ # @return [Hash] Map of locales to message templates.
33
+ attr_reader :messages
34
+ # @return [String, nil] The help URL for this error.
35
+ attr_reader :help_url
36
+
37
+ # @param type [Symbol] The error type.
38
+ def initialize(type)
39
+ @type = type
40
+ @messages = {}
41
+ @help_url = nil
42
+ end
43
+
44
+ # Adds localized messages for the error.
45
+ # @param hashes [Hash] A hash mapping locale symbols to message templates.
46
+ def message(hashes)
47
+ @messages.merge!(hashes)
48
+ end
49
+
50
+ # Sets or returns the help URL for the error.
51
+ # @param url [String, nil] The URL to set.
52
+ # @return [String, nil] The current help URL.
53
+ def help_url(url = nil)
54
+ return @help_url if url.nil?
55
+ raise 'Only one help_url is allowed' if @help_url
56
+
57
+ @help_url = url
58
+ end
59
+
60
+ # Validates whether the error definition is complete.
61
+ # @raise [RuntimeError] if the English message is missing.
62
+ def validate!
63
+ raise "English message is required for error type: #{@type}" unless @messages[:en]
64
+ end
65
+
66
+ # Resolves the error message for a given language.
67
+ # @param lang [Symbol, String]
68
+ # @param msg_keys [Hash]
69
+ # @return [String]
70
+ def resolve_message(lang, msg_keys)
71
+ template = @messages[lang.to_sym] || @messages[:en]
72
+ template % msg_keys
73
+ end
74
+ end
75
+
76
+ # DSL for defining multiple errors.
77
+ class ErrorsDSL
78
+ # @return [Hash<Symbol, ErrorDefinition>]
79
+ attr_reader :definitions
80
+
81
+ # Initializes a new ErrorsDSL.
82
+ def initialize
83
+ @definitions = {}
84
+ end
85
+
86
+ # Defines a new error type.
87
+ # @param type [Symbol] The error type.
88
+ # @yield [ErrorDefinition] The block to configure the error.
89
+ def error_type(type, &block)
90
+ definition = ErrorDefinition.new(type)
91
+ definition.instance_eval(&block) if block_given?
92
+ definition.validate!
93
+ @definitions[type] = definition
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NextStation
4
+ class Operation
5
+ # Represents a node in the operation's execution graph (step or branch).
6
+ class Node
7
+ # @return [Symbol] The node type (:step, :branch, or :root).
8
+ attr_reader :type
9
+ # @return [Symbol, nil] The name of the step.
10
+ attr_reader :name
11
+ # @return [Hash] Execution options.
12
+ attr_reader :options
13
+ # @return [Array<Node>] Child nodes (for branches or root).
14
+ attr_reader :children
15
+
16
+ # @param type [Symbol] :step, :branch, or :root.
17
+ # @param name [Symbol, nil] The name of the step.
18
+ # @param options [Hash] Execution options.
19
+ # @yield The block for branch nodes.
20
+ def initialize(type, name = nil, options = {}, &block)
21
+ @type = type
22
+ @name = name
23
+ @options = options
24
+ @children = []
25
+ instance_eval(&block) if block_given?
26
+ end
27
+
28
+ # Adds a child node.
29
+ # @param node [Node]
30
+ def add_child(node)
31
+ @children << node
32
+ end
33
+
34
+ # Adds a step to the node.
35
+ # @param name [Symbol] The method name to execute.
36
+ # @param options [Hash] Execution options like :skip_if, :retry_if, :attempts, :delay.
37
+ def step(name, options = {})
38
+ @children << Node.new(:step, name, options)
39
+ end
40
+
41
+ # Adds a branch to the node.
42
+ # @param condition [Proc] A proc that receives the state and returns a boolean.
43
+ # @yield The block defining steps inside the branch.
44
+ def branch(condition, &block)
45
+ @children << Node.new(:branch, nil, { condition: condition }, &block)
46
+ end
47
+ end
48
+ end
49
+ end