1use crate::platforms::Platform;
4use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
5use reqwest::Client as HttpClient;
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8use tokio::sync::RwLock;
9
10#[derive(Debug, Clone)]
12pub struct Credentials {
13 pub api_key: String,
15 pub api_secret: String,
17}
18
19struct TokenState {
21 access_token: String,
22 expires_at: Instant,
23}
24
25pub struct DattoClient {
49 http_client: HttpClient,
50 credentials: Credentials,
51 platform: Platform,
52 token_state: Arc<RwLock<Option<TokenState>>>,
53}
54
55impl DattoClient {
56 pub async fn new(platform: Platform, credentials: Credentials) -> Result<Self, Error> {
60 let http_client = HttpClient::builder()
61 .timeout(Duration::from_secs(30))
62 .build()
63 .map_err(Error::HttpClient)?;
64
65 let client = Self {
66 http_client,
67 credentials,
68 platform,
69 token_state: Arc::new(RwLock::new(None)),
70 };
71
72 client.ensure_token().await?;
74
75 Ok(client)
76 }
77
78 pub fn platform(&self) -> Platform {
80 self.platform
81 }
82
83 pub fn base_url(&self) -> &str {
85 self.platform.base_url()
86 }
87
88 pub async fn ensure_token(&self) -> Result<String, Error> {
92 let buffer = Duration::from_secs(5 * 60);
94 {
95 let state = self.token_state.read().await;
96 if let Some(ref ts) = *state {
97 if ts.expires_at > Instant::now() + buffer {
98 return Ok(ts.access_token.clone());
99 }
100 }
101 }
102
103 self.refresh_token().await
105 }
106
107 async fn refresh_token(&self) -> Result<String, Error> {
109 let credentials =
110 BASE64.encode(format!("{}:{}", self.credentials.api_key, self.credentials.api_secret));
111
112 let response = self
113 .http_client
114 .post(self.platform.token_endpoint())
115 .header("Content-Type", "application/x-www-form-urlencoded")
116 .header("Authorization", format!("Basic {}", credentials))
117 .body("grant_type=client_credentials")
118 .send()
119 .await
120 .map_err(Error::HttpClient)?;
121
122 if !response.status().is_success() {
123 let status = response.status();
124 let body = response.text().await.unwrap_or_default();
125 return Err(Error::Auth(format!(
126 "OAuth token request failed: {} - {}",
127 status, body
128 )));
129 }
130
131 #[derive(serde::Deserialize)]
132 struct TokenResponse {
133 access_token: String,
134 expires_in: u64,
135 }
136
137 let token_response: TokenResponse = response.json().await.map_err(Error::HttpClient)?;
138
139 let token_state = TokenState {
140 access_token: token_response.access_token.clone(),
141 expires_at: Instant::now() + Duration::from_secs(token_response.expires_in),
142 };
143
144 {
145 let mut state = self.token_state.write().await;
146 *state = Some(token_state);
147 }
148
149 Ok(token_response.access_token)
150 }
151
152 pub fn http_client(&self) -> &HttpClient {
156 &self.http_client
157 }
158}
159
160#[derive(Debug, thiserror::Error)]
162pub enum Error {
163 #[error("HTTP client error: {0}")]
165 HttpClient(#[from] reqwest::Error),
166
167 #[error("Authentication failed: {0}")]
169 Auth(String),
170
171 #[error("API error: {status} - {message}")]
173 Api {
174 status: u16,
176 message: String,
178 },
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn test_credentials_creation() {
187 let creds = Credentials {
188 api_key: "test-key".to_string(),
189 api_secret: "test-secret".to_string(),
190 };
191 assert_eq!(creds.api_key, "test-key");
192 assert_eq!(creds.api_secret, "test-secret");
193 }
194
195 #[test]
196 fn test_credentials_clone() {
197 let creds1 = Credentials {
198 api_key: "key".to_string(),
199 api_secret: "secret".to_string(),
200 };
201 let creds2 = creds1.clone();
202 assert_eq!(creds1.api_key, creds2.api_key);
203 assert_eq!(creds1.api_secret, creds2.api_secret);
204 }
205
206 #[test]
207 fn test_error_display_http_client() {
208 let err = Error::Auth("invalid credentials".to_string());
210 assert_eq!(err.to_string(), "Authentication failed: invalid credentials");
211 }
212
213 #[test]
214 fn test_error_display_api() {
215 let err = Error::Api {
216 status: 404,
217 message: "Not found".to_string(),
218 };
219 assert_eq!(err.to_string(), "API error: 404 - Not found");
220 }
221
222 #[test]
223 fn test_error_debug() {
224 let err = Error::Auth("test".to_string());
225 let debug_str = format!("{:?}", err);
226 assert!(debug_str.contains("Auth"));
227 assert!(debug_str.contains("test"));
228 }
229}