Skip to content

Make handler field support only snake_case in configuration #54

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 13 commits into from
Jun 17, 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
6 changes: 3 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
hooks-ruby (0.2.1)
hooks-ruby (0.3.0)
dry-schema (~> 1.14, >= 1.14.1)
grape (~> 2.3)
puma (~> 6.6)
Expand Down Expand Up @@ -148,15 +148,15 @@ GEM
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.4)
rubocop (1.76.1)
rubocop (1.76.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.45.0, < 2.0)
rubocop-ast (>= 1.45.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.45.1)
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ Here is a very high-level overview of how Hooks works:
```yaml
# file: config/endpoints/hello.yml
path: /hello
handler: MyCustomHandler # This is a custom handler plugin you would define in the plugins/handlers directory
handler: my_custom_handler # This is a custom handler plugin you would define in the plugins/handlers directory (snake_case)
```

> Note: If your handler's class name is `MyCustomHandler`, you would define it in the `plugins/handlers/my_custom_handler.rb` file. The `handler` field in the endpoint configuration file should be the snake_case version of the class name. So if your handler class is `MyCustomHandler`, you would use `my_custom_handler` in the endpoint configuration file.

3. Now create a corresponding handler plugin in the `plugins/handlers` directory. Here is an example of a simple handler plugin:

```ruby
Expand All @@ -64,7 +66,7 @@ Here is a very high-level overview of how Hooks works:
# For this example, we will just return a success message
{
status: "success",
handler: "MyCustomHandler",
handler: "my_custom_handler",
payload_received: payload,
timestamp: Time.now.utc.iso8601
}
Expand Down Expand Up @@ -208,16 +210,16 @@ Endpoint configurations are defined in the `config/endpoints` directory. Each en
```yaml
# file: config/endpoints/hello.yml
path: /hello # becomes /webhooks/hello based on the root_path in hooks.yml
handler: HelloHandler # This is a custom handler plugin you would define in the plugins/handlers
handler: hello_handler # This is a custom handler plugin you would define in the plugins/handlers
```

```yaml
# file: config/endpoints/goodbye.yml
path: /goodbye # becomes /webhooks/goodbye based on the root_path in hooks.yml
handler: GoodbyeHandler # This is another custom handler plugin you would define in the plugins/handlers
handler: goodbye_handler # This is another custom handler plugin you would define in the plugins/handlers

auth:
type: Goodbye # This is a custom authentication plugin you would define in the plugins/auth
type: goodbye # This is a custom authentication plugin you would define in the plugins/auth
secret_env_key: GOODBYE_API_KEY # the name of the environment variable containing the secret
header: Authorization

Expand Down Expand Up @@ -255,7 +257,7 @@ class GoodbyeHandler < Hooks::Plugins::Handlers::Base
# Ditto for the goodbye endpoint
{
message: "goodbye webhook processed successfully",
handler: "GoodbyeHandler",
handler: "goodbye_handler",
timestamp: Time.now.utc.iso8601
}
end
Expand Down
17 changes: 17 additions & 0 deletions docs/auth_plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,20 @@ module Hooks
end
end
```

The configuration for this IP filtering plugin would look like this:

```yaml
path: /example
handler: CoolNewHandler # could be any handler you want to use

auth:
type: ip_filtering_plugin # using the custom IP filtering plugin (remember IpFilteringPlugin becomes ip_filtering_plugin)

# You can specify additional options in the `opts` section but the `allowed_ips` option is required for this plugin demo to work
opts:
allowed_ips: # list of allowed IPs
- "<ALLOWED_IP_1>"
- "<ALLOWED_IP_2>"
- "<ALLOWED_IP_3>"
```
15 changes: 15 additions & 0 deletions docs/handler_plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Handler plugins are Ruby classes that extend the `Hooks::Plugins::Handlers::Base

