wompi_webhook_endpoint/
lib.rs1#[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 let body_bytes = axum::body::to_bytes(body, 1024 * 1024)
50 .await
51 .map_err(|_| StatusCode::BAD_REQUEST)?;
52
53 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 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 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(¬ification)
129 .send()
130 .await
131 .expect("Failed to send webhook");
132 }
133}