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
19pub struct GarminClient {
20    client: Client,
21    base_url: String,
22}
23
24impl GarminClient {
25    /// Create a new API client for the given domain
26    pub fn new(domain: &str) -> Self {
27        Self {
28            client: Client::builder()
29                .timeout(std::time::Duration::from_secs(30))
30                .build()
31                .expect("Failed to create HTTP client"),
32            base_url: format!("https://connectapi.{}", domain),
33        }
34    }
35
36    /// Create a new API client with a custom base URL (for testing)
37    #[doc(hidden)]
38    pub fn new_with_base_url(base_url: &str) -> Self {
39        Self {
40            client: Client::builder()
41                .timeout(std::time::Duration::from_secs(30))
42                .build()
43                .expect("Failed to create HTTP client"),
44            base_url: base_url.to_string(),
45        }
46    }
47
48    /// Build the full URL for a given path
49    fn build_url(&self, path: &str) -> String {
50        format!("{}{}", self.base_url, path)
51    }
52
53    /// Build headers with authorization
54    fn build_headers(&self, token: &OAuth2Token) -> HeaderMap {
55        let mut headers = HeaderMap::new();
56        headers.insert(USER_AGENT, HeaderValue::from_static(API_USER_AGENT));
57        headers.insert(
58            AUTHORIZATION,
59            HeaderValue::from_str(&token.authorization_header()).unwrap(),
60        );
61        headers
62    }
63
64    /// Make an authenticated GET request and return the response
65    pub async fn get(&self, token: &OAuth2Token, path: &str) -> Result<Response> {
66        let url = self.build_url(path);
67        let headers = self.build_headers(token);
68
69        let response = self
70            .client
71            .get(&url)
72            .headers(headers)
73            .send()
74            .await
75            .map_err(GarminError::Http)?;
76
77        self.handle_response_status(response).await
78    }
79
80    /// Make an authenticated GET request and deserialize JSON response
81    pub async fn get_json<T: DeserializeOwned>(&self, token: &OAuth2Token, path: &str) -> Result<T> {
82        let response = self.get(token, path).await?;
83        response.json().await.map_err(|e| {
84            GarminError::invalid_response(format!("Failed to parse JSON response: {}", e))
85        })
86    }
87
88    /// Make an authenticated POST request with JSON body
89    pub async fn post_json(&self, token: &OAuth2Token, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
90        let url = self.build_url(path);
91        let headers = self.build_headers(token);
92
93        let response = self
94            .client
95            .post(&url)
96            .headers(headers)
97            .json(body)
98            .send()
99            .await
100            .map_err(GarminError::Http)?;
101
102        let response = self.handle_response_status(response).await?;
103        response.json().await.map_err(|e| {
104            GarminError::invalid_response(format!("Failed to parse JSON response: {}", e))
105        })
106    }
107
108    /// Make an authenticated GET request and return raw bytes (for file downloads)
109    pub async fn download(&self, token: &OAuth2Token, path: &str) -> Result<Bytes> {
110        let response = self.get(token, path).await?;
111        response.bytes().await.map_err(GarminError::Http)
112    }
113
114    /// Upload a file using multipart form data
115    pub async fn upload(&self, token: &OAuth2Token, path: &str, file_path: &Path) -> Result<serde_json::Value> {
116        let url = self.build_url(path);
117        let headers = self.build_headers(token);
118
119        // Read the file
120        let file_name = file_path
121            .file_name()
122            .and_then(|n| n.to_str())
123            .unwrap_or("activity.fit")
124            .to_string();
125
126        let file_bytes = tokio::fs::read(file_path)
127            .await
128            .map_err(|e| GarminError::invalid_response(format!("Failed to read file: {}", e)))?;
129
130        // Create multipart form
131        let part = multipart::Part::bytes(file_bytes)
132            .file_name(file_name)
133            .mime_str("application/octet-stream")
134            .map_err(|e| GarminError::invalid_response(format!("Invalid MIME type: {}", e)))?;
135
136        let form = multipart::Form::new().part("file", part);
137
138        let response = self
139            .client
140            .post(&url)
141            .headers(headers)
142            .multipart(form)
143            .send()
144            .await
145            .map_err(GarminError::Http)?;
146
147        let response = self.handle_response_status(response).await?;
148        response.json().await.map_err(|e| {
149            GarminError::invalid_response(format!("Failed to parse upload response: {}", e))
150        })
151    }
152
153    /// Handle response status codes and convert to errors
154    async fn handle_response_status(&self, response: Response) -> Result<Response> {
155        let status = response.status();
156
157        match status {
158            StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED | StatusCode::NO_CONTENT => {
159                Ok(response)
160            }
161            StatusCode::UNAUTHORIZED => Err(GarminError::NotAuthenticated),
162            StatusCode::TOO_MANY_REQUESTS => Err(GarminError::RateLimited),
163            StatusCode::NOT_FOUND => {
164                let url = response.url().to_string();
165                Err(GarminError::NotFound(url))
166            }
167            _ => {
168                let status_code = status.as_u16();
169                let body = response.text().await.unwrap_or_default();
170                Err(GarminError::Api {
171                    status: status_code,
172                    message: body,
173                })
174            }
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_build_url() {
185        let client = GarminClient::new("garmin.com");
186        assert_eq!(
187            client.build_url("/activity-service/activity/123"),
188            "https://connectapi.garmin.com/activity-service/activity/123"
189        );
190    }
191
192    #[test]
193    fn test_client_creation() {
194        let client = GarminClient::new("garmin.com");
195        assert_eq!(client.base_url, "https://connectapi.garmin.com");
196    }
197}