hooks-ruby 0.0.2 → 0.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7588a4a6fdf58e7c927dc4b20b9fa330db86db672f5c779a4c46d51e7e41f386
4
- data.tar.gz: 02bdb939c52fd501387856f432a11a82f842d25053d9e73ef3967eb12be03c7a
3
+ metadata.gz: cc9f23a8b74aef04b7a0016673a9dd835cef5f56eecb316e58b7cb3d19936f0d
4
+ data.tar.gz: '05290bf35da3f0aa73e5a0b9e0661d2986df03733862f6cd85c19248257f5b09'
5
5
  SHA512:
6
- metadata.gz: f11eddcaf09095e1d68bcfeeeba3660eab7e3a9bf42744d780987bfb4b54db7cfa6691c4cc435c5dbda4eb3ee60065a6f3c6239df87bf5b5587f638bb3c8d094
7
- data.tar.gz: af806e9c8144ed2da97e723c3a4dfcb54f2fd8db85970e806aa3f2f56e6ef8ca665ae157066f1456126804c2bba8ba573d5f59f717de0249148d81d11f9e848d
6
+ metadata.gz: a4462d77b184ac3be6b4a0f7ac4466dace8b5d146c722205ca7f97f455dc523de2168a71e2fe61b4a0946efdb7e9b8e4b61dc1e9940c8d14fdbaebebcb8fb497
7
+ data.tar.gz: bc2023bf7a08efd872e69603dd81dbbb2a4798deb4b4c98db37d65f8544ba57cfbdf36b82f2b48b33250d2429eb39946a71d65d7fbdf7c2d5e0a6b724e79a9cb
data/README.md CHANGED
@@ -72,7 +72,7 @@ Here is a very high-level overview of how Hooks works:
72
72
  end
73
73
  ```
74
74
 
75
- That is pretty much it! Below you will find more detailed instructions on how to install and use Hooks, as well as how to create your own plugins. This high-level overview should give you a good idea of how Hooks works and how you can use it to handle webhooks in your applications. You may also be interested in using your own custom authentication plugins to secure your webhook endpoints, which is covered in the [Authentication](#authentication) section below.
75
+ That is pretty much it! Below you will find more detailed instructions on how to install and use Hooks, as well as how to create your own plugins. This high-level overview should give you a good idea of how Hooks works and how you can use it to handle webhooks in your applications. You may also be interested in using your own custom authentication plugins to secure your webhook endpoints, which is covered in the [Authentication](#authentication-plugins) section below.
76
76
 
77
77
  ## Installation 💎
78
78
 
@@ -102,7 +102,7 @@ First, create a `config.ru` file:
102
102
 
103
103
  ```ruby
104
104
  # file: config.ru
105
- require "hooks-ruby"
105
+ require "hooks"
106
106
 
107
107
  # See the config documentation below for the full list of available options
108
108
  # For this example, we will just set use_catchall_route to true
@@ -286,10 +286,22 @@ What these steps have done is set up a Hooks server that listens for incoming we
286
286
 
287
287
  To see a live working version of this example, check out the [`spec/acceptance/`](spec/acceptance/) directory in this repository, which is used for acceptance testing the Hooks framework. It contains a complete example of how to set up a Hooks server with custom plugins, authentication, and more.
288
288
 
289
- ### Authentication
289
+ ### Authentication Plugins
290
290
 
291
291
  See the [Auth Plugins](docs/auth_plugins.md) documentation for even more information on how to create your own custom authentication plugins.
292
292
 
293
+ ### Handler Plugins
294
+
295
+ See the [Handler Plugins](docs/handler_plugins.md) documentation for in-depth information about handler plugins and how you can create your own to extend the functionality of the Hooks framework for your own deployment.
296
+
297
+ ### Lifecycle Plugins
298
+
299
+ See the [Lifecycle Plugins](docs/lifecycle_plugins.md) documentation for information on how to create lifecycle plugins that can hook into the request/response/error lifecycle of the Hooks framework, allowing you to add custom behavior at various stages of processing webhook requests.
300
+
301
+ ### Instrument Plugins
302
+
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
+
293
305
  ## Contributing 🤝
294
306
 
295
307
  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/hooks.gemspec CHANGED
@@ -22,7 +22,6 @@ Gem::Specification.new do |spec|
22
22
  spec.add_dependency "retryable", "~> 3.0", ">= 3.0.5"
23
23
  spec.add_dependency "dry-schema", "~> 1.14", ">= 1.14.1"
24
24
  spec.add_dependency "grape", "~> 2.3"
25
- spec.add_dependency "grape-swagger", "~> 2.1", ">= 2.1.2"
26
25
  spec.add_dependency "puma", "~> 6.6"
27
26
 
28
27
  spec.required_ruby_version = Gem::Requirement.new(">= 3.2.2")
data/lib/hooks/app/api.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "../plugins/handlers/base"
9
9
  require_relative "../plugins/handlers/default"
