#[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();
let body_bytes = axum::body::to_bytes(body, 1024 * 1024)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
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)?;
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));
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(¬ification)
.send()
.await
.expect("Failed to send webhook");
}
}