diff --git a/crates/garmin-cli/src/client/api.rs b/crates/garmin-cli/src/client/api.rs index 2485230..ee5dd56 100644 --- a/crates/garmin-cli/src/client/api.rs +++ b/crates/garmin-cli/src/client/api.rs @@ -7,7 +7,9 @@ use bytes::Bytes; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT}; use reqwest::{multipart, Client, Response, StatusCode}; use serde::de::DeserializeOwned; +use std::io::Write; use std::path::Path; +use std::sync::{Arc, Mutex}; use crate::client::tokens::OAuth2Token; use crate::error::{GarminError, Result}; @@ -20,6 +22,49 @@ const API_USER_AGENT: &str = "GCM-iOS-5.7.2.1"; pub struct GarminClient { client: Client, base_url: String, + logger: Option, +} + +/// Shared sync/request log writer. +#[derive(Clone)] +pub struct RequestLogger { + inner: Arc>, + verbose: bool, +} + +impl RequestLogger { + /// Create a log writer. + pub fn new(file: std::fs::File, verbose: bool) -> Self { + Self { + inner: Arc::new(Mutex::new(file)), + verbose, + } + } + + /// Whether full JSON response logging is enabled. + pub fn verbose(&self) -> bool { + self.verbose + } + + /// Write a single timestamped line. + pub fn log(&self, message: impl AsRef) { + let now = chrono::Utc::now().to_rfc3339(); + if let Ok(mut file) = self.inner.lock() { + let _ = writeln!(file, "[{}] {}", now, message.as_ref()); + } + } + + fn log_response(&self, method: &str, path: &str, status: StatusCode, body: &str) { + if self.verbose { + self.log(format!( + "{} {} -> {}\n{}", + method, + path, + status, + if body.is_empty() { "" } else { body } + )); + } + } } impl GarminClient { @@ -31,9 +76,16 @@ impl GarminClient { .build() .expect("Failed to create HTTP client"), base_url: "https://connectapi.garmin.com".to_string(), + logger: None, } } + /// Attach a shared request logger. + pub fn with_logger(mut self, logger: Option) -> Self { + self.logger = logger; + self + } + /// Create a new API client with a custom base URL (for testing) #[doc(hidden)] pub fn new_with_base_url(base_url: &str) -> Self { @@ -43,6 +95,7 @@ impl GarminClient { .build() .expect("Failed to create HTTP client"), base_url: base_url.to_string(), + logger: None, } } @@ -85,8 +138,68 @@ impl GarminClient { path: &str, ) -> Result { let response = self.get(token, path).await?; - response.json().await.map_err(|e| { - GarminError::invalid_response(format!("Failed to parse JSON response: {}", e)) + let status = response.status(); + let text = response.text().await.map_err(GarminError::Http)?; + if let Some(logger) = &self.logger { + logger.log_response("GET", path, status, &text); + } + serde_json::from_str(&text).map_err(|e| { + if let Some(logger) = &self.logger { + logger.log(format!( + "Malformed JSON response from {} (status {}): {}; body preview: {}", + path, + status, + e, + response_preview(&text) + )); + } + GarminError::invalid_response(format!( + "Failed to parse JSON response from {} (status {}): {}; body preview: {}", + path, + status, + e, + response_preview(&text) + )) + }) + } + + /// Make an authenticated GET request and deserialize an optional JSON response. + /// + /// Garmin returns 204/empty bodies for some optional historical datasets. + /// Callers should use this only where "no body" means "no data". + pub async fn get_optional_json( + &self, + token: &OAuth2Token, + path: &str, + ) -> Result> { + let response = self.get(token, path).await?; + let status = response.status(); + let text = response.text().await.map_err(GarminError::Http)?; + if let Some(logger) = &self.logger { + logger.log_response("GET", path, status, &text); + } + + if status == StatusCode::NO_CONTENT || text.trim().is_empty() { + return Ok(None); + } + + serde_json::from_str(&text).map(Some).map_err(|e| { + if let Some(logger) = &self.logger { + logger.log(format!( + "Malformed JSON response from {} (status {}): {}; body preview: {}", + path, + status, + e, + response_preview(&text) + )); + } + GarminError::invalid_response(format!( + "Failed to parse JSON response from {} (status {}): {}; body preview: {}", + path, + status, + e, + response_preview(&text) + )) }) } @@ -110,8 +223,28 @@ impl GarminClient { .map_err(GarminError::Http)?; let response = self.handle_response_status(response).await?; - response.json().await.map_err(|e| { - GarminError::invalid_response(format!("Failed to parse JSON response: {}", e)) + let status = response.status(); + let text = response.text().await.map_err(GarminError::Http)?; + if let Some(logger) = &self.logger { + logger.log_response("POST", path, status, &text); + } + serde_json::from_str(&text).map_err(|e| { + if let Some(logger) = &self.logger { + logger.log(format!( + "Malformed JSON response from {} (status {}): {}; body preview: {}", + path, + status, + e, + response_preview(&text) + )); + } + GarminError::invalid_response(format!( + "Failed to parse JSON response from {} (status {}): {}; body preview: {}", + path, + status, + e, + response_preview(&text) + )) }) } @@ -222,6 +355,19 @@ impl GarminClient { } } +fn response_preview(text: &str) -> String { + const MAX_PREVIEW_CHARS: usize = 160; + let compact = text.split_whitespace().collect::>().join(" "); + let preview = compact.chars().take(MAX_PREVIEW_CHARS).collect::(); + if compact.chars().count() > MAX_PREVIEW_CHARS { + format!("{}...", preview) + } else if preview.is_empty() { + "".to_string() + } else { + preview + } +} + impl Default for GarminClient { fn default() -> Self { Self::new() @@ -231,6 +377,9 @@ impl Default for GarminClient { #[cfg(test)] mod tests { use super::*; + use crate::client::OAuth2Token; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; #[test] fn test_build_url() { @@ -246,4 +395,56 @@ mod tests { let client = GarminClient::new(); assert_eq!(client.base_url, "https://connectapi.garmin.com"); } + + #[tokio::test] + async fn test_get_optional_json_treats_204_as_none() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/optional")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + let client = GarminClient::new_with_base_url(&server.uri()); + let value: Option = client + .get_optional_json(&test_token(), "/optional") + .await + .unwrap(); + + assert!(value.is_none()); + } + + #[tokio::test] + async fn test_get_json_error_includes_body_preview() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/bad-json")) + .respond_with(ResponseTemplate::new(200).set_body_string("not json")) + .mount(&server) + .await; + + let client = GarminClient::new_with_base_url(&server.uri()); + let error = client + .get_json::(&test_token(), "/bad-json") + .await + .unwrap_err(); + + let message = error.to_string(); + assert!(message.contains("/bad-json")); + assert!(message.contains("not json")); + } + + fn test_token() -> OAuth2Token { + OAuth2Token { + scope: "test".to_string(), + jti: "jti".to_string(), + token_type: "Bearer".to_string(), + access_token: "access".to_string(), + refresh_token: "refresh".to_string(), + expires_in: 3600, + expires_at: 0, + refresh_token_expires_in: 86400, + refresh_token_expires_at: 0, + } + } } diff --git a/crates/garmin-cli/src/client/mod.rs b/crates/garmin-cli/src/client/mod.rs index 3e43bad..d747621 100644 --- a/crates/garmin-cli/src/client/mod.rs +++ b/crates/garmin-cli/src/client/mod.rs @@ -3,7 +3,7 @@ pub mod oauth1; pub mod sso; pub mod tokens; -pub use api::GarminClient; +pub use api::{GarminClient, RequestLogger}; pub use oauth1::{OAuth1Signer, OAuthConsumer, OAuthToken}; pub use sso::SsoClient; pub use tokens::{OAuth1Token, OAuth2Token};