10
10
  require_relative "../core/logger_factory"
11
11
  require_relative "../core/log"
12
+ require_relative "../core/plugin_loader"
12
13
 
13
14
  # Import all core endpoint classes dynamically
14
15
  Dir[File.join(__dir__, "endpoints/**/*.rb")].sort.each { |file| require file }
@@ -21,14 +22,12 @@ module Hooks
21
22
  include Hooks::App::Auth
22
23
 
23
24
  class << self
24
- attr_reader :start_time
25
+ attr_reader :server_start_time
25
26
  end
26
27
 
27
28
  # Create a new configured API class
28
29
  def self.create(config:, endpoints:, log:)
29
- @start_time = Time.now
30
-
31
- Hooks::Log.instance = log
30
+ @server_start_time = Time.now
32
31
 
33
32
  api_class = Class.new(Grape::API) do
34
33
  content_type :json, "application/json"
@@ -51,20 +50,53 @@ module Hooks
51
50
 
52
51
  post(full_path) do
53
52
  request_id = uuid
53
+ start_time = Time.now
54
+
54
55
  request_context = {
55
56
  request_id:,
56
57
  path: full_path,
57
58
  handler: handler_class_name
58
59
  }
59
60
 
61
+ # everything wrapped in the log context has access to the request context and includes it in log messages
62
+ # ex: Hooks::Log.info("message") will include request_id, path, handler, etc
60
63
  Core::LogContext.with(request_context) do
61
64
  begin
65
+ # Build Rack environment for lifecycle hooks
66
+ rack_env = {
67
+ "REQUEST_METHOD" => request.request_method,
68
+ "PATH_INFO" => request.path_info,
69
+ "QUERY_STRING" => request.query_string,
70
+ "HTTP_VERSION" => request.env["HTTP_VERSION"],
71
+ "REQUEST_URI" => request.url,
72
+ "SERVER_NAME" => request.env["SERVER_NAME"],
73
+ "SERVER_PORT" => request.env["SERVER_PORT"],
74
+ "CONTENT_TYPE" => request.content_type,
75
+ "CONTENT_LENGTH" => request.content_length,
76
+ "REMOTE_ADDR" => request.env["REMOTE_ADDR"],
77
+ "hooks.request_id" => request_id,
78
+ "hooks.handler" => handler_class_name,
79
+ "hooks.endpoint_config" => endpoint_config,
80
+ "hooks.start_time" => start_time.iso8601,
81
+ "hooks.full_path" => full_path
82
+ }
83
+
84
+ # Add HTTP headers to environment
85
+ headers.each do |key, value|
86
+ env_key = "HTTP_#{key.upcase.tr('-', '_')}"
87
+ rack_env[env_key] = value
88
+ end
89
+
90
+ # Call lifecycle hooks: on_request
91
+ Core::PluginLoader.lifecycle_plugins.each do |plugin|
92
+ plugin.on_request(rack_env)
93
+ end
94
+
62
95
  enforce_request_limits(config)
63
96
  request.body.rewind
64
97
  raw_body = request.body.read
65
98
 
66
99
  if endpoint_config[:auth]
67
- log.info "validating request (id: #{request_id}, handler: #{handler_class_name})"
68
100
  validate_auth!(raw_body, headers, endpoint_config, config)
69
101
  end
70
102
 
@@ -78,16 +110,29 @@ module Hooks
78
110
  config: endpoint_config
79
111
  )
80
112
 
81
- log.info "request processed successfully (id: #{request_id}, handler: #{handler_class_name})"
113
+ # Call lifecycle hooks: on_response
114
+ Core::PluginLoader.lifecycle_plugins.each do |plugin|
115
+ plugin.on_response(rack_env, response)
116
+ end
117
+
118
+ log.info("successfully processed webhook event with handler: #{handler_class_name}")
119
+ log.debug("processing duration: #{Time.now - start_time}s")
82
120
  status 200
83
121
  content_type "application/json"
84
- (response || { status: "ok" }).to_json
85
- rescue => e
86
- log.error "request failed: #{e.message} (id: #{request_id}, handler: #{handler_class_name})"
122
+ response.to_json
123
+ rescue StandardError => e
124
+ # Call lifecycle hooks: on_error
125
+ if defined?(rack_env)
126
+ Core::PluginLoader.lifecycle_plugins.each do |plugin|
127
+ plugin.on_error(e, rack_env)
128
+ end
129
+ end
130
+
131
+ log.error("an error occuring during the processing of a webhook event - #{e.message}")
87
132
  error_response = {
88
133
  error: e.message,
89
134
  code: determine_error_code(e),
90
- request_id: request_id
135
+ request_id:
91
136
  }
92
137
  error_response[:backtrace] = e.backtrace unless config[:production]
93
138
  status error_response[:code]
@@ -22,7 +22,7 @@ module Hooks
22
22
  def validate_auth!(payload, headers, endpoint_config, global_config = {})
23
23
  auth_config = endpoint_config[:auth]