- `payload`: The webhook payload, which can be a Hash or a String. This is the data that the webhook sender sends to your endpoint.
- `headers`: A Hash of HTTP headers that were sent with the webhook request.
- `env`: A modified Rack environment that contains a lot of context about the request. This includes information about the request method, path, query parameters, and more. See [`rack_env_builder.rb`](../lib/hooks/app/rack_env_builder.rb) for the complete list of available keys.
- `config`: A Hash containing the endpoint configuration. This can include any additional settings or parameters that you want to use in your handler. Most of the time, this won't be used but sometimes endpoint configs add `opts` that can be useful for the handler.

```ruby
Expand All @@ -28,6 +29,20 @@ class Example < Hooks::Plugins::Handlers::Base
end
```

After you write your own handler, it can be referenced in endpoint configuration files like so:

```yaml
# example file path: config/endpoints/example.yml
path: /example_webhook
handler: example # this is the name of the handler plugin class
```

It should be noted that the `handler:` key in the endpoint configuration file should match the name of the handler plugin class, but in lowercase and snake case. For example, if your handler plugin class is named `ExampleHandler`, the `handler:` key in the endpoint configuration file should be `example_handler`. Here are some more examples:

- `ExampleHandler` -> `example_handler`
- `MyCustomHandler` -> `my_custom_handler`
- `Cool2Handler` -> `cool_2_handler`

### `payload` Parameter

The `payload` parameter can be a Hash or a String. If the payload is a String, it will be parsed as JSON. If it is a Hash, it will be passed directly to the handler. The payload can contain any data that the webhook sender wants to send.
Expand Down
8 changes: 8 additions & 0 deletions lib/hooks/app/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "json"
require "securerandom"
require_relative "helpers"
#require_relative "network/ip_filtering"
require_relative "auth/auth"
require_relative "rack_env_builder"
require_relative "../plugins/handlers/base"
Expand Down Expand Up @@ -82,6 +83,13 @@ def self.create(config:, endpoints:, log:)
plugin.on_request(rack_env)
end

# TODO: IP filtering before processing the request if defined
# If IP filtering is enabled at either global or endpoint level, run the filtering rules
# before processing the request
#if config[:ip_filtering] || endpoint_config[:ip_filtering]
#ip_filtering!(headers, endpoint_config, config, request_context, rack_env)
#end

enforce_request_limits(config, request_context)
request.body.rewind
raw_body = request.body.read
Expand Down
2 changes: 1 addition & 1 deletion lib/hooks/app/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def parse_payload(raw_body, headers, symbolize: false)

# Load handler class
#
# @param handler_class_name [String] The name of the handler class to load
# @param handler_class_name [String] The name of the handler in snake_case (e.g., "github_handler")
# @return [Object] An instance of the loaded handler class
# @raise [StandardError] If handler cannot be found
def load_handler(handler_class_name)
Expand Down
9 changes: 5 additions & 4 deletions lib/hooks/core/config_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,12 @@ def self.valid_handler_name?(handler_name)
# Must not be empty or only whitespace
return false if handler_name.strip.empty?

# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
return false unless handler_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
# Must match strict snake_case pattern: starts with lowercase, no trailing/consecutive underscores
return false unless handler_name.match?(/\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/)

# Must not be a system/built-in class name
return false if Hooks::Security::DANGEROUS_CLASSES.include?(handler_name)
# Convert to PascalCase for security check (since DANGEROUS_CLASSES uses PascalCase)
pascal_case_name = handler_name.split("_").map(&:capitalize).join("")
return false if Hooks::Security::DANGEROUS_CLASSES.include?(pascal_case_name)

true
end
Expand Down
6 changes: 4 additions & 2 deletions lib/hooks/core/plugin_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,13 @@ def get_auth_plugin(plugin_name)

# Get handler plugin class by name
#
# @param handler_name [String] Name of the handler (e.g., "DefaultHandler", "Team1Handler")
# @param handler_name [String] Name of the handler in snake_case (e.g., "github_handler", "team_1_handler")
# @return [Class] The handler plugin class
# @raise [StandardError] if handler not found
def get_handler_plugin(handler_name)
plugin_class = @handler_plugins[handler_name]
# Convert snake_case to PascalCase for registry lookup
pascal_case_name = handler_name.split("_").map(&:capitalize).join("")
plugin_class = @handler_plugins[pascal_case_name]

