gaskit 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,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Gaskit
6
+ # Gaskit::Configuration holds global configuration for the Gaskit gem.
7
+ #
8
+ # It allows customization of logging behavior, including:
9
+ # - Log level (`log_level`)
10
+ # - Custom logger (`logger`)
11
+ # - Disabling logging entirely (`disable_logging`)
12
+ # - Structured or custom log formatting (`log_formatter`)
13
+ # - Debug mode (`debug`)
14
+ #
15
+ # @example Configuring Gaskit in an initializer
16
+ # Gaskit.config do |c|
17
+ # c.debug = true
18
+ # c.disable_logging = false
19
+ #
20
+ # c.context_provider = -> {
21
+ # {
22
+ # tenant_id: Current.tenant_id,
23
+ # user_id: Current.user_id
24
+ # }
25
+ # }
26
+ #
27
+ # # Optionally replace the logger
28
+ # custom_logger = Logger.new("log/gaskit.log")
29
+ # c.setup_logger(custom_logger, level: :info, formatter: ->(severity, time, _progname, msg) {
30
+ # message, context = msg.is_a?(Array) ? msg : [msg, {}]
31
+ # "[#{time.strftime('%Y-%m-%d %H:%M:%S')} #{severity}] #{message} (#{context.inspect})\n"
32
+ # })
33
+ # end
34
+ class Configuration
35
+ # @return [Boolean] Whether debug mode is enabled.
36
+ attr_accessor :debug
37
+
38
+ # @return [Boolean] Whether to completely suppress log output.
39
+ attr_accessor :disable_logging
40
+
41
+ # @return [Logger] The logger instance used internally by Gaskit.
42
+ attr_reader :logger
43
+
44
+ # @return[#call] A callable to apply a global context used for all log entries.
45
+ attr_reader :context_provider
46
+
47
+ # Initializes the configuration with default settings.
48
+ #
49
+ # The configuration includes:
50
+ # - Default environment set to `"development"`
51
+ # - Debug mode disabled
52
+ # - Logging to stdout
53
+ # - Default log level set to ::Logger::DEBUG
54
+ # - An empty base context hash
55
+ # - A new `ContractRegistry` instance for contract management
56
+ def initialize
57
+ @debug = false
58
+ @disable_logging = false
59
+ @context_provider = -> { {} }
60
+ @contract_registry = ContractRegistry.new
61
+
62
+ setup_logger
63
+ end
64
+
65
+ # Sets the logger, formatter, and level in one go.
66
+ #
67
+ # @param custom_logger [::Logger, nil] An optional custom logger.
68
+ # @param level [Symbol, Integer] Log level (e.g., :debug, Logger::WARN)
69
+ # @param formatter [Proc] Custom formatter for log entries.
70
+ def setup_logger(custom_logger = nil, level: :debug, formatter: nil)
71
+ @logger = custom_logger || ::Logger.new($stdout)
72
+
73
+ effective_formatter = formatter || @logger&.formatter || Gaskit::Logger.formatter(:pretty)
74
+ self.log_formatter = effective_formatter if effective_formatter.respond_to?(:call)
75
+ self.log_level = level
76
+ end
77
+
78
+ # Sets the logging level.
79
+ #
80
+ # @param level [Symbol, Integer] The log level (e.g., :info, :debug, or Logger::WARN).
81
+ # @raise [NameError] If the provided level is a symbol, and it does not map to a valid Logger constant.
82
+ def log_level=(level)
83
+ level = ::Logger.const_get(level.upcase) if level.is_a?(Symbol)
84
+ @logger.level = level
85
+ end
86
+
87
+ # Sets a custom log formatter.
88
+ #
89
+ # @param formatter [#call] A callable object that receives log arguments (severity, time, progname, msg).
90
+ # @raise [ArgumentError] If the provided formatter is not callable.
91
+ def log_formatter=(formatter)
92
+ raise ArgumentError, "Formatter must be callable" unless formatter.respond_to?(:call)
93
+
94
+ @logger.formatter = formatter
95
+ end
96
+
97
+ # Sets the global context provider used for all log entries.
98
+ #
99
+ # @param provider [#call] A proc or lambda returning a Hash of context values.
100
+ # @raise [ArgumentError] If the provided callable is not callable.
101
+ def context_provider=(provider)
102
+ raise ArgumentError, "Provider must be callable" unless provider.respond_to?(:call)
103
+
104
+ @context_provider = provider
105
+ end
106
+
107
+ # Registers a contract with a name and associated result class.
108
+ #
109
+ # @param name [Symbol, String] The name of the contract.
110
+ # @param result_class [Class] The class that represents the result for the contract.
111
+ # @param override [Boolean] Whether to override an existing contract (default: false).
112
+ # @raise [Gaskit::ContractError] If the contract is already registered and override is not allowed.
113
+ # @raise [Gaskit::ResultTypeError] If the result_class does not inherit from `Gaskit::OperationResult`.
114
+ # @return [void]
115
+ def register_contract(name, result_class, override: false)
116
+ @contract_registry.register(name, result_class, override: override)
117
+ end
118
+
119
+ # Fetches a registered contract's result class by its name.
120
+ #
121
+ # @param name [Symbol, String] The name of the contract.
122
+ # @return [Class] The result class associated with the contract.
123
+ # @raise [Gaskit::ContractError] If the contract is not registered.
124
+ def fetch_contract(name)
125
+ @contract_registry.fetch(name)
126
+ end
127
+
128
+ # Checks if a contract is registered.
129
+ #
130
+ # @param name [Symbol, String] The name of the contract.
131
+ # @return [Boolean] true if the contract is registered, otherwise false.
132
+ def contract_registered?(name)
133
+ @contract_registry.registered?(name)
134
+ end
135
+
136
+ # Lists all registered contracts.
137
+ #
138
+ # @return [Hash<Symbol, Class>] A hash of all registered contracts where keys are
139
+ # contract names and values are their corresponding result classes.
140
+ def registered_contracts
141
+ @contract_registry.all
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "core"
4
+
5
+ module Gaskit
6
+ # Represents a registry for managing contracts and their result classes
7
+ #
8
+ # This class allows registering contracts with associated result classes,
9
+ # checking if they are registered, and fetching them for use.
10
+ # It also includes validation to ensure result classes adhere to the expected base class.
11
+ class ContractRegistry
12
+ class << self
13
+ # Verifies that the given class is a subclass of `Gaskit::BaseResult`
14
+ #
15
+ # @param result_class [Class] The class to verify
16
+ # @raise [Gaskit::ResultTypeError] if the class does not inherit from `Gaskit::BaseResult`
17
+ def verify_result_class!(result_class)
18
+ raise Gaskit::ResultTypeError, result_class unless result_class <= Gaskit::OperationResult
19
+ end
20
+ end
21
+
22
+ # Initializes a new instance of `ContractRegistry`
23
+ #
24
+ # Sets up the internal hash for storing contracts.
25
+ def initialize
26
+ @contracts = {}
27
+ end
28
+
29
+ # Registers a contract with an associated result class
30
+ #
31
+ # @param name [Symbol, String] The name of the contract
32
+ # @param result_class [Class] The class that represents the result for the contract
33
+ # @param override [Boolean] Whether to override an existing registration (default: false)
34
+ # @raise [Gaskit::ContractError] if the contract is already registered and override is not allowed
35
+ # @raise [Gaskit::ResultTypeError] if the result_class does not inherit from `Gaskit::BaseResult`
36
+ # @return [void]
37
+ def register(name, result_class, override: false)
38
+ name = name.to_sym
39
+ raise Gaskit::ContractError, "Contract #{name} already registered" if @contracts.key?(name) && !override
40
+
41
+ ContractRegistry.verify_result_class!(result_class)
42
+ @contracts[name] = result_class.freeze
43
+
44
+ Gaskit.configuration.logger.debug { "[Gaskit] Registered contract #{name}" } if Gaskit.debug?
45
+ end
46
+
47
+ # Checks if a contract is registered
48
+ #
49
+ # @param name [Symbol, String] The name of the contract
50
+ # @return [Boolean] true if the contract is registered, otherwise false
51
+ def registered?(name)
52
+ @contracts.key?(name.to_sym)
53
+ end
54
+
55
+ # Fetches a registered contract's result class
56
+ #
57
+ # @param name [Symbol, String] The name of the contract
58
+ # @return [Class] The result class for the contract
59
+ # @raise [Gaskit::ContractError] if the contract is not registered
60
+ def fetch(name)
61
+ @contracts.fetch(name.to_sym) do
62
+ raise Gaskit::ContractError, "Contract #{name} not registered, register it with " \
63
+ "Gaskit.configuration.register_contract(name, result_class)"
64
+ end
65
+ end
66
+
67
+ # Returns a duplicate of all registered contracts
68
+ #
69
+ # @return [Hash<Symbol, Class>] A hash of all registered contracts where keys are
70
+ # contract names and values are their corresponding result classes
71
+ def all
72
+ @contracts.dup
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "contract_registry"
5
+
6
+ module Gaskit
7
+ class << self
8
+ # Configures the Gaskit system.
9
+ #
10
+ # This yields the configuration instance, allowing you to modify settings such as logger,
11
+ # global context, log level, and formatting.
12
+ #
13
+ # @yieldparam [Gaskit::Configuration] configuration
14
+ # @return [void]
15
+ def config
16
+ yield(configuration)
17
+ end
18
+
19
+ # Retrieves the global Gaskit configuration.
20
+ #
21
+ # @return [Gaskit::Configuration] the configuration instance
22
+ def configuration
23
+ @configuration ||= Configuration.new
24
+ end
25
+
26
+ # Returns configuration.debug.
27
+ #
28
+ # @return [Boolean] `true` is Gaskit is set to debug, `false` otherwise.
29
+ def debug?
30
+ Gaskit.configuration.debug
31
+ end
32
+
33
+ # Registers a new operation contract.
34
+ #
35
+ # @param name [Symbol, String] Contract name
36
+ # @param result_class [Class<Gaskit::OperationResult>] Result class for the operation
37
+ def register_contract(name, result_class, override: false)
38
+ configuration.register_contract(name, result_class, override: override)
39
+ end
40
+
41
+ # Fetches the result and exit classes for the given contract name.
42
+ #
43
+ # @param name [Symbol, String]
44
+ # @return [Class]
45
+ def fetch_contract(name)
46
+ configuration.fetch_contract(name)
47
+ end
48
+
49
+ # Returns whether the contract is registered.
50
+ #
51
+ # @param name [Symbol, String]
52
+ # @return [Boolean]
53
+ def contract_registered?(name)
54
+ configuration.contract_registered?(name)
55
+ end
56
+
57
+ # Returns all registered contracts.
58
+ #
59
+ # @return [Hash{Symbol=>Hash}]
60
+ def registered_contracts
61
+ configuration.registered_contracts
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gaskit
4
+ class Error < StandardError; end
5
+
6
+ class ContractError < Error; end
7
+
8
+ # Raised when an operation contract supplies an incorrect result type.
9
+ class ResultTypeError < Error
10
+ # @param [Class] klazz The class that failed the type check.
11
+ def initialize(klazz)
12
+ message = "Expected result class to inherit from Gaskit::BaseResult, got: #{klazz}"
13
+ super(message)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "core"
5
+ require_relative "flow_result"
6
+ require_relative "helpers"
7
+
8
+ module Gaskit
9
+ # Base class for defining and executing multi-step operation pipelines
10
+ #
11
+ # @example Inline (block-based) flow
12
+ # result = Gaskit::Flow.call(1, 2, context: {}) do
13
+ # step AddOp
14
+ # step MultOp, multiplier: 2
15
+ # end
16
+ #
17
+ # @example Class-based flow
18
+ # class MyFlow < Gaskit::Flow
19
+ # step AddOp
20
+ # step MultOp, multiplier: 1.5
21
+ # end
22
+ #
23
+ # result = MyFlow.call(5, 5, context: { request_id: "abc123" })
24
+ class Flow
25
+ class << self
26
+ # Inherited hook to initialize step DSL
27
+ #
28
+ # @param subclass [Class] The subclass inheriting from Flow
29
+ # @return [void]
30
+ def inherited(subclass)
31
+ subclass.instance_variable_set(:@defined_steps, [])
32
+ super
33
+ end
34
+
35
+ # Returns defined steps for the flow class
36
+ #
37
+ # @return [Array<Array>] An array of [operation, args, kwargs] tuples
38
+ def defined_steps
39
+ @defined_steps ||= []
40
+ end
41
+
42
+ # Adds a step to the flow
43
+ #
44
+ # @param operation [Class<Gaskit::Operation>] The operation class
45
+ # @param args [Array] Positional arguments for the step
46
+ # @param context [Hash] Optional context overrides
47
+ # @param kwargs [Hash] Keyword arguments for the step
48
+ # @return [void]
49
+ def step(operation, *args, context: {}, **kwargs)
50
+ kwargs = kwargs.merge(context: context)
51
+ defined_steps << [operation, args, kwargs]
52
+ end
53
+
54
+ # Executes the flow with soft-failure handling
55
+ #
56
+ # @param args [Array] Positional arguments for the first step
57
+ # @param context [Hash] Shared context across all steps
58
+ # @param kwargs [Hash] Keyword arguments for the first step
59
+ # @return [FlowResult]
60
+ def call(*args, context: {}, **kwargs, &block)
61
+ invoke(false, context, *args, **kwargs, &block)
62
+ end
63
+
64
+ # Executes the flow with hard-failure handling (raises on unhandled errors)
65
+ #
66
+ # @param args [Array] Positional arguments for the first step
67
+ # @param context [Hash] Shared context across all steps
68
+ # @param kwargs [Hash] Keyword arguments for the first step
69
+ # @return [FlowResult]
70
+ def call!(*args, context: {}, **kwargs, &block)
71
+ invoke(true, context, *args, **kwargs, &block)
72
+ end
73
+
74
+ private
75
+
76
+ # Internal flow initializer
77
+ def invoke(raise_on_failure, context, *args, **kwargs, &block)
78
+ flow = new(raise_on_failure, context, [args, kwargs])
79
+ flow.execute(&block)
80
+ end
81
+ end
82
+
83
+ # @return [Hash] Execution context
84
+ attr_reader :context
85
+
86
+ # @return [Gaskit::OperationResult] Most recent result
87
+ attr_reader :result
88
+
89
+ # @return [Array<Hash>] List of step metadata
90
+ attr_reader :steps
91
+
92
+ # Executes a single step of the flow
93
+ #
94
+ # @param operation [Class<Gaskit::Operation>]
95
+ # @param args [Array] Additional positional arguments
96
+ # @param context [Hash] Step-local context
97
+ # @param kwargs [Hash] Additional keyword arguments
98
+ # @return [void]
99
+ def step(operation, *args, context: {}, **kwargs, &block)
100
+ raise ArgumentError, "Operation must be a subclass of Gaskit::Operation" unless operation <= Gaskit::Operation
101
+
102
+ return if result&.early_exit?
103
+
104
+ kwargs = kwargs.merge(context: context)
105
+ @result = execute_step(operation, *args, **kwargs, &block)
106
+ @steps << compile_step_entry(operation, *args, **kwargs)
107
+
108
+ update_input(result)
109
+ end
110
+
111
+ # Executes the flow either via block or pre-defined DSL
112
+ #
113
+ # @return [FlowResult]
114
+ def execute(&block)
115
+ duration, = Gaskit::Helpers.time_execution do
116
+ if block_given?
117
+ instance_eval(&block)
118
+ else
119
+ self.class.defined_steps.each { |(op, args, kwargs)| step(op, *args, **kwargs) }
120
+ end
121
+
122
+ result
123
+ end
124
+
125
+ FlowResult.new(@result, @steps, duration: duration, context: @context)
126
+ end
127
+
128
+ private
129
+
130
+ # Initializes a flow instance
131
+ #
132
+ # @param raise_on_failure [Boolean] Whether to raise on unexpected errors
133
+ # @param context [Hash] Flow context
134
+ # @param input [Array] Initial args/kwargs input bundle
135
+ def initialize(raise_on_failure, context, input)
136
+ @raise_on_failure = raise_on_failure
137
+ @context = apply_context(context)
138
+ @input = input
139
+ @steps = []
140
+ @result = nil
141
+ end
142
+
143
+ # Applies global context, if set, from Gaskit.configuration.context_provider
144
+ # and injects the `gaskit_flow` key to indicate to operations they are a part
145
+ # of a flow.
146
+ #
147
+ # @param context [Hash] The context provided directly to the Flow.
148
+ # @return [Hash] The fully applied context Hash.
149
+ def apply_context(context)
150
+ default_context = Gaskit.configuration.context_provider.call
151
+ context = default_context.merge(
152
+ gaskit_flow: { id: SecureRandom.uuid, name: self.class.name },
153
+ **context
154
+ )
155
+
156
+ Helpers.deep_compact(context)
157
+ end
158
+
159
+ # Executes a single operation step and handles errors
160
+ #
161
+ # @param operation [Class<Gaskit::Operation>]
162
+ # @param kwargs [Hash] Merged args/kwargs/context
163
+ # @return [Gaskit::OperationResult]
164
+ def execute_step(operation, **kwargs, &block)
165
+ input_args, input_kwargs = @input
166
+ kwargs = (input_kwargs || {}).merge(kwargs).merge(context: @context)
167
+
168
+ return operation.call!(*input_args, **kwargs, &block) if @raise_on_failure
169
+
170
+ operation.call(*input_args, **kwargs, &block)
171
+ rescue StandardError => e
172
+ raise e if @raise_on_failure
173
+
174
+ result_class = operation.class.result_class
175
+ result_class.new(false, nil, e, duration: 0.0, context: @context)
176
+ end
177
+
178
+ # Logs a step’s full input and output
179
+ #
180
+ # @param operation [Class]
181
+ # @param args [Array]
182
+ # @param kwargs [Hash]
183
+ # @return [Hash] Step metadata
184
+ def compile_step_entry(operation, *args, **kwargs)
185
+ args, kwargs = step_input(*args, **kwargs)
186
+
187
+ {
188
+ operation: operation,
189
+ args: args,
190
+ kwargs: kwargs,
191
+ result: result.to_h
192
+ }
193
+ end
194
+
195
+ # Combines current flow input with explicit args for logging
196
+ #
197
+ # @param args [Array]
198
+ # @param kwargs [Hash]
199
+ # @return [Array<Array, Hash>]
200
+ def step_input(*args, **kwargs)
201
+ input_args, input_kwargs = @input
202
+ args = (input_args || []).concat(args)
203
+ kwargs = input_kwargs.merge(kwargs)
204
+
205
+ [args, kwargs]
206
+ end
207
+
208
+ # Set the input used to call the next operation. Do not set input if the result
209
+ # is a failure or has a nil value.
210
+ #
211
+ # @param result [Gaskit::OperationResult] The result of the operation.
212
+ # @return [void]
213
+ def update_input(result)
214
+ return if result&.failure? || result&.value.nil?
215
+
216
+ @input =
217
+ case result.value
218
+ when Array
219
+ [result.value, {}]
220
+ when Hash
221
+ [[], result.value]
222
+ else
223
+ [[result.value], {}]
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gaskit
4
+ # Represents the result of a flow execution, including step-by-step trace
5
+ #
6
+ # @example Checking result and accessing steps
7
+ # result = MyFlow.call(1, 2)
8
+ # if result.success?
9
+ # puts "Total: #{result.value}"
10
+ # else
11
+ # puts "Failed at: #{result.steps.last[:operation]}"
12
+ # end
13
+ class FlowResult < OperationResult
14
+ # @return [Array<Hash>] A list of step data executed during the flow
15
+ attr_reader :steps
16
+
17
+ # Initializes a new FlowResult
18
+ #
19
+ # @param result [Gaskit::OperationResult] The final operation result
20
+ # @param steps [Array<Hash>] Step-by-step execution details
21
+ # @param duration [Float, String] Total flow duration
22
+ # @param context [Hash] Execution context
23
+ def initialize(result, steps, duration:, context: {})
24
+ super(
25
+ result.success?,
26
+ result.value,
27
+ result.error,
28
+ duration: duration,
29
+ context: context
30
+ )
31
+
32
+ @steps = steps
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gaskit
4
+ module Helpers
5
+ class << self
6
+ # Measures the time taken for execution.
7
+ #
8
+ # @yield The block containing the logic to time.
9
+ # @return [Array<Float, Object>] The duration and the result.
10
+ def time_execution
11
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
12
+ result = yield
13
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time)
14
+
15
+ [format("%.6f", duration), result]
16
+ end
17
+
18
+ # Applies deep compat on the provided hash.
19
+ #
20
+ # @param hash [Hash] The original hash to compact.
21
+ # @return [Hash] The compacted hash.
22
+ def deep_compact(hash)
23
+ hash.each_with_object({}) do |(k, v), result|
24
+ compacted = v.is_a?(Hash) ? deep_compact(v) : v
25
+ result[k.to_sym] = compacted unless compacted.nil?
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end