mockforge_http/handlers/
webhook_test.rs

1//! Webhook testing utilities and endpoints
2//!
3//! This module provides endpoints for testing webhook notifications
4//! and utilities for validating webhook payloads.
5
6use axum::extract::State;
7use axum::http::StatusCode;
8use axum::response::Json;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::Arc;
12
13/// State for webhook testing
14#[derive(Clone)]
15pub struct WebhookTestState {
16    /// Received webhooks (for testing)
17    pub received_webhooks: Arc<tokio::sync::RwLock<Vec<ReceivedWebhook>>>,
18}
19
20/// Received webhook entry
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ReceivedWebhook {
23    /// Timestamp when webhook was received
24    pub received_at: String,
25    /// Webhook URL
26    pub url: String,
27    /// Event type
28    pub event: String,
29    /// Payload
30    pub payload: serde_json::Value,
31    /// Headers
32    pub headers: HashMap<String, String>,
33}
34
35impl Default for WebhookTestState {
36    fn default() -> Self {
37        Self {
38            received_webhooks: Arc::new(tokio::sync::RwLock::new(Vec::new())),
39        }
40    }
41}
42
43/// Request to test a webhook
44#[derive(Debug, Deserialize, Serialize)]
45pub struct TestWebhookRequest {
46    /// Webhook URL
47    pub url: String,
48    /// Event type
49    pub event: String,
50    /// Payload to send
51    pub payload: serde_json::Value,
52    /// Optional headers
53    pub headers: Option<HashMap<String, String>>,
54}
55
56/// Response for webhook test
57#[derive(Debug, Serialize)]
58pub struct TestWebhookResponse {
59    /// Success status
60    pub success: bool,
61    /// Status code from webhook endpoint
62    pub status_code: Option<u16>,
63    /// Response body
64    pub response_body: Option<String>,
65    /// Error message (if any)
66    pub error: Option<String>,
67}
68
69/// Test a webhook by sending a request
70///
71/// POST /api/v1/webhooks/test
72pub async fn test_webhook(
73    State(_state): State<WebhookTestState>,
74    Json(request): Json<TestWebhookRequest>,
75) -> Result<Json<TestWebhookResponse>, StatusCode> {
76    let client = reqwest::Client::new();
77
78    let mut req = client.post(&request.url).json(&request.payload);
79
80    // Add headers if provided
81    if let Some(headers) = &request.headers {
82        for (key, value) in headers {
83            req = req.header(key, value);
84        }
85    }
86
87    match req.send().await {
88        Ok(response) => {
89            let status_code = response.status().as_u16();
90            let response_body = response.text().await.ok();
91
92            Ok(Json(TestWebhookResponse {
93                success: status_code < 400,
94                status_code: Some(status_code),
95                response_body,
96                error: None,
97            }))
98        }
99        Err(e) => Ok(Json(TestWebhookResponse {
100            success: false,
101            status_code: None,
102            response_body: None,
103            error: Some(e.to_string()),
104        })),
105    }
106}
107
108/// Receive a webhook (for testing webhook delivery)
109///
110/// POST /api/v1/webhooks/receive
111pub async fn receive_webhook(
112    State(state): State<WebhookTestState>,
113    headers: axum::http::HeaderMap,
114    Json(payload): Json<serde_json::Value>,
115) -> Result<Json<serde_json::Value>, StatusCode> {
116    // Extract event type from headers or payload
117    let event = headers
118        .get("x-webhook-event")
119        .and_then(|h| h.to_str().ok())
120        .map(|s| s.to_string())
121        .or_else(|| payload.get("event").and_then(|v| v.as_str()).map(|s| s.to_string()))
122        .unwrap_or_else(|| "unknown".to_string());
123
124    // Extract URL from headers
125    let url = headers
126        .get("x-webhook-url")
127        .and_then(|h| h.to_str().ok())
128        .map(|s| s.to_string())
129        .unwrap_or_else(|| "unknown".to_string());
130
131    // Convert headers to HashMap
132    let mut header_map = HashMap::new();
133    for (key, value) in headers.iter() {
134        let key_str = key.as_str().to_string();
135        if let Ok(value_str) = value.to_str() {
136            header_map.insert(key_str, value_str.to_string());
137        }
138    }
139
140    // Store received webhook
141    let received = ReceivedWebhook {
142        received_at: chrono::Utc::now().to_rfc3339(),
143        url,
144        event,
145        payload,
146        headers: header_map,
147    };
148
149    state.received_webhooks.write().await.push(received.clone());
150
151    Ok(Json(serde_json::json!({
152        "status": "received",
153        "event": received.event,
154        "received_at": received.received_at,
155    })))
156}
157
158/// Get received webhooks (for testing)
159///
160/// GET /api/v1/webhooks/received
161pub async fn get_received_webhooks(
162    State(state): State<WebhookTestState>,
163) -> Result<Json<Vec<ReceivedWebhook>>, StatusCode> {
164    let webhooks = state.received_webhooks.read().await.clone();
165    Ok(Json(webhooks))
166}
167
168/// Clear received webhooks (for testing)
169///
170/// DELETE /api/v1/webhooks/received
171pub async fn clear_received_webhooks(
172    State(state): State<WebhookTestState>,
173) -> Result<Json<serde_json::Value>, StatusCode> {
174    state.received_webhooks.write().await.clear();
175    Ok(Json(serde_json::json!({"status": "cleared"})))
176}
177
178/// Create webhook test router
179pub fn webhook_test_router(state: WebhookTestState) -> axum::Router {
180    use axum::routing::{delete, get, post};
181
182    axum::Router::new()
183        .route("/api/v1/webhooks/test", post(test_webhook))
184        .route("/api/v1/webhooks/receive", post(receive_webhook))
185        .route("/api/v1/webhooks/received", get(get_received_webhooks))
186        .route("/api/v1/webhooks/received", delete(clear_received_webhooks))
187        .with_state(state)
188}