unless plugin_class
raise StandardError, "Handler plugin '#{handler_name}' not found. Available handlers: #{@handler_plugins.keys.join(', ')}"
Expand Down
8 changes: 8 additions & 0 deletions lib/hooks/plugins/auth/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require_relative "../../core/log"
require_relative "../../core/global_components"
require_relative "../../core/component_access"
require_relative "timestamp_validator"

module Hooks
module Plugins
Expand Down Expand Up @@ -53,6 +54,13 @@ def self.fetch_secret(config, secret_env_key_name: :secret_env_key)
return secret.strip
end

# Get timestamp validator instance
#
# @return [TimestampValidator] Singleton timestamp validator instance
def self.timestamp_validator
TimestampValidator.new
end

# Find a header value by name with case-insensitive matching
#
# @param headers [Hash] HTTP headers from the request
Expand Down
9 changes: 0 additions & 9 deletions lib/hooks/plugins/auth/hmac.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
require "openssl"
require "time"
require_relative "base"
require_relative "timestamp_validator"

module Hooks
module Plugins
Expand Down Expand Up @@ -271,14 +270,6 @@ def self.valid_timestamp?(headers, config)
timestamp_validator.valid?(timestamp_value, tolerance)
end

# Get timestamp validator instance
#
# @return [TimestampValidator] Singleton timestamp validator instance
# @api private
def self.timestamp_validator
@timestamp_validator ||= TimestampValidator.new
end

# Compute HMAC signature based on configuration requirements
#
# Generates the expected HMAC signature for the given payload using the
Expand Down
2 changes: 1 addition & 1 deletion lib/hooks/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
module Hooks
# Current version of the Hooks webhook framework
# @return [String] The version string following semantic versioning
VERSION = "0.2.1".freeze
VERSION = "0.3.0".freeze
end
10 changes: 5 additions & 5 deletions spec/acceptance/acceptance_tests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -400,13 +400,13 @@ def expired_unix_timestamp(seconds_ago = 600)
expect_response(response, Net::HTTPSuccess)
body = parse_json_response(response)
expect(body["status"]).to eq("test_success")
expect(body["handler"]).to eq("TestHandler")
expect(body["handler"]).to eq("test_handler")
expect(body["payload_received"]).to eq({})
expect(body["env_received"]).to have_key("REQUEST_METHOD")

env = body["env_received"]
expect(env["hooks.request_id"]).to be_a(String)
expect(env["hooks.handler"]).to eq("TestHandler")
expect(env["hooks.handler"]).to eq("test_handler")
expect(env["hooks.endpoint_config"]).to be_a(Hash)
expect(env["hooks.start_time"]).to be_a(String)
expect(env["hooks.full_path"]).to eq("/webhooks/with_custom_auth_plugin")
Expand Down Expand Up @@ -445,7 +445,7 @@ def expired_unix_timestamp(seconds_ago = 600)
expect(body).to have_key("request_id")
expect(body["request_id"]).to be_a(String)
expect(body).to have_key("handler")
expect(body["handler"]).to eq("Boomtown")
expect(body["handler"]).to eq("boomtown")
end
end

