Skip to main content

garmin_cli/client/
api.rs

1//! Garmin Connect API client for authenticated requests
2//!
3//! This module provides a high-level client for making authenticated requests
4//! to the Garmin Connect API using OAuth2 bearer tokens.
5
6use bytes::Bytes;
7use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
8use reqwest::{multipart, Client, Response, StatusCode};
9use serde::de::DeserializeOwned;
10use std::path::Path;
11
12use crate::client::tokens::OAuth2Token;
13use crate::error::{GarminError, Result};
14
15/// User agent for Connect API requests
16const API_USER_AGENT: &str = "GCM-iOS-5.7.2.1";
17
18/// Garmin Connect API client
19#[derive(Clone)]
20pub struct GarminClient {
21    client: Client,
22    base_url: String,
23}
24
25impl GarminClient {
26    /// Create a new API client for Garmin Connect.
27    pub fn new() -> Self {
28        Self {
29            client: Client::builder()
30                .timeout(std::time::Duration::from_secs(30))
31                .build()
32                .expect("Failed to create HTTP client"),
33            base_url: "https://connectapi.garmin.com".to_string(),
34        }
35    }
36
37    /// Create a new API client with a custom base URL (for testing)
38    #[doc(hidden)]
39    pub fn new_with_base_url(base_url: &str) -> Self {
40        Self {
41            client: Client::builder()
42                .timeout(std::time::Duration::from_secs(30))
43                .build()
44                .expect("Failed to create HTTP client"),
45            base_url: base_url.to_string(),
46        }
47    }
48
49    /// Build the full URL for a given path
50    fn build_url(&self, path: &str) -> String {
51        format!("{}{}", self.base_url, path)
52    }
53
54    /// Build headers with authorization
55    fn build_headers(&self, token: &OAuth2Token) -> HeaderMap {
56        let mut headers = HeaderMap::new();
57        headers.insert(USER_AGENT, HeaderValue::from_static(API_USER_AGENT));
58        headers.insert(
59            AUTHORIZATION,
60            HeaderValue::from_str(&token.authorization_header()).unwrap(),
61        );
62        headers
63    }
64
65    /// Make an authenticated GET request and return the response
66    pub async fn get(&self, token: &OAuth2Token, path: &str) -> Result<Response> {
67        let url = self.build_url(path);
68        let headers = self.build_headers(token);
69
70        let response = self
71            .client
72            .get(&url)
73            .headers(headers)
74            .send()
75            .await
76            .map_err(GarminError::Http)?;
77
78        self.handle_response_status(response).await
79    }
80
81    /// Make an authenticated GET request and deserialize JSON response
82    pub async fn get_json<T: DeserializeOwned>(
83        &self,
84        token: &OAuth2Token,
85        path: &str,
86    ) -> Result<T> {
87        let response = self.get(token, path).await?;
88        response.json().await.map_err(|e| {
89            GarminError::invalid_response(format!("Failed to parse JSON response: {}", e))
90        })
91    }
92
93    /// Make an authenticated POST request with JSON body
94    pub async fn post_json(
95        &self,
96        token: &OAuth2Token,
97        path: &str,
98        body: &serde_json::Value,
99    ) -> Result<serde_json::Value> {
100        let url = self.build_url(path);
101        let headers = self.build_headers(token);
102
103        let response = self
104            .client
105            .post(&url)
106            .headers(headers)
107            .json(body)
108            .send()
109            .await
110            .map_err(GarminError::Http)?;
111
112        let response = self.handle_response_status(response).await?;
113        response.json().await.map_err(|e| {
114            GarminError::invalid_response(format!("Failed to parse JSON response: {}", e))
115        })
116    }
117
118    /// Make an authenticated PUT request with JSON body
119    pub async fn put_json(
120        &self,
121        token: &OAuth2Token,
122        path: &str,
123        body: &serde_json::Value,
124    ) -> Result<serde_json::Value> {
125        let url = self.build_url(path);
126        let headers = self.build_headers(token);
127
128        let response = self
129            .client
130            .put(&url)
131            .headers(headers)
132            .json(body)
133            .send()
134            .await
135            .map_err(GarminError::Http)?;
136
137        let response = self.handle_response_status(response).await?;
138        // Garmin may answer an activity update with an empty body; treat that as success.
139        let text = response.text().await.unwrap_or_default();
140        if text.trim().is_empty() {
141            return Ok(serde_json::Value::Null);
142        }
143        serde_json::from_str(&text).map_err(|e| {
144            GarminError::invalid_response(format!("Failed to parse JSON response: {}", e))
145        })
146    }
147
148    /// Make an authenticated GET request and return raw bytes (for file downloads)
149    pub async fn download(&self, token: &OAuth2Token, path: &str) -> Result<Bytes> {
150        let response = self.get(token, path).await?;
151        response.bytes().await.map_err(GarminError::Http)
152    }
153
154    /// Upload a file using multipart form data
155    pub async fn upload(
156        &self,
157        token: &OAuth2Token,
158        path: &str,
159        file_path: &Path,
160    ) -> Result<serde_json::Value> {
161        let url = self.build_url(path);
162        let headers = self.build_headers(token);
163
164        // Read the file
165        let file_name = file_path
166            .file_name()
167            .and_then(|n| n.to_str())
168            .unwrap_or("activity.fit")
169            .to_string();
170
171        let file_bytes = tokio::fs::read(file_path)
172            .await
173            .map_err(|e| GarminError::invalid_response(format!("Failed to read file: {}", e)))?;
174
175        // Create multipart form
176        let part = multipart::Part::bytes(file_bytes)
177            .file_name(file_name)
178            .mime_str("application/octet-stream")
179            .map_err(|e| GarminError::invalid_response(format!("Invalid MIME type: {}", e)))?;
180
181        let form = multipart::Form::new().part("file", part);
182
183        let response = self
184            .client
185            .post(&url)
186            .headers(headers)
187            .multipart(form)
188            .send()
189            .await
190            .map_err(GarminError::Http)?;
191
192        let response = self.handle_response_status(response).await?;
193        response.json().await.map_err(|e| {
194            GarminError::invalid_response(format!("Failed to parse upload response: {}", e))
195        })
196    }
197
198    /// Handle response status codes and convert to errors
199    async fn handle_response_status(&self, response: Response) -> Result<Response> {
200        let status = response.status();
201
202        match status {
203            StatusCode::OK
204            | StatusCode::CREATED
205            | StatusCode::ACCEPTED
206            | StatusCode::NO_CONTENT => Ok(response),
207            StatusCode::UNAUTHORIZED => Err(GarminError::NotAuthenticated),
208            StatusCode::TOO_MANY_REQUESTS => Err(GarminError::RateLimited),
209            StatusCode::NOT_FOUND => {
210                let url = response.url().to_string();
211                Err(GarminError::NotFound(url))
212            }
213            _ => {
214                let status_code = status.as_u16();
215                let body = response.text().await.unwrap_or_default();
216                Err(GarminError::Api {
217                    status: status_code,
218                    message: body,
219                })
220            }
221        }
222    }
223}
224
225impl Default for GarminClient {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_build_url() {
237        let client = GarminClient::new();
238        assert_eq!(
239            client.build_url("/activity-service/activity/123"),
240            "https://connectapi.garmin.com/activity-service/activity/123"
241        );
242    }
243
244    #[test]
245    fn test_client_creation() {
246        let client = GarminClient::new();
247        assert_eq!(client.base_url, "https://connectapi.garmin.com");
248    }
249}