Skip to content

Extra components #66

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
32 changes: 31 additions & 1 deletion config.ru
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions docs/handler_plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion lib/hooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion lib/hooks/core/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
156 changes: 155 additions & 1 deletion lib/hooks/core/component_access.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading