emailit 2.0.3

The official Rust SDK for the Emailit Email API
Documentation
//! Low-level HTTP client used internally by all service modules.

use reqwest::Client;
use serde::de::DeserializeOwned;

use crate::error::{Error, new_api_error, new_connection_error};

pub(crate) const DEFAULT_BASE_URL: &str = "https://api.emailit.com";
pub(crate) const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");

pub(crate) struct BaseClient {
    pub api_key: String,
    pub base_url: String,
    pub http_client: Client,
}

impl BaseClient {
    pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
        Self {
            api_key: api_key.into(),
            base_url: base_url.into(),
            http_client: Client::new(),
        }
    }

    pub async fn request<T: DeserializeOwned>(
        &self,
        method: &str,
        path: &str,
        body: Option<serde_json::Value>,
        query: Option<&[(&str, String)]>,
    ) -> Result<T, Error> {
        let url = format!("{}{}", self.base_url, path);

        let mut req = match method {
            "GET" => self.http_client.get(&url),
            "POST" => self.http_client.post(&url),
            "PATCH" => self.http_client.patch(&url),
            "DELETE" => self.http_client.delete(&url),
            "PUT" => self.http_client.put(&url),
            _ => self.http_client.get(&url),
        };

        req = req
            .header("Authorization", format!("Bearer {}", self.api_key))
            .header("Content-Type", "application/json")
            .header("User-Agent", format!("emailit-rust/{}", SDK_VERSION));

        if let Some(q) = query {
            req = req.query(q);
        }

        if let Some(b) = body {
            req = req.json(&b);
        }

        let resp = req
            .send()
            .await
            .map_err(|e| new_connection_error(e.to_string()))?;

        let status = resp.status().as_u16();
        let body_text = resp
            .text()
            .await
            .map_err(|e| new_connection_error(e.to_string()))?;

        if status >= 400 {
            let message = extract_error_message(&body_text, status);
            return Err(new_api_error(status, message, body_text));
        }

        serde_json::from_str(&body_text)
            .map_err(|e| new_connection_error(format!("Failed to parse response: {}", e)))
    }

    pub async fn request_raw(&self, method: &str, path: &str) -> Result<RawResponse, Error> {
        let url = format!("{}{}", self.base_url, path);

        let req = match method {
            "GET" => self.http_client.get(&url),
            _ => self.http_client.get(&url),
        };

        let resp = req
            .header("Authorization", format!("Bearer {}", self.api_key))
            .header("User-Agent", format!("emailit-rust/{}", SDK_VERSION))
            .send()
            .await
            .map_err(|e| new_connection_error(e.to_string()))?;

        let status = resp.status().as_u16();
        let headers: Vec<(String, String)> = resp
            .headers()
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
            .collect();

        let body = resp
            .bytes()
            .await
            .map_err(|e| new_connection_error(e.to_string()))?
            .to_vec();

        if status >= 400 {
            let body_str = String::from_utf8_lossy(&body).to_string();
            let message = extract_error_message(&body_str, status);
            return Err(new_api_error(status, message, body_str));
        }

        Ok(RawResponse {
            status,
            headers,
            body,
        })
    }
}

/// A raw HTTP response returned by endpoints that don't have a typed JSON body
/// (e.g. CSV exports).
#[derive(Debug)]
pub struct RawResponse {
    /// HTTP status code (e.g. 200).
    pub status: u16,
    /// Response headers as `(name, value)` pairs.
    pub headers: Vec<(String, String)>,
    /// Raw response body bytes.
    pub body: Vec<u8>,
}

fn extract_error_message(body: &str, status_code: u16) -> String {
    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(body) {
        if let Some(error) = parsed.get("error") {
            if let Some(e) = error.as_str() {
                let mut msg = e.to_string();
                if let Some(m) = parsed.get("message").and_then(|v| v.as_str()) {
                    msg.push_str(": ");
                    msg.push_str(m);
                }
                return msg;
            }
            if let Some(m) = error.get("message").and_then(|v| v.as_str()) {
                return m.to_string();
            }
        }
    }

    format!("API request failed with status {}", status_code)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_extract_error_string() {
        let body = r#"{"error":"Unauthorized","message":"Invalid API key"}"#;
        assert_eq!(
            extract_error_message(body, 401),
            "Unauthorized: Invalid API key"
        );
    }

    #[test]
    fn test_extract_error_nested() {
        let body = r#"{"error":{"message":"Validation failed"}}"#;
        assert_eq!(extract_error_message(body, 422), "Validation failed");
    }

    #[test]
    fn test_extract_error_fallback() {
        assert_eq!(
            extract_error_message("not json", 500),
            "API request failed with status 500"
        );
    }

    #[test]
    fn test_extract_error_string_only() {
        let body = r#"{"error":"Not Found"}"#;
        assert_eq!(extract_error_message(body, 404), "Not Found");
    }
}