Skip to content

Commit bd87347

Browse files
authored
Merge pull request #66 from github/extra-components
Extra components
2 parents 9ec9217 + 58de8cc commit bd87347

File tree

14 files changed

+566
-20
lines changed

14 files changed

+566
-20
lines changed

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
hooks-ruby (0.5.1)
4+
hooks-ruby (0.6.0)
55
dry-schema (~> 1.14, >= 1.14.1)
66
grape (~> 2.3)
77
puma (~> 6.6)

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

docs/handler_plugins.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,61 @@ See the source code at `lib/hooks/utils/retry.rb` for more details on how `Retry
292292
### `#failbot` and `#stats`
293293

294294
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.
295+
296+
### Extra Components
297+
298+
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:
299+
300+
```ruby
301+
# config.ru
302+
303+
# Define some class that you might want all your handlers to be able to call
304+
class ExamplePublisher
305+
def initialize
306+
@published_messages = []
307+
end
308+
309+
def call(data)
310+
@published_messages << data
311+
puts "Published: #{data.inspect}"
312+
"Message published successfully"
313+
end
314+
315+
def publish(data)
316+
call(data)
317+
end
318+
319+
def messages
320+
@published_messages
321+
end
322+
end
323+
324+
# Create publisher instance
325+
publisher = ExamplePublisher.new
326+
327+
# Create and run the hooks application with custom class
328+
app = Hooks.build(config: "./spec/acceptance/config/hooks.yaml", publisher:)
329+
run app
330+
```
331+
332+
Now, in all handler plugins, you can access the `publisher` instance like so:
333+
334+
```ruby
335+
# example file path: plugins/handlers/hello.rb
336+
337+
class Hello < Hooks::Plugins::Handlers::Base
338+
def call(payload:, headers:, env:, config:)
339+
# call the custom publisher instance
340+
publisher.publish("hello")
341+
342+
{
343+
status: "success",
344+
handler: self.class.name,
345+
timestamp: Time.now.utc.iso8601,
346+
messages: publisher.messages,
347+
}
348+
end
349+
end
350+
```
351+
352+
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.

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

0 commit comments

Comments
 (0)