Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 205 additions & 4 deletions crates/garmin-cli/src/client/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<RequestLogger>,
}

/// Shared sync/request log writer.
#[derive(Clone)]
pub struct RequestLogger {
inner: Arc<Mutex<std::fs::File>>,
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<str>) {
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() { "<empty>" } else { body }
));
}
}
}

impl GarminClient {
Expand All @@ -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<RequestLogger>) -> 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 {
Expand All @@ -43,6 +95,7 @@ impl GarminClient {
.build()
.expect("Failed to create HTTP client"),
base_url: base_url.to_string(),
logger: None,
}
}

Expand Down Expand Up @@ -85,8 +138,68 @@ impl GarminClient {
path: &str,
) -> Result<T> {
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<T: DeserializeOwned>(
&self,
token: &OAuth2Token,
path: &str,
) -> Result<Option<T>> {
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)
))
})
}

Expand All @@ -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)
))
})
}

Expand Down Expand Up @@ -222,6 +355,19 @@ impl GarminClient {
}
}

fn response_preview(text: &str) -> String {
const MAX_PREVIEW_CHARS: usize = 160;
let compact = text.split_whitespace().collect::<Vec<_>>().join(" ");
let preview = compact.chars().take(MAX_PREVIEW_CHARS).collect::<String>();
if compact.chars().count() > MAX_PREVIEW_CHARS {
format!("{}...", preview)
} else if preview.is_empty() {
"<empty>".to_string()
} else {
preview
}
}

impl Default for GarminClient {
fn default() -> Self {
Self::new()
Expand All @@ -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() {
Expand All @@ -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<serde_json::Value> = 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("<html>not json</html>"))
.mount(&server)
.await;

let client = GarminClient::new_with_base_url(&server.uri());
let error = client
.get_json::<serde_json::Value>(&test_token(), "/bad-json")
.await
.unwrap_err();

let message = error.to_string();
assert!(message.contains("/bad-json"));
assert!(message.contains("<html>not json</html>"));
}

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,
}
}
}
2 changes: 1 addition & 1 deletion crates/garmin-cli/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Loading