diff --git a/Gemfile.lock b/Gemfile.lock index 4f88962..e8b95c2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - hooks-ruby (0.5.1) + hooks-ruby (0.6.0) dry-schema (~> 1.14, >= 1.14.1) grape (~> 2.3) puma (~> 6.6) diff --git a/config.ru b/config.ru index 25bc151..a1ece1f 100644 --- a/config.ru +++ b/config.ru @@ -1,6 +1,36 @@ # frozen_string_literal: true +# An example file that is a part of the acceptance tests for the Hooks framework. +# This can be used as a reference point as it is a working implementation of a Hooks application. + require_relative "lib/hooks" -app = Hooks.build(config: "./spec/acceptance/config/hooks.yaml") +# Example publisher class that simulates publishing messages +# This class could be literally anything and it is used here to demonstrate how to pass in custom kwargs... +# ... to the Hooks application which later become available in Handlers throughout the application. +class ExamplePublisher + def initialize + @published_messages = [] + end + + def call(data) + @published_messages << data + puts "Published: #{data.inspect}" + "Message published successfully" + end + + def publish(data) + call(data) + end + + def messages + @published_messages + end +end + +# Create publisher instance +publisher = ExamplePublisher.new + +# Create and run the hooks application with custom publisher +app = Hooks.build(config: "./spec/acceptance/config/hooks.yaml", publisher:) run app diff --git a/docs/handler_plugins.md b/docs/handler_plugins.md index 0145747..37331bc 100644 --- a/docs/handler_plugins.md +++ b/docs/handler_plugins.md @@ -292,3 +292,61 @@ See the source code at `lib/hooks/utils/retry.rb` for more details on how `Retry ### `#failbot` and `#stats` The `failbot` and `stats` methods are available in all handler plugins. They are used to report errors and send statistics, respectively. These are custom methods and you can learn more about them in the [Instrumentation Plugins](instrument_plugins.md) documentation. + +### Extra Components + +If you need even more flexibility, you can pass in extra components to your Hooks application when building it. These "extra components" are available globally and can be used in your handler plugins. Here is example that demonstrates using an extra component: + +```ruby +# config.ru + +# Define some class that you might want all your handlers to be able to call +class ExamplePublisher + def initialize + @published_messages = [] + end + + def call(data) + @published_messages << data + puts "Published: #{data.inspect}" + "Message published successfully" + end + + def publish(data) + call(data) + end + + def messages + @published_messages + end +end + +# Create publisher instance +publisher = ExamplePublisher.new + +# Create and run the hooks application with custom class +app = Hooks.build(config: "./spec/acceptance/config/hooks.yaml", publisher:) +run app +``` + +Now, in all handler plugins, you can access the `publisher` instance like so: + +```ruby +# example file path: plugins/handlers/hello.rb + +class Hello < Hooks::Plugins::Handlers::Base + def call(payload:, headers:, env:, config:) + # call the custom publisher instance + publisher.publish("hello") + + { + status: "success", + handler: self.class.name, + timestamp: Time.now.utc.iso8601, + messages: publisher.messages, + } + end +end +``` + +It should be noted that any extra components you pass in like this should be thread-safe if you are running the Hooks server in a multi-threaded environment. This is because the Hooks server can handle multiple requests concurrently, and any shared state should be properly synchronized. diff --git a/lib/hooks.rb b/lib/hooks.rb index 32b62c3..1c25bb3 100644 --- a/lib/hooks.rb +++ b/lib/hooks.rb @@ -32,11 +32,13 @@ module Hooks # # @param config [String, Hash] Path to config file or config hash # @param log [Logger] Custom logger instance (optional) + # @param **extra_components [Hash] Arbitrary user-defined components to make available to handlers # @return [Object] Rack-compatible application - def self.build(config: nil, log: nil) + def self.build(config: nil, log: nil, **extra_components) Core::Builder.new( config:, log:, + **extra_components ).build end end diff --git a/lib/hooks/core/builder.rb b/lib/hooks/core/builder.rb index 099b497..ea5d460 100644 --- a/lib/hooks/core/builder.rb +++ b/lib/hooks/core/builder.rb @@ -15,9 +15,11 @@ class Builder # # @param config [String, Hash] Path to config file or config hash # @param log [Logger] Custom logger instance - def initialize(config: nil, log: nil) + # @param **extra_components [Hash] Arbitrary user-defined components to make available to handlers + def initialize(config: nil, log: nil, **extra_components) @log = log @config_input = config + @extra_components = extra_components end # Build and return Rack-compatible application @@ -37,6 +39,9 @@ def build Hooks::Log.instance = @log + # Register user-defined components globally + Hooks::Core::GlobalComponents.register_extra_components(@extra_components) + # Hydrate our Retryable instance Retry.setup!(log: @log) diff --git a/lib/hooks/core/component_access.rb b/lib/hooks/core/component_access.rb index 3bee516..639e9ae 100644 --- a/lib/hooks/core/component_access.rb +++ b/lib/hooks/core/component_access.rb @@ -2,12 +2,15 @@ module Hooks module Core - # Shared module providing access to global components (logger, stats, failbot) + # Shared module providing access to global components (logger, stats, failbot, and user-defined components) # # This module provides a consistent interface for accessing global components # across all plugin types, eliminating code duplication and ensuring consistent # behavior throughout the application. # + # In addition to built-in components (log, stats, failbot), this module provides + # dynamic access to any user-defined components passed to Hooks.build(). + # # @example Usage in a class that needs instance methods # class MyHandler # include Hooks::Core::ComponentAccess @@ -28,6 +31,33 @@ module Core # stats.increment("requests.validated") # end # end + # + # @example Using user-defined components + # # Application setup + # publisher = KafkaPublisher.new + # email_service = EmailService.new + # app = Hooks.build( + # config: "config.yaml", + # publisher: publisher, + # email_service: email_service + # ) + # + # # Handler implementation + # class WebhookHandler < Hooks::Plugins::Handlers::Base + # include Hooks::Core::ComponentAccess + # + # def call(payload:, headers:, env:, config:) + # # Use built-in components + # log.info("Processing webhook") + # stats.increment("webhooks.received") + # + # # Use user-defined components + # publisher.send_message(payload, topic: "webhooks") + # email_service.send_notification(payload['email'], "Webhook processed") + # + # { status: "success" } + # end + # end module ComponentAccess # Short logger accessor # @return [Hooks::Log] Logger instance for logging messages @@ -64,6 +94,130 @@ def stats def failbot Hooks::Core::GlobalComponents.failbot end + + # Dynamic method access for user-defined components + # + # This method enables handlers to call user-defined components as methods. + # For example, if a user registers a 'publisher' component, handlers can + # call `publisher` or `publisher.some_method` directly. + # + # The method supports multiple usage patterns: + # - Direct access: Returns the component instance for further method calls + # - Callable access: If the component responds to #call, invokes it with provided arguments + # - Method chaining: Allows fluent interface patterns with registered components + # + # @param method_name [Symbol] The method name being called + # @param args [Array] Arguments passed to the method + # @param kwargs [Hash] Keyword arguments passed to the method + # @param block [Proc] Block passed to the method + # @return [Object] The user component or result of method call + # @raise [NoMethodError] If component doesn't exist and no super method available + # + # @example Accessing a publisher component directly + # # Given: Hooks.build(publisher: MyKafkaPublisher.new) + # class MyHandler < Hooks::Plugins::Handlers::Base + # def call(payload:, headers:, env:, config:) + # publisher.send_message(payload, topic: "webhooks") + # { status: "published" } + # end + # end + # + # @example Using a callable component (Proc/Lambda) + # # Given: Hooks.build(notifier: ->(msg) { puts "Notification: #{msg}" }) + # class MyHandler < Hooks::Plugins::Handlers::Base + # def call(payload:, headers:, env:, config:) + # notifier.call("New webhook received") + # # Or use the shorthand syntax: + # notifier("Processing webhook for #{payload['user_id']}") + # { status: "notified" } + # end + # end + # + # @example Using a service object + # # Given: Hooks.build(email_service: EmailService.new(api_key: "...")) + # class MyHandler < Hooks::Plugins::Handlers::Base + # def call(payload:, headers:, env:, config:) + # email_service.send_notification( + # to: payload['email'], + # subject: "Webhook Processed", + # body: "Your webhook has been successfully processed" + # ) + # { status: "email_sent" } + # end + # end + # + # @example Passing blocks to components + # # Given: Hooks.build(batch_processor: BatchProcessor.new) + # class MyHandler < Hooks::Plugins::Handlers::Base + # def call(payload:, headers:, env:, config:) + # batch_processor.process_with_callback(payload) do |result| + # log.info("Batch processing completed: #{result}") + # end + # { status: "batch_queued" } + # end + # end + def method_missing(method_name, *args, **kwargs, &block) + component = Hooks::Core::GlobalComponents.get_extra_component(method_name) + + if component + # If called with arguments or block, try to call the component as a method + if args.any? || kwargs.any? || block + component.call(*args, **kwargs, &block) + else + # Otherwise return the component itself + component + end + else + # Fall back to normal method_missing behavior + super + end + end + + # Respond to user-defined component names + # + # This method ensures that handlers properly respond to user-defined component + # names, enabling proper method introspection and duck typing support. + # + # @param method_name [Symbol] The method name being checked + # @param include_private [Boolean] Whether to include private methods + # @return [Boolean] True if method exists or is a user component + # + # @example Checking if a component is available + # class MyHandler < Hooks::Plugins::Handlers::Base + # def call(payload:, headers:, env:, config:) + # if respond_to?(:publisher) + # publisher.send_message(payload) + # { status: "published" } + # else + # log.warn("Publisher not available, skipping message send") + # { status: "skipped" } + # end + # end + # end + # + # @example Conditional component usage + # class MyHandler < Hooks::Plugins::Handlers::Base + # def call(payload:, headers:, env:, config:) + # results = { status: "processed" } + # + # # Only use analytics if available + # if respond_to?(:analytics) + # analytics.track_event("webhook_processed", payload) + # results[:analytics] = "tracked" + # end + # + # # Only send notifications if notifier is available + # if respond_to?(:notifier) + # notifier.call("Webhook processed: #{payload['id']}") + # results[:notification] = "sent" + # end + # + # results + # end + # end + def respond_to_missing?(method_name, include_private = false) + Hooks::Core::GlobalComponents.extra_component_exists?(method_name) || super + end end end end diff --git a/lib/hooks/core/global_components.rb b/lib/hooks/core/global_components.rb index ea3771c..d0acf2c 100644 --- a/lib/hooks/core/global_components.rb +++ b/lib/hooks/core/global_components.rb @@ -1,11 +1,48 @@ # frozen_string_literal: true +require "monitor" + module Hooks module Core # Global registry for shared components accessible throughout the application class GlobalComponents @test_stats = nil @test_failbot = nil + @extra_components = {} + @mutex = Monitor.new + + # Register arbitrary user-defined components. This method is called on application startup + # + # @param components [Hash] Hash of component name => component instance + # @return [void] + def self.register_extra_components(components) + @mutex.synchronize do + @extra_components = components.dup.freeze + end + end + + # Get a user-defined component by name + # + # @param name [Symbol, String] Component name + # @return [Object, nil] Component instance or nil if not found + def self.get_extra_component(name) + @extra_components[name.to_sym] || @extra_components[name.to_s] + end + + # Get all registered user component names + # + # @return [Array] Array of component names + def self.extra_component_names + @extra_components.keys.map(&:to_sym) + end + + # Check if a user component exists + # + # @param name [Symbol, String] Component name + # @return [Boolean] True if component exists + def self.extra_component_exists?(name) + @extra_components.key?(name.to_sym) || @extra_components.key?(name.to_s) + end # Get the global stats instance # @return [Hooks::Plugins::Instruments::StatsBase] Stats instance for metrics reporting @@ -22,29 +59,36 @@ def self.failbot # Set a custom stats instance (for testing) # @param stats_instance [Object] Custom stats instance def self.stats=(stats_instance) - @test_stats = stats_instance + @mutex.synchronize do + @test_stats = stats_instance + end end # Set a custom failbot instance (for testing) # @param failbot_instance [Object] Custom failbot instance def self.failbot=(failbot_instance) - @test_failbot = failbot_instance + @mutex.synchronize do + @test_failbot = failbot_instance + end 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 - }) + @mutex.synchronize do + @test_stats = nil + @test_failbot = nil + @extra_components = {}.freeze + # 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/version.rb b/lib/hooks/version.rb index cb0bf05..a38b3d2 100644 --- a/lib/hooks/version.rb +++ b/lib/hooks/version.rb @@ -4,5 +4,5 @@ module Hooks # Current version of the Hooks webhook framework # @return [String] The version string following semantic versioning - VERSION = "0.5.1".freeze + VERSION = "0.6.0".freeze end diff --git a/spec/acceptance/acceptance_tests.rb b/spec/acceptance/acceptance_tests.rb index c3e7e3f..560a902 100644 --- a/spec/acceptance/acceptance_tests.rb +++ b/spec/acceptance/acceptance_tests.rb @@ -680,5 +680,22 @@ def expired_unix_timestamp(seconds_ago = 600) expect(body["status"]).to eq("success") end end + + describe "hello" do + it "responds to the /webhooks/hello endpoint with a simple message" do + payload = {}.to_json + headers = { "Content-Type" => "application/json" } + response = make_request(:post, "/webhooks/hello", payload, headers) + + expect_response(response, Net::HTTPSuccess) + body = parse_json_response(response) + expect(body["status"]).to eq("success") + expect(body["handler"]).to eq("Hello") + expect(body).to have_key("timestamp") + expect(body["timestamp"]).to be_a(String) + expect(body["messages"]).to be_a(Array) + expect(body["messages"]).to include("hello") + end + end end end diff --git a/spec/acceptance/plugins/handlers/hello.rb b/spec/acceptance/plugins/handlers/hello.rb index 3523d06..0e1153f 100644 --- a/spec/acceptance/plugins/handlers/hello.rb +++ b/spec/acceptance/plugins/handlers/hello.rb @@ -2,10 +2,13 @@ class Hello < Hooks::Plugins::Handlers::Base def call(payload:, headers:, env:, config:) + publisher.publish("hello") + { status: "success", handler: self.class.name, - timestamp: Time.now.utc.iso8601 + timestamp: Time.now.utc.iso8601, + messages: publisher.messages, } end end diff --git a/spec/unit/hooks_spec.rb b/spec/unit/hooks_spec.rb index 2f60539..afff1a3 100644 --- a/spec/unit/hooks_spec.rb +++ b/spec/unit/hooks_spec.rb @@ -55,5 +55,47 @@ expect(result).to eq("mock_app") end end + + context "with user components" do + it "passes user components to builder" do + publisher = double("Publisher") + service = double("Service") + allow(Hooks::Core::Builder).to receive(:new).and_call_original + allow_any_instance_of(Hooks::Core::Builder).to receive(:build).and_return("mock_app") + + result = Hooks.build(publisher: publisher, service: service) + + expect(Hooks::Core::Builder).to have_received(:new).with( + config: nil, + log: nil, + publisher: publisher, + service: service + ) + expect(result).to eq("mock_app") + end + + it "passes user components along with config and log" do + config_hash = { environment: "test" } + custom_logger = double("Logger") + publisher = double("Publisher") + allow(Hooks::Core::Builder).to receive(:new).and_call_original + allow_any_instance_of(Hooks::Core::Builder).to receive(:build).and_return("mock_app") + + result = Hooks.build( + config: config_hash, + log: custom_logger, + publisher: publisher, + custom_service: "service_instance" + ) + + expect(Hooks::Core::Builder).to have_received(:new).with( + config: config_hash, + log: custom_logger, + publisher: publisher, + custom_service: "service_instance" + ) + expect(result).to eq("mock_app") + end + end end end diff --git a/spec/unit/lib/hooks/core/builder_spec.rb b/spec/unit/lib/hooks/core/builder_spec.rb index 2142419..9f6f8ee 100644 --- a/spec/unit/lib/hooks/core/builder_spec.rb +++ b/spec/unit/lib/hooks/core/builder_spec.rb @@ -20,6 +20,7 @@ expect(builder.instance_variable_get(:@log)).to eq(log) expect(builder.instance_variable_get(:@config_input)).to be_nil + expect(builder.instance_variable_get(:@extra_components)).to eq({}) end it "initializes with config parameter" do @@ -41,6 +42,20 @@ expect(builder.instance_variable_get(:@config_input)).to eq(config) expect(builder.instance_variable_get(:@log)).to eq(log) end + + it "initializes with user components" do + publisher = double("Publisher") + service = double("Service") + builder = described_class.new( + log:, + publisher: publisher, + service: service + ) + + user_components = builder.instance_variable_get(:@extra_components) + expect(user_components[:publisher]).to eq(publisher) + expect(user_components[:service]).to eq(service) + end end describe "#build" do @@ -248,6 +263,44 @@ end end + context "with user components" do + let(:publisher) { double("Publisher") } + let(:service) { double("Service") } + let(:builder) { described_class.new(log:, publisher: publisher, service: service) } + + before do + allow(Hooks::Core::ConfigLoader).to receive(:load).and_return({ + log_level: "info", + environment: "test", + endpoints_dir: "/nonexistent" + }) + allow(Hooks::Core::ConfigValidator).to receive(:validate_global_config).and_return({ + log_level: "info", + environment: "test", + endpoints_dir: "/nonexistent" + }) + allow(Hooks::Core::ConfigLoader).to receive(:load_endpoints).and_return([]) + allow(Hooks::Core::ConfigValidator).to receive(:validate_endpoints).and_return([]) + allow(Hooks::App::API).to receive(:create).and_return("mock_api") + end + + it "registers user components globally during build" do + expect(Hooks::Core::GlobalComponents).to receive(:register_extra_components).with({ + publisher: publisher, + service: service + }) + + builder.build + end + + it "makes user components accessible after build" do + builder.build + + expect(Hooks::Core::GlobalComponents.get_extra_component(:publisher)).to eq(publisher) + expect(Hooks::Core::GlobalComponents.get_extra_component(:service)).to eq(service) + end + end + context "error handling" do let(:builder) { described_class.new(log:) } diff --git a/spec/unit/lib/hooks/core/component_access_spec.rb b/spec/unit/lib/hooks/core/component_access_spec.rb index 7e28c9c..1773f50 100644 --- a/spec/unit/lib/hooks/core/component_access_spec.rb +++ b/spec/unit/lib/hooks/core/component_access_spec.rb @@ -13,6 +13,10 @@ end end + after do + Hooks::Core::GlobalComponents.reset + end + describe "when included" do let(:instance) { test_class_with_include.new } @@ -35,6 +39,59 @@ expect(instance.failbot).to eq(Hooks::Core::GlobalComponents.failbot) end end + + describe "user component access" do + context "when user components are registered" do + let(:publisher) { double("Publisher") } + let(:callable_service) { ->(data) { "Called with #{data}" } } + + before do + Hooks::Core::GlobalComponents.register_extra_components({ + publisher: publisher, + callable_service: callable_service + }) + end + + it "provides access to user components as methods" do + expect(instance.publisher).to eq(publisher) + expect(instance.callable_service).to eq(callable_service) + end + + it "calls user components with arguments when provided" do + expect(callable_service).to receive(:call).with("test_data") + instance.callable_service("test_data") + end + + it "calls user components with keyword arguments when provided" do + expect(callable_service).to receive(:call).with(data: "test") + instance.callable_service(data: "test") + end + + it "calls user components with block when provided" do + block = proc { "test block" } + expect(callable_service).to receive(:call) do |*args, &passed_block| + expect(passed_block).to eq(block) + end + instance.callable_service(&block) + end + + it "responds to user component names" do + expect(instance.respond_to?(:publisher)).to be true + expect(instance.respond_to?(:callable_service)).to be true + expect(instance.respond_to?(:nonexistent)).to be false + end + end + + context "when no user components are registered" do + it "raises NoMethodError for undefined methods" do + expect { instance.nonexistent_method }.to raise_error(NoMethodError) + end + + it "does not respond to non-existent methods" do + expect(instance.respond_to?(:nonexistent_method)).to be false + end + end + end end describe "when extended" do diff --git a/spec/unit/lib/hooks/core/global_components_spec.rb b/spec/unit/lib/hooks/core/global_components_spec.rb index e30aeea..81c00b1 100644 --- a/spec/unit/lib/hooks/core/global_components_spec.rb +++ b/spec/unit/lib/hooks/core/global_components_spec.rb @@ -1,6 +1,84 @@ # frozen_string_literal: true describe Hooks::Core::GlobalComponents do + after do + described_class.reset + end + + describe ".register_extra_components" do + it "registers user-defined components" do + publisher = double("Publisher") + service = double("Service") + + described_class.register_extra_components({ + publisher: publisher, + service: service + }) + + expect(described_class.get_extra_component(:publisher)).to eq(publisher) + expect(described_class.get_extra_component("service")).to eq(service) + end + + it "handles empty components hash" do + described_class.register_extra_components({}) + expect(described_class.extra_component_names).to be_empty + end + end + + describe ".get_extra_component" do + before do + described_class.register_extra_components({ + publisher: "test_publisher", + "string_key" => "test_service" + }) + end + + it "retrieves component by symbol key" do + expect(described_class.get_extra_component(:publisher)).to eq("test_publisher") + end + + it "retrieves component by string key" do + expect(described_class.get_extra_component("string_key")).to eq("test_service") + end + + it "returns nil for non-existent component" do + expect(described_class.get_extra_component(:nonexistent)).to be_nil + end + end + + describe ".extra_component_names" do + it "returns all component names as symbols" do + described_class.register_extra_components({ + publisher: "test_publisher", + "service" => "test_service" + }) + + names = described_class.extra_component_names + expect(names).to contain_exactly(:publisher, :service) + end + end + + describe ".extra_component_exists?" do + before do + described_class.register_extra_components({ + publisher: "test_publisher", + "string_key" => "test_service" + }) + end + + it "returns true for existing component with symbol key" do + expect(described_class.extra_component_exists?(:publisher)).to be true + end + + it "returns true for existing component with string key" do + expect(described_class.extra_component_exists?("string_key")).to be true + end + + it "returns false for non-existent component" do + expect(described_class.extra_component_exists?(:nonexistent)).to be false + end + end + describe ".stats" do it "returns a Stats instance by default" do expect(described_class.stats).to be_a(Hooks::Plugins::Instruments::Stats) @@ -36,12 +114,13 @@ end describe ".reset" do - it "resets both components to default instances" do + it "resets all 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 + described_class.register_extra_components({ publisher: "test" }) # Reset described_class.reset @@ -49,6 +128,8 @@ # 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) + expect(described_class.get_extra_component(:publisher)).to be_nil + expect(described_class.extra_component_names).to be_empty end end end