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>(
82        &self,
83        token: &OAuth2Token,
84        path: &str,
85    ) -> Result<T> {
86        let response = self.get(token, path).await?;
87        response.json().await.map_err(|e| {
88            GarminError::invalid_response(format!("Failed to parse JSON response: {}", e))
89        })
90    }
91
92    /// Make an authenticated POST request with JSON body
93    pub async fn post_json(
94        &self,
95        token: &OAuth2Token,
96        path: &str,
97        body: &serde_json::Value,
98    ) -> Result<serde_json::Value> {
99        let url = self.build_url(path);
100        let headers = self.build_headers(token);
101
102        let response = self
103            .client
104            .post(&url)
105            .headers(headers)
106            .json(body)
107            .send()
108            .await
109            .map_err(GarminError::Http)?;
110
111        let response = self.handle_response_status(response).await?;
112        response.json().await.map_err(|e| {
113            GarminError::invalid_response(format!("Failed to parse JSON response: {}", e))
114        })
115    }
116
117    /// Make an authenticated GET request and return raw bytes (for file downloads)
118    pub async fn download(&self, token: &OAuth2Token, path: &str) -> Result<Bytes> {
119        let response = self.get(token, path).await?;
120        response.bytes().await.map_err(GarminError::Http)
121    }
122
123    /// Upload a file using multipart form data
124    pub async fn upload(
125        &self,
126        token: &OAuth2Token,
127        path: &str,
128        file_path: &Path,
129    ) -> Result<serde_json::Value> {
130        let url = self.build_url(path);
131        let headers = self.build_headers(token);
132
133        // Read the file
134        let file_name = file_path
135            .file_name()
136            .and_then(|n| n.to_str())
137            .unwrap_or("activity.fit")
138            .to_string();
139
140        let file_bytes = tokio::fs::read(file_path)
141            .await
142            .map_err(|e| GarminError::invalid_response(format!("Failed to read file: {}", e)))?;
143
144        // Create multipart form
145        let part = multipart::Part::bytes(file_bytes)
146            .file_name(file_name)
147            .mime_str("application/octet-stream")
148            .map_err(|e| GarminError::invalid_response(format!("Invalid MIME type: {}", e)))?;
149
150        let form = multipart::Form::new().part("file", part);
151
152        let response = self
153            .client
154            .post(&url)
155            .headers(headers)
156            .multipart(form)
157            .send()
158            .await
159            .map_err(GarminError::Http)?;
160
161        let response = self.handle_response_status(response).await?;
162        response.json().await.map_err(|e| {
163            GarminError::invalid_response(format!("Failed to parse upload response: {}", e))
164        })
165    }
166
167    /// Handle response status codes and convert to errors
168    async fn handle_response_status(&self, response: Response) -> Result<Response> {
169        let status = response.status();
170
171        match status {
172            StatusCode::OK
173            | StatusCode::CREATED
174            | StatusCode::ACCEPTED
175            | StatusCode::NO_CONTENT => Ok(response),
176            StatusCode::UNAUTHORIZED => Err(GarminError::NotAuthenticated),
177            StatusCode::TOO_MANY_REQUESTS => Err(GarminError::RateLimited),
178            StatusCode::NOT_FOUND => {
179                let url = response.url().to_string();
180                Err(GarminError::NotFound(url))
181            }
182            _ => {
183                let status_code = status.as_u16();
184                let body = response.text().await.unwrap_or_default();
185                Err(GarminError::Api {
186                    status: status_code,
187                    message: body,
188                })
189            }
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_build_url() {
200        let client = GarminClient::new("garmin.com");
201        assert_eq!(
202            client.build_url("/activity-service/activity/123"),
203            "https://connectapi.garmin.com/activity-service/activity/123"
204        );
205    }
206
207    #[test]
208    fn test_client_creation() {
209        let client = GarminClient::new("garmin.com");
210        assert_eq!(client.base_url, "https://connectapi.garmin.com");
211    }
212}