Skip to content

Commit 48d9906

Browse files
committed
Enhance Hooks framework to support user-defined components
- Added ExamplePublisher class to demonstrate custom component usage. - Updated Hooks.build method to accept arbitrary user-defined components. - Enhanced GlobalComponents for registration and retrieval of user components. - Improved ComponentAccess module to allow dynamic access to user-defined components. - Added tests for user component registration and access in various contexts. - Introduced new acceptance tests for /webhooks/hello endpoint.
1 parent 9ec9217 commit 48d9906

File tree

11 files changed

+535
-18
lines changed

11 files changed

+535
-18
lines changed

config.ru

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,36 @@
11
# frozen_string_literal: true
22

3+
# An example file that is a part of the acceptance tests for the Hooks framework.
4+
# This can be used as a reference point as it is a working implementation of a Hooks application.
5+
36
require_relative "lib/hooks"
47

5-
app = Hooks.build(config: "./spec/acceptance/config/hooks.yaml")
8+
# Example publisher class that simulates publishing messages
9+
# This class could be literally anything and it is used here to demonstrate how to pass in custom kwargs...
10+
# ... to the Hooks application which later become available in Handlers throughout the application.
11+
class ExamplePublisher
12+
def initialize
13+
@published_messages = []
14+
end
15+
16+
def call(data)
17+
@published_messages << data
18+
puts "Published: #{data.inspect}"
19+
"Message published successfully"
20+
end
21+
22+
def publish(data)
23+
call(data)
24+
end
25+
26+
def messages
27+
@published_messages
28+
end
29+
end
30+
31+
# Create publisher instance
32+
publisher = ExamplePublisher.new
33+
34+
# Create and run the hooks application with custom publisher
35+
app = Hooks.build(config: "./spec/acceptance/config/hooks.yaml", publisher:)
636
run app

lib/hooks.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@ module Hooks
3232
#
3333
# @param config [String, Hash] Path to config file or config hash
3434
# @param log [Logger] Custom logger instance (optional)
35+
# @param **extra_components [Hash] Arbitrary user-defined components to make available to handlers
3536
# @return [Object] Rack-compatible application
36-
def self.build(config: nil, log: nil)
37+
def self.build(config: nil, log: nil, **extra_components)
3738
Core::Builder.new(
3839
config:,
3940
log:,
41+
**extra_components
4042
).build
4143
end
4244
end

lib/hooks/core/builder.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ class Builder
1515
#
1616
# @param config [String, Hash] Path to config file or config hash
1717
# @param log [Logger] Custom logger instance
18-
def initialize(config: nil, log: nil)
18+
# @param **extra_components [Hash] Arbitrary user-defined components to make available to handlers
19+
def initialize(config: nil, log: nil, **extra_components)
1920
@log = log
2021
@config_input = config
22+
@extra_components = extra_components
2123
end
2224

2325
# Build and return Rack-compatible application
@@ -37,6 +39,9 @@ def build
3739

3840
Hooks::Log.instance = @log
3941

42+
# Register user-defined components globally
43+
Hooks::Core::GlobalComponents.register_extra_components(@extra_components)
44+
4045
# Hydrate our Retryable instance
4146
Retry.setup!(log: @log)
4247

