Skip to main content

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;