Expand All @@ -454,11 +454,11 @@ def expired_unix_timestamp(seconds_ago = 600)
payload = {}.to_json
headers = {}
response = make_request(:post, "/webhooks/does_not_exist", payload, headers)
expect_response(response, Net::HTTPInternalServerError, /Handler plugin 'DoesNotExist' not found/)
expect_response(response, Net::HTTPInternalServerError, /Handler plugin 'does_not_exist' not found/)
body = parse_json_response(response)
expect(body["error"]).to eq("server_error")
expect(body["message"]).to match(
/Handler plugin 'DoesNotExist' not found. Available handlers: DefaultHandler,.*/
/Handler plugin 'does_not_exist' not found. Available handlers: DefaultHandler,.*/
)
end
end
Expand Down
2 changes: 1 addition & 1 deletion spec/acceptance/config/endpoints/boomtown.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
path: /boomtown
handler: Boomtown
handler: boomtown
method: post
2 changes: 1 addition & 1 deletion spec/acceptance/config/endpoints/boomtown_with_error.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
path: /boomtown_with_error
handler: BoomtownWithError
handler: boomtown_with_error
method: post
2 changes: 1 addition & 1 deletion spec/acceptance/config/endpoints/does_not_exist.yml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
path: /does_not_exist
handler: DoesNotExist
handler: does_not_exist
2 changes: 1 addition & 1 deletion spec/acceptance/config/endpoints/github.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Sample endpoint configuration for GitHub webhooks
path: /github
handler: GithubHandler
handler: github_handler

# GitHub uses HMAC SHA256 signature validation
auth:
Expand Down
2 changes: 1 addition & 1 deletion spec/acceptance/config/endpoints/hello.yml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
path: /hello
handler: Hello
handler: hello
2 changes: 1 addition & 1 deletion spec/acceptance/config/endpoints/hmac_with_timestamp.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
path: /hmac_with_timestamp
handler: Hello
handler: hello

auth:
type: hmac
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
path: /ip_filtering_example
handler: Hello
handler: hello

auth:
type: ip_filtering_example
Expand Down
2 changes: 1 addition & 1 deletion spec/acceptance/config/endpoints/okta.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
path: /okta
handler: OktaHandler
handler: okta_handler

auth:
type: shared_secret
Expand Down
2 changes: 1 addition & 1 deletion spec/acceptance/config/endpoints/okta_setup.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
path: /okta_webhook_setup
handler: OktaSetupHandler
handler: okta_setup_handler
method: get
2 changes: 1 addition & 1 deletion spec/acceptance/config/endpoints/slack.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
path: /slack
handler: SlackHandler
handler: slack_handler

auth:
type: hmac
Expand Down
2 changes: 1 addition & 1 deletion spec/acceptance/config/endpoints/tailscale.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
path: /tailscale
handler: Hello
handler: hello

auth:
type: hmac
Expand Down
2 changes: 1 addition & 1 deletion spec/acceptance/config/endpoints/team1.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Sample endpoint configuration for Team 1
path: /team1
handler: Team1Handler
handler: team_1_handler

# Signature validation (optional)
# auth:
Expand Down
2 changes: 1 addition & 1 deletion spec/acceptance/config/endpoints/with_custom_auth.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
path: /with_custom_auth_plugin
handler: TestHandler
handler: test_handler

auth:
type: example
Expand Down
2 changes: 1 addition & 1 deletion spec/acceptance/plugins/handlers/test_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ class TestHandler < Hooks::Plugins::Handlers::Base
def call(payload:, headers:, env:, config:)
{
status: "test_success",
handler: "TestHandler",
handler: "test_handler",
payload_received: payload,
env_received: env,
config_opts: config[:opts],
Expand Down
2 changes: 1 addition & 1 deletion spec/integration/global_lifecycle_hooks_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def call(payload:, headers:, env:, config:)
# Create an endpoint configuration
endpoint_config_content = <<~YAML
path: /integration-test
handler: IntegrationTestHandler
handler: integration_test_handler
YAML
File.write(File.join(temp_endpoints_dir, "integration_test.yml"), endpoint_config_content)
end
Expand Down
2 changes: 1 addition & 1 deletion spec/integration/hooks_integration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def app
FileUtils.mkdir_p("./spec/integration/tmp/endpoints")
File.write("./spec/integration/tmp/endpoints/test.yaml", {
path: "/test",
handler: "TestHandler",
handler: "test_handler",
opts: { test_mode: true }
}.to_yaml)

Expand Down
Loading