1use 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
15const API_USER_AGENT: &str = "GCM-iOS-5.7.2.1";
17
18pub struct GarminClient {
20 client: Client,
21 base_url: String,
22}
23
24impl GarminClient {
25 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 #[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 fn build_url(&self, path: &str) -> String {
50 format!("{}{}", self.base_url, path)
51 }
52
53 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 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 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 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 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 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 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 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 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}