wompi-client 0.1.0

Client for the Wompi API
Documentation
#![warn(
    clippy::all,
    clippy::missing_errors_doc,
    clippy::style,
    clippy::unseparated_literal_suffix,
    clippy::pedantic,
    clippy::nursery
)]
#![allow(clippy::missing_errors_doc)]

mod routes;
pub use routes::RutaAPI;
#[derive(Debug, thiserror::Error)]
pub enum WompiClientError {
    #[error("Request failed {0}")]
    Reqwest(#[from] reqwest::Error),
    #[error("Env var error")]
    CredentialError(#[from] std::env::VarError),
    #[error("REST API error")]
    RestApiError(#[from] wompi_models::WompiError),
}

#[derive(Default)]
pub struct WompiClient {
    pub client: reqwest::Client,
    token: tokio::sync::Mutex<Option<wompi_models::OAuth2Response>>,
}

impl WompiClient {
    pub async fn refresh_token(&self) -> Result<wompi_models::OAuth2Response, WompiClientError> {
        if let Some(token) = self.token.lock().await.as_ref() {
            if !token.is_expired() {
                return Ok(token.clone());
            }
        }
        let params = wompi_models::OAuth2Form::new_from_env()?;
        let resp = self
            .client
            .post(RutaAPI::OAuth2.as_ref())
            .form(&*params)
            .send()
            .await?;

        debug_assert!(resp.status().is_success());

        let auth: wompi_models::OAuth2Response = resp.json().await?;
        debug_assert_eq!(auth.token_type.to_lowercase(), "bearer");
        self.token.lock().await.replace(auth.clone());
        Ok(auth)
    }
    pub async fn post<
        Request: serde::Serialize,
        Route: reqwest::IntoUrl,
        Response: serde::de::DeserializeOwned,
    >(
        &self,
        route: Route,
        body: Request,
    ) -> Result<Response, WompiClientError> {
        let token = self.refresh_token().await?;
        let resp = self
            .client
            .post(route)
            .bearer_auth(token.access_token)
            .json(&body)
            .send()
            .await?;
        let text = resp.text().await?;
        dbg!(&text);

        Ok(serde_json::from_str::<Response>(&text).expect("Failed to parse JSON"))

        // match resp.error_for_status() {
        //     Ok(r) => Ok(r.json().await?),
        //     Err(e) => {
        //         dbg!(&e);
        //         Err(e.into())
        //     },
        // }
    }
    pub async fn get<Route: reqwest::IntoUrl, Response: serde::de::DeserializeOwned>(
        &self,
        route: Route,
    ) -> Result<Response, WompiClientError> {
        let token = self.refresh_token().await?;
        let resp = self
            .client
            .get(route)
            .bearer_auth(token.access_token)
            .send()
            .await?;

        match resp.error_for_status() {
            Ok(r) => Ok(r.json().await?),
            Err(e) => Err(e.into()),
        }
    }
    pub async fn put<
        Request: serde::Serialize,
        Route: reqwest::IntoUrl,
        Response: serde::de::DeserializeOwned,
    >(
        &self,
        route: Route,
        body: Request,
    ) -> Result<Response, WompiClientError> {
        let token = self.refresh_token().await?;
        let resp = self
            .client
            .put(route)
            .bearer_auth(token.access_token)
            .json(&body)
            .send()
            .await?;

        match resp.error_for_status() {
            Ok(r) => Ok(r.json().await?),
            Err(e) => Err(e.into()),
        }
    }
    pub async fn post_multipart<Route: reqwest::IntoUrl, Response: serde::de::DeserializeOwned>(
        &self,
        route: Route,
        body: reqwest::multipart::Form,
    ) -> Result<Response, WompiClientError> {
        let token = self.refresh_token().await?;
        let resp = self
            .client
            .post(route)
            .bearer_auth(token.access_token)
            .multipart(body)
            .send()
            .await?;

        match resp.error_for_status() {
            Ok(r) => Ok(r.json().await?),
            Err(e) => Err(e.into()),
        }
    }
}

#[cfg(test)]
mod tests {

    use super::*;
    use wompi_models::*;
    static WOMPI_CLIENT: std::sync::LazyLock<WompiClient> =
        std::sync::LazyLock::new(WompiClient::default);

    #[tokio::test]
    async fn test_wompi_auth_and_crear_enlace() {
        let client = &WOMPI_CLIENT;

        let enlace_req = PeticionEnlaceDePago::<serde_json::Value> {
            identificador_enlace_comercio: "test_enlace_23".into(),
            monto: 7.00,
            nombre_producto: "Café Premium".into(),
            forma_pago: Some(FormaPago {
                permitir_tarjeta_credito_debido: true,
                permitir_pago_con_punto_agricola: false,
                permitir_pago_en_cuotas_agricola: false,
                ..Default::default()
            }),
            // configuracion: Some(Configuracion {
            //     url_redirect: "https://example.com".into(),
            //     ..Default::default()
            // }),
            ..Default::default()
        };

        let enlace: RespuestaEnlaceDePago = client
            .post(RutaAPI::CrearEnlaceDePago.to_string(), enlace_req)
            .await
            .expect("Failed to send crear enlace request");

        println!("✅ Enlace creado: {enlace:?}",);

        assert!(enlace.url_enlace.starts_with("https://"));
        assert!(enlace.id_enlace > 0);
    }

    #[tokio::test]
    async fn test_wompi_auth_and_crear_cargo_recurrente() {
        let client = &WOMPI_CLIENT;

        let cargo_req = PeticionCargoRecurrente {
            nombre: "Test cargo recurrente".into(),
            monto: 9.0,
            descripcion_producto: "AuthenticDoc Subscription".into(),
            dia_de_pago: 12,
            id_aplicativo: std::env::var("WOMPI_APP_ID").expect("Missing WOMPI_APP_ID"),
        };

        let cargo: RespuestaCargoRecurrente = client
            .post(RutaAPI::CrearCargoRecurrente.to_string(), cargo_req)
            .await
            .expect("Failed to send crear cargo recurrente request");

        println!("✅ Cargo recurrente creado: {cargo:?}",);

        assert!(cargo.url_enlace.starts_with("https://"));
    }
    #[tokio::test]
    async fn test_listar_subscripciones() {
        let client = &WOMPI_CLIENT;

        let list: RespuestaListarCargoRecurrente = client
            .get(RutaAPI::ListarCargosRecurrentes.to_string())
            .await
            .expect("Failed to send consultar cargo recurrente request");
        println!("✅ Cargo recurrente consultado: {list:#?}");
    }
    #[tokio::test]
    async fn test_detallar_cargo_recurrente() {
        let client = &WOMPI_CLIENT;

        let resp: RespuestaDetallarCargoRecurrente = client
            .get(
                RutaAPI::DetallarCargoRecurrente("d2a25757-0530-44d0-a28d-be0cee7159fd")
                    .to_string(),
            )
            .await
            .expect("Failed to send consultar cargo recurrente request");
        println!("✅ Cargo recurrente detallado: {resp:#?}",);
    }
    #[tokio::test]
    async fn test_listar_subscripciones_de_cargo() {
        let client = &WOMPI_CLIENT;
        let list: serde_json::Value = client
            .get(
                RutaAPI::SuscripcionesCargoRecurrente("d2a25757-0530-44d0-a28d-be0cee7159fd")
                    .to_string(),
            )
            .await
            .expect("Failed to send consultar cargo recurrente request");
        println!("✅ Cargo recurrente consultado: {list:#?}");
    }

    #[tokio::test]
    async fn test_detallar_cargo_recurrente_con_imagen() {
        let client = &WOMPI_CLIENT;

        let resp: RespuestaDetallarCargoRecurrente = client
            .get(
                RutaAPI::DetallarCargoRecurrente("af980e72-8aa0-4104-95cb-2b4c6d5c52a4")
                    .to_string(),
            )
            .await
            .expect("Failed to send consultar cargo recurrente request");

        println!("✅ Cargo recurrente detallado: {resp:#?}",);
    }

    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.";
    const SEPARATOR: &str = "----------";
    #[tokio::test]
    async fn test_crear_cargo_recurrente_con_imagen() {
        use chrono::Datelike as _;
        let form = reqwest::multipart::Form::new()
            .file("imagen", "./logo-negro.png")
            .await
            .expect("Failed to create form");

        let client = &WOMPI_CLIENT;

        let badge_name = "42 test badge";
        let badge_email = "test@example.com";
        let badge_npub = "npubsadhjkfbasdjkhfbaksjdfbakjsdfbkjasdfbn";

        let cargo_req = PeticionCargoRecurrente {
            nombre: "Subscripcion Mensual AuthenticDoc".into(),
            monto: 9.0,
            descripcion_producto: format!(
                r"AuthenticDoc Subscription
{LOREM_IPSUM}
{SEPARATOR}
{badge_name}
{badge_email}
{badge_npub}
"
            ),
            dia_de_pago: chrono::Utc::now().date_naive().day().into(),
            id_aplicativo: std::env::var("WOMPI_APP_ID").expect("Missing WOMPI_APP_ID"),
        };

        let cargo: RespuestaCargoRecurrente = client
            .post(RutaAPI::CrearCargoRecurrente.to_string(), cargo_req)
            .await
            .expect("Failed to send crear cargo recurrente request");

        println!("✅ Cargo recurrente creado: {cargo:?}",);

        assert!(cargo.url_enlace.starts_with("https://"));

        let _resp: RespuestaDetallarCargoRecurrente = client
            .post_multipart(
                RutaAPI::AgregarImagenCargoRecurrente(cargo.id_enlace.as_str()).to_string(),
                form,
            )
            .await
            .expect("Failed to send cargo recurrente imagen");

        println!("✅ Cargo recurrente imagen enviado",);
        println!("✅ Cargo recurrente creado: {cargo:?}",);

        let resp: RespuestaDetallarCargoRecurrente = client
            .get(RutaAPI::DetallarCargoRecurrente(cargo.id_enlace.as_str()).to_string())
            .await
            .expect("Failed to send cargo recurrente imagen");

        let mut desc = resp
            .descripcion_producto
            .split_once(SEPARATOR)
            .map(|(_, s)| s.trim().lines().take(3))
            .expect("Invalid cargo recurrente JSON");

        let name = desc.next().expect("Invalid cargo recurrente JSON");
        let email = desc.next().expect("Invalid cargo recurrente JSON");
        let npub = desc.next().expect("Invalid cargo recurrente JSON");

        assert_eq!(name, badge_name);
        assert_eq!(email, badge_email);
        assert_eq!(npub, badge_npub);
    }
}