wompi-webhook-endpoint 0.1.0

Webhook endpoint for the Wompi API
Documentation
#[derive(Debug, thiserror::Error)]
pub enum WompiWebhookEndpointError {
    #[error("Failed to start server on {0}")]
    Io(#[from] std::io::Error),
    #[error("No listener")]
    NoListener,
    #[error("Server stopped")]
    ServerStopped,
    #[error("Failed to parse address")]
    AddressError(#[from] std::net::AddrParseError),
}

#[derive(Default)]
pub struct WebhookEndpoint {
    app: axum::Router,
    listener: Option<tokio::net::TcpListener>,
}

impl WebhookEndpoint {
    pub fn add_webhook_route<T, H>(mut self, path: &str, handler: H) -> Self
    where
        H: axum::handler::Handler<T, ()>,
        T: 'static,
    {
        self.app = self
            .app
            .route(path, axum::routing::post(handler))
            .layer(axum::middleware::from_fn(validate_wompi_hmac));
        self
    }
    pub async fn add_listener(mut self, addr: &str) -> Result<Self, WompiWebhookEndpointError> {
        let socket_addr: std::net::SocketAddr = addr.parse()?;
        self.listener = Some(tokio::net::TcpListener::bind(socket_addr).await?);
        Ok(self)
    }
    pub async fn run(self) -> Result<(), WompiWebhookEndpointError> {
        let listener = self.listener.ok_or(WompiWebhookEndpointError::NoListener)?;
        axum::serve(listener, self.app).await?;
        Err(WompiWebhookEndpointError::ServerStopped)
    }
}
use axum::http::StatusCode;
pub async fn validate_wompi_hmac(
    req: axum::extract::Request,
    next: axum::middleware::Next,
) -> Result<axum::response::Response, StatusCode> {
    let (parts, body) = req.into_parts();
    // Read the full body, with max size of 1MB
    let body_bytes = axum::body::to_bytes(body, 1024 * 1024)
        .await
        .map_err(|_| StatusCode::BAD_REQUEST)?;

    // Clone the body back into the request so the next handler can still read it

    // Get the wompi_hash header
    let headers: axum::http::HeaderMap = parts.headers.clone();
    let wompi_hash = headers
        .get("wompi_hash")
        .and_then(|v| v.to_str().ok())
        .ok_or(StatusCode::UNAUTHORIZED)?;

    // Compute HMAC-SHA256(body, secret)
    let mut mac: hmac::Hmac<sha2::Sha256> = hmac::Mac::new_from_slice(
        std::env::var("WOMPI_CLIENT_SECRET")
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
            .as_bytes(),
    )
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    hmac::Mac::update(&mut mac, &body_bytes);
    let expected = hex::encode(hmac::Mac::finalize(mac).into_bytes());

    let req = axum::extract::Request::from_parts(parts, axum::body::Body::from(body_bytes));

    // Compare securely
    if subtle::ConstantTimeEq::ct_eq(expected.as_bytes(), wompi_hash.as_bytes()).unwrap_u8() == 1 {
        Ok(next.run(req).await)
    } else {
        Err(StatusCode::UNAUTHORIZED)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_webhook_endpoint() {
        async fn json_handle(
            axum::Json(webhook): axum::Json<
                wompi_models::NotificacionWebhook<wompi_models::ClienteSubscripcion>,
            >,
        ) {
            println!("✅ Webhook recibido: {webhook:?}");
            std::process::exit(0);
        }
        tokio::spawn(async move {
            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
            let notification = wompi_models::NotificacionWebhook {
                cliente: wompi_models::ClienteSubscripcion {
                    cantidad_compra: 1,
                    ..Default::default()
                },
                ..Default::default()
            };
            println!("✅ Enviando notificación: {notification:?}");
        });
        WebhookEndpoint::default()
            .add_webhook_route("/", json_handle)
            .add_listener("0.0.0.0:4444")
            .await
            .expect("Failed to bind to port")
            .run()
            .await
            .expect("Failed to start server");
    }
    #[tokio::test]
    async fn mock_webhook_notification() {
        let notification = wompi_models::NotificacionWebhook {
            cliente: wompi_models::ClienteSubscripcion {
                cantidad_compra: 1,
                id_suscripcion: "4ba78dec-cb3a-436c-8f43-3009e0478e06".to_string(),
                ..Default::default()
            },
            ..Default::default()
        };
        reqwest::Client::new()
            .post("http://0.0.0.0:4444/webhook")
            .json(&notification)
            .send()
            .await
            .expect("Failed to send webhook");
    }
}