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,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "json"
5
+ require "time"
6
+ require "English"
7
+
8
+ require_relative "helpers"
9
+
10
+ module Gaskit
11
+ # A logger class designed for structured logging with support for JSON, contextual data,
12
+ # and environment-aware formatting.
13
+ #
14
+ # This logger wraps a configurable `Logger` instance and supports:
15
+ #
16
+ # - Structured or human-readable log formatting
17
+ # - Inclusion of global and per-call context (with filtering of sensitive keys)
18
+ # - Support for custom log levels, formatters, and disabling output via `Gaskit.config`
19
+ #
20
+ # By default, logs are pretty-printed in development and JSON-formatted in production.
21
+ # These defaults can be overridden via configuration.
22
+ #
23
+ # @example Basic usage with default formatting
24
+ # logger = Gaskit::Logger.new('MyOperation', context: { user_id: 42 })
25
+ #
26
+ # logger.info("Operation started")
27
+ # logger.debug(context: { details: "debug info" }) { "Deferred message" }
28
+ #
29
+ # @example Using logger within a class
30
+ # class UserRepository
31
+ # def self.logger
32
+ # @logger ||= Gaskit::Logger.new(self)
33
+ # end
34
+ #
35
+ # def self.find(id)
36
+ # logger.info("Looking up user", context: { user_id: id })
37
+ # end
38
+ # end
39
+ #
40
+ # @example Customizing the log formatter globally
41
+ # Gaskit.config do |c|
42
+ # c.log_formatter = ->(severity, time, _progname, msg) do
43
+ # message, ctx = msg.is_a?(Array) ? msg : [msg, {}]
44
+ # "[#{time.strftime('%T')}] #{severity}: #{message} #{ctx.to_json}\n"
45
+ # end
46
+ # end
47
+ #
48
+ # @example Configuring JSON or pretty formatters
49
+ # Gaskit.config do |c|
50
+ # c.setup_logger(Logger.new($stdout), formatter: Gaskit::Logger.formatter(:json))
51
+ # end
52
+ #
53
+ # # or
54
+ #
55
+ # Gaskit.config do |c|
56
+ # c.setup_logger(Logger.new($stdout), formatter: Gaskit::Logger.formatter(:pretty))
57
+ # end
58
+ #
59
+ # @example Disabling logs entirely
60
+ # Gaskit.config do |c|
61
+ # c.disable_logging = true
62
+ # end
63
+ #
64
+ # @see Gaskit::Configuration
65
+ class Logger
66
+ SENSITIVE_KEYS = %i[email ip_address password auth_token secret ssn jwt token].freeze
67
+
68
+ class << self
69
+ # Returns a built-in log formatter.
70
+ #
71
+ # Use this method to obtain either the JSON or pretty formatter for logs.
72
+ # This is useful when customizing the logger via `Gaskit.config`.
73
+ #
74
+ # @example Use JSON formatter
75
+ # Gaskit.config do |c|
76
+ # c.setup_logger(Logger.new($stdout), formatter: Gaskit::Logger.formatter(:json))
77
+ # end
78
+ #
79
+ # @example Use pretty formatter
80
+ # Gaskit.config do |c|
81
+ # c.setup_logger(Logger.new($stdout), formatter: Gaskit::Logger.formatter(:pretty))
82
+ # end
83
+ #
84
+ # @param formatter [Symbol] The formatter type to use (`:json` or `:pretty`)
85
+ # @return [Proc] A formatter proc suitable for use with `Logger#formatter`
86
+ # @raise [ArgumentError] If an unknown formatter symbol is provided
87
+ def formatter(formatter = :pretty)
88
+ case formatter
89
+ when :json
90
+ json_formatter
91
+ when :pretty
92
+ pretty_formatter
93
+ else
94
+ raise ArgumentError, "Invalid log formatter: #{formatter}"
95
+ end
96
+ end
97
+
98
+ # JSON formatter (for production or structured logs)
99
+ #
100
+ # @return [Proc] The formatter callable.
101
+ def json_formatter
102
+ lambda do |severity, time, _progname, msg|
103
+ message, context = extract_message_and_context(msg)
104
+
105
+ log_entry = {
106
+ timestamp: time.utc.iso8601,
107
+ level: severity.downcase,
108
+ class: context[:class],
109
+ message: message,
110
+ context: context&.reject { |k| k == :duration }
111
+ }.compact
112
+
113
+ log_entry[:duration] = context[:duration] if context&.key?(:duration)
114
+
115
+ "#{JSON.dump(log_entry)}\n"
116
+ end
117
+ end
118
+
119
+ # Pretty formatter (for dev logs)
120
+ #
121
+ # @return [Proc] The formatter callable.
122
+ def pretty_formatter
123
+ lambda do |severity, time, _progname, msg|
124
+ message, context = extract_message_and_context(msg)
125
+ context ||= {}
126
+
127
+ tags = %W[[#{time.utc.iso8601}] [#{severity}]]
128
+ tags << "[#{context[:class]}]" if context[:class]
129
+ tags += context.map { |k, v| "[#{k}=#{v}]" }
130
+
131
+ "#{tags.join(" ")} #{message}\n"
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ # Extracts the message and context from a log payload.
138
+ #
139
+ # @param msg [Object] The payload passed to the logger.
140
+ # @return [Array] An array with the message and context.
141
+ def extract_message_and_context(msg)
142
+ return [msg[0], msg[1]] if msg.is_a?(Array) && msg.size == 2
143
+
144
+ [msg.to_s, {}]
145
+ end
146
+ end
147
+
148
+ attr_reader :context
149
+
150
+ # Initializes a new logger instance.
151
+ #
152
+ # @param klass [Class] The name of the class being logged.
153
+ # @param context [Hash] Optional additional context to include in every log entry.
154
+ def initialize(klass, context: {})
155
+ @class_name = resolve_name(klass)
156
+ @context = apply_context(context)
157
+
158
+ @logger = Gaskit.configuration.logger || ::Logger.new($stdout)
159
+ rescue StandardError
160
+ ::Logger.new($stdout).error "Failed to initialize logger: #{$ERROR_INFO}"
161
+ @logger = ::Logger.new(nil) # fallback null logger
162
+ end
163
+
164
+ # Logs a debug-level message.
165
+ #
166
+ # @param message [String, nil] The log message (or provide it via the block parameter).
167
+ # @param context [Hash, nil] Additional context for this specific log entry.
168
+ # @yield Block for deferred log message computation.
169
+ def debug(message = nil, context: {}, &block)
170
+ log(:debug, message, context: context, &block)
171
+ end
172
+
173
+ # Logs an info-level message.
174
+ #
175
+ # @param message [String, nil] The log message (or provide it via the block parameter).
176
+ # @param context [Hash, nil] Additional context for this specific log entry.
177
+ # @yield Block for deferred log message computation.
178
+ def info(message = nil, context: {}, &block)
179
+ log(:info, message, context: context, &block)
180
+ end
181
+
182
+ # Logs a warning-level message.
183
+ #
184
+ # @param message [String, nil] The log message (or provide it via the block parameter).
185
+ # @param context [Hash, nil] Additional context for this specific log entry.
186
+ # @yield Block for deferred log message computation.
187
+ def warn(message = nil, context: {}, &block)
188
+ log(:warn, message, context: context, &block)
189
+ end
190
+
191
+ # Logs an error-level message.
192
+ #
193
+ # @param message [String, nil] The log message (or provide it via the block parameter).
194
+ # @param context [Hash, nil] Additional context for this specific log entry.
195
+ # @yield Block for deferred log message computation.
196
+ def error(message = nil, context: {}, &block)
197
+ log(:error, message, context: context, &block)
198
+ end
199
+
200
+ # Logs a message at the specified level.
201
+ #
202
+ # @param level [Symbol] The log level (e.g., :debug, :info).
203
+ # @param message [String, nil] The log message (or provide it via the block parameter).
204
+ # @param context [Hash, nil] Additional context for this specific log entry.
205
+ # @yield Block for deferred log message computation.
206
+ def log(level, message, context: {}, &block)
207
+ return if Gaskit.configuration.disable_logging
208
+
209
+ @logger.public_send(level) do
210
+ combined_context = filtered_context(@context.merge(context))
211
+ combined_context[:class] ||= @class_name
212
+
213
+ msg = message || block&.call
214
+
215
+ [msg, combined_context]
216
+ end
217
+ end
218
+
219
+ # Creates a new logger instance with additional merged context.
220
+ #
221
+ # @param extra_context [Hash] Additional context to include.
222
+ # @return [Gaskit::Logger] A new logger instance with the updated context.
223
+ def with_context(extra_context)
224
+ self.class.new(@class_name, context: @context.merge(extra_context))
225
+ end
226
+
227
+ private
228
+
229
+ # Resolves the class name provided when instantiating the logger.
230
+ #
231
+ # @return [String] The resolved class name.
232
+ def resolve_name(source)
233
+ case source
234
+ when String, Symbol
235
+ source.to_s
236
+ when Class
237
+ source.name
238
+ else
239
+ source.class.name
240
+ end
241
+ end
242
+
243
+ def apply_context(context)
244
+ default_context = Gaskit.configuration.context_provider.call
245
+ context = default_context.merge(context)
246
+
247
+ Helpers.deep_compact(context)
248
+ end
249
+
250
+ # Filters out sensitive values from the log context based on predefined keys.
251
+ #
252
+ # This method transforms all context keys to symbols and checks if each key is considered sensitive.
253
+ # If a key matches one of the predefined sensitive keys, its value is replaced with "[FILTERED]".
254
+ #
255
+ # @param context [Hash] The context hash to be filtered.
256
+ # @return [Hash] The filtered context hash with sensitive values masked.
257
+ def filtered_context(context)
258
+ filtered_context = context.transform_keys(&:to_sym).transform_values.with_index do |value, index|
259
+ key = context.keys[index].to_sym
260
+ sensitive_key?(key) ? "[FILTERED]" : value
261
+ end
262
+
263
+ Helpers.deep_compact(filtered_context)
264
+ end
265
+
266
+ # Determines if a given key is considered sensitive and should be masked in logs.
267
+ #
268
+ # @param key [Symbol, String] The key to evaluate.
269
+ # @return [Boolean] True if the key is sensitive and should be filtered; false otherwise.
270
+ def sensitive_key?(key)
271
+ SENSITIVE_KEYS.include?(key)
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "core"
4
+ require_relative "operation_result"
5
+ require_relative "operation_exit"
6
+ require_relative "helpers"
7
+
8
+ module Gaskit
9
+ # The Gaskit::Operation class defines a structured and extensible pattern for building application operations.
10
+ # It enforces consistent behavior across operations while supporting customization via contracts.
11
+ #
12
+ # # Features
13
+ # - Pluggable contracts via `use_contract`, allowing you to define or reference a `result` and
14
+ # `early_exit` class.
15
+ # - A **contract** is a pairing of a result and early_exit class, either manually defined or registered
16
+ # under a symbol.
17
+ # - Integrated duration tracking, structured logging, and early exits.
18
+ # - Supports `.call` (non-raising) and `.call!` (raising) styles.
19
+ #
20
+ # @example Using a registered contract
21
+ # class MyOperation < Gaskit::Operation
22
+ # use_contract :service
23
+ #
24
+ # def call
25
+ # # Do work
26
+ # "done"
27
+ # end
28
+ # end
29
+ #
30
+ # @example Overriding only part of the contract
31
+ # class MyCustomOp < Gaskit::Operation
32
+ # use_contract :service, result: MyCustomResult
33
+ #
34
+ # def call
35
+ # exit(:unauthorized, "User not allowed") if unauthorized?
36
+ # "okay"
37
+ # end
38
+ # end
39
+ #
40
+ # @example Fully manual contract
41
+ # class ManualOp < Gaskit::Operation
42
+ # use_contract result: MyResult, early_exit: MyExit
43
+ #
44
+ # def call
45
+ # do_work
46
+ # end
47
+ # end
48
+ #
49
+ # result = ManualOp.call(context: { request_id: "abc123" })
50
+ #
51
+ # @abstract Subclass this and define `#call` or `#call!` to create a new operation.
52
+ class Operation
53
+ class << self
54
+ def result_class
55
+ return @result_class if defined?(@result_class) && @result_class
56
+ return superclass&.result_class if superclass.respond_to?(:result_class)
57
+
58
+ nil
59
+ end
60
+
61
+ # Defines the result and early exit classes for this operation.
62
+ # Can reference a named contract registered in `Gaskit::Registry`, override either part manually,
63
+ # or define both without using a named contract.
64
+ #
65
+ # @example Use a registered contract
66
+ # use_contract :service
67
+ #
68
+ # @example Override only result class
69
+ # use_contract :service, result: CustomResult
70
+ #
71
+ # @example Define both without using a contract name
72
+ # use_contract result: CustomResult, early_exit: CustomExit
73
+ #
74
+ # @param contract [Symbol, nil] A registered contract name (e.g., `:service`)
75
+ # @param result [Class, nil] A class that inherits from `Gaskit::BaseResult`
76
+ # @raise [ArgumentError] if contract is not a symbol or unexpected args are passed
77
+ # @raise [ResultTypeError] if `result` is not a subclass of `Gaskit::BaseResult`
78
+ # @raise [EarlyExitTypeError] if `early_exit` is not a subclass of `Gaskit::BaseExit`
79
+ # @return [void]
80
+ def use_contract(contract = nil, result: nil)
81
+ if contract
82
+ raise ArgumentError, "use_contract must be called with a symbol or keyword args" unless contract.is_a?(Symbol)
83
+
84
+ result = Gaskit.fetch_contract(contract)
85
+ end
86
+
87
+ Gaskit::ContractRegistry.verify_result_class!(result)
88
+ @result_class = result
89
+ end
90
+
91
+ # Declares a symbolic error and message for use with `exit(:key)`
92
+ #
93
+ # @example
94
+ # error :unauthorized, "You must be signed in", code: "AUTH-001"
95
+ #
96
+ # @param key [String, Symbol] The key used to declare the error.
97
+ # @param message [String] The error message.
98
+ # @param code [String, nil] Optional error code.
99
+ # @return [void]
100
+ def error(key, message, code: nil)
101
+ errors_registry[key.to_sym] = { message: message, code: code }
102
+ end
103
+
104
+ # Returns the error registry for the operation class
105
+ #
106
+ # @return [void]
107
+ def errors_registry
108
+ @errors_registry ||= {}
109
+ end
110
+
111
+ # Execute the operation without raising an exception on failure.
112
+ #
113
+ # @param [Array] args Positional arguments passed.
114
+ # @param [Hash] kwargs Keyword arguments passed (with optional :context).
115
+ # @yield [Block] Additional block logic to pass during the operation.
116
+ # @return [OperationResult] The result of the operation.
117
+ def call(*args, **kwargs, &block)
118
+ invoke(false, *args, **kwargs, &block)
119
+ end
120
+
121
+ # Execute the operation with raising an exception on failure.
122
+ #
123
+ # @param [Array] args Positional arguments passed.
124
+ # @param [Hash] kwargs Keyword arguments passed (with optional :context).
125
+ # @yield [Block] Additional block logic to pass during the operation.
126
+ # @return [OperationResult] The result of the operation.
127
+ def call!(*args, **kwargs, &block)
128
+ invoke(true, *args, **kwargs, &block)
129
+ end
130
+
131
+ private
132
+
133
+ # Core execution logic for operations, handling errors and timing.
134
+ #
135
+ # @param [Boolean] raise_on_failure Whether to raise exceptions on failure.
136
+ # @param [Array] args Positional arguments for the operation.
137
+ # @param [Hash] kwargs Keyword arguments, including optional :context.
138
+ # @yield [Block] Additional block logic during execution.
139
+ # @raise [NotImplementedError] If operation type is not set in subclasses.
140
+ # @return [OperationResult] The result of the operation.
141
+ def invoke(raise_on_failure, *args, **kwargs, &block)
142
+ unless result_class
143
+ raise NotImplementedError, "No result_class defined for #{name} or its ancestors. " \
144
+ "Did you forget to call `use_contract`?"
145
+ end
146
+
147
+ context = kwargs.delete(:context) || {}
148
+ operation = new(raise_on_failure, context: context)
149
+ duration, (result, error) = execute(operation, *args, **kwargs, &block)
150
+
151
+ operation.logger.debug(context: { duration: duration }) do
152
+ "Operation completed in #{duration} seconds"
153
+ end
154
+
155
+ result_class.new(error.nil?, result, error, duration: duration, context: context)
156
+ end
157
+
158
+ # Executes the operation logic and handles potential exceptions.
159
+ #
160
+ # @param [Gaskit::Operation] operation The instance of the current operation.
161
+ # @param [Array] args Positional arguments passed.
162
+ # @param [Hash] kwargs Keyword arguments passed.
163
+ # @yield [Block] Additional block for the operation.
164
+ # @return [Array] The execution duration, result, and error if any.
165
+ def execute(operation, *args, **kwargs, &block)
166
+ Helpers.time_execution do
167
+ [operation.call(*args, **kwargs, &block), nil]
168
+ rescue StandardError => e
169
+ if e.is_a?(Gaskit::OperationExit)
170
+ log_exit(operation, e)
171
+ else
172
+ log_exception(operation, e)
173
+ raise e if operation.raise_on_failure
174
+
175
+ end
176
+ [nil, e]
177
+ end
178
+ end
179
+
180
+ # Logs an early exit from the operation.
181
+ #
182
+ # @param [Gaskit::Operation] operation The operation instance.
183
+ # @param [StandardError] operation_exit The exit error raised.
184
+ # @return [void]
185
+ def log_exit(operation, operation_exit)
186
+ operation.logger.warn { "Exited early: #{operation_exit.key} – #{operation_exit.message}" }
187
+ end
188
+
189
+ # Logs any unhandled exception during the operation.
190
+ #
191
+ # @param [Gaskit::Operation] operation The operation instance.
192
+ # @param [Exception] exception The raised exception.
193
+ # @return [void]
194
+ def log_exception(operation, exception)
195
+ operation.logger.error { "[#{exception.class}] #{exception.message}" }
196
+ operation.logger.error { exception.backtrace&.join("\n") }
197
+ end
198
+
199
+ # @return [String] The name of the operation class (e.g., "MyOperation").
200
+ def operation_name
201
+ @operation_name ||= self.class.name.to_s
202
+ end
203
+ end
204
+
205
+ attr_reader :raise_on_failure, :context, :logger
206
+
207
+ # Initializes a new Gaskit::Operation instance.
208
+ #
209
+ # @param [Boolean] raise_on_failure Whether to raise exceptions on failure.
210
+ # @param [Hash] context Context data for the operation.
211
+ # @return [void]
212
+ def initialize(raise_on_failure, context: {})
213
+ @raise_on_failure = raise_on_failure
214
+ @context = apply_context(context)
215
+ @logger = Gaskit::Logger.new(self.class, context: @context)
216
+ end
217
+
218
+ # Applies global context, if set, from Gaskit.configuration.context_provider.
219
+ #
220
+ # @param context [Hash] The context provided directly to the Flow.
221
+ # @return [Hash] The fully applied context Hash.
222
+ def apply_context(context)
223
+ default_context = Gaskit.configuration.context_provider.call
224
+ Helpers.deep_compact(default_context.merge(context))
225
+ end
226
+
227
+ # Executes the operation logic.
228
+ #
229
+ # @param [Array] args Positional arguments passed.
230
+ # @param [Hash] kwargs Keyword arguments passed.
231
+ # @return [void]
232
+ # @raise [NotImplementedError] Must be implemented by subclasses.
233
+ def call(*args, **kwargs)
234
+ raise NotImplementedError, "#{self.class.name} must implement `#call`"
235
+ end
236
+
237
+ # Terminates the operation early with a symbolic key.
238
+ #
239
+ # If the key was previously registered via `self.error`, it uses the declared message and code.
240
+ # Otherwise, it uses the key as the message.
241
+ #
242
+ # @param exit_key [Symbol] The symbolic reason for exiting.
243
+ # @param message [String, nil] Optional message override.
244
+ # @raise [OperationExit] always raises an instance with message and optional code
245
+ def exit(exit_key, message = nil)
246
+ exit_key = exit_key.to_sym
247
+ definition = self.class.errors_registry[exit_key]
248
+
249
+ if definition
250
+ message ||= definition[:message]
251
+ code = definition[:code]
252
+ end
253
+
254
+ raise OperationExit.new(exit_key, message, code: code)
255
+ end
256
+
257
+ # @see #exit
258
+ alias abort exit
259
+ end
260
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gaskit
4
+ # OperationExit is a custom exception representing an early exit from an operation.
5
+ #
6
+ # It communicates intent-based flow interruption (e.g., authorization failure, validation issue)
7
+ # and includes a symbolic `key`, optional `message`, and optional `code`.
8
+ #
9
+ # @example Raising an OperationExit with just a key
10
+ # exit(:unauthorized)
11
+ #
12
+ # @example With a custom message and code
13
+ # exit(:unauthorized, "Access denied", code: "AUTH-001")
14
+ #
15
+ # @example Handling OperationExit in a flow
16
+ # begin
17
+ # MyFlow.call!
18
+ # rescue Gaskit::OperationExit => e
19
+ # puts "Exited: #{e.key} - #{e.message} (#{e.code})"
20
+ # end
21
+ class OperationExit < Gaskit::Error
22
+ # @return [Symbol, String] The symbolic or textual reason for the early exit
23
+ attr_reader :key
24
+
25
+ # @return [String, nil] Optional structured code (e.g., "AUTH-001")
26
+ attr_reader :code
27
+
28
+ # Initializes an OperationExit.
29
+ #
30
+ # @param key [Symbol, String] The symbolic exit key
31
+ # @param message [String, nil] A human-readable message (defaults to key if not provided)
32
+ # @param code [String, nil] A structured error code for analytics or debugging
33
+ def initialize(key, message = nil, code: nil)
34
+ super(message || key.to_s)
35
+ @key = key
36
+ @code = code
37
+ end
38
+ end
39
+ end