datto_api/
client.rs

1//! Datto RMM API Client implementation.
2
3use 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/// OAuth 2.0 credentials for the Datto RMM API.
11#[derive(Debug, Clone)]
12pub struct Credentials {
13    /// API Key (client ID)
14    pub api_key: String,
15    /// API Secret (client secret)
16    pub api_secret: String,
17}
18
19/// OAuth token state.
20struct TokenState {
21    access_token: String,
22    expires_at: Instant,
23}
24
25/// Datto RMM API client.
26///
27/// This client handles authentication and provides access to the generated API methods.
28///
29/// # Example
30///
31/// ```no_run
32/// use datto_api::{DattoClient, Platform, Credentials};
33///
34/// #[tokio::main]
35/// async fn main() -> Result<(), datto_api::Error> {
36///     let client = DattoClient::new(
37///         Platform::Merlot,
38///         Credentials {
39///             api_key: "your-api-key".into(),
40///             api_secret: "your-api-secret".into(),
41///         },
42///     ).await?;
43///
44///     // Use the client...
45///     Ok(())
46/// }
47/// ```
48pub struct DattoClient {
49    http_client: HttpClient,
50    credentials: Credentials,
51    platform: Platform,
52    token_state: Arc<RwLock<Option<TokenState>>>,
53}
54
55impl DattoClient {
56    /// Create a new Datto RMM API client.
57    ///
58    /// This will immediately fetch an access token.
59    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        // Pre-fetch initial token
73        client.ensure_token().await?;
74
75        Ok(client)
76    }
77
78    /// Get the platform this client is connected to.
79    pub fn platform(&self) -> Platform {
80        self.platform
81    }
82
83    /// Get the base URL for API requests.
84    pub fn base_url(&self) -> &str {
85        self.platform.base_url()
86    }
87
88    /// Ensure we have a valid access token.
89    ///
90    /// Returns the token if valid, refreshes if expired.
91    pub async fn ensure_token(&self) -> Result<String, Error> {
92        // Check if we have a valid token (with 5 minute buffer)
93        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        // Refresh token
104        self.refresh_token().await
105    }
106
107    /// Force a token refresh.
108    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    /// Get the HTTP client for making custom requests.
153    ///
154    /// Note: You'll need to add the Authorization header yourself.
155    pub fn http_client(&self) -> &HttpClient {
156        &self.http_client
157    }
158}
159
160/// Errors that can occur when using the Datto RMM API client.
161#[derive(Debug, thiserror::Error)]
162pub enum Error {
163    /// HTTP client error
164    #[error("HTTP client error: {0}")]
165    HttpClient(#[from] reqwest::Error),
166
167    /// Authentication error
168    #[error("Authentication failed: {0}")]
169    Auth(String),
170
171    /// API error response
172    #[error("API error: {status} - {message}")]
173    Api {
174        /// HTTP status code
175        status: u16,
176        /// Error message
177        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        // We can't easily create a reqwest::Error, so test the other variants
209        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}