lib/hooks/core/component_access.rb

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
module Hooks
44
module Core
5-
# Shared module providing access to global components (logger, stats, failbot)
5+
# Shared module providing access to global components (logger, stats, failbot, and user-defined components)
66
#
77
# This module provides a consistent interface for accessing global components
88
# across all plugin types, eliminating code duplication and ensuring consistent
99
# behavior throughout the application.
1010
#
11+
# In addition to built-in components (log, stats, failbot), this module provides
12+
# dynamic access to any user-defined components passed to Hooks.build().
13+
#
1114
# @example Usage in a class that needs instance methods
1215
# class MyHandler
1316
# include Hooks::Core::ComponentAccess
@@ -28,6 +31,33 @@ module Core
2831
# stats.increment("requests.validated")
2932
# end
3033
# end
34+
#
35+
# @example Using user-defined components
36+
# # Application setup
37+
# publisher = KafkaPublisher.new
38+
# email_service = EmailService.new
39+
# app = Hooks.build(
40+
# config: "config.yaml",
41+
# publisher: publisher,
42+
# email_service: email_service
43+
# )
44+
#
45+
# # Handler implementation
46+
# class WebhookHandler < Hooks::Plugins::Handlers::Base
47+
# include Hooks::Core::ComponentAccess
48+
#
49+
# def call(payload:, headers:, env:, config:)
50+
# # Use built-in components
51+
# log.info("Processing webhook")
52+
# stats.increment("webhooks.received")
53+
#
54+
# # Use user-defined components
55+
# publisher.send_message(payload, topic: "webhooks")
56+
# email_service.send_notification(payload['email'], "Webhook processed")
57+
#
58+
# { status: "success" }
59+
# end
60+
# end
3161
module ComponentAccess
3262
# Short logger accessor
3363
# @return [Hooks::Log] Logger instance for logging messages
@@ -64,6 +94,130 @@ def stats
6494
def failbot
6595
Hooks::Core::GlobalComponents.failbot
6696
end
97+
98+
# Dynamic method access for user-defined components
99+
#
100+
# This method enables handlers to call user-defined components as methods.
101+
# For example, if a user registers a 'publisher' component, handlers can
102+
# call `publisher` or `publisher.some_method` directly.
103+
#
104+
# The method supports multiple usage patterns:
105+
# - Direct access: Returns the component instance for further method calls
106+
# - Callable access: If the component responds to #call, invokes it with provided arguments
107+
# - Method chaining: Allows fluent interface patterns with registered components
108+
#
109+
# @param method_name [Symbol] The method name being called
110+
# @param args [Array] Arguments passed to the method
111+
# @param kwargs [Hash] Keyword arguments passed to the method
112+
# @param block [Proc] Block passed to the method
113+
# @return [Object] The user component or result of method call
114+
# @raise [NoMethodError] If component doesn't exist and no super method available
115+
#
116+
# @example Accessing a publisher component directly
117+
# # Given: Hooks.build(publisher: MyKafkaPublisher.new)
118+
# class MyHandler < Hooks::Plugins::Handlers::Base
119+
# def call(payload:, headers:, env:, config:)
120+
# publisher.send_message(payload, topic: "webhooks")
121+
# { status: "published" }
122+
# end
123+
# end
124+
#
125+
# @example Using a callable component (Proc/Lambda)
126+
# # Given: Hooks.build(notifier: ->(msg) { puts "Notification: #{msg}" })
127+
# class MyHandler < Hooks::Plugins::Handlers::Base
128+
# def call(payload:, headers:, env:, config:)
129+
# notifier.call("New webhook received")
130+
# # Or use the shorthand syntax:
131+
# notifier("Processing webhook for #{payload['user_id']}")
132+
# { status: "notified" }
133+
# end
134+
# end
135+
#
136+
# @example Using a service object
137+
# # Given: Hooks.build(email_service: EmailService.new(api_key: "..."))
138+
# class MyHandler < Hooks::Plugins::Handlers::Base
139+
# def call(payload:, headers:, env:, config:)
140+
# email_service.send_notification(
141+
# to: payload['email'],
142+
# subject: "Webhook Processed",
143+
# body: "Your webhook has been successfully processed"
144+
# )
145+
# { status: "email_sent" }
146+
# end
147+
# end
148+
#
149+
# @example Passing blocks to components
150+
# # Given: Hooks.build(batch_processor: BatchProcessor.new)
151+
# class MyHandler < Hooks::Plugins::Handlers::Base
152+
# def call(payload:, headers:, env:, config:)
153+
# batch_processor.process_with_callback(payload) do |result|
154+
# log.info("Batch processing completed: #{result}")
155+
# end
156+
# { status: "batch_queued" }
157+
# end
158+
# end
159+
def method_missing(method_name, *args, **kwargs, &block)
160+
component = Hooks::Core::GlobalComponents.get_extra_component(method_name)
161+
162+
if component
163+
# If called with arguments or block, try to call the component as a method
164+
if args.any? || kwargs.any? || block
165+
component.call(*args, **kwargs, &block)
166+
else
167+
# Otherwise return the component itself
168+
component
169+
end
170+
else
171+
# Fall back to normal method_missing behavior
172+
super
173+
end
174+
end
175+
176+
# Respond to user-defined component names
177+
#
178+
# This method ensures that handlers properly respond to user-defined component
179+
# names, enabling proper method introspection and duck typing support.
180+
#
181+
# @param method_name [Symbol] The method name being checked
182+
# @param include_private [Boolean] Whether to include private methods
183+
# @return [Boolean] True if method exists or is a user component
184+
#
185+
# @example Checking if a component is available
186+
# class MyHandler < Hooks::Plugins::Handlers::Base
187+
# def call(payload:, headers:, env:, config:)
188+
# if respond_to?(:publisher)
189+
# publisher.send_message(payload)
190+
# { status: "published" }
191+
# else
192+
# log.warn("Publisher not available, skipping message send")
193+
# { status: "skipped" }
194+
# end
195+
# end
196+
# end
197+
#
198+
# @example Conditional component usage
199+
# class MyHandler < Hooks::Plugins::Handlers::Base
200+
# def call(payload:, headers:, env:, config:)
201+
# results = { status: "processed" }
202+
#
203+
# # Only use analytics if available
204+
# if respond_to?(:analytics)
205+
# analytics.track_event("webhook_processed", payload)
206+
# results[:analytics] = "tracked"
207+
# end
208+
#
209+
# # Only send notifications if notifier is available
210+
# if respond_to?(:notifier)
211+
# notifier.call("Webhook processed: #{payload['id']}")
212+
# results[:notification] = "sent"
213+
# end
214+
#
215+
# results
216+
# end
217+
# end
218+
def respond_to_missing?(method_name, include_private = false)
219+
Hooks::Core::GlobalComponents.extra_component_exists?(method_name) || super
220+
end
67221
end
68222
end
69223
end

