wompi_webhook_endpoint/
lib.rs

1#[derive(Debug, thiserror::Error)]
2pub enum WompiWebhookEndpointError {
3    #[error("Failed to start server on {0}")]
4    Io(#[from] std::io::Error),
5    #[error("No listener")]
6    NoListener,
7    #[error("Server stopped")]
8    ServerStopped,
9    #[error("Failed to parse address")]
10    AddressError(#[from] std::net::AddrParseError),
11}
12
13#[derive(Default)]
14pub struct WebhookEndpoint {
15    app: axum::Router,
16    listener: Option<tokio::net::TcpListener>,
17}
18
19impl WebhookEndpoint {
20    pub fn add_webhook_route<T, H>(mut self, path: &str, handler: H) -> Self
21    where
22        H: axum::handler::Handler<T, ()>,
23        T: 'static,
24    {
25        self.app = self
26            .app
27            .route(path, axum::routing::post(handler))
28            .layer(axum::middleware::from_fn(validate_wompi_hmac));
29        self
30    }
31    pub async fn add_listener(mut self, addr: &str) -> Result<Self, WompiWebhookEndpointError> {
32        let socket_addr: std::net::SocketAddr = addr.parse()?;
33        self.listener = Some(tokio::net::TcpListener::bind(socket_addr).await?);
34        Ok(self)
35    }
36    pub async fn run(self) -> Result<(), WompiWebhookEndpointError> {
37        let listener = self.listener.ok_or(WompiWebhookEndpointError::NoListener)?;
38        axum::serve(listener, self.app).await?;
39        Err(WompiWebhookEndpointError::ServerStopped)
40    }
41}
42use axum::http::StatusCode;
43pub async fn validate_wompi_hmac(
44    req: axum::extract::Request,
45    next: axum::middleware::Next,
46) -> Result<axum::response::Response, StatusCode> {
47    let (parts, body) = req.into_parts();
48    // Read the full body, with max size of 1MB
49    let body_bytes = axum::body::to_bytes(body, 1024 * 1024)
50        .await
51        .map_err(|_| StatusCode::BAD_REQUEST)?;
52
53    // Clone the body back into the request so the next handler can still read it
54
55    // Get the wompi_hash header
56    let headers: axum::http::HeaderMap = parts.headers.clone();
57    let wompi_hash = headers
58        .get("wompi_hash")
59        .and_then(|v| v.to_str().ok())
60        .ok_or(StatusCode::UNAUTHORIZED)?;
61
62    // Compute HMAC-SHA256(body, secret)
63    let mut mac: hmac::Hmac<sha2::Sha256> = hmac::Mac::new_from_slice(
64        std::env::var("WOMPI_CLIENT_SECRET")
65            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
66            .as_bytes(),
67    )
68    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
69    hmac::Mac::update(&mut mac, &body_bytes);
70    let expected = hex::encode(hmac::Mac::finalize(mac).into_bytes());
71
72    let req = axum::extract::Request::from_parts(parts, axum::body::Body::from(body_bytes));
73
74    // Compare securely
75    if subtle::ConstantTimeEq::ct_eq(expected.as_bytes(), wompi_hash.as_bytes()).unwrap_u8() == 1 {
76        Ok(next.run(req).await)
77    } else {
78        Err(StatusCode::UNAUTHORIZED)
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[tokio::test]
87    async fn test_webhook_endpoint() {
88        async fn json_handle(
89            axum::Json(webhook): axum::Json<
90                wompi_models::NotificacionWebhook<wompi_models::ClienteSubscripcion>,
91            >,
92        ) {
93            println!("✅ Webhook recibido: {webhook:?}");
94            std::process::exit(0);
95        }
96        tokio::spawn(async move {
97            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
98            let notification = wompi_models::NotificacionWebhook {
99                cliente: wompi_models::ClienteSubscripcion {
100                    cantidad_compra: 1,
101                    ..Default::default()
102                },
103                ..Default::default()
104            };
105            println!("✅ Enviando notificación: {notification:?}");
106        });
107        WebhookEndpoint::default()
108            .add_webhook_route("/", json_handle)
109            .add_listener("0.0.0.0:4444")
110            .await
111            .expect("Failed to bind to port")
112            .run()
113            .await
114            .expect("Failed to start server");
115    }
116    #[tokio::test]
117    async fn mock_webhook_notification() {
118        let notification = wompi_models::NotificacionWebhook {
119            cliente: wompi_models::ClienteSubscripcion {
120                cantidad_compra: 1,
121                id_suscripcion: "4ba78dec-cb3a-436c-8f43-3009e0478e06".to_string(),
122                ..Default::default()
123            },
124            ..Default::default()
125        };
126        reqwest::Client::new()
127            .post("http://0.0.0.0:4444/webhook")
128            .json(&notification)
129            .send()
130            .await
131            .expect("Failed to send webhook");
132    }
133}