24
24
 
25
- # Security: Ensure auth type is present and valid
25
+ # Ensure auth type is present and valid
26
26
  auth_type = auth_config&.dig(:type)
27
27
  unless auth_type&.is_a?(String) && !auth_type.strip.empty?
28
28
  error!("authentication configuration missing or invalid", 500)
@@ -32,9 +32,12 @@ module Hooks
32
32
  begin
33
33
  auth_class = Core::PluginLoader.get_auth_plugin(auth_type)
34
34
  rescue => e
35
+ log.error("failed to load auth plugin '#{auth_type}': #{e.message}")
35
36
  error!("unsupported auth type '#{auth_type}'", 400)
36
37
  end
37
38
 
39
+ log.debug("validating auth for request with auth_class: #{auth_class.name}")
40
+
38
41
  unless auth_class.valid?(
39
42
  payload:,
40
43
  headers:,
@@ -43,6 +46,16 @@ module Hooks
43
46
  error!("authentication failed", 401)
44
47
  end
45
48
  end
49
+
50
+ private
51
+
52
+ # Short logger accessor for auth module
53
+ # @return [Hooks::Log] Logger instance
54
+ #
55
+ # Provides access to the application logger for authentication operations.
56
+ def log
57
+ Hooks::Log.instance
58
+ end
46
59
  end
47
60
  end
48
61
  end
@@ -1,5 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # !!! IMPORTANT !!!
4
+ # This file handles the catchall endpoint for the Hooks application.
5
+ # You should not be using catchall endpoints in production.
6
+ # This is mainly for development, testing, and demo purposes.
7
+ # The logging is limited, lifecycle hooks are not called,
8
+ # and it does not support plugins or instruments.
9
+ # Use with caution!
10
+
3
11
  require "grape"
4
12
  require_relative "../../plugins/handlers/default"
5
13
  require_relative "../helpers"
@@ -10,10 +18,13 @@ module Hooks
10
18
  include Hooks::App::Helpers
11
19
 
12
20
  def self.mount_path(config)
21
+ # :nocov:
13
22
  "#{config[:root_path]}/*path"
23
+ # :nocov:
14
24
  end
15
25
 
16
26
  def self.route_block(captured_config, captured_logger)
27
+ # :nocov:
17
28
  proc do
18
29
  request_id = uuid
19
30
 
@@ -23,7 +34,7 @@ module Hooks
23
34
 
24
35
  # Set request context for logging
25
36
  request_context = {
26
- request_id: request_id,
37
+ request_id:,
27
38
  path: "/#{params[:path]}",
28
39
  handler: "DefaultHandler"
29
40
  }
@@ -45,8 +56,8 @@ module Hooks
45
56
 
46
57
  # Call handler
47
58
  response = handler.call(
48
- payload: payload,
49
- headers: headers,
59
+ payload:,
60
+ headers:,
50
61
  config: {}
51
62
  )
52
63
 
@@ -78,6 +89,7 @@ module Hooks
78
89
  end
79
90
  end
80
91
  end
92
+ # :nocov:
81
93
  end
82
94
  end
83
95
  end
@@ -12,7 +12,7 @@ module Hooks
12
12
  status: "healthy",
13
13
  timestamp: Time.now.iso8601,
14
14
  version: Hooks::VERSION,
15
- uptime_seconds: (Time.now - Hooks::App::API.start_time).to_i
15
+ uptime_seconds: (Time.now - Hooks::App::API.server_start_time).to_i
16
16
  }.to_json
17
17
  end
18
18
  end
@@ -5,6 +5,7 @@ require_relative "config_validator"
5
5
  require_relative "logger_factory"
6
6
  require_relative "plugin_loader"
7
7
  require_relative "../app/api"
8
+ require_relative "../utils/retry"
8
9
 
9
10
  module Hooks
10
11
  module Core
@@ -34,6 +35,11 @@ module Hooks
34
35
  )
35
36
  end
36
37
 
38
+ Hooks::Log.instance = @log
39
+
40
+ # Hydrate our Retryable instance
41
+ Retry.setup!(log: @log)
42
+
37
43
  # Load all plugins at boot time
38
44
  load_plugins(config)
39
45
 
@@ -15,6 +15,8 @@ module Hooks
15
15
  optional(:handler_dir).filled(:string) # For backward compatibility
16
16
  optional(:handler_plugin_dir).filled(:string)
17
17
  optional(:auth_plugin_dir).maybe(:string)
18
+ optional(:lifecycle_plugin_dir).maybe(:string)
19
+ optional(:instruments_plugin_dir).maybe(:string)
18
20
  optional(:log_level).filled(:string, included_in?: %w[debug info warn error])
19
21
  optional(:request_limit).filled(:integer, gt?: 0)
20
22
  optional(:request_timeout).filled(:integer, gt?: 0)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooks
