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 }
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 ..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}