lib/hooks/core/global_components.rb

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,48 @@
11
# frozen_string_literal: true
22

3+
require "monitor"
4+
35
module Hooks
46
module Core
57
# Global registry for shared components accessible throughout the application
68
class GlobalComponents
79
@test_stats = nil
810
@test_failbot = nil
11+
@extra_components = {}
12+
@mutex = Monitor.new
13+
14+
# Register arbitrary user-defined components. This method is called on application startup
15+
#
16+
# @param components [Hash] Hash of component name => component instance
17+
# @return [void]
18+
def self.register_extra_components(components)
19+
@mutex.synchronize do
20+
@extra_components = components.dup.freeze
21+
end
22+
end
23+
24+
# Get a user-defined component by name
25+
#
26+
# @param name [Symbol, String] Component name
27+
# @return [Object, nil] Component instance or nil if not found
28+
def self.get_extra_component(name)
29+
@extra_components[name.to_sym] || @extra_components[name.to_s]
30+
end
31+
32+
# Get all registered user component names
33+
#
34+
# @return [Array<Symbol>] Array of component names
35+
def self.extra_component_names
36+
@extra_components.keys.map(&:to_sym)
37+
end
38+
39+
# Check if a user component exists
40+
#
41+
# @param name [Symbol, String] Component name
42+
# @return [Boolean] True if component exists
43+
def self.extra_component_exists?(name)
44+
@extra_components.key?(name.to_sym) || @extra_components.key?(name.to_s)
45+
end
946

1047
# Get the global stats instance
1148
# @return [Hooks::Plugins::Instruments::StatsBase] Stats instance for metrics reporting
@@ -22,29 +59,36 @@ def self.failbot
2259
# Set a custom stats instance (for testing)
2360
# @param stats_instance [Object] Custom stats instance
2461
def self.stats=(stats_instance)
25-
@test_stats = stats_instance
62+
@mutex.synchronize do
63+
@test_stats = stats_instance
64+
end
2665
end
2766

2867
# Set a custom failbot instance (for testing)
2968
# @param failbot_instance [Object] Custom failbot instance
3069
def self.failbot=(failbot_instance)
31-
@test_failbot = failbot_instance
70+
@mutex.synchronize do
71+
@test_failbot = failbot_instance
72+
end
3273
end
3374

3475
# Reset components to default instances (for testing)
3576
#
3677
# @return [void]
3778
def self.reset
38-
@test_stats = nil
39-
@test_failbot = nil
40-
# Clear and reload default instruments
41-
PluginLoader.clear_plugins
42-
require_relative "../plugins/instruments/stats"
43-
require_relative "../plugins/instruments/failbot"
44-
PluginLoader.instance_variable_set(:@instrument_plugins, {
45-
stats: Hooks::Plugins::Instruments::Stats.new,
46-
failbot: Hooks::Plugins::Instruments::Failbot.new
47-
})
79+
@mutex.synchronize do
80+
@test_stats = nil
81+
@test_failbot = nil
82+
@extra_components = {}.freeze
83+
# Clear and reload default instruments
84+
PluginLoader.clear_plugins
85+
require_relative "../plugins/instruments/stats"
86+
require_relative "../plugins/instruments/failbot"
87+
PluginLoader.instance_variable_set(:@instrument_plugins, {
88+
stats: Hooks::Plugins::Instruments::Stats.new,
89+
failbot: Hooks::Plugins::Instruments::Failbot.new
90+
})
91+
end
4892
end
4993
end
5094
end

spec/acceptance/acceptance_tests.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,5 +680,22 @@ def expired_unix_timestamp(seconds_ago = 600)
680680
expect(body["status"]).to eq("success")
681681
end
682682
end
683+
684+
describe "hello" do
685+
it "responds to the /webhooks/hello endpoint with a simple message" do
686+
payload = {}.to_json
687+
headers = { "Content-Type" => "application/json" }
688+
response = make_request(:post, "/webhooks/hello", payload, headers)
689+
690+
expect_response(response, Net::HTTPSuccess)
691+
body = parse_json_response(response)
692+
expect(body["status"]).to eq("success")
693+
expect(body["handler"]).to eq("Hello")
694+
expect(body).to have_key("timestamp")
695+
expect(body["timestamp"]).to be_a(String)
696+
expect(body["messages"]).to be_a(Array)
697+
expect(body["messages"]).to include("hello")
698+
end
699+
end
683700
end
684701
end

spec/acceptance/plugins/handlers/hello.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
class Hello < Hooks::Plugins::Handlers::Base
44
def call(payload:, headers:, env:, config:)
5+
publisher.publish("hello")
6+
57
{
68
status: "success",
79
handler: self.class.name,
8-
timestamp: Time.now.utc.iso8601
10+
timestamp: Time.now.utc.iso8601,
11+
messages: publisher.messages,
912
}
1013
end
1114
end

0 commit comments

Comments
 (0)