4
+ module Core
5
+ # Global failbot component for error reporting
6
+ #
7
+ # This is a stub implementation that does nothing by default.
8
+ # Users can replace this with their own implementation for services
9
+ # like Sentry, Rollbar, etc.
10
+ class Failbot
11
+ # Report an error or exception
12
+ #
13
+ # @param error_or_message [Exception, String] Exception object or error message
14
+ # @param context [Hash] Optional context information
15
+ # @return [void]
16
+ def report(error_or_message, context = {})
17
+ # Override in subclass for actual error reporting
18
+ end
19
+
20
+ # Report a critical error
21
+ #
22
+ # @param error_or_message [Exception, String] Exception object or error message
23
+ # @param context [Hash] Optional context information
24
+ # @return [void]
25
+ def critical(error_or_message, context = {})
26
+ # Override in subclass for actual error reporting
27
+ end
28
+
29
+ # Report a warning
30
+ #
31
+ # @param message [String] Warning message
32
+ # @param context [Hash] Optional context information
33
+ # @return [void]
34
+ def warning(message, context = {})
35
+ # Override in subclass for actual warning reporting
36
+ end
37
+
38
+ # Capture an exception during block execution
39
+ #
40
+ # @param context [Hash] Optional context information
41
+ # @return [Object] Return value of the block
42
+ def capture(context = {})
43
+ yield
44
+ rescue => e
45
+ report(e, context)
46
+ raise
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooks
4
+ module Core
5
+ # Global registry for shared components accessible throughout the application
6
+ class GlobalComponents
7
+ @test_stats = nil
8
+ @test_failbot = nil
9
+
10
+ # Get the global stats instance
11
+ # @return [Hooks::Plugins::Instruments::StatsBase] Stats instance for metrics reporting
12
+ def self.stats
13
+ @test_stats || PluginLoader.get_instrument_plugin(:stats)
14
+ end
15
+
16
+ # Get the global failbot instance
17
+ # @return [Hooks::Plugins::Instruments::FailbotBase] Failbot instance for error reporting
18
+ def self.failbot
19
+ @test_failbot || PluginLoader.get_instrument_plugin(:failbot)
20
+ end
21
+
22
+ # Set a custom stats instance (for testing)
23
+ # @param stats_instance [Object] Custom stats instance
24
+ def self.stats=(stats_instance)
25
+ @test_stats = stats_instance
26
+ end
27
+
28
+ # Set a custom failbot instance (for testing)
29
+ # @param failbot_instance [Object] Custom failbot instance
30
+ def self.failbot=(failbot_instance)
31
+ @test_failbot = failbot_instance
32
+ end
33
+
34
+ # Reset components to default instances (for testing)
35
+ #
36
+ # @return [void]
37
+ def self.reset
38
+ @test_stats = nil
39
+ @test_failbot = nil
40
+ # Clear and reload default instruments
41
+ PluginLoader.clear_plugins
42
+ require_relative "../plugins/instruments/stats"
43
+ require_relative "../plugins/instruments/failbot"
44
+ PluginLoader.instance_variable_set(:@instrument_plugins, {
45
+ stats: Hooks::Plugins::Instruments::Stats.new,
46
+ failbot: Hooks::Plugins::Instruments::Failbot.new
47
+ })
48
+ end
49
+ end
50
+ end
51
+ end
@@ -5,14 +5,16 @@ require_relative "../security"
5
5
 
6
6
  module Hooks
7
7
  module Core
8
- # Loads and caches all plugins (auth + handlers) at boot time
8
+ # Loads and caches all plugins (auth + handlers + lifecycle + instruments) at boot time
9
9
  class PluginLoader
10
10
  # Class-level registries for loaded plugins
11
11
  @auth_plugins = {}
12
12
  @handler_plugins = {}
13
+ @lifecycle_plugins = []
14
+ @instrument_plugins = { stats: nil, failbot: nil }
13
15
 
14
16
  class << self
15
- attr_reader :auth_plugins, :handler_plugins
17
+ attr_reader :auth_plugins, :handler_plugins, :lifecycle_plugins, :instrument_plugins
16
18
 
17
19
  # Load all plugins at boot time
18
20
  #
@@ -22,6 +24,8 @@ module Hooks
22
24
  # Clear existing registries
23
25
  @auth_plugins = {}
24
26
  @handler_plugins = {}
27
+ @lifecycle_plugins = []
28
+ @instrument_plugins = { stats: nil, failbot: nil }
25
29
 
26
30
  # Load built-in plugins first
27
31
  load_builtin_plugins
@@ -29,6 +33,11 @@ module Hooks
29
33
  # Load custom plugins if directories are configured
30
34
  load_custom_auth_plugins(config[:auth_plugin_dir]) if config[:auth_plugin_dir]
31
35
  load_custom_handler_plugins(config[:handler_plugin_dir]) if config[:handler_plugin_dir]
36
+ load_custom_lifecycle_plugins(config[:lifecycle_plugin_dir]) if config[:lifecycle_plugin_dir]
37
+ load_custom_instrument_plugins(config[:instruments_plugin_dir]) if config[:instruments_plugin_dir]
38
+
39
+ # Load default instruments if no custom ones were loaded
40
+ load_default_instruments
32
41
 
33
42
  # Log loaded plugins
34
43
  log_loaded_plugins
@@ -65,12 +74,29 @@ module Hooks
65
74
  plugin_class
66
75
  end
67
76
 
77
+ # Get instrument plugin instance by type
78
+ #
79
+ # @param instrument_type [Symbol] Type of instrument (:stats or :failbot)
80
+ # @return [Object] The instrument plugin instance
81
+ # @raise [StandardError] if instrument not found
82
+ def get_instrument_plugin(instrument_type)
83
+ instrument_instance = @instrument_plugins[instrument_type]
84
+
85
+ unless instrument_instance
86
+ raise StandardError, "Instrument plugin '#{instrument_type}' not found"
87
+ end
88
+
89
+ instrument_instance
90
+ end
91
+
68
92
  # Clear all loaded plugins (for testing purposes)
69
93
  #
70
94
  # @return [void]
71
95
  def clear_plugins
72
96
  @auth_plugins = {}
73
97
  @handler_plugins = {}
98
+ @lifecycle_plugins = []
99
+ @instrument_plugins = { stats: nil, failbot: nil }
74
100
  end
75
101
 
76
102
  private
@@ -119,6 +145,38 @@ module Hooks
119
145
  end
120
146
  end
121
147
 
148
+ # Load custom lifecycle plugins from directory
149
+ #
150
+ # @param lifecycle_plugin_dir [String] Directory containing custom lifecycle plugins
151
+ # @return [void]
152
+ def load_custom_lifecycle_plugins(lifecycle_plugin_dir)
153
+ return unless lifecycle_plugin_dir && Dir.exist?(lifecycle_plugin_dir)
154
+
155
+ Dir.glob(File.join(lifecycle_plugin_dir, "*.rb")).sort.each do |file_path|
156
+ begin
157
+ load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir)
158
+ rescue => e
159
+ raise StandardError, "Failed to load lifecycle plugin from #{file_path}: #{e.message}"
160
+ end
161
+ end
162
+ end
163
+
164
+ # Load custom instrument plugins from directory
165
+ #
166
+ # @param instruments_plugin_dir [String] Directory containing custom instrument plugins
167
+ # @return [void]
168
+ def load_custom_instrument_plugins(instruments_plugin_dir)
169
+ return unless instruments_plugin_dir && Dir.exist?(instruments_plugin_dir)
170
+
171
+ Dir.glob(File.join(instruments_plugin_dir, "*.rb")).sort.each do |file_path|
172
+ begin
173
+ load_custom_instrument_plugin(file_path, instruments_plugin_dir)
174
+ rescue => e
175
+ raise StandardError, "Failed to load instrument plugin from #{file_path}: #{e.message}"
176
+ end
177
+ end
178
+ end
179
+
122
180
  # Load a single custom auth plugin file
123
181
  #
124
182
  # @param file_path [String] Path to the auth plugin file
@@ -189,6 +247,90 @@ module Hooks
189
247
  @handler_plugins[class_name] = handler_class
190
248
  end
191
249
 
250
+ # Load a single custom lifecycle plugin file
251
+ #
252
+ # @param file_path [String] Path to the lifecycle plugin file
253
+ # @param lifecycle_plugin_dir [String] Base directory for lifecycle plugins
254
+ # @return [void]
255
+ def load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir)
256
+ # Security: Ensure the file path doesn't escape the lifecycle plugin directory
257
+ normalized_lifecycle_dir = Pathname.new(File.expand_path(lifecycle_plugin_dir))
258
+ normalized_file_path = Pathname.new(File.expand_path(file_path))
259
+ unless normalized_file_path.descend.any? { |path| path == normalized_lifecycle_dir }
260
+ raise SecurityError, "Lifecycle plugin path outside of lifecycle plugin directory: #{file_path}"
261
+ end
262
+
263
+ # Extract class name from file (e.g., logging_lifecycle.rb -> LoggingLifecycle)
264
+ file_name = File.basename(file_path, ".rb")
265
+ class_name = file_name.split("_").map(&:capitalize).join("")
266
+
267
+ # Security: Validate class name
268
+ unless valid_lifecycle_class_name?(class_name)
269
+ raise StandardError, "Invalid lifecycle plugin class name: #{class_name}"
270
+ end
271
+
272
+ # Load the file
273
+ require file_path
274
+
275
+ # Get the class and validate it
276
+ lifecycle_class = Object.const_get(class_name)
277
+ unless lifecycle_class < Hooks::Plugins::Lifecycle
278
+ raise StandardError, "Lifecycle plugin class must inherit from Hooks::Plugins::Lifecycle: #{class_name}"
279
+ end
280
+
281
+ # Register the plugin instance
282
+ @lifecycle_plugins << lifecycle_class.new
283
+ end
284
+
285
+ # Load a single custom instrument plugin file
286
+ #
287
+ # @param file_path [String] Path to the instrument plugin file
288
+ # @param instruments_plugin_dir [String] Base directory for instrument plugins
289
+ # @return [void]
290
+ def load_custom_instrument_plugin(file_path, instruments_plugin_dir)
291
+ # Security: Ensure the file path doesn't escape the instruments plugin directory
292
+ normalized_instruments_dir = Pathname.new(File.expand_path(instruments_plugin_dir))
293
+ normalized_file_path = Pathname.new(File.expand_path(file_path))
294
+ unless normalized_file_path.descend.any? { |path| path == normalized_instruments_dir }
295
+ raise SecurityError, "Instrument plugin path outside of instruments plugin directory: #{file_path}"
296
+ end
297
+
298
+ # Extract class name from file (e.g., custom_stats.rb -> CustomStats)
299
+ file_name = File.basename(file_path, ".rb")
300
+ class_name = file_name.split("_").map(&:capitalize).join("")
301
+
302
+ # Security: Validate class name
303
+ unless valid_instrument_class_name?(class_name)
304
+ raise StandardError, "Invalid instrument plugin class name: #{class_name}"
305
+ end
306
+
307
+ # Load the file
308
+ require file_path
309
+
310
+ # Get the class and validate it
311
+ instrument_class = Object.const_get(class_name)
312
+
313
+ # Determine instrument type based on inheritance
314
+ if instrument_class < Hooks::Plugins::Instruments::StatsBase
315
+ @instrument_plugins[:stats] = instrument_class.new
316
+ elsif instrument_class < Hooks::Plugins::Instruments::FailbotBase
317
+ @instrument_plugins[:failbot] = instrument_class.new
318
+ else
319
+ raise StandardError, "Instrument plugin class must inherit from StatsBase or FailbotBase: #{class_name}"
320
+ end
321
+ end
322
+
323
+ # Load default instrument implementations if no custom ones were loaded
324
+ #
325
+ # @return [void]
326
+ def load_default_instruments
327
+ require_relative "../plugins/instruments/stats"
328
+ require_relative "../plugins/instruments/failbot"
329
+
330
+ @instrument_plugins[:stats] ||= Hooks::Plugins::Instruments::Stats.new
331
+ @instrument_plugins[:failbot] ||= Hooks::Plugins::Instruments::Failbot.new
332
+ end
333
+
192
334
  # Log summary of loaded plugins
193
335
  #
194
336
  # @return [void]
@@ -201,6 +343,8 @@ module Hooks
201
343
 
202
344
  log.info "Loaded #{@auth_plugins.size} auth plugins: #{@auth_plugins.keys.join(', ')}"
203
345
  log.info "Loaded #{@handler_plugins.size} handler plugins: #{@handler_plugins.keys.join(', ')}"
346
+ log.info "Loaded #{@lifecycle_plugins.size} lifecycle plugins"
347
+ log.info "Loaded instruments: #{@instrument_plugins.keys.select { |k| @instrument_plugins[k] }.join(', ')}"
204
348
  end
205
349
 
206
350
  # Validate that an auth plugin class name is safe to load
@@ -244,6 +388,48 @@ module Hooks
244
388
 
245
389
  true
246
390
  end
391
+
392
+ # Validate that a lifecycle plugin class name is safe to load
393
+ #
394
+ # @param class_name [String] The class name to validate
395
+ # @return [Boolean] true if the class name is safe, false otherwise
396
+ def valid_lifecycle_class_name?(class_name)
397
+ # Must be a string
398
+ return false unless class_name.is_a?(String)
399
+
400
+ # Must not be empty or only whitespace
401
+ return false if class_name.strip.empty?
402
+
403
+ # Must match a safe pattern: alphanumeric + underscore, starting with uppercase
404
+ # Examples: LoggingLifecycle, MetricsLifecycle, CustomLifecycle
405
+ return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
406
+
407
+ # Must not be a system/built-in class name
408
+ return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
409
+
410
+ true
411
+ end
412
+
413
+ # Validate that an instrument plugin class name is safe to load
414
+ #
415
+ # @param class_name [String] The class name to validate
416
+ # @return [Boolean] true if the class name is safe, false otherwise
417
+ def valid_instrument_class_name?(class_name)
418
+ # Must be a string
419
+ return false unless class_name.is_a?(String)
420
+
421
+ # Must not be empty or only whitespace
422
+ return false if class_name.strip.empty?
423
+
424
+ # Must match a safe pattern: alphanumeric + underscore, starting with uppercase
425
+ # Examples: CustomStats, CustomFailbot, DatadogStats
426
+ return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
427
+
428
+ # Must not be a system/built-in class name
429
+ return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
430
+
431
+ true
432
+ end
247
433
  end
248
434
  end
