fixups
This commit is contained in:
parent
89091a03d3
commit
eda8e76a20
4 changed files with 481 additions and 218 deletions
161
src/intervals.rs
161
src/intervals.rs
|
|
@ -1,7 +1,7 @@
|
|||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
|
|
@ -9,13 +9,28 @@ use crate::config::Config;
|
|||
pub struct IntervalsClient {
|
||||
client: Client,
|
||||
config: Config,
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
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<String>) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
config,
|
||||
api_key: Some(api_key.into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -24,7 +39,9 @@ impl IntervalsClient {
|
|||
}
|
||||
|
||||
fn api_key(&self) -> Result<&str> {
|
||||
self.config.require_api_key()
|
||||
self.api_key
|
||||
.as_deref()
|
||||
.context("Intervals.icu API key is not configured")
|
||||
}
|
||||
|
||||
fn api_url(&self, path: &str) -> String {
|
||||
|
|
@ -41,12 +58,13 @@ impl IntervalsClient {
|
|||
|
||||
async fn get_json(&self, url: String) -> Result<Value> {
|
||||
let response = self
|
||||
.authenticated(self.client.get(url))?
|
||||
.authenticated(self.client.get(&url))?
|
||||
.send()
|
||||
.await
|
||||
.context("Intervals.icu request failed")?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
|
|
@ -60,40 +78,103 @@ impl IntervalsClient {
|
|||
.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<Value> {
|
||||
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<Value> {
|
||||
let url = self.api_url(&format!("athlete/{athlete_id}"));
|
||||
|
||||
self.get_json(url).await
|
||||
}
|
||||
|
||||
/// Get a single activity.
|
||||
///
|
||||
/// Uses:
|
||||
/// GET /api/v1/activity/{id}
|
||||
pub async fn activity(&self, activity_id: &str) -> Result<Value> {
|
||||
let url = self.api_url(&format!("activity/{activity_id}"));
|
||||
let url = self.api_url(&format!("activity/{activity_id}?intervals=true"));
|
||||
|
||||
self.get_json(url).await
|
||||
}
|
||||
|
||||
/// Get activity streams.
|
||||
///
|
||||
/// Uses:
|
||||
/// GET /api/v1/activity/{id}/streams.json
|
||||
///
|
||||
/// The response is normally an array of stream objects:
|
||||
///
|
||||
/// [
|
||||
/// {"type":"time","data":[...]},
|
||||
/// {"type":"latlng","data":[...]},
|
||||
/// ...
|
||||
/// ]
|
||||
/// We explicitly request the streams needed for GPS processing.
|
||||
pub async fn streams(&self, activity_id: &str) -> Result<Value> {
|
||||
let url = self.api_url(&format!("activity/{activity_id}/streams.json"));
|
||||
let url = self.api_url(&format!(
|
||||
"activity/{activity_id}/streams.json?types=time,latlng"
|
||||
));
|
||||
|
||||
self.get_json(url).await
|
||||
}
|
||||
|
||||
/// Fetch the athlete's activities.
|
||||
///
|
||||
/// `0` means the athlete belonging to the API key.
|
||||
pub async fn activities(&self, oldest: &str, newest: &str) -> Result<Value> {
|
||||
let athlete_id = &self.config.intervals_athlete_id;
|
||||
/// Find a stream object by type.
|
||||
pub fn find_stream<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Value> {
|
||||
// Normal API response:
|
||||
//
|
||||
// [
|
||||
// {
|
||||
// "type": "time",
|
||||
// "data": [...]
|
||||
// },
|
||||
// {
|
||||
// "type": "latlng",
|
||||
// "data": [...],
|
||||
// "data2": [...]
|
||||
// }
|
||||
// ]
|
||||
if let Some(array) = streams.as_array() {
|
||||
return array
|
||||
.iter()
|
||||
.find(|stream| stream.get("type").and_then(Value::as_str) == Some(stream_type));
|
||||
}
|
||||
|
||||
// Also tolerate:
|
||||
//
|
||||
// {
|
||||
// "time": {...},
|
||||
// "latlng": {...}
|
||||
// }
|
||||
streams.get(stream_type)
|
||||
}
|
||||
|
||||
/// Get the `data` array of a normal stream.
|
||||
pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec<Value>> {
|
||||
let stream = Self::find_stream(streams, stream_type)?;
|
||||
|
||||
stream.get("data").and_then(Value::as_array)
|
||||
}
|
||||
|
||||
/// Get the latitude and longitude arrays from the
|
||||
/// Intervals.icu `latlng` stream.
|
||||
///
|
||||
/// Intervals.icu represents latlng as:
|
||||
///
|
||||
/// data = latitude
|
||||
/// data2 = longitude
|
||||
///
|
||||
pub fn latlng_values<'a>(streams: &'a Value) -> Option<(&'a Vec<Value>, &'a Vec<Value>)> {
|
||||
let stream = Self::find_stream(streams, "latlng")?;
|
||||
|
||||
let lat = stream.get("data")?.as_array()?;
|
||||
|
||||
let lon = stream.get("data2")?.as_array()?;
|
||||
|
||||
Some((lat, lon))
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
let url = format!(
|
||||
"{}?oldest={}&newest={}",
|
||||
self.api_url(&format!("athlete/{athlete_id}/activities")),
|
||||
|
|
@ -104,27 +185,6 @@ impl IntervalsClient {
|
|||
self.get_json(url).await
|
||||
}
|
||||
|
||||
/// Return a stream's values independent of whether the API
|
||||
/// returned an object or an array of stream objects.
|
||||
pub fn stream_values<'a>(streams: &'a Value, stream_type: &str) -> Option<&'a Vec<Value>> {
|
||||
// Current API format: array of streams.
|
||||
if let Some(array) = streams.as_array() {
|
||||
for stream in array {
|
||||
if stream.get("type").and_then(Value::as_str) == Some(stream_type) {
|
||||
return stream.get("data").and_then(Value::as_array);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also support a dictionary-like response:
|
||||
//
|
||||
// {
|
||||
// "time": [...],
|
||||
// "latlng": [...]
|
||||
// }
|
||||
streams.get(stream_type).and_then(Value::as_array)
|
||||
}
|
||||
|
||||
/// Extract the activity start timestamp.
|
||||
pub fn activity_start(activity: &Value) -> Result<DateTime<Utc>> {
|
||||
let value = activity
|
||||
|
|
@ -142,9 +202,6 @@ impl IntervalsClient {
|
|||
|
||||
/// Try to find an Intervals.icu interval corresponding
|
||||
/// to a GPS stream index.
|
||||
///
|
||||
/// The exact interval representation has changed over
|
||||
/// time, so this deliberately supports the common forms.
|
||||
pub fn matching_interval(activity: &Value, track_index: usize) -> Option<Value> {
|
||||
let intervals = activity.get("icu_intervals")?;
|
||||
|
||||
|
|
@ -158,6 +215,7 @@ impl IntervalsClient {
|
|||
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 {
|
||||
|
|
@ -168,8 +226,7 @@ impl IntervalsClient {
|
|||
None
|
||||
}
|
||||
|
||||
/// OAuth methods are intentionally retained so we can
|
||||
/// re-enable multi-user OAuth later.
|
||||
/// OAuth methods retained for the normal multi-user flow.
|
||||
pub fn oauth_authorize_url(&self, state: &str) -> Result<String> {
|
||||
let client_id = self
|
||||
.config
|
||||
|
|
@ -219,6 +276,7 @@ impl IntervalsClient {
|
|||
.context("OAuth token request failed")?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
let body = response.text().await?;
|
||||
|
||||
if !status.is_success() {
|
||||
|
|
@ -252,9 +310,14 @@ impl IntervalsClient {
|
|||
let athlete_id = athlete
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| athlete.get("id").and_then(Value::as_i64).map(|_| ""))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
.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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue