|
| 1 | +# `hooks` — A Pluggable Ruby Webhook Server Framework |
| 2 | + |
| 3 | +## 📜 1. Project Overview |
| 4 | + |
| 5 | +`hooks` is a **pure-Ruby**, **Grape-based**, **Rack-compatible** webhook server gem that: |
| 6 | + |
| 7 | +* Dynamically mounts endpoints from per-team configs under a configurable `root_path` |
| 8 | +* Loads **team handlers** and **global plugins** at boot, with priority ordering for hooks |
| 9 | +* Validates configs via **Dry::Schema**, failing fast on invalid YAML/JSON/Hash |
| 10 | +* Supports **signature validation** (default HMAC) and **custom validator** classes |
| 11 | +* Enforces **allowed\_env\_vars** per endpoint—handlers can only read declared ENV keys |
| 12 | +* Offers built-in **authentication modules**: IP whitelisting, API key, OAuth flows |
| 13 | +* Applies **CORS policy** globally and allows **per-endpoint overrides** |
| 14 | +* Enforces **request limits** (body size) and **timeouts**, configurable at runtime |
| 15 | +* Emits **metrics events** (`:request_start`, `:request_end`, `:error`) for downstream integration |
| 16 | +* Ships with operational endpoints: |
| 17 | + |
| 18 | + * **GET** `<health_path>`: liveness/readiness payload |
| 19 | + * **GET** `<metrics_path>`: JSON array of recent events |
| 20 | + * **GET** `<version_path>`: current gem version |
| 21 | + |
| 22 | +* Supplies a **scaffold CLI** and **optional test helpers** |
| 23 | +* Boots a demo `<root_path>/hello` route when no config is supplied, to verify setup |
| 24 | + |
| 25 | +> **Server Agnostic:** `hooks` exports a Rack-compatible app. Mount under any Rack server (Puma, Unicorn, Thin, etc.). |
| 26 | +
|
| 27 | +--- |
| 28 | + |
| 29 | +## 🎯 2. Core Goals |
| 30 | + |
| 31 | +1. **Config-Driven Endpoints** |
| 32 | + |
| 33 | + * Single file per endpoint: YAML, JSON, or Ruby Hash |
| 34 | + * Merged into `AppConfig` at boot, validated |
| 35 | + * Each endpoint `path` is prefixed by global `root_path` (default `/webhooks`) |
| 36 | + |
| 37 | +2. **Plugin Architecture** |
| 38 | + |
| 39 | + * **Team Handlers**: `class MyHandler < Hooks::Handlers::Base` |
| 40 | + * **Global Plugins**: `class MyPlugin < Hooks::Plugins::Lifecycle` |
| 41 | + * **Signature Validators**: implement `.valid?(payload:, headers:, secret:, config:)` |
| 42 | + * **Hook Priority**: specify ordering in global settings |
| 43 | + |
| 44 | +3. **Security & Isolation** |
| 45 | + |
| 46 | + * `allowed_env_vars` restricts ENV access per handler |
| 47 | + * **Sandbox** prevents `require`/`load` outside `handler_dir` and `plugin_dir` |
| 48 | + * Auth modules guard endpoints before handler invocation |
| 49 | + * Default JSON error responses, with detailed hooks |
| 50 | + |
| 51 | +4. **Operational Endpoints** |
| 52 | + |
| 53 | + * **Health**: liveness/readiness, config checksums |
| 54 | + * **Metrics**: JSON events log (last N entries) |
| 55 | + * **Version**: gem version report |
| 56 | + |
| 57 | +5. **Developer & Operator Experience** |
| 58 | + |
| 59 | + * Single entrypoint: `app = Hooks.build(...)` |
| 60 | + * Multiple configuration methods: path(s), ENV, Ruby Hash |
| 61 | + * Graceful shutdown on SIGINT/SIGTERM |
| 62 | + * Structured JSON logging with `request_id`, `path`, `handler`, timestamp |
| 63 | + * Scaffold generators for handlers and plugins |
| 64 | + * Optional `hooks-test` gem for RSpec support |
| 65 | + |
| 66 | +--- |
| 67 | + |
| 68 | +## ⚙️ 3. Installation & Invocation |
| 69 | + |
| 70 | +### Gemfile |
| 71 | + |
| 72 | +```ruby |
| 73 | +gem "hooks" |
| 74 | +``` |
| 75 | + |
| 76 | +### Programmatic Invocation |
| 77 | + |
| 78 | +```ruby |
| 79 | +require "hooks" |
| 80 | + |
| 81 | +# Returns a Rack-compatible app |
| 82 | +app = Hooks.build( |
| 83 | + config: "/path/to/endpoints/", # Directory or Array/Hash |
| 84 | + settings: "/path/to/settings.yaml", # YAML, JSON, or Hash |
| 85 | + log: MyCustomLogger.new, # Optional logger (must respond to #info, #error, etc.) |
| 86 | + request_limit: 1_048_576, # Default max body size (bytes) |
| 87 | + request_timeout: 15, # Default timeout (seconds) |
| 88 | + cors: { |
| 89 | + allow_origin: "*", # Default CORS (merged with overrides) |
| 90 | + allow_methods: ["GET","POST","OPTIONS"], |
| 91 | + allow_headers: ["Content-Type","Authorization"] |
| 92 | + }, |
| 93 | + root_path: "/webhooks" # Default mount prefix |
| 94 | +) |
| 95 | +``` |
| 96 | + |
| 97 | +Mount in `config.ru`: |
| 98 | + |
| 99 | +```ruby |
| 100 | +run app |
| 101 | +``` |
| 102 | + |
| 103 | +### ENV-Based Bootstrap |
| 104 | + |
| 105 | +```bash |
| 106 | +export HOOKS_CONFIG_DIR=./config/endpoints |
| 107 | +export HOOKS_SETTINGS=./config/settings.yaml |
| 108 | +export HOOKS_LOGGER_CLASS=MyCustomLogger |
| 109 | +export HOOKS_REQUEST_LIMIT=1048576 |
| 110 | +export HOOKS_REQUEST_TIMEOUT=15 |
| 111 | +export HOOKS_CORS='{"allow_origin":"*"}' |
| 112 | +export HOOKS_ROOT_PATH="/webhooks" |
| 113 | +ruby app.rb |
| 114 | +``` |
| 115 | + |
| 116 | +> **Hello-World Mode** |
| 117 | +> If invoked without `config` or `settings`, serves `GET <root_path>/hello`: |
| 118 | +> |
| 119 | +> ```json |
| 120 | +> { "message": "Hooks is working!" } |
| 121 | +> ``` |
| 122 | +
|
| 123 | +--- |
| 124 | +
|
| 125 | +## 📁 4. Directory Layout |
| 126 | +
|
| 127 | +```text |
| 128 | +lib/hooks/ |
| 129 | +├── app/ |
| 130 | +│ ├── api.rb # Grape::API subclass exporting all endpoints |
| 131 | +│ ├── router_builder.rb # Reads AppConfig to define routes |
| 132 | +│ └── endpoint_builder.rb # Wraps each route: CORS, auth, signature, hooks, handler |
| 133 | +│ |
| 134 | +├── core/ |
| 135 | +│ ├── builder.rb # Hooks.build: config loading, validation, signal handling - builds a rack compatible app |
| 136 | +│ ├── config_loader.rb # Loads + merges per-endpoint configs |
| 137 | +│ ├── settings_loader.rb # Loads global settings |
| 138 | +│ ├── config_validator.rb # Dry::Schema-based validation |
| 139 | +│ ├── logger_factory.rb # Structured JSON logger + context enrichment |
| 140 | +│ ├── metrics_emitter.rb # Event emitter for request metrics |
| 141 | +│ ├── sandbox.rb # Enforce require/load restrictions |
| 142 | +│ └── signal_handler.rb # Trap SIGINT/SIGTERM for graceful shutdown |
| 143 | +│ |
| 144 | +├── handlers/ |
| 145 | +│ └── base.rb # `Hooks::Handlers::Base` interface: defines #call |
| 146 | +│ |
| 147 | +├── plugins/ |
| 148 | +│ ├── lifecycle.rb # `Hooks::Plugins::Lifecycle` hooks (on_request, response, error) |
| 149 | +│ └── signature_validator/ # Default & sample validators |
| 150 | +│ ├── base.rb # Abstract interface |
| 151 | +│ └── hmac_sha256.rb # Default implementation |
| 152 | +│ |
| 153 | +├── auth/ |
| 154 | +│ ├── ip_whitelist.rb # Checks `env['REMOTE_ADDR']` |
| 155 | +│ ├── api_key.rb # Validates header or param |
| 156 | +│ └── oauth.rb # Simple OAuth token validation |
| 157 | +│ |
| 158 | +├── version.rb # Provides `Hooks::VERSION` |
| 159 | +└── hooks.rb # `require 'hooks'` entrypoint defining Hooks module |
| 160 | +``` |
| 161 | +
|
| 162 | +--- |
| 163 | + |
| 164 | +## 🛠️ 5. Config Models |
| 165 | + |
| 166 | +### 5.1 Endpoint Config (per-file) |
| 167 | + |
| 168 | +```yaml |
| 169 | +# config/endpoints/team1.yaml |
| 170 | +path: /team1 # Mounted at <root_path>/team1 |
| 171 | +handler: Team1Handler # Class in handler_dir |
| 172 | + |
| 173 | +# Signature validation |
| 174 | +verify_signature: |
| 175 | + type: default # 'default' uses HMACSHA256, or a custom class name |
| 176 | + secret_env_key: TEAM1_SECRET |
| 177 | + header: X-Hub-Signature |
| 178 | + algorithm: sha256 |
| 179 | + |
| 180 | +# Authentication (any mix) |
| 181 | +auth: |
| 182 | + ip_whitelist: |
| 183 | + - 192.0.2.0/28 |
| 184 | + api_keys: |
| 185 | + - KEY1 |
| 186 | + - KEY2 |
| 187 | + oauth: |
| 188 | + client_id_env: TEAM1_OAUTH_ID |
| 189 | + |
| 190 | +allowed_env_vars: |
| 191 | + - TEAM1_SECRET |
| 192 | + - DATADOG_API_KEY |
| 193 | + |
| 194 | +opts: # Freeform user-defined options |
| 195 | + env: staging |
| 196 | + teams: ["infra","billing"] |
| 197 | + |
| 198 | +cors: # Overrides global CORS |
| 199 | + allow_origin: "https://github.com" |
| 200 | + allow_methods: ["POST"] |
| 201 | + allow_headers: ["Content-Type","X-Github-Event"] |
| 202 | +``` |
| 203 | +
|
| 204 | +### 5.2 Global Settings Config |
| 205 | +
|
| 206 | +```yaml |
| 207 | +# config/settings.yaml |
| 208 | +plugin_dir: ./plugins # global plugin directory |
| 209 | +handler_dir: ./handlers # handler class directory |
| 210 | +log_level: info # debug | info | warn | error |
| 211 | +request_limit: 1048576 # max request body size (bytes) |
| 212 | +request_timeout: 15 # seconds to allow per request |
| 213 | +cors: # default CORS policy |
| 214 | + allow_origin: "*" |
| 215 | + allow_methods: ["GET","POST","OPTIONS"] |
| 216 | + allow_headers: ["Content-Type","Authorization"] |
| 217 | +root_path: /webhooks # base path for all endpoint routes - can be completely changed in endpoint configs, ex: /foo |
| 218 | +health_path: /health # operational health endpoint |
| 219 | +metrics_path: /metrics # operational metrics endpoint |
| 220 | +version_path: /version # gem version endpoint |
| 221 | +environment: production # development | production |
| 222 | +``` |
| 223 | +
|
| 224 | +--- |
| 225 | +
|
| 226 | +## 🔍 6. Core Components & Flow |
| 227 | +
|
| 228 | +1. **Builder (`core/builder.rb`)** |
| 229 | + |
| 230 | + * Load settings (env or file) via `settings_loader` |
| 231 | + * Load endpoint configs via `config_loader` |
| 232 | + * Validate via `config_validator` (Dry::Schema); halt if invalid at boot |
| 233 | + * Initialize structured JSON logger via `logger_factory` |
| 234 | + * Emit startup `:request_start` for `/health`, `/metrics`, `/version` |
| 235 | + * Trap SIGINT/SIGTERM for graceful shutdown |
| 236 | + * Build and return Rack app from `app/api.rb` |
| 237 | + |
| 238 | +2. **API Definition (`app/api.rb`)** |
| 239 | + |
| 240 | + * Uses Grape::API |
| 241 | + * Mounts: |
| 242 | + |
| 243 | + * `<root_path>/hello` (demo) |
| 244 | + * `<health_path>`, `<metrics_path>`, `<version_path>` |
| 245 | + * Each team endpoint under `<root_path>/<path>` |
| 246 | + |
| 247 | +3. **Router & Endpoint Builder** |
| 248 | + |
| 249 | + * For each endpoint config: |
| 250 | + |
| 251 | + * Compose `effective_cors` = deep\_merge(global.cors, endpoint.cors) |
| 252 | + * Define Grape route with: |
| 253 | + |
| 254 | + * **Before**: enforce `request_limit`, `request_timeout`, CORS headers |
| 255 | + * **Auth**: apply IP whitelist, API key, OAuth |
| 256 | + * **Signature**: call custom or default validator |
| 257 | + * **Hooks**: run `on_request` plugins in priority order |
| 258 | + * **Handler**: invoke `MyHandler.new.call(payload:, headers:, config:)` |
| 259 | + * **After**: run `on_response` plugins |
| 260 | + * **Rescue**: on exception, emit metrics `:error`, run `on_error`, rethrow or format JSON error |
| 261 | + |
| 262 | +4. **Metrics Emitter** |
| 263 | + |
| 264 | + * Listen to lifecycle events, build in-memory ring buffer of last N events |
| 265 | + * `/metrics` returns the JSON array of these events (configurable size) |
| 266 | + |
| 267 | +5. **Graceful Shutdown** |
| 268 | + |
| 269 | + * On SIGINT/SIGTERM: allow in-flight requests to finish, exit |
| 270 | + |
| 271 | +--- |
| 272 | + |
| 273 | +## 🔒 7. Security & Isolation |
| 274 | + |
| 275 | +* **Allowed ENV Vars**: endpoints cannot access undisclosed ENV keys |
| 276 | +* **Sandbox**: plugin & handler `require` limited to configured dirs |
| 277 | +* **Authentication**: built-in modules guard routes |
| 278 | +* **Request Validation**: size, timeout, signature, CORS enforced systematically |
| 279 | +* **Error Handling**: exceptions bubble to Grape catchall, with JSON schema |
| 280 | + |
| 281 | +--- |
| 282 | + |
| 283 | +## 🚨 8. Error Handling & Logging |
| 284 | + |
| 285 | +* **Default JSON Error**: |
| 286 | + |
| 287 | + ```json |
| 288 | + { "error": "Error message", "code": 500 } |
| 289 | + ``` |
| 290 | + |
| 291 | +* **Dev Mode**: include full stack trace |
| 292 | +* **Prod Mode**: hide backtrace, log internally |
| 293 | +* **Structured Logs**: each entry includes: |
| 294 | + |
| 295 | + * `timestamp` (ISO8601) |
| 296 | + * `level`, `message` |
| 297 | + * `request_id`, `path`, `handler`, `status`, `duration_ms` |
| 298 | +* **Lifecycle Hooks**: global plugins get `on_error(exception, env)` |
| 299 | + |
| 300 | +--- |
| 301 | + |
| 302 | +## 📈 9. Metrics & Instrumentation |
| 303 | + |
| 304 | +* Hooks for: |
| 305 | + |
| 306 | + * `:request_start` (path, method, request\_id) |
| 307 | + * `:request_end` (status, duration) |
| 308 | + * `:error` (exception details) |
| 309 | +* Users subscribe via global plugins to forward to StatsD, Prometheus, etc. |
| 310 | + |
| 311 | +--- |
| 312 | + |
| 313 | +## 🛠️ 11. CLI & Scaffolding |
| 314 | + |
| 315 | +`bin/hooks`: |
| 316 | + |
| 317 | +```bash |
| 318 | +# Create a new handler skeleton |
| 319 | +hooks scaffold handler my_endpoint |
| 320 | +
|
| 321 | +# Create a new global plugin skeleton |
| 322 | +hooks scaffold plugin my_plugin |
| 323 | +``` |
| 324 | + |
| 325 | +Generates: |
| 326 | + |
| 327 | +* `handlers/my_endpoint_handler.rb` |
| 328 | +* `config/endpoints/my_endpoint.yaml` |
| 329 | +* `plugins/my_plugin.rb` |
| 330 | + |
| 331 | +--- |
| 332 | + |
| 333 | +## 🧪 12. Testing Helpers (Optional) |
| 334 | + |
| 335 | +Add to `Gemfile, group :test`: |
| 336 | + |
| 337 | +```ruby |
| 338 | +gem "hooks-test" |
| 339 | +``` |
| 340 | + |
| 341 | +Provides modules and RSpec matchers to: |
| 342 | + |
| 343 | +* Stub ENV safely |
| 344 | +* Simulate HTTP requests against Rack app |
| 345 | +* Assert metrics and hook invocations |
| 346 | + |
| 347 | +--- |
| 348 | + |
| 349 | +## 📦 13. Hello-World Default |
| 350 | + |
| 351 | +If no config provided, `/webhooks/hello` responds: |
| 352 | + |
| 353 | +```json |
| 354 | +{ "message": "Hooks is working!" } |
| 355 | +``` |
0 commit comments