249
435
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooks
4
+ module Core
5
+ # Global stats component for metrics reporting
6
+ #
7
+ # This is a stub implementation that does nothing by default.
8
+ # Users can replace this with their own implementation for services
9
+ # like DataDog, New Relic, etc.
10
+ class Stats
11
+ # Record a metric
12
+ #
13
+ # @param metric_name [String] Name of the metric
14
+ # @param value [Numeric] Value to record
15
+ # @param tags [Hash] Optional tags/labels for the metric
16
+ # @return [void]
17
+ def record(metric_name, value, tags = {})
18
+ # Override in subclass for actual metrics reporting
19
+ end
20
+
21
+ # Increment a counter
22
+ #
23
+ # @param metric_name [String] Name of the counter
24
+ # @param tags [Hash] Optional tags/labels for the metric
25
+ # @return [void]
26
+ def increment(metric_name, tags = {})
27
+ # Override in subclass for actual metrics reporting
28
+ end
29
+
30
+ # Record a timing metric
31
+ #
32
+ # @param metric_name [String] Name of the timing metric
33
+ # @param duration [Numeric] Duration in seconds
34
+ # @param tags [Hash] Optional tags/labels for the metric
35
+ # @return [void]
36
+ def timing(metric_name, duration, tags = {})
37
+ # Override in subclass for actual metrics reporting
38
+ end
39
+
40
+ # Measure execution time of a block
41
+ #
42
+ # @param metric_name [String] Name of the timing metric
43
+ # @param tags [Hash] Optional tags/labels for the metric
44
+ # @return [Object] Return value of the block
45
+ def measure(metric_name, tags = {})
46
+ start_time = Time.now
47
+ result = yield
48
+ duration = Time.now - start_time
49
+ timing(metric_name, duration, tags)
50
+ result
51
+ end
52
+ end
53
+ end
54
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rack/utils"
4
4
  require_relative "../../core/log"
5
+ require_relative "../../core/global_components"
5
6
 
6
7
  module Hooks
7
8
  module Plugins
@@ -33,6 +34,30 @@ module Hooks
33
34
  Hooks::Log.instance
34
35
  end
35
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
+
36
61
  # Retrieve the secret from the environment variable based on the key set in the configuration
37
62
  #
38
63
  # Note: This method is intended to be used by subclasses
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../core/global_components"
4
+
3
5
  module Hooks
4
6
  module Plugins
5
7
  module Handlers
@@ -29,6 +31,30 @@ module Hooks
29
31
  def log
30
32
  Hooks::Log.instance
31
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
32
58
  end
33
59
  end
34
60
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "failbot_base"
4
+
5
+ module Hooks
6
+ module Plugins
7
+ module Instruments
8
+ # Default failbot instrument implementation
9
+ #
10
+ # This is a stub implementation that does nothing by default.
11
+ # Users can replace this with their own implementation for services
12
+ # like Sentry, Rollbar, etc.
13
+ class Failbot < FailbotBase
14
+ # Inherit from FailbotBase to provide a default implementation of the failbot instrument.
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooks
4
+ module Plugins
5
+ module Instruments
6
+ # Base class for all failbot instrument plugins
7
+ #
8
+ # All custom failbot implementations must inherit from this class and implement
9
+ # the required methods for error reporting.
10
+ class FailbotBase
11
+ # Short logger accessor for all subclasses
12
+ # @return [Hooks::Log] Logger instance
13
+ #
14
+ # Provides a convenient way for instruments to log messages without needing
15
+ # to reference the full Hooks::Log namespace.
16
+ #
17
+ # @example Logging debug info in an inherited class
18
+ # log.debug("Sending error to external service")
19
+ def log
20
+ Hooks::Log.instance
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stats_base"
4
+
5
+ module Hooks
6
+ module Plugins
7
+ module Instruments
8
+ # Default stats instrument implementation
9
+ #
10
+ # This is a stub implementation that does nothing by default.
11
+ # Users can replace this with their own implementation for services
12
+ # like DataDog, New Relic, etc.
13
+ class Stats < StatsBase
14
+ # Inherit from StatsBase to provide a default implementation of the stats instrument.
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooks
4
+ module Plugins
5
+ module Instruments
6
+ # Base class for all stats instrument plugins
7
+ #
8
+ # All custom stats implementations must inherit from this class and implement
9
+ # the required methods for metrics reporting.
10
+ class StatsBase
11
+ # Short logger accessor for all subclasses
12
+ # @return [Hooks::Log] Logger instance
13
+ #
14
+ # Provides a convenient way for instruments to log messages without needing
15
+ # to reference the full Hooks::Log namespace.
16
+ #
17
+ # @example Logging an error in an inherited class
18
+ # log.error("Failed to send metric to external service")
19
+ def log
20
+ Hooks::Log.instance
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../core/global_components"
4
+
3
5
  module Hooks
4
6
  module Plugins
5
7
  # Base class for global lifecycle plugins
@@ -28,6 +30,42 @@ module Hooks
28
30
  def on_error(exception, env)
29
31
  # Override in subclass for error handling logic
