wompi_client/
lib.rs

1#![warn(
2    clippy::all,
3    clippy::missing_errors_doc,
4    clippy::style,
5    clippy::unseparated_literal_suffix,
6    clippy::pedantic,
7    clippy::nursery
8)]
9#![allow(clippy::missing_errors_doc)]
10
11mod routes;
12pub use routes::RutaAPI;
13#[derive(Debug, thiserror::Error)]
14pub enum WompiClientError {
15    #[error("Request failed {0}")]
16    Reqwest(#[from] reqwest::Error),
17    #[error("Env var error")]
18    CredentialError(#[from] std::env::VarError),
19    #[error("REST API error")]
20    RestApiError(#[from] wompi_models::WompiError),
21}
22
23#[derive(Default)]
24pub struct WompiClient {
25    pub client: reqwest::Client,
26    token: tokio::sync::Mutex<Option<wompi_models::OAuth2Response>>,
27}
28
29impl WompiClient {
30    pub async fn refresh_token(&self) -> Result<wompi_models::OAuth2Response, WompiClientError> {
31        if let Some(token) = self.token.lock().await.as_ref() {
32            if !token.is_expired() {
33                return Ok(token.clone());
34            }
35        }
36        let params = wompi_models::OAuth2Form::new_from_env()?;
37        let resp = self
38            .client
39            .post(RutaAPI::OAuth2.as_ref())
40            .form(&*params)
41            .send()
42            .await?;
43
44        debug_assert!(resp.status().is_success());
45
46        let auth: wompi_models::OAuth2Response = resp.json().await?;
47        debug_assert_eq!(auth.token_type.to_lowercase(), "bearer");
48        self.token.lock().await.replace(auth.clone());
49        Ok(auth)
50    }
51    pub async fn post<
52        Request: serde::Serialize,
53        Route: reqwest::IntoUrl,
54        Response: serde::de::DeserializeOwned,
55    >(
56        &self,
57        route: Route,
58        body: Request,
59    ) -> Result<Response, WompiClientError> {
60        let token = self.refresh_token().await?;
61        let resp = self
62            .client
63            .post(route)
64            .bearer_auth(token.access_token)
65            .json(&body)
66            .send()
67            .await?;
68        let text = resp.text().await?;
69        dbg!(&text);
70
71        Ok(serde_json::from_str::<Response>(&text).expect("Failed to parse JSON"))
72
73        // match resp.error_for_status() {
74        //     Ok(r) => Ok(r.json().await?),
75        //     Err(e) => {
76        //         dbg!(&e);
77        //         Err(e.into())
78        //     },
79        // }
80    }
81    pub async fn get<Route: reqwest::IntoUrl, Response: serde::de::DeserializeOwned>(
82        &self,
83        route: Route,
84    ) -> Result<Response, WompiClientError> {
85        let token = self.refresh_token().await?;
86        let resp = self
87            .client
88            .get(route)
89            .bearer_auth(token.access_token)
90            .send()
91            .await?;
92
93        match resp.error_for_status() {
94            Ok(r) => Ok(r.json().await?),
95            Err(e) => Err(e.into()),
96        }
97    }
98    pub async fn put<
99        Request: serde::Serialize,
100        Route: reqwest::IntoUrl,
101        Response: serde::de::DeserializeOwned,
102    >(
103        &self,
104        route: Route,
105        body: Request,
106    ) -> Result<Response, WompiClientError> {
107        let token = self.refresh_token().await?;
108        let resp = self
109            .client
110            .put(route)
111            .bearer_auth(token.access_token)
112            .json(&body)
113            .send()
114            .await?;
115
116        match resp.error_for_status() {
117            Ok(r) => Ok(r.json().await?),
118            Err(e) => Err(e.into()),
119        }
120    }
121    pub async fn post_multipart<Route: reqwest::IntoUrl, Response: serde::de::DeserializeOwned>(
122        &self,
123        route: Route,
124        body: reqwest::multipart::Form,
125    ) -> Result<Response, WompiClientError> {
126        let token = self.refresh_token().await?;
127        let resp = self
128            .client
129            .post(route)
130            .bearer_auth(token.access_token)
131            .multipart(body)
132            .send()
133            .await?;
134
135        match resp.error_for_status() {
136            Ok(r) => Ok(r.json().await?),
137            Err(e) => Err(e.into()),
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144
145    use super::*;
146    use wompi_models::*;
147    static WOMPI_CLIENT: std::sync::LazyLock<WompiClient> =
148        std::sync::LazyLock::new(WompiClient::default);
149
150    #[tokio::test]
151    async fn test_wompi_auth_and_crear_enlace() {
152        let client = &WOMPI_CLIENT;
153
154        let enlace_req = PeticionEnlaceDePago::<serde_json::Value> {
155            identificador_enlace_comercio: "test_enlace_23".into(),
156            monto: 7.00,
157            nombre_producto: "Café Premium".into(),
158            forma_pago: Some(FormaPago {
159                permitir_tarjeta_credito_debido: true,
160                permitir_pago_con_punto_agricola: false,
161                permitir_pago_en_cuotas_agricola: false,
162                ..Default::default()
163            }),
164            // configuracion: Some(Configuracion {
165            //     url_redirect: "https://example.com".into(),
166            //     ..Default::default()
167            // }),
168            ..Default::default()
169        };
170
171        let enlace: RespuestaEnlaceDePago = client
172            .post(RutaAPI::CrearEnlaceDePago.to_string(), enlace_req)
173            .await
174            .expect("Failed to send crear enlace request");
175
176        println!("✅ Enlace creado: {enlace:?}",);
177
178        assert!(enlace.url_enlace.starts_with("https://"));
179        assert!(enlace.id_enlace > 0);
180    }
181
182    #[tokio::test]
183    async fn test_wompi_auth_and_crear_cargo_recurrente() {
184        let client = &WOMPI_CLIENT;
185
186        let cargo_req = PeticionCargoRecurrente {
187            nombre: "Test cargo recurrente".into(),
188            monto: 9.0,
189            descripcion_producto: "AuthenticDoc Subscription".into(),
190            dia_de_pago: 12,
191            id_aplicativo: std::env::var("WOMPI_APP_ID").expect("Missing WOMPI_APP_ID"),
192        };
193
194        let cargo: RespuestaCargoRecurrente = client
195            .post(RutaAPI::CrearCargoRecurrente.to_string(), cargo_req)
196            .await
197            .expect("Failed to send crear cargo recurrente request");
198
199        println!("✅ Cargo recurrente creado: {cargo:?}",);
200
201        assert!(cargo.url_enlace.starts_with("https://"));
202    }
203    #[tokio::test]
204    async fn test_listar_subscripciones() {
205        let client = &WOMPI_CLIENT;
206
207        let list: RespuestaListarCargoRecurrente = client
208            .get(RutaAPI::ListarCargosRecurrentes.to_string())
209            .await
210            .expect("Failed to send consultar cargo recurrente request");
211        println!("✅ Cargo recurrente consultado: {list:#?}");
212    }
213    #[tokio::test]
214    async fn test_detallar_cargo_recurrente() {
215        let client = &WOMPI_CLIENT;
216
217        let resp: RespuestaDetallarCargoRecurrente = client
218            .get(
219                RutaAPI::DetallarCargoRecurrente("d2a25757-0530-44d0-a28d-be0cee7159fd")
220                    .to_string(),
221            )
222            .await
223            .expect("Failed to send consultar cargo recurrente request");
224        println!("✅ Cargo recurrente detallado: {resp:#?}",);
225    }
226    #[tokio::test]
227    async fn test_listar_subscripciones_de_cargo() {
228        let client = &WOMPI_CLIENT;
229        let list: serde_json::Value = client
230            .get(
231                RutaAPI::SuscripcionesCargoRecurrente("d2a25757-0530-44d0-a28d-be0cee7159fd")
232                    .to_string(),
233            )
234            .await
235            .expect("Failed to send consultar cargo recurrente request");
236        println!("✅ Cargo recurrente consultado: {list:#?}");
237    }
238
239    #[tokio::test]
240    async fn test_detallar_cargo_recurrente_con_imagen() {
241        let client = &WOMPI_CLIENT;
242
243        let resp: RespuestaDetallarCargoRecurrente = client
244            .get(
245                RutaAPI::DetallarCargoRecurrente("af980e72-8aa0-4104-95cb-2b4c6d5c52a4")
246                    .to_string(),
247            )
248            .await
249            .expect("Failed to send consultar cargo recurrente request");
250
251        println!("✅ Cargo recurrente detallado: {resp:#?}",);
252    }
253
254    const LOREM_IPSUM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.";
255    const SEPARATOR: &str = "----------";
256    #[tokio::test]
257    async fn test_crear_cargo_recurrente_con_imagen() {
258        use chrono::Datelike as _;
259        let form = reqwest::multipart::Form::new()
260            .file("imagen", "./logo-negro.png")
261            .await
262            .expect("Failed to create form");
263
264        let client = &WOMPI_CLIENT;
265
266        let badge_name = "42 test badge";
267        let badge_email = "test@example.com";
268        let badge_npub = "npubsadhjkfbasdjkhfbaksjdfbakjsdfbkjasdfbn";
269
270        let cargo_req = PeticionCargoRecurrente {
271            nombre: "Subscripcion Mensual AuthenticDoc".into(),
272            monto: 9.0,
273            descripcion_producto: format!(
274                r"AuthenticDoc Subscription
275{LOREM_IPSUM}
276{SEPARATOR}
277{badge_name}
278{badge_email}
279{badge_npub}
280"
281            ),
282            dia_de_pago: chrono::Utc::now().date_naive().day().into(),
283            id_aplicativo: std::env::var("WOMPI_APP_ID").expect("Missing WOMPI_APP_ID"),
284        };
285
286        let cargo: RespuestaCargoRecurrente = client
287            .post(RutaAPI::CrearCargoRecurrente.to_string(), cargo_req)
288            .await
289            .expect("Failed to send crear cargo recurrente request");
290
291        println!("✅ Cargo recurrente creado: {cargo:?}",);
292
293        assert!(cargo.url_enlace.starts_with("https://"));
294
295        let _resp: RespuestaDetallarCargoRecurrente = client
296            .post_multipart(
297                RutaAPI::AgregarImagenCargoRecurrente(cargo.id_enlace.as_str()).to_string(),
298                form,
299            )
300            .await
301            .expect("Failed to send cargo recurrente imagen");
302
303        println!("✅ Cargo recurrente imagen enviado",);
304        println!("✅ Cargo recurrente creado: {cargo:?}",);
305
306        let resp: RespuestaDetallarCargoRecurrente = client
307            .get(RutaAPI::DetallarCargoRecurrente(cargo.id_enlace.as_str()).to_string())
308            .await
309            .expect("Failed to send cargo recurrente imagen");
310
311        let mut desc = resp
312            .descripcion_producto
313            .split_once(SEPARATOR)
314            .map(|(_, s)| s.trim().lines().take(3))
315            .expect("Invalid cargo recurrente JSON");
316
317        let name = desc.next().expect("Invalid cargo recurrente JSON");
318        let email = desc.next().expect("Invalid cargo recurrente JSON");
319        let npub = desc.next().expect("Invalid cargo recurrente JSON");
320
321        assert_eq!(name, badge_name);
322        assert_eq!(email, badge_email);
323        assert_eq!(npub, badge_npub);
324    }
325}