github_bot_sdk/webhook/receiver.rs
1//! Webhook receiver for HTTP intake and async processing coordination.
2//!
3//! This module provides the core webhook receiving functionality that applications
4//! use to accept GitHub webhooks, validate them, and dispatch to handlers. The
5//! receiver implements the fire-and-forget pattern to ensure fast HTTP responses.
6//!
7//! # Fire-and-Forget Pattern
8//!
9//! The receiver follows this pattern:
10//! 1. Extract headers and payload from HTTP request (fast)
11//! 2. Validate signature (fast, ~10ms)
12//! 3. Process/normalize event (fast, ~5ms)
13//! 4. Return HTTP response immediately (target <100ms total)
14//! 5. Spawn async tasks for handlers (non-blocking)
15//!
16//! This ensures GitHub receives a response within the 10-second timeout while
17//! allowing handlers to perform longer operations.
18//!
19//! # Examples
20//!
21//! ```rust,no_run
22//! use github_bot_sdk::webhook::{WebhookReceiver, WebhookHandler, WebhookRequest};
23//! use github_bot_sdk::auth::SecretProvider;
24//! use github_bot_sdk::events::{EventProcessor, ProcessorConfig};
25//! use std::sync::Arc;
26//! use std::collections::HashMap;
27//!
28//! # async fn example(secret_provider: Arc<dyn SecretProvider>) {
29//! // Create receiver with dependencies
30//! let processor = EventProcessor::new(ProcessorConfig::default());
31//! let receiver = WebhookReceiver::new(secret_provider, processor);
32//!
33//! // Register handlers
34//! // receiver.add_handler(my_handler);
35//!
36//! // Process incoming webhook
37//! let headers = HashMap::from([
38//! ("x-github-event".to_string(), "pull_request".to_string()),
39//! ("x-github-delivery".to_string(), "12345".to_string()),
40//! ("x-hub-signature-256".to_string(), "sha256=abc...".to_string()),
41//! ]);
42//! let body = bytes::Bytes::from_static(b"{\"action\":\"opened\"}");
43//! let request = WebhookRequest::new(headers, body);
44//!
45//! let response = receiver.receive_webhook(request).await;
46//! println!("Status: {}", response.status_code());
47//! # }
48//! ```
49
50use crate::auth::SecretProvider;
51use crate::events::EventProcessor;
52use crate::webhook::handler::WebhookHandler;
53use crate::webhook::validation::SignatureValidator;
54use bytes::Bytes;
55use std::collections::HashMap;
56use std::sync::Arc;
57use tokio::sync::RwLock;
58use tracing::{error, info, warn};
59
60// ============================================================================
61// Webhook Request/Response Types
62// ============================================================================
63
64/// Raw HTTP webhook request data.
65///
66/// Contains the headers and body from an incoming GitHub webhook HTTP request.
67/// Headers should include `X-GitHub-Event`, `X-GitHub-Delivery`, and
68/// `X-Hub-Signature-256`.
69///
70/// # Examples
71///
72/// ```rust
73/// use github_bot_sdk::webhook::WebhookRequest;
74/// use std::collections::HashMap;
75///
76/// let headers = HashMap::from([
77/// ("x-github-event".to_string(), "pull_request".to_string()),
78/// ("x-github-delivery".to_string(), "12345".to_string()),
79/// ]);
80/// let body = b"{\"action\":\"opened\"}".to_vec();
81///
82/// let request = WebhookRequest::new(headers, body.into());
83/// assert_eq!(request.event_type(), Some("pull_request"));
84/// ```
85#[derive(Debug, Clone)]
86pub struct WebhookRequest {
87 headers: HashMap<String, String>,
88 body: Bytes,
89}
90
91impl WebhookRequest {
92 /// Create a new webhook request.
93 ///
94 /// # Arguments
95 ///
96 /// * `headers` - HTTP headers (case-insensitive keys recommended)
97 /// * `body` - Raw webhook payload bytes
98 pub fn new(headers: HashMap<String, String>, body: Bytes) -> Self {
99 Self { headers, body }
100 }
101
102 /// Get the event type from X-GitHub-Event header.
103 pub fn event_type(&self) -> Option<&str> {
104 self.headers
105 .get("x-github-event")
106 .or_else(|| self.headers.get("X-GitHub-Event"))
107 .map(|s| s.as_str())
108 }
109
110 /// Get the delivery ID from X-GitHub-Delivery header.
111 pub fn delivery_id(&self) -> Option<&str> {
112 self.headers
113 .get("x-github-delivery")
114 .or_else(|| self.headers.get("X-GitHub-Delivery"))
115 .map(|s| s.as_str())
116 }
117
118 /// Get the signature from X-Hub-Signature-256 header.
119 pub fn signature(&self) -> Option<&str> {
120 self.headers
121 .get("x-hub-signature-256")
122 .or_else(|| self.headers.get("X-Hub-Signature-256"))
123 .map(|s| s.as_str())
124 }
125
126 /// Get the raw payload bytes.
127 pub fn payload(&self) -> &[u8] {
128 &self.body
129 }
130
131 /// Get all headers.
132 pub fn headers(&self) -> &HashMap<String, String> {
133 &self.headers
134 }
135}
136
137/// HTTP response for webhook requests.
138///
139/// Represents the immediate HTTP response sent to GitHub after webhook
140/// validation and processing (but before handler execution).
141#[derive(Debug, Clone)]
142pub enum WebhookResponse {
143 /// 200 OK - Webhook accepted and queued for processing
144 Ok { message: String, event_id: String },
145
146 /// 401 Unauthorized - Invalid or missing signature
147 Unauthorized { message: String },
148
149 /// 400 Bad Request - Malformed request (missing headers, invalid JSON)
150 BadRequest { message: String },
151
152 /// 500 Internal Server Error - Processing failed
153 InternalError { message: String },
154}
155
156impl WebhookResponse {
157 /// Get the HTTP status code for this response.
158 pub fn status_code(&self) -> u16 {
159 match self {
160 Self::Ok { .. } => 200,
161 Self::Unauthorized { .. } => 401,
162 Self::BadRequest { .. } => 400,
163 Self::InternalError { .. } => 500,
164 }
165 }
166
167 /// Get the response message.
168 pub fn message(&self) -> &str {
169 match self {
170 Self::Ok { message, .. } => message,
171 Self::Unauthorized { message } => message,
172 Self::BadRequest { message } => message,
173 Self::InternalError { message } => message,
174 }
175 }
176
177 /// Check if response indicates success.
178 pub fn is_success(&self) -> bool {
179 matches!(self, Self::Ok { .. })
180 }
181}
182
183// ============================================================================
184// Webhook Receiver
185// ============================================================================
186
187/// Webhook receiver for processing incoming GitHub webhooks.
188///
189/// The receiver coordinates validation, event processing, and handler
190/// execution using a fire-and-forget pattern to ensure fast HTTP responses.
191///
192/// # Architecture
193///
194/// - Validates signatures using SignatureValidator
195/// - Processes events using EventProcessor
196/// - Dispatches to registered WebhookHandlers asynchronously
197/// - Returns HTTP responses within 100ms (target)
198///
199/// # Examples
200///
201/// ```rust,no_run
202/// use github_bot_sdk::webhook::WebhookReceiver;
203/// use github_bot_sdk::auth::SecretProvider;
204/// use github_bot_sdk::events::{EventProcessor, ProcessorConfig};
205/// use std::sync::Arc;
206///
207/// # async fn example(secret_provider: Arc<dyn SecretProvider>) -> Result<(), Box<dyn std::error::Error>> {
208/// let processor = EventProcessor::new(ProcessorConfig::default());
209/// let receiver = WebhookReceiver::new(secret_provider, processor);
210/// # Ok(())
211/// # }
212/// ```
213pub struct WebhookReceiver {
214 validator: SignatureValidator,
215 processor: EventProcessor,
216 handlers: Arc<RwLock<Vec<Arc<dyn WebhookHandler>>>>,
217}
218
219impl WebhookReceiver {
220 /// Create a new webhook receiver.
221 ///
222 /// # Arguments
223 ///
224 /// * `secrets` - Provider for retrieving webhook secrets
225 /// * `processor` - Event processor for normalizing webhooks
226 ///
227 /// # Examples
228 ///
229 /// ```rust,no_run
230 /// # use github_bot_sdk::webhook::WebhookReceiver;
231 /// # use github_bot_sdk::auth::SecretProvider;
232 /// # use github_bot_sdk::events::{EventProcessor, ProcessorConfig};
233 /// # use std::sync::Arc;
234 /// # async fn example(secret_provider: Arc<dyn SecretProvider>) -> Result<(), Box<dyn std::error::Error>> {
235 /// let processor = EventProcessor::new(ProcessorConfig::default());
236 /// let receiver = WebhookReceiver::new(secret_provider, processor);
237 /// # Ok(())
238 /// # }
239 /// ```
240 pub fn new(secrets: Arc<dyn SecretProvider>, processor: EventProcessor) -> Self {
241 let validator = SignatureValidator::new(secrets);
242
243 Self {
244 validator,
245 processor,
246 handlers: Arc::new(RwLock::new(Vec::new())),
247 }
248 }
249
250 /// Register a webhook handler.
251 ///
252 /// Handlers are invoked asynchronously after the HTTP response is sent.
253 /// Multiple handlers can be registered and will execute concurrently.
254 ///
255 /// # Arguments
256 ///
257 /// * `handler` - The handler implementation to register
258 ///
259 /// # Examples
260 ///
261 /// ```rust,no_run
262 /// # use github_bot_sdk::webhook::{WebhookReceiver, WebhookHandler};
263 /// # use github_bot_sdk::auth::SecretProvider;
264 /// # use github_bot_sdk::events::{EventProcessor, ProcessorConfig, EventEnvelope};
265 /// # use std::sync::Arc;
266 /// # use async_trait::async_trait;
267 /// # struct MyHandler;
268 /// # #[async_trait]
269 /// # impl WebhookHandler for MyHandler {
270 /// # async fn handle_event(&self, envelope: &EventEnvelope) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
271 /// # Ok(())
272 /// # }
273 /// # }
274 /// # async fn example(secret_provider: Arc<dyn SecretProvider>) -> Result<(), Box<dyn std::error::Error>> {
275 /// let processor = EventProcessor::new(ProcessorConfig::default());
276 /// let mut receiver = WebhookReceiver::new(secret_provider, processor);
277 ///
278 /// receiver.add_handler(Arc::new(MyHandler)).await;
279 /// # Ok(())
280 /// # }
281 /// ```
282 pub async fn add_handler(&mut self, handler: Arc<dyn WebhookHandler>) {
283 let mut handlers = self.handlers.write().await;
284 handlers.push(handler);
285 }
286
287 /// Process an incoming webhook request.
288 ///
289 /// This is the main entry point for webhook processing. It performs
290 /// validation, event processing, and returns an immediate HTTP response.
291 /// Handler execution happens asynchronously after the response is returned.
292 ///
293 /// # Processing Steps
294 ///
295 /// 1. Extract headers (event type, delivery ID, signature)
296 /// 2. Validate signature using webhook secret
297 /// 3. Process event (parse and normalize)
298 /// 4. Return HTTP response immediately
299 /// 5. Spawn async task for handlers (fire-and-forget)
300 ///
301 /// # Arguments
302 ///
303 /// * `request` - The incoming webhook request
304 ///
305 /// # Returns
306 ///
307 /// HTTP response to send to GitHub
308 ///
309 /// # Errors
310 ///
311 /// Returns error responses for:
312 /// - Missing required headers (BadRequest)
313 /// - Invalid signature (Unauthorized)
314 /// - Malformed payload (BadRequest)
315 /// - Processing failures (InternalError)
316 pub async fn receive_webhook(&self, request: WebhookRequest) -> WebhookResponse {
317 // Extract required headers
318 let event_type = match request.event_type() {
319 Some(et) => et,
320 None => {
321 return WebhookResponse::BadRequest {
322 message: "Missing X-GitHub-Event header".to_string(),
323 };
324 }
325 };
326
327 let signature = match request.signature() {
328 Some(sig) => sig,
329 None => {
330 return WebhookResponse::Unauthorized {
331 message: "Missing X-Hub-Signature-256 header".to_string(),
332 };
333 }
334 };
335
336 let delivery_id = request.delivery_id();
337
338 // Validate signature
339 match self.validator.validate(request.payload(), signature).await {
340 Ok(true) => {
341 info!(
342 event_type = %event_type,
343 delivery_id = ?delivery_id,
344 "Webhook signature validated"
345 );
346 }
347 Ok(false) => {
348 warn!(
349 event_type = %event_type,
350 delivery_id = ?delivery_id,
351 "Invalid webhook signature"
352 );
353 return WebhookResponse::Unauthorized {
354 message: "Invalid signature".to_string(),
355 };
356 }
357 Err(e) => {
358 error!(
359 event_type = %event_type,
360 delivery_id = ?delivery_id,
361 error = %e,
362 "Signature validation failed"
363 );
364 return WebhookResponse::InternalError {
365 message: format!("Validation error: {}", e),
366 };
367 }
368 }
369
370 // Process event (parse and normalize)
371 let envelope = match self
372 .processor
373 .process_webhook(event_type, request.payload(), delivery_id)
374 .await
375 {
376 Ok(env) => env,
377 Err(e) => {
378 error!(
379 event_type = %event_type,
380 delivery_id = ?delivery_id,
381 error = %e,
382 "Event processing failed"
383 );
384 return WebhookResponse::BadRequest {
385 message: format!("Invalid webhook payload: {}", e),
386 };
387 }
388 };
389
390 let event_id = envelope.event_id.to_string();
391
392 info!(
393 event_id = %envelope.event_id,
394 event_type = %envelope.event_type,
395 repository = %envelope.repository.full_name,
396 "Webhook processed successfully"
397 );
398
399 // Spawn async handler tasks (fire-and-forget)
400 let handlers = self.handlers.clone();
401 tokio::spawn(async move {
402 let handlers_guard = handlers.read().await;
403 for handler in handlers_guard.iter() {
404 let handler_clone = handler.clone();
405 let envelope_clone = envelope.clone();
406
407 tokio::spawn(async move {
408 if let Err(e) = handler_clone.handle_event(&envelope_clone).await {
409 error!(
410 event_id = %envelope_clone.event_id,
411 error = %e,
412 "Handler execution failed"
413 );
414 }
415 });
416 }
417 });
418
419 // Return immediate response
420 WebhookResponse::Ok {
421 message: "Webhook received".to_string(),
422 event_id,
423 }
424 }
425}
426
427#[cfg(test)]
428#[path = "receiver_tests.rs"]
429mod tests;