30
32
  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
31
69
  end
32
70
  end
33
71
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "retryable"
4
+
5
+ # Utility module for retry functionality
6
+ module Retry
7
+ # This method should be called as early as possible in the startup of your application
8
+ # It sets up the Retryable gem with custom contexts and passes through a few options
9
+ # Should the number of retries be reached without success, the last exception will be raised
10
+ #
11
+ # @param log [Logger] The logger to use for retryable logging
12
+ # @raise [ArgumentError] If no logger is provided
13
+ # @return [void]
14
+ def self.setup!(log: nil, log_retries: ENV.fetch("RETRY_LOG_RETRIES", "true") == "true")
15
+ raise ArgumentError, "a logger must be provided" if log.nil?
16
+
17
+ log_method = lambda do |retries, exception|
18
+ # :nocov:
19
+ if log_retries
20
+ log.debug("[retry ##{retries}] #{exception.class}: #{exception.message} - #{exception.backtrace.join("\n")}")
21
+ end
22
+ # :nocov:
23
+ end
24
+
25
+ ######## Retryable Configuration ########
26
+ # All defaults available here:
27
+ # https://github.com/nfedyashev/retryable/blob/6a04027e61607de559e15e48f281f3ccaa9750e8/lib/retryable/configuration.rb#L22-L33
28
+ Retryable.configure do |config|
29
+ config.contexts[:default] = {
30
+ on: [StandardError],
31
+ sleep: ENV.fetch("DEFAULT_RETRY_SLEEP", "1").to_i,
32
+ tries: ENV.fetch("DEFAULT_RETRY_TRIES", "10").to_i,
33
+ log_method:
34
+ }
35
+ end
36
+ end
37
+ end
data/lib/hooks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hooks
4
- VERSION = "0.0.2"
4
+ VERSION = "0.0.3"
5
5
  end
data/lib/hooks.rb CHANGED
@@ -3,6 +3,11 @@
3
3
  require_relative "hooks/version"
4
4
  require_relative "hooks/core/builder"
5
5
 
6
+ # Load all core components
7
+ Dir[File.join(__dir__, "hooks/core/**/*.rb")].sort.each do |file|
8
+ require file
9
+ end
10
+
6
11
  # Load all plugins (auth plugins, handler plugins, lifecycle hooks, etc.)
7
12
  Dir[File.join(__dir__, "hooks/plugins/**/*.rb")].sort.each do |file|
8
13
  require file
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.2
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - github
@@ -78,26 +78,6 @@ dependencies:
78
78
  - - "~>"
79
79
  - !ruby/object:Gem::Version
80
80
  version: '2.3'
81
- - !ruby/object:Gem::Dependency
82
- name: grape-swagger
83
- requirement: !ruby/object:Gem::Requirement
84
- requirements:
85
- - - "~>"
86
- - !ruby/object:Gem::Version
87
- version: '2.1'
88
- - - ">="
89
- - !ruby/object:Gem::Version
90
- version: 2.1.2
91
- type: :runtime
92
- prerelease: false
93
- version_requirements: !ruby/object:Gem::Requirement
94
- requirements:
95
- - - "~>"
96
- - !ruby/object:Gem::Version
97
- version: '2.1'
98
- - - ">="
99
- - !ruby/object:Gem::Version
100
- version: 2.1.2
101
81
  - !ruby/object:Gem::Dependency
102
82
  name: puma
103
83
  requirement: !ruby/object:Gem::Requirement
@@ -152,17 +132,25 @@ files:
152
132
  - lib/hooks/core/builder.rb
153
133
  - lib/hooks/core/config_loader.rb
154
134
  - lib/hooks/core/config_validator.rb
135
+ - lib/hooks/core/failbot.rb
136
+ - lib/hooks/core/global_components.rb
155
137
  - lib/hooks/core/log.rb
156
138
  - lib/hooks/core/logger_factory.rb
157
139
  - lib/hooks/core/plugin_loader.rb
140
+ - lib/hooks/core/stats.rb
158
141
  - lib/hooks/plugins/auth/base.rb
159
142
  - lib/hooks/plugins/auth/hmac.rb
160
143
  - lib/hooks/plugins/auth/shared_secret.rb
161
144
  - lib/hooks/plugins/handlers/base.rb
162
145
  - lib/hooks/plugins/handlers/default.rb
146
+ - lib/hooks/plugins/instruments/failbot.rb
147
+ - lib/hooks/plugins/instruments/failbot_base.rb
148
+ - lib/hooks/plugins/instruments/stats.rb
149
+ - lib/hooks/plugins/instruments/stats_base.rb
163
150
  - lib/hooks/plugins/lifecycle.rb
164
151
  - lib/hooks/security.rb
165
152
  - lib/hooks/utils/normalize.rb
153
+ - lib/hooks/utils/retry.rb
166
154
  - lib/hooks/version.rb
167
155
  homepage: https://github.com/github/hooks
168
156
  licenses: