hooks-ruby 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71eaa47f9861440469e4356da0f891a4f274e5ee2a638b856ac4c27e00e2ddba
4
- data.tar.gz: 3e9cf71cac5846098ec371c39831c91cb2f1dba13f5e44937153168a9b7aee5a
3
+ metadata.gz: 001f3c92a2c07552f4293bc9b5d078e720a6d3ee601ffee80ca83ef1cf9425a5
4
+ data.tar.gz: 51e7307ad34ea50049dcc2f148e7650626f332854f9f4e409fdf461f0c699547
5
5
  SHA512:
6
- metadata.gz: c93e5f9cf308dd37cb6a8b064637399e5f4746a85a9967ddea2e6305775e1809eaa59d3944fed1dc0406f801c2a58cd8c0542c3d0453e9dadc34002c4c17bcc5
7
- data.tar.gz: fa67425558654ac6e9cfa421d5a7286682bc517a35a2a2a62ef54b51b8c7a589fbcd5f8083b61b45101b4e44b31daab482f21b564852d4ab38b2d1001f6ef852
6
+ metadata.gz: d36cbea00db3a9752a4acd20732bcb3c61072fb64593345a3b947110fbe29fbeb2b21afe47fb8821546679f003276561ba63caf79eba15838b9a185ea062afc6
7
+ data.tar.gz: 2c7252c823b0e004a9877fb4c30c57e51ac2322fcf8e503485da526ad504f3345c3aaf08db37736ba9b04df653bf5fe901b6070e9bb92e0aa14d76f9f7a00f22
data/README.md CHANGED
@@ -140,6 +140,8 @@ Congratulations! You have successfully set up a basic Hooks server which will li
140
140
 
141
141
  Keep reading to learn how to customize your Hooks server with different plugins for handlers, authentication, and more.
142
142
 
143
+ For an in-depth flow diagram of how the Hooks server processes incoming requests, see the [Architecture Flow](docs/architecture_flow.md) documentation.
144
+
143
145
  ### Advanced
144
146
 
145
147
  This section will go into a more advanced and detailed example of how to setup a Hooks server with custom plugins, authentication, and more. This section also assumes you already have the `hooks-ruby` gem installed via a bundler Gemfile as shown in the [Installation](#installation-) section above.
data/config.ru CHANGED
@@ -1,6 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
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
+
3
6
  require_relative "lib/hooks"
4
7
 
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:)
6
36
  run app
@@ -15,9 +15,11 @@ module Hooks
15
15
  #
16
16
  # @param config [String, Hash] Path to config file or config hash
17
17
  # @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)
19
20
  @log = log
20
21
  @config_input = config
22
+ @extra_components = extra_components
21
23
  end
22
24
 
23
25
  # Build and return Rack-compatible application
@@ -37,6 +39,9 @@ module Hooks
37
39
 
38
40
  Hooks::Log.instance = @log
39
41
 
42
+ # Register user-defined components globally
43
+ Hooks::Core::GlobalComponents.register_extra_components(@extra_components)
44
+
40
45
  # Hydrate our Retryable instance
41
46
  Retry.setup!(log: @log)
42
47
 
@@ -2,12 +2,15 @@
2
2
 
3
3
  module Hooks
4
4
  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)
6
6
  #
7
7
  # This module provides a consistent interface for accessing global components
8
8
  # across all plugin types, eliminating code duplication and ensuring consistent
9
9
  # behavior throughout the application.
10
10
  #
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
+ #
11
14
  # @example Usage in a class that needs instance methods
12
15
  # class MyHandler
13
16
  # include Hooks::Core::ComponentAccess
@@ -28,6 +31,33 @@ module Hooks
28
31
  # stats.increment("requests.validated")
29
32
  # end
30
33
  # 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
31
61
  module ComponentAccess
32
62
  # Short logger accessor
33
63
  # @return [Hooks::Log] Logger instance for logging messages
@@ -64,6 +94,130 @@ module Hooks
64
94
  def failbot
65
95
  Hooks::Core::GlobalComponents.failbot
66
96
  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
67
221
  end
68
222
  end
69
223
  end
@@ -1,11 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  module Hooks
4
6
  module Core
5
7
  # Global registry for shared components accessible throughout the application
6
8
  class GlobalComponents
7
9
  @test_stats = nil
8
10
  @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
9
46
 
10
47
  # Get the global stats instance
11
48
  # @return [Hooks::Plugins::Instruments::StatsBase] Stats instance for metrics reporting
@@ -22,29 +59,36 @@ module Hooks
22
59
  # Set a custom stats instance (for testing)
23
60
  # @param stats_instance [Object] Custom stats instance
24
61
  def self.stats=(stats_instance)
25
- @test_stats = stats_instance
62
+ @mutex.synchronize do
63
+ @test_stats = stats_instance
64
+ end
26
65
  end
27
66
 
28
67
  # Set a custom failbot instance (for testing)
29
68
  # @param failbot_instance [Object] Custom failbot instance
30
69
  def self.failbot=(failbot_instance)
31
- @test_failbot = failbot_instance
70
+ @mutex.synchronize do
71
+ @test_failbot = failbot_instance
72
+ end
32
73
  end
33
74
 
34
75
  # Reset components to default instances (for testing)
35
76
  #
36
77
  # @return [void]
37
78
  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
48
92
  end
49
93
  end
50
94
  end
data/lib/hooks/version.rb CHANGED
@@ -4,5 +4,5 @@
4
4
  module Hooks
5
5
  # Current version of the Hooks webhook framework
6
6
  # @return [String] The version string following semantic versioning
7
- VERSION = "0.5.1".freeze
7
+ VERSION = "0.6.0".freeze
8
8
  end
data/lib/hooks.rb CHANGED
@@ -32,11 +32,13 @@ module Hooks
32
32
  #
33
33
  # @param config [String, Hash] Path to config file or config hash
34
34
  # @param log [Logger] Custom logger instance (optional)
35
+ # @param **extra_components [Hash] Arbitrary user-defined components to make available to handlers
35
36
  # @return [Object] Rack-compatible application
36
- def self.build(config: nil, log: nil)
37
+ def self.build(config: nil, log: nil, **extra_components)
37
38
  Core::Builder.new(
38
39
  config:,
39
40
  log:,
41
+ **extra_components
40
42
  ).build
41
43
  end
42
44
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hooks-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - github