use anyhow::{Context, Result, anyhow}; use chrono::{DateTime, NaiveDateTime, Utc}; use reqwest::Client; use serde_json::Value; use crate::config::Config; #[derive(Clone)] pub struct IntervalsClient { client: Client, config: Config, api_key: Option, } impl IntervalsClient { pub fn new(config: Config) -> Self { let api_key = config.intervals_api_key.clone(); Self { client: Client::new(), config, api_key, } } /// Create a client using an explicitly supplied personal API key. /// /// Primarily used by /dev/sync/{api_key}/{athlete_id}. pub fn with_api_key(config: Config, api_key: impl Into) -> Self { Self { client: Client::new(), config, api_key: Some(api_key.into()), } } fn base_url(&self) -> &str { self.config.intervals_base_url.trim_end_matches('/') } fn api_key(&self) -> Result<&str> { self.api_key .as_deref() .context("Intervals.icu API key is not configured") } fn api_url(&self, path: &str) -> String { format!( "{}/api/v1/{}", self.base_url(), path.trim_start_matches('/') ) } fn authenticated(&self, request: reqwest::RequestBuilder) -> Result { Ok(request.basic_auth("API_KEY", Some(self.api_key()?))) } async fn get_json(&self, url: String) -> Result { let response = self .authenticated(self.client.get(&url))? .send() .await .context("Intervals.icu request failed")?; let status = response.status(); let body = response .text() .await .context("cannot read Intervals.icu response")?; if !status.is_success() { return Err(anyhow!("Intervals.icu returned HTTP {}: {}", status, body)); } serde_json::from_str(&body) .with_context(|| format!("invalid JSON returned by Intervals.icu: {}", body)) } /// Get the athlete belonging to the configured API key. /// /// GET /api/v1/athlete/0 pub async fn owner(&self) -> Result { let url = self.api_url("athlete/0"); self.get_json(url).await } /// Get a specific athlete. pub async fn athlete(&self, athlete_id: &str) -> Result { let url = self.api_url(&format!("athlete/{athlete_id}")); self.get_json(url).await } /// Get a single activity. /// /// GET /api/v1/activity/{id} pub async fn activity(&self, activity_id: &str) -> Result { /* * Ask Intervals.icu to include interval information, * which is later used to associate county crossings * with intervals. */ let url = self.api_url(&format!("activity/{activity_id}?intervals=true")); self.get_json(url).await } /// Get activity streams. /// /// GET /api/v1/activity/{id}/streams.json /// /// We explicitly request the streams needed for GPS /// processing. pub async fn streams(&self, activity_id: &str) -> Result { let url = self.api_url(&format!( "activity/{activity_id}/streams.json?types=time,latlng" )); tracing::info!( activity_id = %activity_id, "requesting Intervals.icu streams" ); let value = self.get_json(url).await?; let stream_count = value.as_array().map(|streams| streams.len()).unwrap_or(0); let time_points = Self::stream_values(&value, "time") .map(|values| values.len()) .unwrap_or(0); let (latitude_points, longitude_points) = Self::latlng_values(&value) .map(|(lat, lon)| (lat.len(), lon.len())) .unwrap_or((0, 0)); tracing::info!( activity_id = %activity_id, stream_count, time_points, latitude_points, longitude_points, "received Intervals.icu streams" ); Ok(value) } /// Find a stream object by type. /// /// Normal Intervals.icu response: /// /// [ /// { /// "type": "time", /// "data": [...] /// }, /// { /// "type": "latlng", /// "data": [...], /// "data2": [...] /// } /// ] /// /// Also tolerates a dictionary-like response. pub fn find_stream<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Value> { if let Some(array) = streams.as_array() { return array .iter() .find(|stream| stream.get("type").and_then(Value::as_str) == Some(stream_type)); } streams.get(stream_type) } /// Get the `data` array of a normal stream. /// /// For example: /// /// { /// "type": "time", /// "data": [0, 1, 2, 3] /// } pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec> { let stream = Self::find_stream(streams, stream_type)?; stream.get("data").and_then(Value::as_array) } /// Get latitude and longitude arrays from the /// Intervals.icu `latlng` stream. /// /// Intervals.icu represents latlng as: /// /// { /// "type": "latlng", /// "data": [latitude, latitude, ...], /// "data2": [longitude, longitude, ...] /// } /// /// Therefore: /// /// data = latitude /// data2 = longitude /// /// It is NOT: /// /// data = [[lat, lon], [lat, lon], ...] pub fn latlng_values<'a>(streams: &'a Value) -> Option<(&'a Vec, &'a Vec)> { let stream = Self::find_stream(streams, "latlng")?; let latitudes = stream.get("data").and_then(Value::as_array)?; let longitudes = stream.get("data2").and_then(Value::as_array)?; Some((latitudes, longitudes)) } /// Fetch an athlete's activities. /// /// `athlete_id = "0"` means the athlete belonging to /// the API key. pub async fn activities(&self, athlete_id: &str, oldest: &str, newest: &str) -> Result { let url = format!( "{}?oldest={}&newest={}", self.api_url(&format!("athlete/{athlete_id}/activities"),), urlencoding::encode(oldest), urlencoding::encode(newest), ); self.get_json(url).await } /// Extract the activity start timestamp. pub fn activity_start(activity: &Value) -> Result> { let value = activity .get("start_date") .or_else(|| activity.get("start_date_local")) .or_else(|| activity.get("start_time")) .ok_or_else(|| anyhow!("activity has no start timestamp"))?; let string = value .as_str() .ok_or_else(|| anyhow!("activity start timestamp is not a string"))?; parse_datetime(string) } /// Try to find an Intervals.icu interval corresponding /// to a GPS stream index. pub fn matching_interval(activity: &Value, track_index: usize) -> Option { let intervals = activity.get("icu_intervals")?; let array = intervals.as_array()?; for interval in array { let start = interval .get("start_index") .or_else(|| interval.get("start")); let end = interval.get("end_index").or_else(|| interval.get("end")); let start = start.and_then(Value::as_u64)?; let end = end.and_then(Value::as_u64)?; if (start as usize) <= track_index && track_index <= end as usize { return Some(interval.clone()); } } None } /// OAuth methods retained for the normal multi-user flow. pub fn oauth_authorize_url(&self, state: &str) -> Result { let client_id = self .config .intervals_client_id .as_deref() .context("INTERVALS_CLIENT_ID is not configured")?; let redirect_uri = self .config .intervals_redirect_uri .as_deref() .context("INTERVALS_REDIRECT_URI is not configured")?; Ok(format!( "{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}", self.base_url(), urlencoding::encode(client_id), urlencoding::encode(redirect_uri), urlencoding::encode("ACTIVITY:READ"), urlencoding::encode(state), )) } pub async fn exchange_code(&self, code: &str) -> Result { let client_id = self .config .intervals_client_id .as_deref() .context("INTERVALS_CLIENT_ID is not configured")?; let client_secret = self .config .intervals_client_secret .as_deref() .context("INTERVALS_CLIENT_SECRET is not configured")?; let response = self .client .post(format!("{}/api/oauth/token", self.base_url())) .form(&[ ("client_id", client_id), ("client_secret", client_secret), ("code", code), ]) .send() .await .context("OAuth token request failed")?; let status = response.status(); let body = response.text().await?; if !status.is_success() { return Err(anyhow!( "OAuth token exchange returned HTTP {}: {}", status, body )); } Ok(serde_json::from_str(&body)?) } pub fn token_data(response: &Value) -> Result<(String, String, String, String)> { let access_token = response .get("access_token") .and_then(Value::as_str) .context("OAuth response has no access_token")? .to_string(); let scope = response .get("scope") .and_then(Value::as_str) .unwrap_or("") .to_string(); let athlete = response .get("athlete") .context("OAuth response has no athlete")?; let athlete_id = athlete .get("id") .and_then(Value::as_str) .map(str::to_string) .or_else(|| { athlete .get("id") .and_then(Value::as_i64) .map(|id| id.to_string()) }) .unwrap_or_default(); let display_name = athlete .get("name") .or_else(|| athlete.get("display_name")) .and_then(Value::as_str) .unwrap_or("Intervals.icu athlete") .to_string(); Ok((access_token, scope, athlete_id, display_name)) } } fn parse_datetime(value: &str) -> Result> { if let Ok(value) = DateTime::parse_from_rfc3339(value) { return Ok(value.with_timezone(&Utc)); } if let Ok(value) = DateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%z") { return Ok(value.with_timezone(&Utc)); } if let Ok(value) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") { return Ok(DateTime::::from_naive_utc_and_offset(value, Utc)); } Err(anyhow!("cannot parse activity timestamp: {}", value)) }