use crate::platforms::Platform;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use reqwest::Client as HttpClient;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
#[derive(Debug, Clone)]
pub struct Credentials {
pub api_key: String,
pub api_secret: String,
}
struct TokenState {
access_token: String,
expires_at: Instant,
}
pub struct DattoClient {
http_client: HttpClient,
credentials: Credentials,
platform: Platform,
token_state: Arc<RwLock<Option<TokenState>>>,
}
impl DattoClient {
pub async fn new(platform: Platform, credentials: Credentials) -> Result<Self, Error> {
let http_client = HttpClient::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(Error::HttpClient)?;
let client = Self {
http_client,
credentials,
platform,
token_state: Arc::new(RwLock::new(None)),
};
client.ensure_token().await?;
Ok(client)
}
pub fn platform(&self) -> Platform {
self.platform
}
pub fn base_url(&self) -> &str {
self.platform.base_url()
}
pub async fn ensure_token(&self) -> Result<String, Error> {
let buffer = Duration::from_secs(5 * 60);
{
let state = self.token_state.read().await;
if let Some(ref ts) = *state {
if ts.expires_at > Instant::now() + buffer {
return Ok(ts.access_token.clone());
}
}
}
self.refresh_token().await
}
async fn refresh_token(&self) -> Result<String, Error> {
let credentials =
BASE64.encode(format!("{}:{}", self.credentials.api_key, self.credentials.api_secret));
let response = self
.http_client
.post(self.platform.token_endpoint())
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", format!("Basic {}", credentials))
.body("grant_type=client_credentials")
.send()
.await
.map_err(Error::HttpClient)?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(Error::Auth(format!(
"OAuth token request failed: {} - {}",
status, body
)));
}
#[derive(serde::Deserialize)]
struct TokenResponse {
access_token: String,
expires_in: u64,
}
let token_response: TokenResponse = response.json().await.map_err(Error::HttpClient)?;
let token_state = TokenState {
access_token: token_response.access_token.clone(),
expires_at: Instant::now() + Duration::from_secs(token_response.expires_in),
};
{
let mut state = self.token_state.write().await;
*state = Some(token_state);
}
Ok(token_response.access_token)
}
pub fn http_client(&self) -> &HttpClient {
&self.http_client
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("HTTP client error: {0}")]
HttpClient(#[from] reqwest::Error),
#[error("Authentication failed: {0}")]
Auth(String),
#[error("API error: {status} - {message}")]
Api {
status: u16,
message: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_credentials_creation() {
let creds = Credentials {
api_key: "test-key".to_string(),
api_secret: "test-secret".to_string(),
};
assert_eq!(creds.api_key, "test-key");
assert_eq!(creds.api_secret, "test-secret");
}
#[test]
fn test_credentials_clone() {
let creds1 = Credentials {
api_key: "key".to_string(),
api_secret: "secret".to_string(),
};
let creds2 = creds1.clone();
assert_eq!(creds1.api_key, creds2.api_key);
assert_eq!(creds1.api_secret, creds2.api_secret);
}
#[test]
fn test_error_display_http_client() {
let err = Error::Auth("invalid credentials".to_string());
assert_eq!(err.to_string(), "Authentication failed: invalid credentials");
}
#[test]
fn test_error_display_api() {
let err = Error::Api {
status: 404,
message: "Not found".to_string(),
};
assert_eq!(err.to_string(), "API error: 404 - Not found");
}
#[test]
fn test_error_debug() {
let err = Error::Auth("test".to_string());
let debug_str = format!("{:?}", err);
assert!(debug_str.contains("Auth"));
assert!(debug_str.contains("test"));
}
}