diff --git a/Gemfile b/Gemfile index dd1d2a5d..02fad98f 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ gemspec group :development do gem "irb", "~> 1" + gem "rack-test", "~> 2.2" gem "rspec", "~> 3" gem "rubocop", "~> 1" gem "rubocop-github", "~> 0.26" diff --git a/Gemfile.lock b/Gemfile.lock index eba3c056..932eb34e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,10 +1,9 @@ PATH remote: . specs: - hooks-ruby (0.0.2) + hooks-ruby (0.0.3) dry-schema (~> 1.14, >= 1.14.1) grape (~> 2.3) - grape-swagger (~> 2.1, >= 2.1.2) puma (~> 6.6) redacting-logger (~> 1.5) retryable (~> 3.0, >= 3.0.5) @@ -76,9 +75,6 @@ GEM mustermann-grape (~> 1.1.0) rack (>= 2) zeitwerk - grape-swagger (2.1.2) - grape (>= 1.7, < 3.0) - rack-test (~> 2) hashdiff (1.2.0) i18n (1.14.7) concurrent-ruby (~> 1.0) @@ -225,6 +221,7 @@ PLATFORMS DEPENDENCIES hooks-ruby! irb (~> 1) + rack-test (~> 2.2) rspec (~> 3) rubocop (~> 1) rubocop-github (~> 0.26) diff --git a/README.md b/README.md index a7cd9e7d..20892397 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,14 @@ See the [Auth Plugins](docs/auth_plugins.md) documentation for even more informa 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. +### Lifecycle Plugins + +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. + +### Instrument Plugins + +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. + ## Contributing 🀝 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. diff --git a/docs/instrument_plugins.md b/docs/instrument_plugins.md new file mode 100644 index 00000000..3610582f --- /dev/null +++ b/docs/instrument_plugins.md @@ -0,0 +1,330 @@ +# Instrument Plugins + +Instrument plugins provide global components for cross-cutting concerns like metrics collection and error reporting. The hooks framework includes two built-in instrument types: `stats` for metrics and `failbot` for error reporting. By default, these instruments are no-op implementations that do not require any external dependencies. You can create custom implementations to integrate with your preferred monitoring and error reporting services. + +## Overview + +By default, the framework provides no-op stub implementations that do nothing. This allows you to write code that calls instrument methods without requiring external dependencies. You can replace these stubs with real implementations that integrate with your monitoring and error reporting services. + +The instrument plugins are accessible throughout the entire application: + +- In handlers via `stats` and `failbot` methods +- In auth plugins via `stats` and `failbot` class methods +- In lifecycle plugins via `stats` and `failbot` methods + +## Creating Custom Instruments + +To create custom instrument implementations, inherit from the appropriate base class and implement the required methods. + +To actually have `stats` and `failbot` do something useful, you need to create custom classes that inherit from the base classes provided by the framework. Here’s an example of how to implement custom stats and failbot plugins. + +You would then set the following attribute in your `hooks.yml` configuration file to point to these custom instrument plugins: + +```yaml +# hooks.yml +instruments_plugin_dir: ./plugins/instruments +``` + +### Custom Stats Implementation + +```ruby +# plugins/instruments/stats.rb +class Stats < Hooks::Plugins::Instruments::StatsBase + def initialize + # Initialize your metrics client + @client = MyMetricsService.new( + api_key: ENV["METRICS_API_KEY"], + namespace: "webhooks" + ) + end + + def record(metric_name, value, tags = {}) + @client.gauge(metric_name, value, tags: tags) + rescue => e + log.error("Failed to record metric: #{e.message}") + end + + def increment(metric_name, tags = {}) + @client.increment(metric_name, tags: tags) + rescue => e + log.error("Failed to increment metric: #{e.message}") + end + + def timing(metric_name, duration, tags = {}) + # Convert to milliseconds if your service expects that + duration_ms = (duration * 1000).round + @client.timing(metric_name, duration_ms, tags: tags) + rescue => e + log.error("Failed to record timing: #{e.message}") + end + + # Optional: Add custom methods specific to your service + def histogram(metric_name, value, tags = {}) + @client.histogram(metric_name, value, tags: tags) + rescue => e + log.error("Failed to record histogram: #{e.message}") + end +end +``` + +### Custom Failbot Implementation + +```ruby +# plugins/instruments/failbot.rb +class Failbot < Hooks::Plugins::Instruments::FailbotBase + def initialize + # Initialize your error reporting client + @client = MyErrorService.new( + api_key: ENV["ERROR_REPORTING_API_KEY"], + environment: ENV["RAILS_ENV"] || "production" + ) + end + + def report(error_or_message, context = {}) + if error_or_message.is_a?(Exception) + @client.report_exception(error_or_message, context) + else + @client.report_message(error_or_message, context) + end + rescue => e + log.error("Failed to report error: #{e.message}") + end + + def critical(error_or_message, context = {}) + enhanced_context = context.merge(severity: "critical") + report(error_or_message, enhanced_context) + end + + def warning(message, context = {}) + enhanced_context = context.merge(severity: "warning") + @client.report_message(message, enhanced_context) + rescue => e + log.error("Failed to report warning: #{e.message}") + end + + # Optional: Add custom methods specific to your service + def set_user_context(user_id:, email: nil) + @client.set_user_context(user_id: user_id, email: email) + rescue => e + log.error("Failed to set user context: #{e.message}") + end + + def add_breadcrumb(message, category: "webhook", data: {}) + @client.add_breadcrumb(message, category: category, data: data) + rescue => e + log.error("Failed to add breadcrumb: #{e.message}") + end +end +``` + +## Configuration + +To use custom instrument plugins, specify the `instruments_plugin_dir` in your configuration: + +```yaml +# hooks.yml +instruments_plugin_dir: ./plugins/instruments +handler_plugin_dir: ./plugins/handlers +auth_plugin_dir: ./plugins/auth +lifecycle_plugin_dir: ./plugins/lifecycle +``` + +Place your instrument plugin files in the specified directory: + +```text +plugins/ +└── instruments/ + β”œβ”€β”€ stats.rb + └── failbot.rb +``` + +## File Naming and Class Detection + +The framework automatically detects which type of instrument you're creating based on inheritance: + +- Classes inheriting from `StatsBase` become the `stats` instrument +- Classes inheriting from `FailbotBase` become the `failbot` instrument + +File naming follows snake_case to PascalCase conversion: + +- `stats.rb` β†’ `stats` +- `sentry_failbot.rb` β†’ `SentryFailbot` + +You can only have one `stats` plugin and one `failbot` plugin loaded. If multiple plugins of the same type are found, the last one loaded will be used. + +## Usage in Your Code + +Once configured, your custom instruments are available throughout the application: + +### In Handlers + +```ruby +class MyHandler < Hooks::Plugins::Handlers::Base + def call(payload:, headers:, config:) + # Use your custom stats methods + stats.increment("handler.calls", { handler: "MyHandler" }) + + # Use custom methods if you added them + stats.histogram("payload.size", payload.to_s.length) if stats.respond_to?(:histogram) + + result = stats.measure("handler.processing", { handler: "MyHandler" }) do + process_webhook(payload, headers, config) + end + + # Use your custom failbot methods + failbot.add_breadcrumb("Handler completed successfully") if failbot.respond_to?(:add_breadcrumb) + + result + rescue => e + failbot.report(e, { handler: "MyHandler", event: headers["x-github-event"] }) + raise + end +end +``` + +### In Lifecycle Plugins + +```ruby +class MetricsLifecycle < Hooks::Plugins::Lifecycle + def on_request(env) + # Your custom stats implementation will be used + stats.increment("requests.total", { + path: env["PATH_INFO"], + method: env["REQUEST_METHOD"] + }) + end + + def on_error(exception, env) + # Your custom failbot implementation will be used + failbot.report(exception, { + path: env["PATH_INFO"], + handler: env["hooks.handler"] + }) + end +end +``` + +## Popular Integrations + +### DataDog Stats + +```ruby +class DatadogStats < Hooks::Plugins::Instruments::StatsBase + def initialize + require "datadog/statsd" + @statsd = Datadog::Statsd.new("localhost", 8125, namespace: "webhooks") + end + + def record(metric_name, value, tags = {}) + @statsd.gauge(metric_name, value, tags: format_tags(tags)) + end + + def increment(metric_name, tags = {}) + @statsd.increment(metric_name, tags: format_tags(tags)) + end + + def timing(metric_name, duration, tags = {}) + @statsd.timing(metric_name, duration, tags: format_tags(tags)) + end + + private + + def format_tags(tags) + tags.map { |k, v| "#{k}:#{v}" } + end +end +``` + +### Sentry Failbot + +```ruby +class SentryFailbot < Hooks::Plugins::Instruments::FailbotBase + def initialize + require "sentry-ruby" + Sentry.init do |config| + config.dsn = ENV["SENTRY_DSN"] + config.environment = ENV["RAILS_ENV"] || "production" + end + end + + def report(error_or_message, context = {}) + Sentry.with_scope do |scope| + context.each { |key, value| scope.set_context(key, value) } + + if error_or_message.is_a?(Exception) + Sentry.capture_exception(error_or_message) + else + Sentry.capture_message(error_or_message) + end + end + end + + def critical(error_or_message, context = {}) + Sentry.with_scope do |scope| + scope.set_level(:fatal) + context.each { |key, value| scope.set_context(key, value) } + + if error_or_message.is_a?(Exception) + Sentry.capture_exception(error_or_message) + else + Sentry.capture_message(error_or_message) + end + end + end + + def warning(message, context = {}) + Sentry.with_scope do |scope| + scope.set_level(:warning) + context.each { |key, value| scope.set_context(key, value) } + Sentry.capture_message(message) + end + end +end +``` + +## Testing Your Instruments + +When testing, you may want to use test doubles or capture calls: + +```ruby +# In your test setup +class TestStats < Hooks::Plugins::Instruments::StatsBase + attr_reader :recorded_metrics + + def initialize + @recorded_metrics = [] + end + + def record(metric_name, value, tags = {}) + @recorded_metrics << { type: :record, name: metric_name, value: value, tags: tags } + end + + def increment(metric_name, tags = {}) + @recorded_metrics << { type: :increment, name: metric_name, tags: tags } + end + + def timing(metric_name, duration, tags = {}) + @recorded_metrics << { type: :timing, name: metric_name, duration: duration, tags: tags } + end +end + +# Use in tests +test_stats = TestStats.new +Hooks::Core::GlobalComponents.stats = test_stats + +# Your test code here + +expect(test_stats.recorded_metrics).to include( + { type: :increment, name: "webhook.processed", tags: { handler: "MyHandler" } } +) +``` + +## Best Practices + +1. **Handle errors gracefully**: Instrument failures should not break webhook processing +2. **Use appropriate log levels**: Log instrument failures at error level +3. **Add timeouts**: Network calls to external services should have reasonable timeouts +4. **Validate configuration**: Check for required environment variables in `initialize` +5. **Document custom methods**: If you add methods beyond the base interface, document them +6. **Consider performance**: Instruments are called frequently, so keep operations fast +7. **Use connection pooling**: For high-throughput scenarios, use connection pooling for external services diff --git a/docs/lifecycle_plugins.md b/docs/lifecycle_plugins.md new file mode 100644 index 00000000..f36e3709 --- /dev/null +++ b/docs/lifecycle_plugins.md @@ -0,0 +1,255 @@ +# Lifecycle Plugins + +Lifecycle plugins allow you to hook into webhook request processing at three key points in the request lifecycle. This enables you to add custom functionality like metrics collection, error reporting, request logging, and more. + +## Overview + +The webhook processing lifecycle provides three hooks: + +- **`on_request`**: Called before handler execution with request environment data +- **`on_response`**: Called after successful handler execution with response data +- **`on_error`**: Called when any error occurs during request processing + +All lifecycle plugins have access to the global `stats` and `failbot` instruments for metrics and error reporting. + +## Creating a Lifecycle Plugin + +All lifecycle plugins must inherit from `Hooks::Plugins::Lifecycle` and can implement any or all of the lifecycle methods: + +```ruby +class MetricsLifecycle < Hooks::Plugins::Lifecycle + def on_request(env) + # Called before handler execution + # env contains Rack environment with request details + stats.increment("webhook.requests", { + path: env["PATH_INFO"], + method: env["REQUEST_METHOD"] + }) + + log.info "Processing webhook request: #{env['REQUEST_METHOD']} #{env['PATH_INFO']}" + end + + def on_response(env, response) + # Called after successful handler execution + # env contains the request environment + # response contains the handler's response data + stats.timing("webhook.response_time", env["hooks.processing_time"] || 0) + + log.info "Webhook processed successfully: #{response.inspect}" + end + + def on_error(exception, env) + # Called when any error occurs during request processing + # exception is the error that occurred + # env contains the request environment + failbot.report(exception, { + path: env["PATH_INFO"], + handler: env["hooks.handler"], + method: env["REQUEST_METHOD"] + }) + + log.error "Webhook processing failed: #{exception.message}" + end +end +``` + +## Available Data + +### Request Environment (`env`) + +The environment hash contains standard Rack environment variables plus webhook-specific data: + +```ruby +{ + "REQUEST_METHOD" => "POST", + "PATH_INFO" => "/webhook/my-endpoint", + "HTTP_X_GITHUB_EVENT" => "push", + "HTTP_X_HUB_SIGNATURE_256" => "sha256=...", + "hooks.handler" => "MyHandler", + "hooks.config" => { ... }, # Endpoint configuration + "hooks.payload" => { ... }, # Parsed webhook payload + "hooks.headers" => { ... }, # Cleaned HTTP headers + "hooks.processing_time" => 0.123 # Available in on_response +} +``` + +### Response Data + +The response parameter in `on_response` contains the data returned by your handler: + +```ruby +{ + status: "success", + message: "Webhook processed", + data: { ... } +} +``` + +## Global Components + +Lifecycle plugins have access to global components for cross-cutting concerns: + +### Logger (`log`) + +```ruby +def on_request(env) + log.debug("Request details: #{env.inspect}") + log.info("Processing #{env['HTTP_X_GITHUB_EVENT']} event") + log.warn("Missing expected header") unless env["HTTP_X_GITHUB_EVENT"] + log.error("Critical validation failure") +end +``` + +### Stats (`stats`) + +```ruby +def on_request(env) + # Increment counters (example) + stats.increment("webhook.requests", { event: env["HTTP_X_GITHUB_EVENT"] }) + + # Record values (example) + stats.record("webhook.payload_size", env["CONTENT_LENGTH"].to_i) + + # Measure execution time (example) + stats.measure("webhook.processing", { handler: env["hooks.handler"] }) do + # Processing happens in the handler + end +end + +def on_response(env, response) + # Record timing from environment (example) + stats.timing("webhook.duration", env["hooks.processing_time"]) +end +``` + +### Failbot (`failbot`) + +```ruby +def on_error(exception, env) + # Report errors with context (example) + failbot.report(exception, { + endpoint: env["PATH_INFO"], + event_type: env["HTTP_X_GITHUB_EVENT"], + handler: env["hooks.handler"] + }) + + # Report critical errors (example) + failbot.critical("Handler crashed", { handler: env["hooks.handler"] }) + + # Report warnings (example) + failbot.warning("Slow webhook processing", { duration: env["hooks.processing_time"] }) +end + +def on_request(env) + # Capture and report exceptions during processing (example) + failbot.capture({ context: "request_validation" }) do + validate_webhook_signature(env) + end +end +``` + +## Configuration + +To use custom lifecycle plugins, specify the `lifecycle_plugin_dir` in your configuration: + +```yaml +# hooks.yml +lifecycle_plugin_dir: ./plugins/lifecycle +handler_plugin_dir: ./plugins/handlers +auth_plugin_dir: ./plugins/auth +``` + +Place your lifecycle plugin files in the specified directory: + +```text +plugins/ +└── lifecycle/ + β”œβ”€β”€ metrics_lifecycle.rb + β”œβ”€β”€ audit_lifecycle.rb + └── performance_lifecycle.rb +``` + +## File Naming + +Plugin files should be named using snake_case and the class name should be PascalCase: + +- `metrics_lifecycle.rb` β†’ `MetricsLifecycle` +- `audit_lifecycle.rb` β†’ `AuditLifecycle` +- `performance_lifecycle.rb` β†’ `PerformanceLifecycle` + +## Example: Complete Monitoring Plugin + +```ruby +class MonitoringLifecycle < Hooks::Plugins::Lifecycle + def on_request(env) + # Record request metrics + stats.increment("webhook.requests.total", { + method: env["REQUEST_METHOD"], + path: env["PATH_INFO"], + event: env["HTTP_X_GITHUB_EVENT"] || "unknown" + }) + + # Log request start + log.info("Webhook request started", { + method: env["REQUEST_METHOD"], + path: env["PATH_INFO"], + user_agent: env["HTTP_USER_AGENT"], + content_length: env["CONTENT_LENGTH"] + }) + end + + def on_response(env, response) + # Record successful processing + stats.increment("webhook.requests.success", { + handler: env["hooks.handler"], + event: env["HTTP_X_GITHUB_EVENT"] + }) + + # Record processing time + if env["hooks.processing_time"] + stats.timing("webhook.processing_time", env["hooks.processing_time"], { + handler: env["hooks.handler"] + }) + end + + log.info("Webhook request completed successfully", { + handler: env["hooks.handler"], + response_type: response.class.name, + processing_time: env["hooks.processing_time"] + }) + end + + def on_error(exception, env) + # Record error metrics + stats.increment("webhook.requests.error", { + handler: env["hooks.handler"], + error_type: exception.class.name, + event: env["HTTP_X_GITHUB_EVENT"] + }) + + # Report error with full context + failbot.report(exception, { + handler: env["hooks.handler"], + method: env["REQUEST_METHOD"], + path: env["PATH_INFO"], + event: env["HTTP_X_GITHUB_EVENT"], + user_agent: env["HTTP_USER_AGENT"], + content_length: env["CONTENT_LENGTH"] + }) + + log.error("Webhook request failed", { + error: exception.message, + handler: env["hooks.handler"], + path: env["PATH_INFO"] + }) + end +end +``` + +## Best Practices + +1. **Keep lifecycle methods fast**: Avoid slow operations that could impact webhook processing performance +2. **Handle errors gracefully**: Lifecycle plugins should not cause webhook processing to fail +3. **Use appropriate log levels**: Debug for detailed info, info for normal flow, warn for issues, error for failures +4. **Include relevant context**: Add useful tags and context to metrics and error reports +5. **Test thoroughly**: Lifecycle plugins run for every webhook request, so bugs have high impact diff --git a/hooks.gemspec b/hooks.gemspec index 61b27710..731d36ce 100644 --- a/hooks.gemspec +++ b/hooks.gemspec @@ -22,7 +22,6 @@ Gem::Specification.new do |spec| spec.add_dependency "retryable", "~> 3.0", ">= 3.0.5" spec.add_dependency "dry-schema", "~> 1.14", ">= 1.14.1" spec.add_dependency "grape", "~> 2.3" - spec.add_dependency "grape-swagger", "~> 2.1", ">= 2.1.2" spec.add_dependency "puma", "~> 6.6" spec.required_ruby_version = Gem::Requirement.new(">= 3.2.2") diff --git a/lib/hooks.rb b/lib/hooks.rb index 2105ae8a..d1ab8cea 100644 --- a/lib/hooks.rb +++ b/lib/hooks.rb @@ -3,6 +3,11 @@ require_relative "hooks/version" require_relative "hooks/core/builder" +# Load all core components +Dir[File.join(__dir__, "hooks/core/**/*.rb")].sort.each do |file| + require file +end + # Load all plugins (auth plugins, handler plugins, lifecycle hooks, etc.) Dir[File.join(__dir__, "hooks/plugins/**/*.rb")].sort.each do |file| require file diff --git a/lib/hooks/app/api.rb b/lib/hooks/app/api.rb index a11a1fc9..0d43f1df 100644 --- a/lib/hooks/app/api.rb +++ b/lib/hooks/app/api.rb @@ -9,6 +9,7 @@ require_relative "../plugins/handlers/default" require_relative "../core/logger_factory" require_relative "../core/log" +require_relative "../core/plugin_loader" # Import all core endpoint classes dynamically Dir[File.join(__dir__, "endpoints/**/*.rb")].sort.each { |file| require file } @@ -28,8 +29,6 @@ class << self def self.create(config:, endpoints:, log:) @server_start_time = Time.now - Hooks::Log.instance = log - api_class = Class.new(Grape::API) do content_type :json, "application/json" content_type :txt, "text/plain" @@ -63,6 +62,36 @@ def self.create(config:, endpoints:, log:) # ex: Hooks::Log.info("message") will include request_id, path, handler, etc Core::LogContext.with(request_context) do begin + # Build Rack environment for lifecycle hooks + rack_env = { + "REQUEST_METHOD" => request.request_method, + "PATH_INFO" => request.path_info, + "QUERY_STRING" => request.query_string, + "HTTP_VERSION" => request.env["HTTP_VERSION"], + "REQUEST_URI" => request.url, + "SERVER_NAME" => request.env["SERVER_NAME"], + "SERVER_PORT" => request.env["SERVER_PORT"], + "CONTENT_TYPE" => request.content_type, + "CONTENT_LENGTH" => request.content_length, + "REMOTE_ADDR" => request.env["REMOTE_ADDR"], + "hooks.request_id" => request_id, + "hooks.handler" => handler_class_name, + "hooks.endpoint_config" => endpoint_config, + "hooks.start_time" => start_time.iso8601, + "hooks.full_path" => full_path + } + + # Add HTTP headers to environment + headers.each do |key, value| + env_key = "HTTP_#{key.upcase.tr('-', '_')}" + rack_env[env_key] = value + end + + # Call lifecycle hooks: on_request + Core::PluginLoader.lifecycle_plugins.each do |plugin| + plugin.on_request(rack_env) + end + enforce_request_limits(config) request.body.rewind raw_body = request.body.read @@ -81,13 +110,25 @@ def self.create(config:, endpoints:, log:) config: endpoint_config ) - log.info "request processed successfully by handler: #{handler_class_name}" - log.debug "request duration: #{Time.now - start_time}s" + # Call lifecycle hooks: on_response + Core::PluginLoader.lifecycle_plugins.each do |plugin| + plugin.on_response(rack_env, response) + end + + log.info("successfully processed webhook event with handler: #{handler_class_name}") + log.debug("processing duration: #{Time.now - start_time}s") status 200 content_type "application/json" response.to_json - rescue => e - log.error "request failed: #{e.message}" + rescue StandardError => e + # Call lifecycle hooks: on_error + if defined?(rack_env) + Core::PluginLoader.lifecycle_plugins.each do |plugin| + plugin.on_error(e, rack_env) + end + end + + log.error("an error occuring during the processing of a webhook event - #{e.message}") error_response = { error: e.message, code: determine_error_code(e), diff --git a/lib/hooks/app/endpoints/catchall.rb b/lib/hooks/app/endpoints/catchall.rb index e26ccc3a..eb3eb777 100644 --- a/lib/hooks/app/endpoints/catchall.rb +++ b/lib/hooks/app/endpoints/catchall.rb @@ -1,5 +1,13 @@ # frozen_string_literal: true +# !!! IMPORTANT !!! +# This file handles the catchall endpoint for the Hooks application. +# You should not be using catchall endpoints in production. +# This is mainly for development, testing, and demo purposes. +# The logging is limited, lifecycle hooks are not called, +# and it does not support plugins or instruments. +# Use with caution! + require "grape" require_relative "../../plugins/handlers/default" require_relative "../helpers" @@ -10,10 +18,13 @@ class CatchallEndpoint < Grape::API include Hooks::App::Helpers def self.mount_path(config) + # :nocov: "#{config[:root_path]}/*path" + # :nocov: end def self.route_block(captured_config, captured_logger) + # :nocov: proc do request_id = uuid @@ -23,7 +34,7 @@ def self.route_block(captured_config, captured_logger) # Set request context for logging request_context = { - request_id: request_id, + request_id:, path: "/#{params[:path]}", handler: "DefaultHandler" } @@ -45,8 +56,8 @@ def self.route_block(captured_config, captured_logger) # Call handler response = handler.call( - payload: payload, - headers: headers, + payload:, + headers:, config: {} ) @@ -78,6 +89,7 @@ def self.route_block(captured_config, captured_logger) end end end + # :nocov: end end end diff --git a/lib/hooks/core/builder.rb b/lib/hooks/core/builder.rb index e7ccd129..099b497a 100644 --- a/lib/hooks/core/builder.rb +++ b/lib/hooks/core/builder.rb @@ -35,6 +35,8 @@ def build ) end + Hooks::Log.instance = @log + # Hydrate our Retryable instance Retry.setup!(log: @log) diff --git a/lib/hooks/core/config_validator.rb b/lib/hooks/core/config_validator.rb index 5e1204a1..0fb98007 100644 --- a/lib/hooks/core/config_validator.rb +++ b/lib/hooks/core/config_validator.rb @@ -15,6 +15,8 @@ class ValidationError < StandardError; end optional(:handler_dir).filled(:string) # For backward compatibility optional(:handler_plugin_dir).filled(:string) optional(:auth_plugin_dir).maybe(:string) + optional(:lifecycle_plugin_dir).maybe(:string) + optional(:instruments_plugin_dir).maybe(:string) optional(:log_level).filled(:string, included_in?: %w[debug info warn error]) optional(:request_limit).filled(:integer, gt?: 0) optional(:request_timeout).filled(:integer, gt?: 0) diff --git a/lib/hooks/core/failbot.rb b/lib/hooks/core/failbot.rb new file mode 100644 index 00000000..4f1c4447 --- /dev/null +++ b/lib/hooks/core/failbot.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Hooks + module Core + # Global failbot component for error reporting + # + # This is a stub implementation that does nothing by default. + # Users can replace this with their own implementation for services + # like Sentry, Rollbar, etc. + class Failbot + # Report an error or exception + # + # @param error_or_message [Exception, String] Exception object or error message + # @param context [Hash] Optional context information + # @return [void] + def report(error_or_message, context = {}) + # Override in subclass for actual error reporting + end + + # Report a critical error + # + # @param error_or_message [Exception, String] Exception object or error message + # @param context [Hash] Optional context information + # @return [void] + def critical(error_or_message, context = {}) + # Override in subclass for actual error reporting + end + + # Report a warning + # + # @param message [String] Warning message + # @param context [Hash] Optional context information + # @return [void] + def warning(message, context = {}) + # Override in subclass for actual warning reporting + end + + # Capture an exception during block execution + # + # @param context [Hash] Optional context information + # @return [Object] Return value of the block + def capture(context = {}) + yield + rescue => e + report(e, context) + raise + end + end + end +end diff --git a/lib/hooks/core/global_components.rb b/lib/hooks/core/global_components.rb new file mode 100644 index 00000000..ea3771c0 --- /dev/null +++ b/lib/hooks/core/global_components.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Hooks + module Core + # Global registry for shared components accessible throughout the application + class GlobalComponents + @test_stats = nil + @test_failbot = nil + + # Get the global stats instance + # @return [Hooks::Plugins::Instruments::StatsBase] Stats instance for metrics reporting + def self.stats + @test_stats || PluginLoader.get_instrument_plugin(:stats) + end + + # Get the global failbot instance + # @return [Hooks::Plugins::Instruments::FailbotBase] Failbot instance for error reporting + def self.failbot + @test_failbot || PluginLoader.get_instrument_plugin(:failbot) + end + + # Set a custom stats instance (for testing) + # @param stats_instance [Object] Custom stats instance + def self.stats=(stats_instance) + @test_stats = stats_instance + end + + # Set a custom failbot instance (for testing) + # @param failbot_instance [Object] Custom failbot instance + def self.failbot=(failbot_instance) + @test_failbot = failbot_instance + end + + # Reset components to default instances (for testing) + # + # @return [void] + def self.reset + @test_stats = nil + @test_failbot = nil + # Clear and reload default instruments + PluginLoader.clear_plugins + require_relative "../plugins/instruments/stats" + require_relative "../plugins/instruments/failbot" + PluginLoader.instance_variable_set(:@instrument_plugins, { + stats: Hooks::Plugins::Instruments::Stats.new, + failbot: Hooks::Plugins::Instruments::Failbot.new + }) + end + end + end +end diff --git a/lib/hooks/core/plugin_loader.rb b/lib/hooks/core/plugin_loader.rb index f55dee4b..97869298 100644 --- a/lib/hooks/core/plugin_loader.rb +++ b/lib/hooks/core/plugin_loader.rb @@ -5,14 +5,16 @@ module Hooks module Core - # Loads and caches all plugins (auth + handlers) at boot time + # Loads and caches all plugins (auth + handlers + lifecycle + instruments) at boot time class PluginLoader # Class-level registries for loaded plugins @auth_plugins = {} @handler_plugins = {} + @lifecycle_plugins = [] + @instrument_plugins = { stats: nil, failbot: nil } class << self - attr_reader :auth_plugins, :handler_plugins + attr_reader :auth_plugins, :handler_plugins, :lifecycle_plugins, :instrument_plugins # Load all plugins at boot time # @@ -22,6 +24,8 @@ def load_all_plugins(config) # Clear existing registries @auth_plugins = {} @handler_plugins = {} + @lifecycle_plugins = [] + @instrument_plugins = { stats: nil, failbot: nil } # Load built-in plugins first load_builtin_plugins @@ -29,6 +33,11 @@ def load_all_plugins(config) # Load custom plugins if directories are configured load_custom_auth_plugins(config[:auth_plugin_dir]) if config[:auth_plugin_dir] load_custom_handler_plugins(config[:handler_plugin_dir]) if config[:handler_plugin_dir] + load_custom_lifecycle_plugins(config[:lifecycle_plugin_dir]) if config[:lifecycle_plugin_dir] + load_custom_instrument_plugins(config[:instruments_plugin_dir]) if config[:instruments_plugin_dir] + + # Load default instruments if no custom ones were loaded + load_default_instruments # Log loaded plugins log_loaded_plugins @@ -65,12 +74,29 @@ def get_handler_plugin(handler_name) plugin_class end + # Get instrument plugin instance by type + # + # @param instrument_type [Symbol] Type of instrument (:stats or :failbot) + # @return [Object] The instrument plugin instance + # @raise [StandardError] if instrument not found + def get_instrument_plugin(instrument_type) + instrument_instance = @instrument_plugins[instrument_type] + + unless instrument_instance + raise StandardError, "Instrument plugin '#{instrument_type}' not found" + end + + instrument_instance + end + # Clear all loaded plugins (for testing purposes) # # @return [void] def clear_plugins @auth_plugins = {} @handler_plugins = {} + @lifecycle_plugins = [] + @instrument_plugins = { stats: nil, failbot: nil } end private @@ -119,6 +145,38 @@ def load_custom_handler_plugins(handler_plugin_dir) end end + # Load custom lifecycle plugins from directory + # + # @param lifecycle_plugin_dir [String] Directory containing custom lifecycle plugins + # @return [void] + def load_custom_lifecycle_plugins(lifecycle_plugin_dir) + return unless lifecycle_plugin_dir && Dir.exist?(lifecycle_plugin_dir) + + Dir.glob(File.join(lifecycle_plugin_dir, "*.rb")).sort.each do |file_path| + begin + load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir) + rescue => e + raise StandardError, "Failed to load lifecycle plugin from #{file_path}: #{e.message}" + end + end + end + + # Load custom instrument plugins from directory + # + # @param instruments_plugin_dir [String] Directory containing custom instrument plugins + # @return [void] + def load_custom_instrument_plugins(instruments_plugin_dir) + return unless instruments_plugin_dir && Dir.exist?(instruments_plugin_dir) + + Dir.glob(File.join(instruments_plugin_dir, "*.rb")).sort.each do |file_path| + begin + load_custom_instrument_plugin(file_path, instruments_plugin_dir) + rescue => e + raise StandardError, "Failed to load instrument plugin from #{file_path}: #{e.message}" + end + end + end + # Load a single custom auth plugin file # # @param file_path [String] Path to the auth plugin file @@ -189,6 +247,90 @@ def load_custom_handler_plugin(file_path, handler_plugin_dir) @handler_plugins[class_name] = handler_class end + # Load a single custom lifecycle plugin file + # + # @param file_path [String] Path to the lifecycle plugin file + # @param lifecycle_plugin_dir [String] Base directory for lifecycle plugins + # @return [void] + def load_custom_lifecycle_plugin(file_path, lifecycle_plugin_dir) + # Security: Ensure the file path doesn't escape the lifecycle plugin directory + normalized_lifecycle_dir = Pathname.new(File.expand_path(lifecycle_plugin_dir)) + normalized_file_path = Pathname.new(File.expand_path(file_path)) + unless normalized_file_path.descend.any? { |path| path == normalized_lifecycle_dir } + raise SecurityError, "Lifecycle plugin path outside of lifecycle plugin directory: #{file_path}" + end + + # Extract class name from file (e.g., logging_lifecycle.rb -> LoggingLifecycle) + file_name = File.basename(file_path, ".rb") + class_name = file_name.split("_").map(&:capitalize).join("") + + # Security: Validate class name + unless valid_lifecycle_class_name?(class_name) + raise StandardError, "Invalid lifecycle plugin class name: #{class_name}" + end + + # Load the file + require file_path + + # Get the class and validate it + lifecycle_class = Object.const_get(class_name) + unless lifecycle_class < Hooks::Plugins::Lifecycle + raise StandardError, "Lifecycle plugin class must inherit from Hooks::Plugins::Lifecycle: #{class_name}" + end + + # Register the plugin instance + @lifecycle_plugins << lifecycle_class.new + end + + # Load a single custom instrument plugin file + # + # @param file_path [String] Path to the instrument plugin file + # @param instruments_plugin_dir [String] Base directory for instrument plugins + # @return [void] + def load_custom_instrument_plugin(file_path, instruments_plugin_dir) + # Security: Ensure the file path doesn't escape the instruments plugin directory + normalized_instruments_dir = Pathname.new(File.expand_path(instruments_plugin_dir)) + normalized_file_path = Pathname.new(File.expand_path(file_path)) + unless normalized_file_path.descend.any? { |path| path == normalized_instruments_dir } + raise SecurityError, "Instrument plugin path outside of instruments plugin directory: #{file_path}" + end + + # Extract class name from file (e.g., custom_stats.rb -> CustomStats) + file_name = File.basename(file_path, ".rb") + class_name = file_name.split("_").map(&:capitalize).join("") + + # Security: Validate class name + unless valid_instrument_class_name?(class_name) + raise StandardError, "Invalid instrument plugin class name: #{class_name}" + end + + # Load the file + require file_path + + # Get the class and validate it + instrument_class = Object.const_get(class_name) + + # Determine instrument type based on inheritance + if instrument_class < Hooks::Plugins::Instruments::StatsBase + @instrument_plugins[:stats] = instrument_class.new + elsif instrument_class < Hooks::Plugins::Instruments::FailbotBase + @instrument_plugins[:failbot] = instrument_class.new + else + raise StandardError, "Instrument plugin class must inherit from StatsBase or FailbotBase: #{class_name}" + end + end + + # Load default instrument implementations if no custom ones were loaded + # + # @return [void] + def load_default_instruments + require_relative "../plugins/instruments/stats" + require_relative "../plugins/instruments/failbot" + + @instrument_plugins[:stats] ||= Hooks::Plugins::Instruments::Stats.new + @instrument_plugins[:failbot] ||= Hooks::Plugins::Instruments::Failbot.new + end + # Log summary of loaded plugins # # @return [void] @@ -201,6 +343,8 @@ def log_loaded_plugins log.info "Loaded #{@auth_plugins.size} auth plugins: #{@auth_plugins.keys.join(', ')}" log.info "Loaded #{@handler_plugins.size} handler plugins: #{@handler_plugins.keys.join(', ')}" + log.info "Loaded #{@lifecycle_plugins.size} lifecycle plugins" + log.info "Loaded instruments: #{@instrument_plugins.keys.select { |k| @instrument_plugins[k] }.join(', ')}" end # Validate that an auth plugin class name is safe to load @@ -244,6 +388,48 @@ def valid_handler_class_name?(class_name) true end + + # Validate that a lifecycle plugin class name is safe to load + # + # @param class_name [String] The class name to validate + # @return [Boolean] true if the class name is safe, false otherwise + def valid_lifecycle_class_name?(class_name) + # Must be a string + return false unless class_name.is_a?(String) + + # Must not be empty or only whitespace + return false if class_name.strip.empty? + + # Must match a safe pattern: alphanumeric + underscore, starting with uppercase + # Examples: LoggingLifecycle, MetricsLifecycle, CustomLifecycle + return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/) + + # Must not be a system/built-in class name + return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name) + + true + end + + # Validate that an instrument plugin class name is safe to load + # + # @param class_name [String] The class name to validate + # @return [Boolean] true if the class name is safe, false otherwise + def valid_instrument_class_name?(class_name) + # Must be a string + return false unless class_name.is_a?(String) + + # Must not be empty or only whitespace + return false if class_name.strip.empty? + + # Must match a safe pattern: alphanumeric + underscore, starting with uppercase + # Examples: CustomStats, CustomFailbot, DatadogStats + return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/) + + # Must not be a system/built-in class name + return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name) + + true + end end end end diff --git a/lib/hooks/core/stats.rb b/lib/hooks/core/stats.rb new file mode 100644 index 00000000..650a0dd1 --- /dev/null +++ b/lib/hooks/core/stats.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Hooks + module Core + # Global stats component for metrics reporting + # + # This is a stub implementation that does nothing by default. + # Users can replace this with their own implementation for services + # like DataDog, New Relic, etc. + class Stats + # Record a metric + # + # @param metric_name [String] Name of the metric + # @param value [Numeric] Value to record + # @param tags [Hash] Optional tags/labels for the metric + # @return [void] + def record(metric_name, value, tags = {}) + # Override in subclass for actual metrics reporting + end + + # Increment a counter + # + # @param metric_name [String] Name of the counter + # @param tags [Hash] Optional tags/labels for the metric + # @return [void] + def increment(metric_name, tags = {}) + # Override in subclass for actual metrics reporting + end + + # Record a timing metric + # + # @param metric_name [String] Name of the timing metric + # @param duration [Numeric] Duration in seconds + # @param tags [Hash] Optional tags/labels for the metric + # @return [void] + def timing(metric_name, duration, tags = {}) + # Override in subclass for actual metrics reporting + end + + # Measure execution time of a block + # + # @param metric_name [String] Name of the timing metric + # @param tags [Hash] Optional tags/labels for the metric + # @return [Object] Return value of the block + def measure(metric_name, tags = {}) + start_time = Time.now + result = yield + duration = Time.now - start_time + timing(metric_name, duration, tags) + result + end + end + end +end diff --git a/lib/hooks/plugins/auth/base.rb b/lib/hooks/plugins/auth/base.rb index 1b451dbe..a97d38db 100644 --- a/lib/hooks/plugins/auth/base.rb +++ b/lib/hooks/plugins/auth/base.rb @@ -2,6 +2,7 @@ require "rack/utils" require_relative "../../core/log" +require_relative "../../core/global_components" module Hooks module Plugins @@ -33,6 +34,30 @@ def self.log Hooks::Log.instance end + # Global stats component accessor + # @return [Hooks::Core::Stats] Stats instance for metrics reporting + # + # Provides access to the global stats component for reporting metrics + # to services like DataDog, New Relic, etc. + # + # @example Recording a metric in an inherited class + # stats.increment("auth.validation", { plugin: "hmac" }) + def self.stats + Hooks::Core::GlobalComponents.stats + end + + # Global failbot component accessor + # @return [Hooks::Core::Failbot] Failbot instance for error reporting + # + # Provides access to the global failbot component for reporting errors + # to services like Sentry, Rollbar, etc. + # + # @example Reporting an error in an inherited class + # failbot.report("Auth validation failed", { plugin: "hmac" }) + def self.failbot + Hooks::Core::GlobalComponents.failbot + end + # Retrieve the secret from the environment variable based on the key set in the configuration # # Note: This method is intended to be used by subclasses diff --git a/lib/hooks/plugins/handlers/base.rb b/lib/hooks/plugins/handlers/base.rb index e0dffc2a..6dac147c 100644 --- a/lib/hooks/plugins/handlers/base.rb +++ b/lib/hooks/plugins/handlers/base.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative "../../core/global_components" + module Hooks module Plugins module Handlers @@ -29,6 +31,30 @@ def call(payload:, headers:, config:) def log Hooks::Log.instance end + + # Global stats component accessor + # @return [Hooks::Core::Stats] Stats instance for metrics reporting + # + # Provides access to the global stats component for reporting metrics + # to services like DataDog, New Relic, etc. + # + # @example Recording a metric in an inherited class + # stats.increment("webhook.processed", { handler: "MyHandler" }) + def stats + Hooks::Core::GlobalComponents.stats + end + + # Global failbot component accessor + # @return [Hooks::Core::Failbot] Failbot instance for error reporting + # + # Provides access to the global failbot component for reporting errors + # to services like Sentry, Rollbar, etc. + # + # @example Reporting an error in an inherited class + # failbot.report("Something went wrong", { handler: "MyHandler" }) + def failbot + Hooks::Core::GlobalComponents.failbot + end end end end diff --git a/lib/hooks/plugins/instruments/failbot.rb b/lib/hooks/plugins/instruments/failbot.rb new file mode 100644 index 00000000..bf324423 --- /dev/null +++ b/lib/hooks/plugins/instruments/failbot.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative "failbot_base" + +module Hooks + module Plugins + module Instruments + # Default failbot instrument implementation + # + # This is a stub implementation that does nothing by default. + # Users can replace this with their own implementation for services + # like Sentry, Rollbar, etc. + class Failbot < FailbotBase + # Inherit from FailbotBase to provide a default implementation of the failbot instrument. + end + end + end +end diff --git a/lib/hooks/plugins/instruments/failbot_base.rb b/lib/hooks/plugins/instruments/failbot_base.rb new file mode 100644 index 00000000..986d3e8a --- /dev/null +++ b/lib/hooks/plugins/instruments/failbot_base.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Hooks + module Plugins + module Instruments + # Base class for all failbot instrument plugins + # + # All custom failbot implementations must inherit from this class and implement + # the required methods for error reporting. + class FailbotBase + # Short logger accessor for all subclasses + # @return [Hooks::Log] Logger instance + # + # Provides a convenient way for instruments to log messages without needing + # to reference the full Hooks::Log namespace. + # + # @example Logging debug info in an inherited class + # log.debug("Sending error to external service") + def log + Hooks::Log.instance + end + end + end + end +end diff --git a/lib/hooks/plugins/instruments/stats.rb b/lib/hooks/plugins/instruments/stats.rb new file mode 100644 index 00000000..058c8911 --- /dev/null +++ b/lib/hooks/plugins/instruments/stats.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative "stats_base" + +module Hooks + module Plugins + module Instruments + # Default stats instrument implementation + # + # This is a stub implementation that does nothing by default. + # Users can replace this with their own implementation for services + # like DataDog, New Relic, etc. + class Stats < StatsBase + # Inherit from StatsBase to provide a default implementation of the stats instrument. + end + end + end +end diff --git a/lib/hooks/plugins/instruments/stats_base.rb b/lib/hooks/plugins/instruments/stats_base.rb new file mode 100644 index 00000000..8f83cc77 --- /dev/null +++ b/lib/hooks/plugins/instruments/stats_base.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Hooks + module Plugins + module Instruments + # Base class for all stats instrument plugins + # + # All custom stats implementations must inherit from this class and implement + # the required methods for metrics reporting. + class StatsBase + # Short logger accessor for all subclasses + # @return [Hooks::Log] Logger instance + # + # Provides a convenient way for instruments to log messages without needing + # to reference the full Hooks::Log namespace. + # + # @example Logging an error in an inherited class + # log.error("Failed to send metric to external service") + def log + Hooks::Log.instance + end + end + end + end +end diff --git a/lib/hooks/plugins/lifecycle.rb b/lib/hooks/plugins/lifecycle.rb index c9b54f68..86778e21 100644 --- a/lib/hooks/plugins/lifecycle.rb +++ b/lib/hooks/plugins/lifecycle.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative "../core/global_components" + module Hooks module Plugins # Base class for global lifecycle plugins @@ -28,6 +30,42 @@ def on_response(env, response) def on_error(exception, env) # Override in subclass for error handling logic end + + # Short logger accessor for all subclasses + # @return [Hooks::Log] Logger instance + # + # Provides a convenient way for lifecycle plugins to log messages without needing + # to reference the full Hooks::Log namespace. + # + # @example Logging an error in an inherited class + # log.error("oh no an error occured") + def log + Hooks::Log.instance + end + + # Global stats component accessor + # @return [Hooks::Core::Stats] Stats instance for metrics reporting + # + # Provides access to the global stats component for reporting metrics + # to services like DataDog, New Relic, etc. + # + # @example Recording a metric in an inherited class + # stats.increment("lifecycle.request_processed") + def stats + Hooks::Core::GlobalComponents.stats + end + + # Global failbot component accessor + # @return [Hooks::Core::Failbot] Failbot instance for error reporting + # + # Provides access to the global failbot component for reporting errors + # to services like Sentry, Rollbar, etc. + # + # @example Reporting an error in an inherited class + # failbot.report("Lifecycle hook failed") + def failbot + Hooks::Core::GlobalComponents.failbot + end end end end diff --git a/lib/hooks/version.rb b/lib/hooks/version.rb index cf93033a..f02ec502 100644 --- a/lib/hooks/version.rb +++ b/lib/hooks/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Hooks - VERSION = "0.0.2" + VERSION = "0.0.3" end diff --git a/script/integration b/script/integration index 59fd26da..dc9c697d 100755 --- a/script/integration +++ b/script/integration @@ -4,7 +4,7 @@ set -e # prevent any kind of script failures source script/env "$@" -echo -e "${PURPLE}[#]${OFF} ${BLUE}Running acceptance tests${OFF}" +echo -e "${PURPLE}[#]${OFF} ${BLUE}Running integration tests${OFF}" bundle exec bin/rspec spec/integration && rspec_exit=$? || rspec_exit=$? echo "" echo "---------------------------------------" diff --git a/spec/acceptance/acceptance_tests.rb b/spec/acceptance/acceptance_tests.rb index 59311be2..a80246f4 100644 --- a/spec/acceptance/acceptance_tests.rb +++ b/spec/acceptance/acceptance_tests.rb @@ -180,5 +180,16 @@ expect(response.body).to include("authentication failed") end end + + describe "boomtown" do + it "sends a POST request to the /webhooks/boomtown endpoint and it explodes" do + payload = {}.to_json + headers = {} + response = http.post("/webhooks/boomtown", payload, headers) + + expect(response).to be_a(Net::HTTPInternalServerError) + expect(response.body).to include("Boomtown error occurred") + end + end end end diff --git a/spec/acceptance/config/endpoints/boomtown.yaml b/spec/acceptance/config/endpoints/boomtown.yaml new file mode 100644 index 00000000..af0bc05b --- /dev/null +++ b/spec/acceptance/config/endpoints/boomtown.yaml @@ -0,0 +1,2 @@ +path: /boomtown +handler: Boomtown diff --git a/spec/acceptance/config/hooks.yaml b/spec/acceptance/config/hooks.yaml index 0ff9c7f0..8da97489 100644 --- a/spec/acceptance/config/hooks.yaml +++ b/spec/acceptance/config/hooks.yaml @@ -1,6 +1,8 @@ # Sample configuration for Hooks webhook server handler_plugin_dir: ./spec/acceptance/plugins/handlers auth_plugin_dir: ./spec/acceptance/plugins/auth +lifecycle_plugin_dir: ./spec/acceptance/plugins/lifecycle +instruments_plugin_dir: ./spec/acceptance/plugins/instruments log_level: debug diff --git a/spec/acceptance/plugins/handlers/boomtown.rb b/spec/acceptance/plugins/handlers/boomtown.rb new file mode 100644 index 00000000..9d9d2b14 --- /dev/null +++ b/spec/acceptance/plugins/handlers/boomtown.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Boomtown < Hooks::Plugins::Handlers::Base + def call(payload:, headers:, config:) + raise StandardError, "Boomtown error occurred" + end +end diff --git a/spec/acceptance/plugins/instruments/failbot.rb b/spec/acceptance/plugins/instruments/failbot.rb new file mode 100644 index 00000000..dad609e3 --- /dev/null +++ b/spec/acceptance/plugins/instruments/failbot.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Failbot < Hooks::Plugins::Instruments::FailbotBase + def initialize + # just a demo implementation + end + + def oh_no + log.error("oh no, something went wrong!") + end +end diff --git a/spec/acceptance/plugins/instruments/stats.rb b/spec/acceptance/plugins/instruments/stats.rb new file mode 100644 index 00000000..20e39d5f --- /dev/null +++ b/spec/acceptance/plugins/instruments/stats.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Stats < Hooks::Plugins::Instruments::StatsBase + def initialize + # just a demo implementation + end + + def success + log.debug("response success recorded") + end +end diff --git a/spec/acceptance/plugins/lifecycle/request_method_logger.rb b/spec/acceptance/plugins/lifecycle/request_method_logger.rb new file mode 100644 index 00000000..1037f10a --- /dev/null +++ b/spec/acceptance/plugins/lifecycle/request_method_logger.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# This is mostly just an example lifecycle plugin that logs the request method as a demonstration +class RequestMethodLogger < Hooks::Plugins::Lifecycle + def on_request(env) + log.debug("on_request called with method: #{env['REQUEST_METHOD']}") + end + + def on_error(error, env) + log.error("on_error called with error: #{error.message}") + failbot.oh_no + end +end diff --git a/spec/acceptance/plugins/lifecycle/response_success_hook.rb b/spec/acceptance/plugins/lifecycle/response_success_hook.rb new file mode 100644 index 00000000..9b572c79 --- /dev/null +++ b/spec/acceptance/plugins/lifecycle/response_success_hook.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ResponseSuccessHook < Hooks::Plugins::Lifecycle + def on_response(env, response) + stats.success + end +end diff --git a/spec/integration/global_lifecycle_hooks_spec.rb b/spec/integration/global_lifecycle_hooks_spec.rb new file mode 100644 index 00000000..03b3c38f --- /dev/null +++ b/spec/integration/global_lifecycle_hooks_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require_relative "../../lib/hooks" +require "rack/test" +require "json" +require "fileutils" +require "tmpdir" +require "yaml" + +RSpec.describe "Global Lifecycle Hooks Integration" do + include Rack::Test::Methods + + def app + @app ||= Hooks.build(config: config_hash) + end + let(:temp_config_dir) { Dir.mktmpdir("config") } + let(:temp_lifecycle_dir) { Dir.mktmpdir("lifecycle_plugins") } + let(:temp_handler_dir) { Dir.mktmpdir("handler_plugins") } + let(:temp_endpoints_dir) { Dir.mktmpdir("endpoints") } + + let(:config_hash) do + { + lifecycle_plugin_dir: temp_lifecycle_dir, + handler_plugin_dir: temp_handler_dir, + endpoints_dir: temp_endpoints_dir, + log_level: "error", # Reduce noise in tests + root_path: "/webhooks", + health_path: "/health", + version_path: "/version", + environment: "development" + } + end + + before do + # Create a test lifecycle plugin + lifecycle_plugin_content = <<~RUBY + class TestingLifecycle < Hooks::Plugins::Lifecycle + @@events = [] + + def self.events + @@events + end + + def self.clear_events + @@events = [] + end + + def on_request(env) + @@events << { + type: :request, + path: env["PATH_INFO"], + method: env["REQUEST_METHOD"], + handler: env["hooks.handler"] + } + end + + def on_response(env, response) + @@events << { + type: :response, + path: env["PATH_INFO"], + response: response, + handler: env["hooks.handler"] + } + end + + def on_error(exception, env) + @@events << { + type: :error, + path: env["PATH_INFO"], + error: exception.class.name, + message: exception.message, + handler: env["hooks.handler"] + } + end + end + RUBY + File.write(File.join(temp_lifecycle_dir, "testing_lifecycle.rb"), lifecycle_plugin_content) + + # Create a test handler plugin that uses stats and failbot + handler_plugin_content = <<~RUBY + class IntegrationTestHandler < Hooks::Plugins::Handlers::Base + def call(payload:, headers:, config:) + stats.increment("handler.called", { handler: "IntegrationTestHandler" }) + + if payload&.dig("should_fail") + failbot.report("Intentional test failure", { payload: }) + raise StandardError, "Test failure requested" + end + + { + status: "success", + handler: "IntegrationTestHandler", + timestamp: Time.now.iso8601, + payload_received: !payload.nil? + } + end + end + RUBY + File.write(File.join(temp_handler_dir, "integration_test_handler.rb"), handler_plugin_content) + + # Create an endpoint configuration + endpoint_config_content = <<~YAML + path: /integration-test + handler: IntegrationTestHandler + YAML + File.write(File.join(temp_endpoints_dir, "integration_test.yml"), endpoint_config_content) + end + + after do + FileUtils.rm_rf(temp_config_dir) + FileUtils.rm_rf(temp_lifecycle_dir) + FileUtils.rm_rf(temp_handler_dir) + FileUtils.rm_rf(temp_endpoints_dir) + + # Clean up any test classes + Object.send(:remove_const, :TestingLifecycle) if defined?(TestingLifecycle) + Object.send(:remove_const, :IntegrationTestHandler) if defined?(IntegrationTestHandler) + end + + it "integrates lifecycle hooks with handler execution and global components" do + # Set up custom stats and failbot to capture events + captured_stats = [] + captured_failbot = [] + + custom_stats = Class.new(Hooks::Core::Stats) do + def initialize(collector) + @collector = collector + end + + def increment(metric_name, tags = {}) + @collector << { action: :increment, metric: metric_name, tags: } + end + end + + custom_failbot = Class.new(Hooks::Core::Failbot) do + def initialize(collector) + @collector = collector + end + + def report(error_or_message, context = {}) + @collector << { action: :report, message: error_or_message, context: } + end + end + + # Ensure the app is built and plugins are loaded before accessing global components + app + + original_stats = Hooks::Core::GlobalComponents.stats + original_failbot = Hooks::Core::GlobalComponents.failbot + + begin + Hooks::Core::GlobalComponents.stats = custom_stats.new(captured_stats) + Hooks::Core::GlobalComponents.failbot = custom_failbot.new(captured_failbot) + + # Force reload to ensure our plugin is loaded + load File.join(temp_lifecycle_dir, "testing_lifecycle.rb") + + # Verify the lifecycle plugin was loaded + expect(defined?(TestingLifecycle)).to be_truthy + TestingLifecycle.clear_events + + # Test successful request + post "/webhooks/integration-test", { "test" => "data" }.to_json, { "CONTENT_TYPE" => "application/json" } + + expect(last_response.status).to eq(200) + response_data = JSON.parse(last_response.body) + expect(response_data["status"]).to eq("success") + expect(response_data["handler"]).to eq("IntegrationTestHandler") + + # Check that stats were recorded + expect(captured_stats).to include( + { action: :increment, metric: "handler.called", tags: { handler: "IntegrationTestHandler" } } + ) + + # Check that lifecycle plugins are available + expect(Hooks::Core::PluginLoader.lifecycle_plugins).not_to be_empty + + ensure + Hooks::Core::GlobalComponents.stats = original_stats + Hooks::Core::GlobalComponents.failbot = original_failbot + end + end +end diff --git a/spec/unit/lib/hooks/core/failbot_spec.rb b/spec/unit/lib/hooks/core/failbot_spec.rb new file mode 100644 index 00000000..b21147cd --- /dev/null +++ b/spec/unit/lib/hooks/core/failbot_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +describe Hooks::Core::Failbot do + let(:failbot) { described_class.new } + + describe "#report" do + it "can be called with string message" do + expect { failbot.report("Test error message") }.not_to raise_error + end + + it "can be called with exception" do + exception = StandardError.new("Test exception") + expect { failbot.report(exception) }.not_to raise_error + end + + it "accepts context parameter" do + expect { failbot.report("Error", { handler: "TestHandler" }) }.not_to raise_error + end + + it "can be overridden in subclasses" do + custom_failbot_class = Class.new(described_class) do + def initialize + @reported_errors = [] + end + + def report(error_or_message, context = {}) + @reported_errors << { error: error_or_message, context: } + end + + attr_reader :reported_errors + end + + custom_failbot = custom_failbot_class.new + custom_failbot.report("Test error", { test: true }) + + expect(custom_failbot.reported_errors).to eq([ + { error: "Test error", context: { test: true } } + ]) + end + end + + describe "#critical" do + it "can be called with string message" do + expect { failbot.critical("Critical error") }.not_to raise_error + end + + it "can be called with exception" do + exception = StandardError.new("Critical exception") + expect { failbot.critical(exception) }.not_to raise_error + end + + it "accepts context parameter" do + expect { failbot.critical("Critical", { handler: "TestHandler" }) }.not_to raise_error + end + end + + describe "#warning" do + it "can be called with message" do + expect { failbot.warning("Warning message") }.not_to raise_error + end + + it "accepts context parameter" do + expect { failbot.warning("Warning", { handler: "TestHandler" }) }.not_to raise_error + end + end + + describe "#capture" do + it "returns block result when no exception" do + result = failbot.capture { "success" } + expect(result).to eq("success") + end + + it "reports and re-raises exceptions" do + capturing_failbot_class = Class.new(described_class) do + def initialize + @captured_errors = [] + end + + def report(error_or_message, context = {}) + @captured_errors << { error: error_or_message, context: } + end + + attr_reader :captured_errors + end + + capturing_failbot = capturing_failbot_class.new + test_error = StandardError.new("Test error") + + expect { + capturing_failbot.capture({ test: true }) { raise test_error } + }.to raise_error(test_error) + + expect(capturing_failbot.captured_errors).to eq([ + { error: test_error, context: { test: true } } + ]) + end + end +end diff --git a/spec/unit/lib/hooks/core/global_components_spec.rb b/spec/unit/lib/hooks/core/global_components_spec.rb new file mode 100644 index 00000000..e30aeea8 --- /dev/null +++ b/spec/unit/lib/hooks/core/global_components_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +describe Hooks::Core::GlobalComponents do + describe ".stats" do + it "returns a Stats instance by default" do + expect(described_class.stats).to be_a(Hooks::Plugins::Instruments::Stats) + end + + it "can be set to a custom stats instance" do + custom_stats = double("CustomStats") + original_stats = described_class.stats + + described_class.stats = custom_stats + expect(described_class.stats).to eq(custom_stats) + + # Restore original for other tests + described_class.stats = original_stats + end + end + + describe ".failbot" do + it "returns a Failbot instance by default" do + expect(described_class.failbot).to be_a(Hooks::Plugins::Instruments::Failbot) + end + + it "can be set to a custom failbot instance" do + custom_failbot = double("CustomFailbot") + original_failbot = described_class.failbot + + described_class.failbot = custom_failbot + expect(described_class.failbot).to eq(custom_failbot) + + # Restore original for other tests + described_class.failbot = original_failbot + end + end + + describe ".reset" do + it "resets both components to default instances" do + # Set custom instances + custom_stats = double("CustomStats") + custom_failbot = double("CustomFailbot") + described_class.stats = custom_stats + described_class.failbot = custom_failbot + + # Reset + described_class.reset + + # Verify they are back to default instances + expect(described_class.stats).to be_a(Hooks::Plugins::Instruments::Stats) + expect(described_class.failbot).to be_a(Hooks::Plugins::Instruments::Failbot) + end + end +end diff --git a/spec/unit/lib/hooks/core/plugin_loader_instruments_spec.rb b/spec/unit/lib/hooks/core/plugin_loader_instruments_spec.rb new file mode 100644 index 00000000..25b08f83 --- /dev/null +++ b/spec/unit/lib/hooks/core/plugin_loader_instruments_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +describe Hooks::Core::PluginLoader do + describe "instrument plugins" do + let(:test_plugin_dir) { "/tmp/test_instrument_plugins" } + + before do + # Clear plugins before each test + described_class.clear_plugins + + # Create test plugin directory + FileUtils.mkdir_p(test_plugin_dir) + + # Stub built-in plugins + allow(described_class).to receive(:load_builtin_plugins) + end + + after do + # Clean up test directory + FileUtils.rm_rf(test_plugin_dir) if Dir.exist?(test_plugin_dir) + + # Reset to defaults + described_class.clear_plugins + described_class.load_all_plugins({ + auth_plugin_dir: nil, + handler_plugin_dir: nil, + lifecycle_plugin_dir: nil, + instruments_plugin_dir: nil + }) + end + + describe ".load_custom_instrument_plugins" do + it "loads custom stats instrument plugins" do + # Create a custom stats plugin file + custom_stats_content = <<~RUBY + class CustomStats < Hooks::Plugins::Instruments::StatsBase + def record(metric_name, value, tags = {}) + # Custom implementation + end + + def increment(metric_name, tags = {}) + # Custom implementation + end + + def timing(metric_name, duration, tags = {}) + # Custom implementation + end + end + RUBY + + File.write(File.join(test_plugin_dir, "custom_stats.rb"), custom_stats_content) + + expect { described_class.send(:load_custom_instrument_plugins, test_plugin_dir) }.not_to raise_error + + # Verify the stats plugin was loaded + expect(described_class.instrument_plugins[:stats]).to be_a(CustomStats) + end + + it "loads custom failbot instrument plugins" do + # Create a custom failbot plugin file + custom_failbot_content = <<~RUBY + class CustomFailbot < Hooks::Plugins::Instruments::FailbotBase + def report(error_or_message, context = {}) + # Custom implementation + end + + def critical(error_or_message, context = {}) + # Custom implementation + end + + def warning(message, context = {}) + # Custom implementation + end + end + RUBY + + File.write(File.join(test_plugin_dir, "custom_failbot.rb"), custom_failbot_content) + + expect { described_class.send(:load_custom_instrument_plugins, test_plugin_dir) }.not_to raise_error + + # Verify the failbot plugin was loaded + expect(described_class.instrument_plugins[:failbot]).to be_a(CustomFailbot) + end + + it "raises error for invalid inheritance" do + # Create an invalid plugin file that doesn't inherit from base classes + invalid_content = <<~RUBY + class InvalidInstrument + def some_method + # This doesn't inherit from the right base class + end + end + RUBY + + File.write(File.join(test_plugin_dir, "invalid_instrument.rb"), invalid_content) + + expect do + described_class.send(:load_custom_instrument_plugins, test_plugin_dir) + end.to raise_error(StandardError, /must inherit from StatsBase or FailbotBase/) + end + + it "validates class names for security" do + malicious_content = <<~RUBY + class File < Hooks::Plugins::Instruments::StatsBase + def record(metric_name, value, tags = {}) + # Malicious implementation + end + end + RUBY + + File.write(File.join(test_plugin_dir, "file.rb"), malicious_content) + + expect do + described_class.send(:load_custom_instrument_plugins, test_plugin_dir) + end.to raise_error(StandardError, /Invalid instrument plugin class name/) + end + end + + describe ".get_instrument_plugin" do + before do + # Load default instruments + described_class.send(:load_default_instruments) + end + + it "returns the stats instrument" do + stats = described_class.get_instrument_plugin(:stats) + expect(stats).to be_a(Hooks::Plugins::Instruments::Stats) + end + + it "returns the failbot instrument" do + failbot = described_class.get_instrument_plugin(:failbot) + expect(failbot).to be_a(Hooks::Plugins::Instruments::Failbot) + end + + it "raises error for unknown instrument type" do + expect do + described_class.get_instrument_plugin(:unknown) + end.to raise_error(StandardError, "Instrument plugin 'unknown' not found") + end + end + + describe ".load_default_instruments" do + it "loads default stats and failbot instances" do + described_class.send(:load_default_instruments) + + expect(described_class.instrument_plugins[:stats]).to be_a(Hooks::Plugins::Instruments::Stats) + expect(described_class.instrument_plugins[:failbot]).to be_a(Hooks::Plugins::Instruments::Failbot) + end + + it "doesn't override custom instruments if already loaded" do + # Create custom stats + custom_stats_content = <<~RUBY + class MyCustomStats < Hooks::Plugins::Instruments::StatsBase + def record(metric_name, value, tags = {}) + # Custom implementation + end + end + RUBY + + File.write(File.join(test_plugin_dir, "my_custom_stats.rb"), custom_stats_content) + described_class.send(:load_custom_instrument_plugins, test_plugin_dir) + + # Load defaults + described_class.send(:load_default_instruments) + + # Should still have custom stats, but default failbot + expect(described_class.instrument_plugins[:stats]).to be_a(MyCustomStats) + expect(described_class.instrument_plugins[:failbot]).to be_a(Hooks::Plugins::Instruments::Failbot) + end + end + + describe ".valid_instrument_class_name?" do + it "accepts valid class names" do + expect(described_class.send(:valid_instrument_class_name?, "CustomStats")).to be true + expect(described_class.send(:valid_instrument_class_name?, "MyCustomFailbot")).to be true + expect(described_class.send(:valid_instrument_class_name?, "DatadogStats")).to be true + end + + it "rejects invalid class names" do + expect(described_class.send(:valid_instrument_class_name?, "")).to be false + expect(described_class.send(:valid_instrument_class_name?, "lowercaseClass")).to be false + expect(described_class.send(:valid_instrument_class_name?, "Class-With-Dashes")).to be false + expect(described_class.send(:valid_instrument_class_name?, "File")).to be false + end + end + end +end diff --git a/spec/unit/lib/hooks/core/plugin_loader_lifecycle_spec.rb b/spec/unit/lib/hooks/core/plugin_loader_lifecycle_spec.rb new file mode 100644 index 00000000..2f54d8c8 --- /dev/null +++ b/spec/unit/lib/hooks/core/plugin_loader_lifecycle_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +describe Hooks::Core::PluginLoader do + # Reset plugin state between tests + around do |example| + original_auth = described_class.auth_plugins.dup + original_handler = described_class.handler_plugins.dup + original_lifecycle = described_class.lifecycle_plugins.dup + + example.run + + # Restore original state + described_class.instance_variable_set(:@auth_plugins, original_auth) + described_class.instance_variable_set(:@handler_plugins, original_handler) + described_class.instance_variable_set(:@lifecycle_plugins, original_lifecycle) + end + + describe ".lifecycle_plugins" do + it "returns an array" do + expect(described_class.lifecycle_plugins).to be_an(Array) + end + + it "starts empty" do + described_class.clear_plugins + expect(described_class.lifecycle_plugins).to be_empty + end + end + + describe ".load_all_plugins" do + it "loads lifecycle plugins from directory" do + # Create a temporary lifecycle plugin file + temp_dir = Dir.mktmpdir("lifecycle_plugins") + plugin_file = File.join(temp_dir, "test_lifecycle.rb") + + File.write(plugin_file, <<~RUBY) + class TestLifecycle < Hooks::Plugins::Lifecycle + def on_request(env) + # Test implementation + end + end + RUBY + + config = { lifecycle_plugin_dir: temp_dir } + + expect { + described_class.load_all_plugins(config) + }.not_to raise_error + + expect(described_class.lifecycle_plugins).not_to be_empty + expect(described_class.lifecycle_plugins.first).to be_a(TestLifecycle) + + # Cleanup + FileUtils.rm_rf(temp_dir) + end + + it "handles missing lifecycle plugin directory gracefully" do + config = { lifecycle_plugin_dir: "/nonexistent/directory" } + + expect { + described_class.load_all_plugins(config) + }.not_to raise_error + + # Should not affect existing plugins + expect(described_class.auth_plugins).not_to be_empty + expect(described_class.handler_plugins).not_to be_empty + end + + it "handles nil lifecycle plugin directory gracefully" do + config = { lifecycle_plugin_dir: nil } + + expect { + described_class.load_all_plugins(config) + }.not_to raise_error + end + end + + describe ".clear_plugins" do + it "clears lifecycle plugins" do + # Simulate having some lifecycle plugins + described_class.instance_variable_set(:@lifecycle_plugins, [double("Plugin")]) + + described_class.clear_plugins + + expect(described_class.lifecycle_plugins).to be_empty + end + end + + describe ".log_loaded_plugins" do + it "includes lifecycle plugin count in logs" do + # Mock a logger that captures messages + logger_double = double("Logger") + allow(Hooks::Log).to receive(:instance).and_return(logger_double) + allow(logger_double).to receive(:class).and_return(double(name: "TestLogger")) + + expect(logger_double).to receive(:info).with(/Loaded \d+ auth plugins/) + expect(logger_double).to receive(:info).with(/Loaded \d+ handler plugins/) + expect(logger_double).to receive(:info).with(/Loaded \d+ lifecycle plugins/) + expect(logger_double).to receive(:info).with(/Loaded instruments:/) + + described_class.send(:log_loaded_plugins) + end + end + + describe "lifecycle plugin validation" do + describe ".valid_lifecycle_class_name?" do + it "accepts valid class names" do + expect(described_class.send(:valid_lifecycle_class_name?, "TestLifecycle")).to be true + expect(described_class.send(:valid_lifecycle_class_name?, "LoggingLifecycle")).to be true + expect(described_class.send(:valid_lifecycle_class_name?, "Custom123Lifecycle")).to be true + end + + it "rejects invalid class names" do + expect(described_class.send(:valid_lifecycle_class_name?, "")).to be false + expect(described_class.send(:valid_lifecycle_class_name?, "lowercase")).to be false + expect(described_class.send(:valid_lifecycle_class_name?, "123Invalid")).to be false + expect(described_class.send(:valid_lifecycle_class_name?, nil)).to be false + expect(described_class.send(:valid_lifecycle_class_name?, "Class-WithDash")).to be false + end + + it "rejects dangerous class names" do + # We can't mock the frozen constant, so we'll test with a name we know is in the list + expect(described_class.send(:valid_lifecycle_class_name?, "Object")).to be false + expect(described_class.send(:valid_lifecycle_class_name?, "Class")).to be false + expect(described_class.send(:valid_lifecycle_class_name?, "Module")).to be false + end + end + end + + describe "lifecycle plugin loading" do + it "validates plugin file paths for security" do + temp_dir = Dir.mktmpdir("lifecycle_plugins") + outside_file = File.join(Dir.tmpdir, "evil_plugin.rb") + + File.write(outside_file, "class EvilPlugin; end") + + expect { + described_class.send(:load_custom_lifecycle_plugin, outside_file, temp_dir) + }.to raise_error(SecurityError, /outside of lifecycle plugin directory/) + + # Cleanup + FileUtils.rm_rf(temp_dir) + File.delete(outside_file) if File.exist?(outside_file) + end + + it "validates plugin inheritance" do + temp_dir = Dir.mktmpdir("lifecycle_plugins") + plugin_file = File.join(temp_dir, "invalid_lifecycle.rb") + + File.write(plugin_file, <<~RUBY) + class InvalidLifecycle + # Does not inherit from Hooks::Plugins::Lifecycle + end + RUBY + + expect { + described_class.send(:load_custom_lifecycle_plugin, plugin_file, temp_dir) + }.to raise_error(StandardError, /must inherit from Hooks::Plugins::Lifecycle/) + + # Cleanup + FileUtils.rm_rf(temp_dir) + end + end +end diff --git a/spec/unit/lib/hooks/core/stats_spec.rb b/spec/unit/lib/hooks/core/stats_spec.rb new file mode 100644 index 00000000..9ba76680 --- /dev/null +++ b/spec/unit/lib/hooks/core/stats_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +describe Hooks::Core::Stats do + let(:stats) { described_class.new } + + describe "#record" do + it "can be called without error" do + expect { stats.record("test.metric", 123) }.not_to raise_error + end + + it "accepts tags parameter" do + expect { stats.record("test.metric", 456, { handler: "TestHandler" }) }.not_to raise_error + end + + it "can be overridden in subclasses" do + custom_stats_class = Class.new(described_class) do + def initialize + @recorded_metrics = [] + end + + def record(metric_name, value, tags = {}) + @recorded_metrics << { metric: metric_name, value:, tags: } + end + + attr_reader :recorded_metrics + end + + custom_stats = custom_stats_class.new + custom_stats.record("test.metric", 789, { test: true }) + + expect(custom_stats.recorded_metrics).to eq([ + { metric: "test.metric", value: 789, tags: { test: true } } + ]) + end + end + + describe "#increment" do + it "can be called without error" do + expect { stats.increment("test.counter") }.not_to raise_error + end + + it "accepts tags parameter" do + expect { stats.increment("test.counter", { handler: "TestHandler" }) }.not_to raise_error + end + end + + describe "#timing" do + it "can be called without error" do + expect { stats.timing("test.timer", 1.5) }.not_to raise_error + end + + it "accepts tags parameter" do + expect { stats.timing("test.timer", 2.3, { handler: "TestHandler" }) }.not_to raise_error + end + end + + describe "#measure" do + it "can measure block execution" do + result = stats.measure("test.execution") { "block result" } + expect(result).to eq("block result") + end + + it "accepts tags parameter" do + result = stats.measure("test.execution", { handler: "TestHandler" }) { 42 } + expect(result).to eq(42) + end + + it "measures timing in subclasses" do + timed_stats_class = Class.new(described_class) do + def initialize + @timings = [] + end + + def timing(metric_name, duration, tags = {}) + @timings << { metric: metric_name, duration:, tags: } + end + + attr_reader :timings + end + + timed_stats = timed_stats_class.new + result = timed_stats.measure("test.block") { "measured" } + + expect(result).to eq("measured") + expect(timed_stats.timings.size).to eq(1) + expect(timed_stats.timings[0][:metric]).to eq("test.block") + expect(timed_stats.timings[0][:duration]).to be_a(Numeric) + expect(timed_stats.timings[0][:duration]).to be >= 0 + end + end +end diff --git a/spec/unit/lib/hooks/handlers/base_spec.rb b/spec/unit/lib/hooks/handlers/base_spec.rb index 717f1356..a433f40d 100644 --- a/spec/unit/lib/hooks/handlers/base_spec.rb +++ b/spec/unit/lib/hooks/handlers/base_spec.rb @@ -145,7 +145,7 @@ def call(payload:, headers:, config:) describe "documentation compliance" do it "has the expected public interface" do - expect(described_class.instance_methods(false)).to include(:call) + expect(described_class.instance_methods(false)).to include(:call, :log, :stats, :failbot) end it "call method accepts the documented parameters" do @@ -155,4 +155,92 @@ def call(payload:, headers:, config:) expect(method.parameters).to include([:keyreq, :config]) end end + + describe "global component access" do + let(:handler) { described_class.new } + + describe "#log" do + it "provides access to global log" do + expect(handler.log).to be(Hooks::Log.instance) + end + end + + describe "#stats" do + it "provides access to global stats" do + expect(handler.stats).to be_a(Hooks::Plugins::Instruments::Stats) + expect(handler.stats).to eq(Hooks::Core::GlobalComponents.stats) + end + end + + describe "#failbot" do + it "provides access to global failbot" do + expect(handler.failbot).to be_a(Hooks::Plugins::Instruments::Failbot) + expect(handler.failbot).to eq(Hooks::Core::GlobalComponents.failbot) + end + end + + it "allows stats and failbot usage in subclasses" do + test_handler_class = Class.new(described_class) do + def call(payload:, headers:, config:) + stats.increment("handler.called", { handler: "TestHandler" }) + + if payload.nil? + failbot.report("Payload is nil", { handler: "TestHandler" }) + end + + { status: "processed" } + end + end + + # Create custom components for testing + collected_data = [] + + custom_stats = Class.new(Hooks::Core::Stats) do + def initialize(collector) + @collector = collector + end + + def increment(metric_name, tags = {}) + @collector << { type: :stats, action: :increment, metric: metric_name, tags: } + end + end + + custom_failbot = Class.new(Hooks::Core::Failbot) do + def initialize(collector) + @collector = collector + end + + def report(error_or_message, context = {}) + @collector << { type: :failbot, action: :report, message: error_or_message, context: } + end + end + + original_stats = Hooks::Core::GlobalComponents.stats + original_failbot = Hooks::Core::GlobalComponents.failbot + + begin + Hooks::Core::GlobalComponents.stats = custom_stats.new(collected_data) + Hooks::Core::GlobalComponents.failbot = custom_failbot.new(collected_data) + + handler = test_handler_class.new + + # Test with non-nil payload + handler.call(payload: { "test" => "data" }, headers: {}, config: {}) + expect(collected_data).to include( + { type: :stats, action: :increment, metric: "handler.called", tags: { handler: "TestHandler" } } + ) + + # Test with nil payload + collected_data.clear + handler.call(payload: nil, headers: {}, config: {}) + expect(collected_data).to match_array([ + { type: :stats, action: :increment, metric: "handler.called", tags: { handler: "TestHandler" } }, + { type: :failbot, action: :report, message: "Payload is nil", context: { handler: "TestHandler" } } + ]) + ensure + Hooks::Core::GlobalComponents.stats = original_stats + Hooks::Core::GlobalComponents.failbot = original_failbot + end + end + end end diff --git a/spec/unit/lib/hooks/plugins/auth/base_spec.rb b/spec/unit/lib/hooks/plugins/auth/base_spec.rb index 6a607e25..a9a38ea7 100644 --- a/spec/unit/lib/hooks/plugins/auth/base_spec.rb +++ b/spec/unit/lib/hooks/plugins/auth/base_spec.rb @@ -206,7 +206,7 @@ def self.valid?(payload:, headers:, config:) describe "documentation compliance" do it "has the expected public interface" do - expect(described_class.methods(false)).to include(:valid?) + expect(described_class.methods(false)).to include(:valid?, :log, :stats, :failbot, :fetch_secret) end it "valid? method accepts the documented parameters" do @@ -216,4 +216,100 @@ def self.valid?(payload:, headers:, config:) expect(method.parameters).to include([:keyreq, :config]) end end + + describe "global component access" do + describe ".log" do + it "provides access to global log" do + expect(described_class.log).to be(Hooks::Log.instance) + end + end + + describe ".stats" do + it "provides access to global stats" do + expect(described_class.stats).to be_a(Hooks::Plugins::Instruments::Stats) + expect(described_class.stats).to eq(Hooks::Core::GlobalComponents.stats) + end + end + + describe ".failbot" do + it "provides access to global failbot" do + expect(described_class.failbot).to be_a(Hooks::Plugins::Instruments::Failbot) + expect(described_class.failbot).to eq(Hooks::Core::GlobalComponents.failbot) + end + end + + it "allows stats and failbot usage in subclasses" do + test_auth_class = Class.new(described_class) do + def self.valid?(payload:, headers:, config:) + stats.increment("auth.validation", { plugin: "TestAuth" }) + + # Simulate validation failure + if headers["Authorization"].nil? + failbot.report("Missing authorization header", { plugin: "TestAuth" }) + return false + end + + true + end + end + + # Create custom components for testing + collected_data = [] + + custom_stats = Class.new(Hooks::Core::Stats) do + def initialize(collector) + @collector = collector + end + + def increment(metric_name, tags = {}) + @collector << { type: :stats, action: :increment, metric: metric_name, tags: } + end + end + + custom_failbot = Class.new(Hooks::Core::Failbot) do + def initialize(collector) + @collector = collector + end + + def report(error_or_message, context = {}) + @collector << { type: :failbot, action: :report, message: error_or_message, context: } + end + end + + original_stats = Hooks::Core::GlobalComponents.stats + original_failbot = Hooks::Core::GlobalComponents.failbot + + begin + Hooks::Core::GlobalComponents.stats = custom_stats.new(collected_data) + Hooks::Core::GlobalComponents.failbot = custom_failbot.new(collected_data) + + # Test with authorization header (should pass) + result = test_auth_class.valid?( + payload: '{"test": "data"}', + headers: { "Authorization" => "Bearer token" }, + config: {} + ) + expect(result).to be true + expect(collected_data).to include( + { type: :stats, action: :increment, metric: "auth.validation", tags: { plugin: "TestAuth" } } + ) + + # Test without authorization header (should fail and report error) + collected_data.clear + result = test_auth_class.valid?( + payload: '{"test": "data"}', + headers: {}, + config: {} + ) + expect(result).to be false + expect(collected_data).to match_array([ + { type: :stats, action: :increment, metric: "auth.validation", tags: { plugin: "TestAuth" } }, + { type: :failbot, action: :report, message: "Missing authorization header", context: { plugin: "TestAuth" } } + ]) + ensure + Hooks::Core::GlobalComponents.stats = original_stats + Hooks::Core::GlobalComponents.failbot = original_failbot + end + end + end end diff --git a/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb b/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb new file mode 100644 index 00000000..42aaf633 --- /dev/null +++ b/spec/unit/lib/hooks/plugins/instruments/failbot_base_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +describe Hooks::Plugins::Instruments::FailbotBase do + let(:failbot) { described_class.new } + + describe "#log" do + it "provides access to the global logger" do + allow(Hooks::Log).to receive(:instance).and_return(double("Logger")) + expect(failbot.log).to eq(Hooks::Log.instance) + end + end +end diff --git a/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb b/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb new file mode 100644 index 00000000..3c8e9e09 --- /dev/null +++ b/spec/unit/lib/hooks/plugins/instruments/failbot_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +describe Hooks::Plugins::Instruments::Failbot do + let(:failbot) { described_class.new } + + it "inherits from FailbotBase" do + expect(described_class).to be < Hooks::Plugins::Instruments::FailbotBase + expect(failbot).to be_a(Hooks::Plugins::Instruments::FailbotBase) + end +end diff --git a/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb b/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb new file mode 100644 index 00000000..7294aaee --- /dev/null +++ b/spec/unit/lib/hooks/plugins/instruments/stats_base_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +describe Hooks::Plugins::Instruments::StatsBase do + let(:stats) { described_class.new } + + describe "#log" do + it "provides access to the global logger" do + allow(Hooks::Log).to receive(:instance).and_return(double("Logger")) + expect(stats.log).to eq(Hooks::Log.instance) + end + end +end diff --git a/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb b/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb new file mode 100644 index 00000000..49545429 --- /dev/null +++ b/spec/unit/lib/hooks/plugins/instruments/stats_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +describe Hooks::Plugins::Instruments::Stats do + let(:stats) { described_class.new } + + it "inherits from StatsBase" do + expect(described_class).to be < Hooks::Plugins::Instruments::StatsBase + expect(stats).to be_a(Hooks::Plugins::Instruments::StatsBase) + end +end diff --git a/spec/unit/lib/hooks/plugins/lifecycle_spec.rb b/spec/unit/lib/hooks/plugins/lifecycle_spec.rb index 2bd42872..07b7379b 100644 --- a/spec/unit/lib/hooks/plugins/lifecycle_spec.rb +++ b/spec/unit/lib/hooks/plugins/lifecycle_spec.rb @@ -244,4 +244,81 @@ def on_error(exception, env) ]) end end + + describe "global component access" do + describe "#stats" do + it "provides access to global stats" do + expect(plugin.stats).to be_a(Hooks::Plugins::Instruments::Stats) + expect(plugin.stats).to eq(Hooks::Core::GlobalComponents.stats) + end + end + + describe "#failbot" do + it "provides access to global failbot" do + expect(plugin.failbot).to be_a(Hooks::Plugins::Instruments::Failbot) + expect(plugin.failbot).to eq(Hooks::Core::GlobalComponents.failbot) + end + end + + it "allows stats and failbot usage in subclasses" do + metrics_plugin_class = Class.new(described_class) do + def initialize + @recorded_metrics = [] + @reported_errors = [] + end + + def on_request(env) + stats.increment("lifecycle.request", { path: env["PATH_INFO"] }) + end + + def on_error(exception, env) + failbot.report(exception, { path: env["PATH_INFO"] }) + end + + attr_reader :recorded_metrics, :reported_errors + end + + # Create custom stats and failbot for testing + custom_stats = Class.new(Hooks::Core::Stats) do + def initialize(collector) + @collector = collector + end + + def increment(metric_name, tags = {}) + @collector << { type: :increment, metric: metric_name, tags: } + end + end + + custom_failbot = Class.new(Hooks::Core::Failbot) do + def initialize(collector) + @collector = collector + end + + def report(error_or_message, context = {}) + @collector << { type: :report, error: error_or_message, context: } + end + end + + collected_data = [] + original_stats = Hooks::Core::GlobalComponents.stats + original_failbot = Hooks::Core::GlobalComponents.failbot + + begin + Hooks::Core::GlobalComponents.stats = custom_stats.new(collected_data) + Hooks::Core::GlobalComponents.failbot = custom_failbot.new(collected_data) + + plugin = metrics_plugin_class.new + plugin.on_request(env) + plugin.on_error(exception, env) + + expect(collected_data).to match_array([ + { type: :increment, metric: "lifecycle.request", tags: { path: "/webhook" } }, + { type: :report, error: exception, context: { path: "/webhook" } } + ]) + ensure + Hooks::Core::GlobalComponents.stats = original_stats + Hooks::Core::GlobalComponents.failbot = original_failbot + end + end + end end diff --git a/spec/unit/required_coverage_percentage.rb b/spec/unit/required_coverage_percentage.rb index 13a026cf..d11ab8af 100644 --- a/spec/unit/required_coverage_percentage.rb +++ b/spec/unit/required_coverage_percentage.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -REQUIRED_COVERAGE_PERCENTAGE = 85 +REQUIRED_COVERAGE_PERCENTAGE = 90 diff --git a/spec/unit/spec_helper.rb b/spec/unit/spec_helper.rb index 3d7d4e13..3abf307a 100644 --- a/spec/unit/spec_helper.rb +++ b/spec/unit/spec_helper.rb @@ -39,7 +39,9 @@ # Initialize plugins for tests Hooks::Core::PluginLoader.load_all_plugins({ auth_plugin_dir: nil, - handler_plugin_dir: nil + handler_plugin_dir: nil, + lifecycle_plugin_dir: nil, + instruments_plugin_dir: nil }) RSpec.configure do |config| diff --git a/vendor/cache/grape-swagger-2.1.2.gem b/vendor/cache/grape-swagger-2.1.2.gem deleted file mode 100644 index 0d39c0fa..00000000 Binary files a/vendor/cache/grape-swagger-2.1.2.gem and /dev/null differ