hooks-ruby 0.0.3 → 0.0.5
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 +4 -4
- data/README.md +7 -3
- data/lib/hooks/app/api.rb +6 -2
- data/lib/hooks/app/auth/auth.rb +2 -6
- data/lib/hooks/app/endpoints/health.rb +1 -1
- data/lib/hooks/app/endpoints/version.rb +1 -1
- data/lib/hooks/app/helpers.rb +39 -9
- data/lib/hooks/core/component_access.rb +69 -0
- data/lib/hooks/core/config_loader.rb +44 -10
- data/lib/hooks/core/config_validator.rb +1 -0
- data/lib/hooks/core/log.rb +15 -0
- data/lib/hooks/core/plugin_loader.rb +4 -4
- data/lib/hooks/plugins/auth/base.rb +3 -36
- data/lib/hooks/plugins/auth/hmac.rb +20 -18
- data/lib/hooks/plugins/auth/timestamp_validator.rb +133 -0
- data/lib/hooks/plugins/handlers/base.rb +5 -38
- data/lib/hooks/plugins/handlers/default.rb +32 -3
- data/lib/hooks/plugins/instruments/failbot.rb +18 -4
- data/lib/hooks/plugins/instruments/failbot_base.rb +57 -10
- data/lib/hooks/plugins/instruments/stats.rb +18 -4
- data/lib/hooks/plugins/instruments/stats_base.rb +73 -10
- data/lib/hooks/plugins/lifecycle.rb +3 -36
- data/lib/hooks/utils/normalize.rb +33 -0
- data/lib/hooks/version.rb +4 -1
- data/lib/hooks.rb +23 -15
- metadata +3 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 20afc330ce2c974fa7cb50950e261e0ed8bfb01a9e65aa615f1d1fc4aeff3b90
|
4
|
+
data.tar.gz: 2e561e403eab068f75cce3f41b48ba0642d4fd5f81124d9bea66d563f1472ff4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1152f3eef93d6c1d872b13dc59154584229f3bfed17a46c2cc748c0f8d1bd9847441dbe96ca2d084bdb6ed6f30077bcf0b49515c8e46bf668c355c8bdb5d52b0
|
7
|
+
data.tar.gz: '038523ad1ed72fc0f27fad4172a15b316c02d52b95936951a3f9f9efbdc2034dc074847665ee43524d7af93f6432ddd91c0ac97b2a0dbafa0f10deea8238fd67'
|
data/README.md
CHANGED
@@ -66,7 +66,7 @@ Here is a very high-level overview of how Hooks works:
|
|
66
66
|
status: "success",
|
67
67
|
handler: "MyCustomHandler",
|
68
68
|
payload_received: payload,
|
69
|
-
timestamp: Time.now.iso8601
|
69
|
+
timestamp: Time.now.utc.iso8601
|
70
70
|
}
|
71
71
|
end
|
72
72
|
end
|
@@ -229,7 +229,7 @@ class HelloHandler < Hooks::Plugins::Handlers::Base
|
|
229
229
|
{
|
230
230
|
message: "webhook processed successfully",
|
231
231
|
handler: "HelloHandler",
|
232
|
-
timestamp: Time.now.iso8601
|
232
|
+
timestamp: Time.now.utc.iso8601
|
233
233
|
}
|
234
234
|
end
|
235
235
|
end
|
@@ -245,7 +245,7 @@ class GoodbyeHandler < Hooks::Plugins::Handlers::Base
|
|
245
245
|
{
|
246
246
|
message: "goodbye webhook processed successfully",
|
247
247
|
handler: "GoodbyeHandler",
|
248
|
-
timestamp: Time.now.iso8601
|
248
|
+
timestamp: Time.now.utc.iso8601
|
249
249
|
}
|
250
250
|
end
|
251
251
|
end
|
@@ -302,6 +302,10 @@ See the [Lifecycle Plugins](docs/lifecycle_plugins.md) documentation for informa
|
|
302
302
|
|
303
303
|
See the [Instrument Plugins](docs/instrument_plugins.md) documentation for information on how to create instrument plugins that can be used to collect metrics or report exceptions during webhook processing. These plugins can be used to integrate with monitoring and alerting systems.
|
304
304
|
|
305
|
+
### Configuration
|
306
|
+
|
307
|
+
See the [Configuration](docs/configuration.md) documentation for detailed information on how to configure your Hooks server, including global options, endpoint options, and more.
|
308
|
+
|
305
309
|
## Contributing 🤝
|
306
310
|
|
307
311
|
See the [Contributing](CONTRIBUTING.md) document for information on how to contribute to the Hooks project, including setting up your development environment, running tests, and releasing new versions.
|
data/lib/hooks/app/api.rb
CHANGED
@@ -27,6 +27,7 @@ module Hooks
|
|
27
27
|
|
28
28
|
# Create a new configured API class
|
29
29
|
def self.create(config:, endpoints:, log:)
|
30
|
+
# :nocov:
|
30
31
|
@server_start_time = Time.now
|
31
32
|
|
32
33
|
api_class = Class.new(Grape::API) do
|
@@ -47,8 +48,9 @@ module Hooks
|
|
47
48
|
endpoints.each do |endpoint_config|
|
48
49
|
full_path = "#{config[:root_path]}#{endpoint_config[:path]}"
|
49
50
|
handler_class_name = endpoint_config[:handler]
|
51
|
+
http_method = (endpoint_config[:method] || "post").downcase.to_sym
|
50
52
|
|
51
|
-
|
53
|
+
send(http_method, full_path) do
|
52
54
|
request_id = uuid
|
53
55
|
start_time = Time.now
|
54
56
|
|
@@ -103,10 +105,11 @@ module Hooks
|
|
103
105
|
payload = parse_payload(raw_body, headers, symbolize: config[:symbolize_payload])
|
104
106
|
handler = load_handler(handler_class_name)
|
105
107
|
normalized_headers = config[:normalize_headers] ? Hooks::Utils::Normalize.headers(headers) : headers
|
108
|
+
symbolized_headers = config[:symbolize_headers] ? Hooks::Utils::Normalize.symbolize_headers(normalized_headers) : normalized_headers
|
106
109
|
|
107
110
|
response = handler.call(
|
108
111
|
payload:,
|
109
|
-
headers:
|
112
|
+
headers: symbolized_headers,
|
110
113
|
config: endpoint_config
|
111
114
|
)
|
112
115
|
|
@@ -151,6 +154,7 @@ module Hooks
|
|
151
154
|
end
|
152
155
|
|
153
156
|
api_class
|
157
|
+
# :nocov:
|
154
158
|
end
|
155
159
|
end
|
156
160
|
end
|
data/lib/hooks/app/auth/auth.rb
CHANGED
@@ -37,12 +37,8 @@ module Hooks
|
|
37
37
|
end
|
38
38
|
|
39
39
|
log.debug("validating auth for request with auth_class: #{auth_class.name}")
|
40
|
-
|
41
|
-
|
42
|
-
payload:,
|
43
|
-
headers:,
|
44
|
-
config: endpoint_config
|
45
|
-
)
|
40
|
+
unless auth_class.valid?(payload:, headers:, config: endpoint_config)
|
41
|
+
log.warn("authentication failed for request with auth_class: #{auth_class.name}")
|
46
42
|
error!("authentication failed", 401)
|
47
43
|
end
|
48
44
|
end
|
data/lib/hooks/app/helpers.rb
CHANGED
@@ -21,13 +21,15 @@ module Hooks
|
|
21
21
|
# @return [void]
|
22
22
|
# @note Timeout enforcement should be handled at the server level (e.g., Puma)
|
23
23
|
def enforce_request_limits(config)
|
24
|
-
#
|
25
|
-
content_length =
|
26
|
-
headers["content-length"] || headers["HTTP_CONTENT_LENGTH"] ||
|
27
|
-
env["CONTENT_LENGTH"] || env["HTTP_CONTENT_LENGTH"]
|
24
|
+
# Optimized content length check - check most common sources first
|
25
|
+
content_length = request.content_length if respond_to?(:request) && request.respond_to?(:content_length)
|
28
26
|
|
29
|
-
|
30
|
-
|
27
|
+
content_length ||= headers["Content-Length"] ||
|
28
|
+
headers["CONTENT_LENGTH"] ||
|
29
|
+
headers["content-length"] ||
|
30
|
+
headers["HTTP_CONTENT_LENGTH"] ||
|
31
|
+
env["CONTENT_LENGTH"] ||
|
32
|
+
env["HTTP_CONTENT_LENGTH"]
|
31
33
|
|
32
34
|
content_length = content_length&.to_i
|
33
35
|
|
@@ -45,16 +47,21 @@ module Hooks
|
|
45
47
|
# @param symbolize [Boolean] Whether to symbolize keys in parsed JSON (default: true)
|
46
48
|
# @return [Hash, String] Parsed JSON as Hash (optionally symbolized), or raw body if not JSON
|
47
49
|
def parse_payload(raw_body, headers, symbolize: true)
|
50
|
+
# Optimized content type check - check most common header first
|
48
51
|
content_type = headers["Content-Type"] || headers["CONTENT_TYPE"] || headers["content-type"] || headers["HTTP_CONTENT_TYPE"]
|
49
52
|
|
50
53
|
# Try to parse as JSON if content type suggests it or if it looks like JSON
|
51
54
|
if content_type&.include?("application/json") || (raw_body.strip.start_with?("{", "[") rescue false)
|
52
55
|
begin
|
53
|
-
|
56
|
+
# Security: Limit JSON parsing depth and complexity to prevent JSON bombs
|
57
|
+
parsed_payload = safe_json_parse(raw_body)
|
54
58
|
parsed_payload = parsed_payload.transform_keys(&:to_sym) if symbolize && parsed_payload.is_a?(Hash)
|
55
59
|
return parsed_payload
|
56
|
-
rescue JSON::ParserError
|
57
|
-
# If JSON parsing fails, return raw body
|
60
|
+
rescue JSON::ParserError, ArgumentError => e
|
61
|
+
# If JSON parsing fails or security limits exceeded, return raw body
|
62
|
+
if e.message.include?("nesting") || e.message.include?("depth")
|
63
|
+
log.warn("JSON parsing limit exceeded: #{e.message}")
|
64
|
+
end
|
58
65
|
end
|
59
66
|
end
|
60
67
|
|
@@ -79,6 +86,29 @@ module Hooks
|
|
79
86
|
|
80
87
|
private
|
81
88
|
|
89
|
+
# Safely parse JSON
|
90
|
+
#
|
91
|
+
# @param json_string [String] The JSON string to parse
|
92
|
+
# @return [Hash, Array] Parsed JSON object
|
93
|
+
# @raise [JSON::ParserError] If JSON is invalid
|
94
|
+
# @raise [ArgumentError] If security limits are exceeded
|
95
|
+
def safe_json_parse(json_string)
|
96
|
+
# Security limits for JSON parsing
|
97
|
+
max_nesting = ENV.fetch("JSON_MAX_NESTING", "20").to_i
|
98
|
+
|
99
|
+
# Additional size check before parsing
|
100
|
+
if json_string.length > ENV.fetch("JSON_MAX_SIZE", "10485760").to_i # 10MB default
|
101
|
+
raise ArgumentError, "JSON payload too large for parsing"
|
102
|
+
end
|
103
|
+
|
104
|
+
JSON.parse(json_string, {
|
105
|
+
max_nesting: max_nesting,
|
106
|
+
create_additions: false, # Security: Disable object creation from JSON
|
107
|
+
object_class: Hash, # Use plain Hash instead of custom classes
|
108
|
+
array_class: Array # Use plain Array instead of custom classes
|
109
|
+
})
|
110
|
+
end
|
111
|
+
|
82
112
|
# Determine HTTP error code from exception
|
83
113
|
#
|
84
114
|
# @param exception [Exception] The exception to map to an HTTP status code
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Hooks
|
4
|
+
module Core
|
5
|
+
# Shared module providing access to global components (logger, stats, failbot)
|
6
|
+
#
|
7
|
+
# This module provides a consistent interface for accessing global components
|
8
|
+
# across all plugin types, eliminating code duplication and ensuring consistent
|
9
|
+
# behavior throughout the application.
|
10
|
+
#
|
11
|
+
# @example Usage in a class that needs instance methods
|
12
|
+
# class MyHandler
|
13
|
+
# include Hooks::Core::ComponentAccess
|
14
|
+
#
|
15
|
+
# def process
|
16
|
+
# log.info("Processing request")
|
17
|
+
# stats.increment("requests.processed")
|
18
|
+
# failbot.report("Error occurred") if error?
|
19
|
+
# end
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# @example Usage in a class that needs class methods
|
23
|
+
# class MyValidator
|
24
|
+
# extend Hooks::Core::ComponentAccess
|
25
|
+
#
|
26
|
+
# def self.validate
|
27
|
+
# log.info("Validating request")
|
28
|
+
# stats.increment("requests.validated")
|
29
|
+
# end
|
30
|
+
# end
|
31
|
+
module ComponentAccess
|
32
|
+
# Short logger accessor
|
33
|
+
# @return [Hooks::Log] Logger instance for logging messages
|
34
|
+
#
|
35
|
+
# Provides a convenient way to log messages without needing
|
36
|
+
# to reference the full Hooks::Log namespace.
|
37
|
+
#
|
38
|
+
# @example Logging an error
|
39
|
+
# log.error("Something went wrong")
|
40
|
+
def log
|
41
|
+
Hooks::Log.instance
|
42
|
+
end
|
43
|
+
|
44
|
+
# Global stats component accessor
|
45
|
+
# @return [Hooks::Plugins::Instruments::Stats] Stats instance for metrics reporting
|
46
|
+
#
|
47
|
+
# Provides access to the global stats component for reporting metrics
|
48
|
+
# to services like DataDog, New Relic, etc.
|
49
|
+
#
|
50
|
+
# @example Recording a metric
|
51
|
+
# stats.increment("webhook.processed", { handler: "MyHandler" })
|
52
|
+
def stats
|
53
|
+
Hooks::Core::GlobalComponents.stats
|
54
|
+
end
|
55
|
+
|
56
|
+
# Global failbot component accessor
|
57
|
+
# @return [Hooks::Plugins::Instruments::Failbot] Failbot instance for error reporting
|
58
|
+
#
|
59
|
+
# Provides access to the global failbot component for reporting errors
|
60
|
+
# to services like Sentry, Rollbar, etc.
|
61
|
+
#
|
62
|
+
# @example Reporting an error
|
63
|
+
# failbot.report("Something went wrong", { context: "additional info" })
|
64
|
+
def failbot
|
65
|
+
Hooks::Core::GlobalComponents.failbot
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -21,26 +21,43 @@ module Hooks
|
|
21
21
|
endpoints_dir: "./config/endpoints",
|
22
22
|
use_catchall_route: false,
|
23
23
|
symbolize_payload: true,
|
24
|
-
normalize_headers: true
|
24
|
+
normalize_headers: true,
|
25
|
+
symbolize_headers: true
|
25
26
|
}.freeze
|
26
27
|
|
28
|
+
SILENCE_CONFIG_LOADER_MESSAGES = ENV.fetch(
|
29
|
+
"HOOKS_SILENCE_CONFIG_LOADER_MESSAGES", "false"
|
30
|
+
).downcase == "true".freeze
|
31
|
+
|
27
32
|
# Load and merge configuration from various sources
|
28
33
|
#
|
29
34
|
# @param config_path [String, Hash] Path to config file or config hash
|
30
35
|
# @return [Hash] Merged configuration
|
31
36
|
def self.load(config_path: nil)
|
32
37
|
config = DEFAULT_CONFIG.dup
|
38
|
+
overrides = []
|
33
39
|
|
34
40
|
# Load from file if path provided
|
35
41
|
if config_path.is_a?(String) && File.exist?(config_path)
|
36
42
|
file_config = load_config_file(config_path)
|
37
|
-
|
38
|
-
|
39
|
-
|
43
|
+
if file_config
|
44
|
+
overrides << "file config"
|
45
|
+
config.merge!(file_config)
|
46
|
+
end
|
40
47
|
end
|
41
48
|
|
42
|
-
# Override with environment variables
|
43
|
-
|
49
|
+
# Override with environment variables (before programmatic config)
|
50
|
+
env_config = load_env_config
|
51
|
+
if env_config.any?
|
52
|
+
overrides << "environment variables"
|
53
|
+
config.merge!(env_config)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Programmatic config has highest priority
|
57
|
+
if config_path.is_a?(Hash)
|
58
|
+
overrides << "programmatic config"
|
59
|
+
config.merge!(config_path)
|
60
|
+
end
|
44
61
|
|
45
62
|
# Convert string keys to symbols for consistency
|
46
63
|
config = symbolize_keys(config)
|
@@ -51,6 +68,11 @@ module Hooks
|
|
51
68
|
config[:production] = false
|
52
69
|
end
|
53
70
|
|
71
|
+
# Log overrides if any were made
|
72
|
+
if overrides.any?
|
73
|
+
puts "INFO: Configuration overrides applied from: #{overrides.join(', ')}" unless SILENCE_CONFIG_LOADER_MESSAGES
|
74
|
+
end
|
75
|
+
|
54
76
|
return config
|
55
77
|
end
|
56
78
|
|
@@ -93,8 +115,9 @@ module Hooks
|
|
93
115
|
end
|
94
116
|
|
95
117
|
result
|
96
|
-
rescue =>
|
97
|
-
#
|
118
|
+
rescue => e
|
119
|
+
# Log this error with meaningful information
|
120
|
+
puts "ERROR: Failed to load config file '#{file_path}': #{e.message}" unless SILENCE_CONFIG_LOADER_MESSAGES
|
98
121
|
nil
|
99
122
|
end
|
100
123
|
|
@@ -105,8 +128,11 @@ module Hooks
|
|
105
128
|
env_config = {}
|
106
129
|
|
107
130
|
env_mappings = {
|
131
|
+
"HOOKS_HANDLER_DIR" => :handler_dir,
|
108
132
|
"HOOKS_HANDLER_PLUGIN_DIR" => :handler_plugin_dir,
|
109
133
|
"HOOKS_AUTH_PLUGIN_DIR" => :auth_plugin_dir,
|
134
|
+
"HOOKS_LIFECYCLE_PLUGIN_DIR" => :lifecycle_plugin_dir,
|
135
|
+
"HOOKS_INSTRUMENTS_PLUGIN_DIR" => :instruments_plugin_dir,
|
110
136
|
"HOOKS_LOG_LEVEL" => :log_level,
|
111
137
|
"HOOKS_REQUEST_LIMIT" => :request_limit,
|
112
138
|
"HOOKS_REQUEST_TIMEOUT" => :request_timeout,
|
@@ -114,17 +140,25 @@ module Hooks
|
|
114
140
|
"HOOKS_HEALTH_PATH" => :health_path,
|
115
141
|
"HOOKS_VERSION_PATH" => :version_path,
|
116
142
|
"HOOKS_ENVIRONMENT" => :environment,
|
117
|
-
"HOOKS_ENDPOINTS_DIR" => :endpoints_dir
|
143
|
+
"HOOKS_ENDPOINTS_DIR" => :endpoints_dir,
|
144
|
+
"HOOKS_USE_CATCHALL_ROUTE" => :use_catchall_route,
|
145
|
+
"HOOKS_SYMBOLIZE_PAYLOAD" => :symbolize_payload,
|
146
|
+
"HOOKS_NORMALIZE_HEADERS" => :normalize_headers,
|
147
|
+
"HOOKS_SYMBOLIZE_HEADERS" => :symbolize_headers,
|
148
|
+
"HOOKS_SOME_STRING_VAR" => :some_string_var # Added for test
|
118
149
|
}
|
119
150
|
|
120
151
|
env_mappings.each do |env_key, config_key|
|
121
152
|
value = ENV[env_key]
|
122
153
|
next unless value
|
123
154
|
|
124
|
-
# Convert
|
155
|
+
# Convert values to appropriate types
|
125
156
|
case config_key
|
126
157
|
when :request_limit, :request_timeout
|
127
158
|
env_config[config_key] = value.to_i
|
159
|
+
when :use_catchall_route, :symbolize_payload, :normalize_headers, :symbolize_headers
|
160
|
+
# Convert string to boolean
|
161
|
+
env_config[config_key] = %w[true 1 yes on].include?(value.downcase)
|
128
162
|
else
|
129
163
|
env_config[config_key] = value
|
130
164
|
end
|
@@ -34,6 +34,7 @@ module Hooks
|
|
34
34
|
ENDPOINT_CONFIG_SCHEMA = Dry::Schema.Params do
|
35
35
|
required(:path).filled(:string)
|
36
36
|
required(:handler).filled(:string)
|
37
|
+
optional(:method).filled(:string, included_in?: %w[get post put patch delete head options])
|
37
38
|
|
38
39
|
optional(:auth).hash do
|
39
40
|
required(:type).filled(:string)
|
data/lib/hooks/core/log.rb
CHANGED
@@ -1,8 +1,23 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Hooks
|
4
|
+
# Global logger accessor module
|
5
|
+
#
|
6
|
+
# Provides a singleton-like access pattern for the application logger.
|
7
|
+
# The logger instance is set during application initialization and can
|
8
|
+
# be accessed throughout the application lifecycle.
|
9
|
+
#
|
10
|
+
# @example Setting the logger instance
|
11
|
+
# Hooks::Log.instance = Logger.new(STDOUT)
|
12
|
+
#
|
13
|
+
# @example Accessing the logger
|
14
|
+
# Hooks::Log.instance.info("Application started")
|
4
15
|
module Log
|
5
16
|
class << self
|
17
|
+
# Get or set the global logger instance
|
18
|
+
# @return [Logger] The global logger instance
|
19
|
+
# @attr_reader instance [Logger] Current logger instance
|
20
|
+
# @attr_writer instance [Logger] Set the logger instance
|
6
21
|
attr_accessor :instance
|
7
22
|
end
|
8
23
|
end
|
@@ -123,7 +123,7 @@ module Hooks
|
|
123
123
|
Dir.glob(File.join(auth_plugin_dir, "*.rb")).sort.each do |file_path|
|
124
124
|
begin
|
125
125
|
load_custom_auth_plugin(file_path, auth_plugin_dir)
|
126
|
-
rescue => e
|
126
|
+
rescue StandardError, SyntaxError => e
|
127
127
|
raise StandardError, "Failed to load auth plugin from #{file_path}: #{e.message}"
|
128
128
|
end
|
129
129
|
end
|
@@ -139,7 +139,7 @@ module Hooks
|
|
139
139
|
Dir.glob(File.join(handler_plugin_dir, "*.rb")).sort.each do |file_path|
|
140
140
|
begin
|
141
141
|
load_custom_handler_plugin(file_path, handler_plugin_dir)
|
142
|
-
rescue => e
|
142
|
+
rescue StandardError, SyntaxError => e
|
143
143
|
raise StandardError, "Failed to load handler plugin from #{file_path}: #{e.message}"
|
144
144
|
end
|
145
145
|
end
|
@@ -155,7 +155,7 @@ module Hooks
|
|
155
155
|
Dir.glob(File.join(lifecycle_plugin_dir, "*.rb")).sort.each do |file_path|
|
156
156
|
begin
|
157
157
|
load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir)
|
158
|
-
rescue => e
|
158
|
+
rescue StandardError, SyntaxError => e
|
159
159
|
raise StandardError, "Failed to load lifecycle plugin from #{file_path}: #{e.message}"
|
160
160
|
end
|
161
161
|
end
|
@@ -171,7 +171,7 @@ module Hooks
|
|
171
171
|
Dir.glob(File.join(instruments_plugin_dir, "*.rb")).sort.each do |file_path|
|
172
172
|
begin
|
173
173
|
load_custom_instrument_plugin(file_path, instruments_plugin_dir)
|
174
|
-
rescue => e
|
174
|
+
rescue StandardError, SyntaxError => e
|
175
175
|
raise StandardError, "Failed to load instrument plugin from #{file_path}: #{e.message}"
|
176
176
|
end
|
177
177
|
end
|
@@ -3,6 +3,7 @@
|
|
3
3
|
require "rack/utils"
|
4
4
|
require_relative "../../core/log"
|
5
5
|
require_relative "../../core/global_components"
|
6
|
+
require_relative "../../core/component_access"
|
6
7
|
|
7
8
|
module Hooks
|
8
9
|
module Plugins
|
@@ -11,6 +12,8 @@ module Hooks
|
|
11
12
|
#
|
12
13
|
# All custom Auth plugins must inherit from this class
|
13
14
|
class Base
|
15
|
+
extend Hooks::Core::ComponentAccess
|
16
|
+
|
14
17
|
# Validate request
|
15
18
|
#
|
16
19
|
# @param payload [String] Raw request body
|
@@ -22,42 +25,6 @@ module Hooks
|
|
22
25
|
raise NotImplementedError, "Validator must implement .valid? class method"
|
23
26
|
end
|
24
27
|
|
25
|
-
# Short logger accessor for all subclasses
|
26
|
-
# @return [Hooks::Log] Logger instance for request validation
|
27
|
-
#
|
28
|
-
# Provides a convenient way for validators to log messages without needing
|
29
|
-
# to reference the full Hooks::Log namespace.
|
30
|
-
#
|
31
|
-
# @example Logging an error in an inherited class
|
32
|
-
# log.error("oh no an error occured")
|
33
|
-
def self.log
|
34
|
-
Hooks::Log.instance
|
35
|
-
end
|
36
|
-
|
37
|
-
# Global stats component accessor
|
38
|
-
# @return [Hooks::Core::Stats] Stats instance for metrics reporting
|
39
|
-
#
|
40
|
-
# Provides access to the global stats component for reporting metrics
|
41
|
-
# to services like DataDog, New Relic, etc.
|
42
|
-
#
|
43
|
-
# @example Recording a metric in an inherited class
|
44
|
-
# stats.increment("auth.validation", { plugin: "hmac" })
|
45
|
-
def self.stats
|
46
|
-
Hooks::Core::GlobalComponents.stats
|
47
|
-
end
|
48
|
-
|
49
|
-
# Global failbot component accessor
|
50
|
-
# @return [Hooks::Core::Failbot] Failbot instance for error reporting
|
51
|
-
#
|
52
|
-
# Provides access to the global failbot component for reporting errors
|
53
|
-
# to services like Sentry, Rollbar, etc.
|
54
|
-
#
|
55
|
-
# @example Reporting an error in an inherited class
|
56
|
-
# failbot.report("Auth validation failed", { plugin: "hmac" })
|
57
|
-
def self.failbot
|
58
|
-
Hooks::Core::GlobalComponents.failbot
|
59
|
-
end
|
60
|
-
|
61
28
|
# Retrieve the secret from the environment variable based on the key set in the configuration
|
62
29
|
#
|
63
30
|
# Note: This method is intended to be used by subclasses
|
@@ -3,6 +3,7 @@
|
|
3
3
|
require "openssl"
|
4
4
|
require "time"
|
5
5
|
require_relative "base"
|
6
|
+
require_relative "timestamp_validator"
|
6
7
|
|
7
8
|
module Hooks
|
8
9
|
module Plugins
|
@@ -39,6 +40,7 @@ module Hooks
|
|
39
40
|
DEFAULT_CONFIG = {
|
40
41
|
algorithm: "sha256",
|
41
42
|
format: "algorithm=signature", # Format: algorithm=hash
|
43
|
+
header: "X-Signature", # Default header containing the signature
|
42
44
|
timestamp_tolerance: 300, # 5 minutes tolerance for timestamp validation
|
43
45
|
version_prefix: "v0" # Default version prefix for versioned signatures
|
44
46
|
}.freeze
|
@@ -117,7 +119,10 @@ module Hooks
|
|
117
119
|
|
118
120
|
# Validate timestamp if required (for services that include timestamp validation)
|
119
121
|
if validator_config[:timestamp_header]
|
120
|
-
|
122
|
+
unless valid_timestamp?(normalized_headers, validator_config)
|
123
|
+
log.warn("Auth::HMAC validation failed: Invalid timestamp")
|
124
|
+
return false
|
125
|
+
end
|
121
126
|
end
|
122
127
|
|
123
128
|
# Compute expected signature
|
@@ -153,7 +158,7 @@ module Hooks
|
|
153
158
|
tolerance = validator_config[:timestamp_tolerance] || DEFAULT_CONFIG[:timestamp_tolerance]
|
154
159
|
|
155
160
|
DEFAULT_CONFIG.merge({
|
156
|
-
header: validator_config[:header] ||
|
161
|
+
header: validator_config[:header] || DEFAULT_CONFIG[:header],
|
157
162
|
timestamp_header: validator_config[:timestamp_header],
|
158
163
|
timestamp_tolerance: tolerance,
|
159
164
|
algorithm: algorithm,
|
@@ -180,6 +185,7 @@ module Hooks
|
|
180
185
|
#
|
181
186
|
# Checks if the provided timestamp is within the configured tolerance
|
182
187
|
# of the current time. This prevents replay attacks using old requests.
|
188
|
+
# Supports both ISO 8601 UTC timestamps and Unix timestamps.
|
183
189
|
#
|
184
190
|
# @param headers [Hash<String, String>] Normalized HTTP headers
|
185
191
|
# @param config [Hash<Symbol, Object>] Validator configuration
|
@@ -189,25 +195,21 @@ module Hooks
|
|
189
195
|
# @api private
|
190
196
|
def self.valid_timestamp?(headers, config)
|
191
197
|
timestamp_header = config[:timestamp_header]
|
198
|
+
tolerance = config[:timestamp_tolerance] || 300
|
192
199
|
return false unless timestamp_header
|
193
200
|
|
194
|
-
|
195
|
-
timestamp_value = headers[timestamp_header]
|
196
|
-
|
201
|
+
timestamp_value = headers[timestamp_header.downcase]
|
197
202
|
return false unless timestamp_value
|
198
203
|
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
timestamp = timestamp_value.to_i
|
203
|
-
|
204
|
-
# Ensure timestamp is a positive integer (reject zero and negative)
|
205
|
-
return false unless timestamp > 0
|
206
|
-
|
207
|
-
current_time = Time.now.to_i
|
208
|
-
tolerance = config[:timestamp_tolerance]
|
204
|
+
timestamp_validator.valid?(timestamp_value, tolerance)
|
205
|
+
end
|
209
206
|
|
210
|
-
|
207
|
+
# Get timestamp validator instance
|
208
|
+
#
|
209
|
+
# @return [TimestampValidator] Singleton timestamp validator instance
|
210
|
+
# @api private
|
211
|
+
def self.timestamp_validator
|
212
|
+
@timestamp_validator ||= TimestampValidator.new
|
211
213
|
end
|
212
214
|
|
213
215
|
# Compute HMAC signature based on configuration requirements
|
@@ -257,7 +259,7 @@ module Hooks
|
|
257
259
|
# - {body}: Replaced with the raw payload
|
258
260
|
# @example Template usage
|
259
261
|
# template: "{version}:{timestamp}:{body}"
|
260
|
-
# result: "v0:1609459200:{"event"
|
262
|
+
# result: "v0:1609459200:{\"event\":\"push\"}"
|
261
263
|
# @api private
|
262
264
|
def self.build_signing_payload(payload:, headers:, config:)
|
263
265
|
template = config[:payload_template]
|
@@ -287,7 +289,7 @@ module Hooks
|
|
287
289
|
# - :algorithm_prefixed: "sha256=abc123..." (GitHub style)
|
288
290
|
# - :hash_only: "abc123..." (Shopify style)
|
289
291
|
# - :version_prefixed: "v0=abc123..." (Slack style)
|
290
|
-
# @note Defaults to
|
292
|
+
# @note Defaults to algorithm-prefixed format for unknown format styles
|
291
293
|
# @api private
|
292
294
|
def self.format_signature(hash, config)
|
293
295
|
format_style = FORMATS[config[:format]]
|
@@ -0,0 +1,133 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "time"
|
4
|
+
|
5
|
+
module Hooks
|
6
|
+
module Plugins
|
7
|
+
module Auth
|
8
|
+
# Validates and parses timestamps for webhook authentication
|
9
|
+
#
|
10
|
+
# This class provides secure timestamp validation supporting both
|
11
|
+
# ISO 8601 UTC format and Unix timestamp format. It includes
|
12
|
+
# strict validation to prevent various injection attacks.
|
13
|
+
#
|
14
|
+
# @example Basic usage
|
15
|
+
# validator = TimestampValidator.new
|
16
|
+
# validator.valid?("1609459200", 300) # => true/false
|
17
|
+
# validator.parse("2021-01-01T00:00:00Z") # => 1609459200
|
18
|
+
#
|
19
|
+
# @api private
|
20
|
+
class TimestampValidator
|
21
|
+
# Validate timestamp against current time with tolerance
|
22
|
+
#
|
23
|
+
# @param timestamp_value [String] The timestamp string to validate
|
24
|
+
# @param tolerance [Integer] Maximum age in seconds (default: 300)
|
25
|
+
# @return [Boolean] true if timestamp is valid and within tolerance
|
26
|
+
def valid?(timestamp_value, tolerance = 300)
|
27
|
+
return false if timestamp_value.nil? || timestamp_value.strip.empty?
|
28
|
+
|
29
|
+
parsed_timestamp = parse(timestamp_value.strip)
|
30
|
+
return false unless parsed_timestamp.is_a?(Integer)
|
31
|
+
|
32
|
+
now = Time.now.utc.to_i
|
33
|
+
(now - parsed_timestamp).abs <= tolerance
|
34
|
+
end
|
35
|
+
|
36
|
+
# Parse timestamp value supporting both ISO 8601 UTC and Unix formats
|
37
|
+
#
|
38
|
+
# @param timestamp_value [String] The timestamp string to parse
|
39
|
+
# @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise
|
40
|
+
# @note Security: Strict validation prevents various injection attacks
|
41
|
+
def parse(timestamp_value)
|
42
|
+
return nil if invalid_characters?(timestamp_value)
|
43
|
+
|
44
|
+
parse_iso8601_timestamp(timestamp_value) || parse_unix_timestamp(timestamp_value)
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
# Check for control characters, whitespace, or null bytes
|
50
|
+
#
|
51
|
+
# @param timestamp_value [String] The timestamp to check
|
52
|
+
# @return [Boolean] true if contains invalid characters
|
53
|
+
def invalid_characters?(timestamp_value)
|
54
|
+
if timestamp_value =~ /[\u0000-\u001F\u007F-\u009F]/
|
55
|
+
log_warning("Timestamp contains invalid characters")
|
56
|
+
true
|
57
|
+
else
|
58
|
+
false
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Parse ISO 8601 UTC timestamp string (must have UTC indicator)
|
63
|
+
#
|
64
|
+
# @param timestamp_value [String] ISO 8601 timestamp string
|
65
|
+
# @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise
|
66
|
+
def parse_iso8601_timestamp(timestamp_value)
|
67
|
+
# Handle space-separated format and convert to standard ISO format
|
68
|
+
if timestamp_value =~ /\A(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?: )\+0000\z/
|
69
|
+
timestamp_value = "#{$1}T#{$2}+00:00"
|
70
|
+
end
|
71
|
+
|
72
|
+
# Ensure the timestamp explicitly includes a UTC indicator
|
73
|
+
return nil unless timestamp_value =~ /(Z|\+00:00|\+0000)\z/
|
74
|
+
return nil unless iso8601_format?(timestamp_value)
|
75
|
+
|
76
|
+
parsed_time = parse_time_safely(timestamp_value)
|
77
|
+
return nil unless parsed_time&.utc_offset&.zero?
|
78
|
+
|
79
|
+
parsed_time.to_i
|
80
|
+
end
|
81
|
+
|
82
|
+
# Parse Unix timestamp string (must be positive integer, no leading zeros except for "0")
|
83
|
+
#
|
84
|
+
# @param timestamp_value [String] Unix timestamp string
|
85
|
+
# @return [Integer, nil] Epoch seconds if parsing succeeds, nil otherwise
|
86
|
+
def parse_unix_timestamp(timestamp_value)
|
87
|
+
return nil unless unix_format?(timestamp_value)
|
88
|
+
|
89
|
+
ts = timestamp_value.to_i
|
90
|
+
return nil if ts <= 0
|
91
|
+
|
92
|
+
ts
|
93
|
+
end
|
94
|
+
|
95
|
+
# Check if timestamp string looks like ISO 8601 format
|
96
|
+
#
|
97
|
+
# @param timestamp_value [String] The timestamp string to check
|
98
|
+
# @return [Boolean] true if it appears to be ISO 8601 format
|
99
|
+
def iso8601_format?(timestamp_value)
|
100
|
+
!!(timestamp_value =~ /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|\+00:00|\+0000)?\z/)
|
101
|
+
end
|
102
|
+
|
103
|
+
# Check if timestamp string looks like Unix timestamp format
|
104
|
+
#
|
105
|
+
# @param timestamp_value [String] The timestamp string to check
|
106
|
+
# @return [Boolean] true if it appears to be Unix timestamp format
|
107
|
+
def unix_format?(timestamp_value)
|
108
|
+
return true if timestamp_value == "0"
|
109
|
+
!!(timestamp_value =~ /\A[1-9]\d*\z/)
|
110
|
+
end
|
111
|
+
|
112
|
+
# Safely parse time string with error handling
|
113
|
+
#
|
114
|
+
# @param timestamp_value [String] The timestamp string to parse
|
115
|
+
# @return [Time, nil] Parsed time object or nil if parsing fails
|
116
|
+
def parse_time_safely(timestamp_value)
|
117
|
+
Time.parse(timestamp_value)
|
118
|
+
rescue ArgumentError
|
119
|
+
nil
|
120
|
+
end
|
121
|
+
|
122
|
+
# Log warning message
|
123
|
+
#
|
124
|
+
# @param message [String] Warning message to log
|
125
|
+
def log_warning(message)
|
126
|
+
return unless defined?(Hooks::Log) && Hooks::Log.instance
|
127
|
+
|
128
|
+
Hooks::Log.instance.warn("Auth::TimestampValidator validation failed: #{message}")
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "../../core/global_components"
|
4
|
+
require_relative "../../core/component_access"
|
4
5
|
|
5
6
|
module Hooks
|
6
7
|
module Plugins
|
@@ -9,52 +10,18 @@ module Hooks
|
|
9
10
|
#
|
10
11
|
# All custom handlers must inherit from this class and implement the #call method
|
11
12
|
class Base
|
13
|
+
include Hooks::Core::ComponentAccess
|
14
|
+
|
12
15
|
# Process a webhook request
|
13
16
|
#
|
14
17
|
# @param payload [Hash, String] Parsed request body (JSON Hash) or raw string
|
15
|
-
# @param headers [Hash
|
16
|
-
# @param config [Hash] Merged endpoint configuration including opts section
|
18
|
+
# @param headers [Hash] HTTP headers (symbolized keys by default)
|
19
|
+
# @param config [Hash] Merged endpoint configuration including opts section (symbolized keys)
|
17
20
|
# @return [Hash, String, nil] Response body (will be auto-converted to JSON)
|
18
21
|
# @raise [NotImplementedError] if not implemented by subclass
|
19
22
|
def call(payload:, headers:, config:)
|
20
23
|
raise NotImplementedError, "Handler must implement #call method"
|
21
24
|
end
|
22
|
-
|
23
|
-
# Short logger accessor for all subclasses
|
24
|
-
# @return [Hooks::Log] Logger instance
|
25
|
-
#
|
26
|
-
# Provides a convenient way for handlers to log messages without needing
|
27
|
-
# to reference the full Hooks::Log namespace.
|
28
|
-
#
|
29
|
-
# @example Logging an error in an inherited class
|
30
|
-
# log.error("oh no an error occured")
|
31
|
-
def log
|
32
|
-
Hooks::Log.instance
|
33
|
-
end
|
34
|
-
|
35
|
-
# Global stats component accessor
|
36
|
-
# @return [Hooks::Core::Stats] Stats instance for metrics reporting
|
37
|
-
#
|
38
|
-
# Provides access to the global stats component for reporting metrics
|
39
|
-
# to services like DataDog, New Relic, etc.
|
40
|
-
#
|
41
|
-
# @example Recording a metric in an inherited class
|
42
|
-
# stats.increment("webhook.processed", { handler: "MyHandler" })
|
43
|
-
def stats
|
44
|
-
Hooks::Core::GlobalComponents.stats
|
45
|
-
end
|
46
|
-
|
47
|
-
# Global failbot component accessor
|
48
|
-
# @return [Hooks::Core::Failbot] Failbot instance for error reporting
|
49
|
-
#
|
50
|
-
# Provides access to the global failbot component for reporting errors
|
51
|
-
# to services like Sentry, Rollbar, etc.
|
52
|
-
#
|
53
|
-
# @example Reporting an error in an inherited class
|
54
|
-
# failbot.report("Something went wrong", { handler: "MyHandler" })
|
55
|
-
def failbot
|
56
|
-
Hooks::Core::GlobalComponents.failbot
|
57
|
-
end
|
58
25
|
end
|
59
26
|
end
|
60
27
|
end
|
@@ -1,8 +1,37 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
# Default
|
4
|
-
#
|
3
|
+
# Default webhook handler implementation
|
4
|
+
#
|
5
|
+
# This handler provides a basic webhook processing implementation that can be used
|
6
|
+
# as a fallback when no custom handler is configured for an endpoint. It demonstrates
|
7
|
+
# the standard handler interface and provides basic logging functionality.
|
8
|
+
#
|
9
|
+
# @example Usage in endpoint configuration
|
10
|
+
# handler:
|
11
|
+
# type: DefaultHandler
|
12
|
+
#
|
13
|
+
# @see Hooks::Plugins::Handlers::Base
|
5
14
|
class DefaultHandler < Hooks::Plugins::Handlers::Base
|
15
|
+
# Process a webhook request with basic acknowledgment
|
16
|
+
#
|
17
|
+
# Provides a simple webhook processing implementation that logs the request
|
18
|
+
# and returns a standard acknowledgment response. This is useful for testing
|
19
|
+
# webhook endpoints or as a placeholder during development.
|
20
|
+
#
|
21
|
+
# @param payload [Hash, String] The webhook payload (parsed JSON or raw string)
|
22
|
+
# @param headers [Hash<String, String>] HTTP headers from the webhook request
|
23
|
+
# @param config [Hash] Endpoint configuration containing handler options
|
24
|
+
# @return [Hash] Response indicating successful processing
|
25
|
+
# @option config [Hash] :opts Additional handler-specific configuration options
|
26
|
+
#
|
27
|
+
# @example Basic usage
|
28
|
+
# handler = DefaultHandler.new
|
29
|
+
# response = handler.call(
|
30
|
+
# payload: { "event" => "push" },
|
31
|
+
# headers: { "Content-Type" => "application/json" },
|
32
|
+
# config: { opts: {} }
|
33
|
+
# )
|
34
|
+
# # => { message: "webhook processed successfully", handler: "DefaultHandler", timestamp: "..." }
|
6
35
|
def call(payload:, headers:, config:)
|
7
36
|
|
8
37
|
log.info("🔔 Default handler invoked for webhook 🔔")
|
@@ -15,7 +44,7 @@ class DefaultHandler < Hooks::Plugins::Handlers::Base
|
|
15
44
|
{
|
16
45
|
message: "webhook processed successfully",
|
17
46
|
handler: "DefaultHandler",
|
18
|
-
timestamp: Time.now.iso8601
|
47
|
+
timestamp: Time.now.utc.iso8601
|
19
48
|
}
|
20
49
|
end
|
21
50
|
end
|
@@ -7,11 +7,25 @@ module Hooks
|
|
7
7
|
module Instruments
|
8
8
|
# Default failbot instrument implementation
|
9
9
|
#
|
10
|
-
# This is a
|
11
|
-
#
|
12
|
-
#
|
10
|
+
# This is a no-op implementation that provides the error reporting interface
|
11
|
+
# without actually sending errors anywhere. It serves as a safe default when
|
12
|
+
# no custom error reporting implementation is configured.
|
13
|
+
#
|
14
|
+
# Users should replace this with their own implementation for services
|
15
|
+
# like Sentry, Rollbar, Honeybadger, etc.
|
16
|
+
#
|
17
|
+
# @example Replacing with a custom implementation
|
18
|
+
# # In your application initialization:
|
19
|
+
# custom_failbot = MySentryFailbotImplementation.new
|
20
|
+
# Hooks::Core::GlobalComponents.failbot = custom_failbot
|
21
|
+
#
|
22
|
+
# @see Hooks::Plugins::Instruments::FailbotBase
|
23
|
+
# @see Hooks::Core::GlobalComponents
|
13
24
|
class Failbot < FailbotBase
|
14
|
-
# Inherit from FailbotBase to provide a default implementation
|
25
|
+
# Inherit from FailbotBase to provide a default no-op implementation
|
26
|
+
# of the error reporting instrument interface.
|
27
|
+
#
|
28
|
+
# All methods from FailbotBase are inherited and provide safe no-op behavior.
|
15
29
|
end
|
16
30
|
end
|
17
31
|
end
|
@@ -1,23 +1,70 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "../../core/component_access"
|
4
|
+
|
3
5
|
module Hooks
|
4
6
|
module Plugins
|
5
7
|
module Instruments
|
6
8
|
# Base class for all failbot instrument plugins
|
7
9
|
#
|
8
|
-
#
|
9
|
-
#
|
10
|
+
# This class provides the foundation for implementing custom error reporting
|
11
|
+
# instruments. Subclasses should implement specific methods for their target
|
12
|
+
# error reporting service (Sentry, Rollbar, Honeybadger, etc.).
|
13
|
+
#
|
14
|
+
# @abstract Subclass and implement service-specific error reporting methods
|
15
|
+
# @example Implementing a custom failbot instrument
|
16
|
+
# class MySentryFailbot < Hooks::Plugins::Instruments::FailbotBase
|
17
|
+
# def report(error_or_message, context = {})
|
18
|
+
# case error_or_message
|
19
|
+
# when Exception
|
20
|
+
# Sentry.capture_exception(error_or_message, extra: context)
|
21
|
+
# else
|
22
|
+
# Sentry.capture_message(error_or_message.to_s, extra: context)
|
23
|
+
# end
|
24
|
+
# log.debug("Reported error to Sentry")
|
25
|
+
# end
|
26
|
+
# end
|
27
|
+
#
|
28
|
+
# @see Hooks::Plugins::Instruments::Failbot
|
10
29
|
class FailbotBase
|
11
|
-
|
12
|
-
|
30
|
+
include Hooks::Core::ComponentAccess
|
31
|
+
|
32
|
+
# Report an error or message to the error tracking service
|
33
|
+
#
|
34
|
+
# This is a no-op implementation that subclasses should override
|
35
|
+
# to provide actual error reporting functionality.
|
36
|
+
#
|
37
|
+
# @param error_or_message [Exception, String] The error to report or message string
|
38
|
+
# @param context [Hash] Additional context information about the error
|
39
|
+
# @return [void]
|
40
|
+
# @note Subclasses should implement this method for their specific service
|
41
|
+
# @example Override in subclass
|
42
|
+
# def report(error_or_message, context = {})
|
43
|
+
# if error_or_message.is_a?(Exception)
|
44
|
+
# ErrorService.report_exception(error_or_message, context)
|
45
|
+
# else
|
46
|
+
# ErrorService.report_message(error_or_message, context)
|
47
|
+
# end
|
48
|
+
# end
|
49
|
+
def report(error_or_message, context = {})
|
50
|
+
# No-op implementation for base class
|
51
|
+
end
|
52
|
+
|
53
|
+
# Report a warning-level message
|
13
54
|
#
|
14
|
-
#
|
15
|
-
# to
|
55
|
+
# This is a no-op implementation that subclasses should override
|
56
|
+
# to provide actual warning reporting functionality.
|
16
57
|
#
|
17
|
-
# @
|
18
|
-
#
|
19
|
-
|
20
|
-
|
58
|
+
# @param message [String] Warning message to report
|
59
|
+
# @param context [Hash] Additional context information
|
60
|
+
# @return [void]
|
61
|
+
# @note Subclasses should implement this method for their specific service
|
62
|
+
# @example Override in subclass
|
63
|
+
# def warn(message, context = {})
|
64
|
+
# ErrorService.report_warning(message, context)
|
65
|
+
# end
|
66
|
+
def warn(message, context = {})
|
67
|
+
# No-op implementation for base class
|
21
68
|
end
|
22
69
|
end
|
23
70
|
end
|
@@ -7,11 +7,25 @@ module Hooks
|
|
7
7
|
module Instruments
|
8
8
|
# Default stats instrument implementation
|
9
9
|
#
|
10
|
-
# This is a
|
11
|
-
#
|
12
|
-
#
|
10
|
+
# This is a no-op implementation that provides the stats interface without
|
11
|
+
# actually sending metrics anywhere. It serves as a safe default when no
|
12
|
+
# custom stats implementation is configured.
|
13
|
+
#
|
14
|
+
# Users should replace this with their own implementation for services
|
15
|
+
# like DataDog, New Relic, StatsD, etc.
|
16
|
+
#
|
17
|
+
# @example Replacing with a custom implementation
|
18
|
+
# # In your application initialization:
|
19
|
+
# custom_stats = MyCustomStatsImplementation.new
|
20
|
+
# Hooks::Core::GlobalComponents.stats = custom_stats
|
21
|
+
#
|
22
|
+
# @see Hooks::Plugins::Instruments::StatsBase
|
23
|
+
# @see Hooks::Core::GlobalComponents
|
13
24
|
class Stats < StatsBase
|
14
|
-
# Inherit from StatsBase to provide a default implementation
|
25
|
+
# Inherit from StatsBase to provide a default no-op implementation
|
26
|
+
# of the stats instrument interface.
|
27
|
+
#
|
28
|
+
# All methods from StatsBase are inherited and provide safe no-op behavior.
|
15
29
|
end
|
16
30
|
end
|
17
31
|
end
|
@@ -1,23 +1,86 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "../../core/component_access"
|
4
|
+
|
3
5
|
module Hooks
|
4
6
|
module Plugins
|
5
7
|
module Instruments
|
6
8
|
# Base class for all stats instrument plugins
|
7
9
|
#
|
8
|
-
#
|
9
|
-
#
|
10
|
+
# This class provides the foundation for implementing custom metrics reporting
|
11
|
+
# instruments. Subclasses should implement specific methods for their target
|
12
|
+
# metrics service (DataDog, New Relic, StatsD, etc.).
|
13
|
+
#
|
14
|
+
# @abstract Subclass and implement service-specific metrics methods
|
15
|
+
# @example Implementing a custom stats instrument
|
16
|
+
# class MyStatsImplementation < Hooks::Plugins::Instruments::StatsBase
|
17
|
+
# def increment(metric_name, tags = {})
|
18
|
+
# # Send increment metric to your service
|
19
|
+
# MyMetricsService.increment(metric_name, tags)
|
20
|
+
# log.debug("Sent increment metric: #{metric_name}")
|
21
|
+
# end
|
22
|
+
#
|
23
|
+
# def timing(metric_name, duration, tags = {})
|
24
|
+
# # Send timing metric to your service
|
25
|
+
# MyMetricsService.timing(metric_name, duration, tags)
|
26
|
+
# end
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# @see Hooks::Plugins::Instruments::Stats
|
10
30
|
class StatsBase
|
11
|
-
|
12
|
-
|
31
|
+
include Hooks::Core::ComponentAccess
|
32
|
+
|
33
|
+
# Record an increment metric
|
34
|
+
#
|
35
|
+
# This is a no-op implementation that subclasses should override
|
36
|
+
# to provide actual metrics reporting functionality.
|
37
|
+
#
|
38
|
+
# @param metric_name [String] Name of the metric to increment
|
39
|
+
# @param tags [Hash] Optional tags/labels for the metric
|
40
|
+
# @return [void]
|
41
|
+
# @note Subclasses should implement this method for their specific service
|
42
|
+
# @example Override in subclass
|
43
|
+
# def increment(metric_name, tags = {})
|
44
|
+
# statsd.increment(metric_name, tags: tags)
|
45
|
+
# end
|
46
|
+
def increment(metric_name, tags = {})
|
47
|
+
# No-op implementation for base class
|
48
|
+
end
|
49
|
+
|
50
|
+
# Record a timing/duration metric
|
51
|
+
#
|
52
|
+
# This is a no-op implementation that subclasses should override
|
53
|
+
# to provide actual metrics reporting functionality.
|
54
|
+
#
|
55
|
+
# @param metric_name [String] Name of the timing metric
|
56
|
+
# @param duration [Numeric] Duration value (typically in milliseconds)
|
57
|
+
# @param tags [Hash] Optional tags/labels for the metric
|
58
|
+
# @return [void]
|
59
|
+
# @note Subclasses should implement this method for their specific service
|
60
|
+
# @example Override in subclass
|
61
|
+
# def timing(metric_name, duration, tags = {})
|
62
|
+
# statsd.timing(metric_name, duration, tags: tags)
|
63
|
+
# end
|
64
|
+
def timing(metric_name, duration, tags = {})
|
65
|
+
# No-op implementation for base class
|
66
|
+
end
|
67
|
+
|
68
|
+
# Record a gauge metric
|
13
69
|
#
|
14
|
-
#
|
15
|
-
# to
|
70
|
+
# This is a no-op implementation that subclasses should override
|
71
|
+
# to provide actual metrics reporting functionality.
|
16
72
|
#
|
17
|
-
# @
|
18
|
-
#
|
19
|
-
|
20
|
-
|
73
|
+
# @param metric_name [String] Name of the gauge metric
|
74
|
+
# @param value [Numeric] Current value for the gauge
|
75
|
+
# @param tags [Hash] Optional tags/labels for the metric
|
76
|
+
# @return [void]
|
77
|
+
# @note Subclasses should implement this method for their specific service
|
78
|
+
# @example Override in subclass
|
79
|
+
# def gauge(metric_name, value, tags = {})
|
80
|
+
# statsd.gauge(metric_name, value, tags: tags)
|
81
|
+
# end
|
82
|
+
def gauge(metric_name, value, tags = {})
|
83
|
+
# No-op implementation for base class
|
21
84
|
end
|
22
85
|
end
|
23
86
|
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "../core/global_components"
|
4
|
+
require_relative "../core/component_access"
|
4
5
|
|
5
6
|
module Hooks
|
6
7
|
module Plugins
|
@@ -8,6 +9,8 @@ module Hooks
|
|
8
9
|
#
|
9
10
|
# Plugins can hook into request/response/error lifecycle events
|
10
11
|
class Lifecycle
|
12
|
+
include Hooks::Core::ComponentAccess
|
13
|
+
|
11
14
|
# Called before handler execution
|
12
15
|
#
|
13
16
|
# @param env [Hash] Rack environment
|
@@ -30,42 +33,6 @@ module Hooks
|
|
30
33
|
def on_error(exception, env)
|
31
34
|
# Override in subclass for error handling logic
|
32
35
|
end
|
33
|
-
|
34
|
-
# Short logger accessor for all subclasses
|
35
|
-
# @return [Hooks::Log] Logger instance
|
36
|
-
#
|
37
|
-
# Provides a convenient way for lifecycle plugins to log messages without needing
|
38
|
-
# to reference the full Hooks::Log namespace.
|
39
|
-
#
|
40
|
-
# @example Logging an error in an inherited class
|
41
|
-
# log.error("oh no an error occured")
|
42
|
-
def log
|
43
|
-
Hooks::Log.instance
|
44
|
-
end
|
45
|
-
|
46
|
-
# Global stats component accessor
|
47
|
-
# @return [Hooks::Core::Stats] Stats instance for metrics reporting
|
48
|
-
#
|
49
|
-
# Provides access to the global stats component for reporting metrics
|
50
|
-
# to services like DataDog, New Relic, etc.
|
51
|
-
#
|
52
|
-
# @example Recording a metric in an inherited class
|
53
|
-
# stats.increment("lifecycle.request_processed")
|
54
|
-
def stats
|
55
|
-
Hooks::Core::GlobalComponents.stats
|
56
|
-
end
|
57
|
-
|
58
|
-
# Global failbot component accessor
|
59
|
-
# @return [Hooks::Core::Failbot] Failbot instance for error reporting
|
60
|
-
#
|
61
|
-
# Provides access to the global failbot component for reporting errors
|
62
|
-
# to services like Sentry, Rollbar, etc.
|
63
|
-
#
|
64
|
-
# @example Reporting an error in an inherited class
|
65
|
-
# failbot.report("Lifecycle hook failed")
|
66
|
-
def failbot
|
67
|
-
Hooks::Core::GlobalComponents.failbot
|
68
|
-
end
|
69
36
|
end
|
70
37
|
end
|
71
38
|
end
|
@@ -58,6 +58,39 @@ module Hooks
|
|
58
58
|
normalized
|
59
59
|
end
|
60
60
|
|
61
|
+
# Symbolize header keys in a hash
|
62
|
+
#
|
63
|
+
# @param headers [Hash, #each] Headers hash or hash-like object
|
64
|
+
# @return [Hash] Hash with symbolized keys (hyphens converted to underscores)
|
65
|
+
#
|
66
|
+
# @example Header symbolization
|
67
|
+
# headers = { "content-type" => "application/json", "x-github-event" => "push" }
|
68
|
+
# symbolized = Normalize.symbolize_headers(headers)
|
69
|
+
# # => { content_type: "application/json", x_github_event: "push" }
|
70
|
+
#
|
71
|
+
# @example Handle various input types
|
72
|
+
# Normalize.symbolize_headers(nil) # => nil
|
73
|
+
# Normalize.symbolize_headers({}) # => {}
|
74
|
+
def self.symbolize_headers(headers)
|
75
|
+
# Handle nil input
|
76
|
+
return nil if headers.nil?
|
77
|
+
|
78
|
+
# Fast path for non-enumerable inputs
|
79
|
+
return {} unless headers.respond_to?(:each)
|
80
|
+
|
81
|
+
symbolized = {}
|
82
|
+
|
83
|
+
headers.each do |key, value|
|
84
|
+
next if key.nil?
|
85
|
+
|
86
|
+
# Convert key to symbol, replacing hyphens with underscores
|
87
|
+
symbolized_key = key.to_s.tr("-", "_").to_sym
|
88
|
+
symbolized[symbolized_key] = value
|
89
|
+
end
|
90
|
+
|
91
|
+
symbolized
|
92
|
+
end
|
93
|
+
|
61
94
|
# Normalize a single HTTP header name
|
62
95
|
#
|
63
96
|
# @param header [String] Header name to normalize
|
data/lib/hooks/version.rb
CHANGED
@@ -1,5 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# Main Hooks module containing version information
|
3
4
|
module Hooks
|
4
|
-
|
5
|
+
# Current version of the Hooks webhook framework
|
6
|
+
# @return [String] The version string following semantic versioning
|
7
|
+
VERSION = "0.0.5".freeze
|
5
8
|
end
|
data/lib/hooks.rb
CHANGED
@@ -2,21 +2,29 @@
|
|
2
2
|
|
3
3
|
require_relative "hooks/version"
|
4
4
|
require_relative "hooks/core/builder"
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
5
|
+
require_relative "hooks/core/config_loader"
|
6
|
+
require_relative "hooks/core/config_validator"
|
7
|
+
require_relative "hooks/core/logger_factory"
|
8
|
+
require_relative "hooks/core/plugin_loader"
|
9
|
+
require_relative "hooks/core/global_components"
|
10
|
+
require_relative "hooks/core/component_access"
|
11
|
+
require_relative "hooks/core/log"
|
12
|
+
require_relative "hooks/core/failbot"
|
13
|
+
require_relative "hooks/core/stats"
|
14
|
+
require_relative "hooks/plugins/auth/base"
|
15
|
+
require_relative "hooks/plugins/auth/hmac"
|
16
|
+
require_relative "hooks/plugins/auth/shared_secret"
|
17
|
+
require_relative "hooks/plugins/handlers/base"
|
18
|
+
require_relative "hooks/plugins/handlers/default"
|
19
|
+
require_relative "hooks/plugins/lifecycle"
|
20
|
+
require_relative "hooks/plugins/instruments/stats_base"
|
21
|
+
require_relative "hooks/plugins/instruments/failbot_base"
|
22
|
+
require_relative "hooks/plugins/instruments/stats"
|
23
|
+
require_relative "hooks/plugins/instruments/failbot"
|
24
|
+
require_relative "hooks/utils/normalize"
|
25
|
+
require_relative "hooks/utils/retry"
|
26
|
+
require_relative "hooks/security"
|
27
|
+
require_relative "hooks/version"
|
20
28
|
|
21
29
|
# Main module for the Hooks webhook server framework
|
22
30
|
module Hooks
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: hooks-ruby
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- github
|
@@ -130,6 +130,7 @@ files:
|
|
130
130
|
- lib/hooks/app/endpoints/version.rb
|
131
131
|
- lib/hooks/app/helpers.rb
|
132
132
|
- lib/hooks/core/builder.rb
|
133
|
+
- lib/hooks/core/component_access.rb
|
133
134
|
- lib/hooks/core/config_loader.rb
|
134
135
|
- lib/hooks/core/config_validator.rb
|
135
136
|
- lib/hooks/core/failbot.rb
|
@@ -141,6 +142,7 @@ files:
|
|
141
142
|
- lib/hooks/plugins/auth/base.rb
|
142
143
|
- lib/hooks/plugins/auth/hmac.rb
|
143
144
|
- lib/hooks/plugins/auth/shared_secret.rb
|
145
|
+
- lib/hooks/plugins/auth/timestamp_validator.rb
|
144
146
|
- lib/hooks/plugins/handlers/base.rb
|
145
147
|
- lib/hooks/plugins/handlers/default.rb
|
146
148
|
- lib/hooks/plugins/instruments/failbot.rb
|