state
This commit is contained in:
parent
eda8e76a20
commit
45bcc13788
3 changed files with 678 additions and 174 deletions
431
src/intervals.rs
431
src/intervals.rs
|
|
@ -1,4 +1,4 @@
|
|||
use anyhow::{Context, Result, anyhow};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
|
|
@ -26,7 +26,10 @@ impl IntervalsClient {
|
|||
/// 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 {
|
||||
pub fn with_api_key(
|
||||
config: Config,
|
||||
api_key: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
config,
|
||||
|
|
@ -35,13 +38,17 @@ impl IntervalsClient {
|
|||
}
|
||||
|
||||
fn base_url(&self) -> &str {
|
||||
self.config.intervals_base_url.trim_end_matches('/')
|
||||
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")
|
||||
.context(
|
||||
"Intervals.icu API key is not configured",
|
||||
)
|
||||
}
|
||||
|
||||
fn api_url(&self, path: &str) -> String {
|
||||
|
|
@ -52,30 +59,54 @@ impl IntervalsClient {
|
|||
)
|
||||
}
|
||||
|
||||
fn authenticated(&self, request: reqwest::RequestBuilder) -> Result<reqwest::RequestBuilder> {
|
||||
Ok(request.basic_auth("API_KEY", Some(self.api_key()?)))
|
||||
fn authenticated(
|
||||
&self,
|
||||
request: reqwest::RequestBuilder,
|
||||
) -> Result<reqwest::RequestBuilder> {
|
||||
Ok(
|
||||
request.basic_auth(
|
||||
"API_KEY",
|
||||
Some(self.api_key()?),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_json(&self, url: String) -> Result<Value> {
|
||||
async fn get_json(
|
||||
&self,
|
||||
url: String,
|
||||
) -> Result<Value> {
|
||||
let response = self
|
||||
.authenticated(self.client.get(&url))?
|
||||
.send()
|
||||
.await
|
||||
.context("Intervals.icu request failed")?;
|
||||
.context(
|
||||
"Intervals.icu request failed",
|
||||
)?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.context("cannot read Intervals.icu response")?;
|
||||
.context(
|
||||
"cannot read Intervals.icu response",
|
||||
)?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("Intervals.icu returned HTTP {}: {}", status, body));
|
||||
return Err(anyhow!(
|
||||
"Intervals.icu returned HTTP {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::from_str(&body)
|
||||
.with_context(|| format!("invalid JSON returned by Intervals.icu: {}", body))
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"invalid JSON returned by Intervals.icu: {}",
|
||||
body
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the athlete belonging to the configured API key.
|
||||
|
|
@ -83,12 +114,18 @@ impl IntervalsClient {
|
|||
/// 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}"));
|
||||
pub async fn athlete(
|
||||
&self,
|
||||
athlete_id: &str,
|
||||
) -> Result<Value> {
|
||||
let url = self.api_url(
|
||||
&format!("athlete/{athlete_id}"),
|
||||
);
|
||||
|
||||
self.get_json(url).await
|
||||
}
|
||||
|
|
@ -96,8 +133,20 @@ impl IntervalsClient {
|
|||
/// Get a single activity.
|
||||
///
|
||||
/// GET /api/v1/activity/{id}
|
||||
pub async fn activity(&self, activity_id: &str) -> Result<Value> {
|
||||
let url = self.api_url(&format!("activity/{activity_id}?intervals=true"));
|
||||
pub async fn activity(
|
||||
&self,
|
||||
activity_id: &str,
|
||||
) -> Result<Value> {
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
|
|
@ -106,78 +155,180 @@ impl IntervalsClient {
|
|||
///
|
||||
/// 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<Value> {
|
||||
let url = self.api_url(&format!(
|
||||
"activity/{activity_id}/streams.json?types=time,latlng"
|
||||
));
|
||||
/// 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?types=time,latlng"
|
||||
),
|
||||
);
|
||||
|
||||
self.get_json(url).await
|
||||
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.
|
||||
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));
|
||||
///
|
||||
/// 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)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 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)?;
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// {
|
||||
/// "type": "time",
|
||||
/// "data": [0, 1, 2, 3]
|
||||
/// }
|
||||
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)
|
||||
stream
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
}
|
||||
|
||||
/// Get the latitude and longitude arrays from the
|
||||
/// 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
|
||||
///
|
||||
pub fn latlng_values<'a>(streams: &'a Value) -> Option<(&'a Vec<Value>, &'a Vec<Value>)> {
|
||||
let stream = Self::find_stream(streams, "latlng")?;
|
||||
/// It is NOT:
|
||||
///
|
||||
/// data = [[lat, lon], [lat, lon], ...]
|
||||
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 latitudes = stream
|
||||
.get("data")
|
||||
.and_then(Value::as_array)?;
|
||||
|
||||
let lon = stream.get("data2")?.as_array()?;
|
||||
let longitudes = stream
|
||||
.get("data2")
|
||||
.and_then(Value::as_array)?;
|
||||
|
||||
Some((lat, lon))
|
||||
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<Value> {
|
||||
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")),
|
||||
self.api_url(
|
||||
&format!(
|
||||
"athlete/{athlete_id}/activities"
|
||||
),
|
||||
),
|
||||
urlencoding::encode(oldest),
|
||||
urlencoding::encode(newest),
|
||||
);
|
||||
|
|
@ -186,39 +337,68 @@ impl IntervalsClient {
|
|||
}
|
||||
|
||||
/// Extract the activity start timestamp.
|
||||
pub fn activity_start(activity: &Value) -> Result<DateTime<Utc>> {
|
||||
pub fn activity_start(
|
||||
activity: &Value,
|
||||
) -> Result<DateTime<Utc>> {
|
||||
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"))?;
|
||||
.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"))?;
|
||||
.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<Value> {
|
||||
let intervals = activity.get("icu_intervals")?;
|
||||
pub fn matching_interval(
|
||||
activity: &Value,
|
||||
track_index: usize,
|
||||
) -> Option<Value> {
|
||||
let intervals =
|
||||
activity.get("icu_intervals")?;
|
||||
|
||||
let array = intervals.as_array()?;
|
||||
let array =
|
||||
intervals.as_array()?;
|
||||
|
||||
for interval in array {
|
||||
let start = interval
|
||||
.get("start_index")
|
||||
.or_else(|| interval.get("start"));
|
||||
.or_else(|| {
|
||||
interval.get("start")
|
||||
});
|
||||
|
||||
let end = interval.get("end_index").or_else(|| interval.get("end"));
|
||||
let end = interval
|
||||
.get("end_index")
|
||||
.or_else(|| {
|
||||
interval.get("end")
|
||||
});
|
||||
|
||||
let start = start.and_then(Value::as_u64)?;
|
||||
let start =
|
||||
start.and_then(Value::as_u64)?;
|
||||
|
||||
let end = end.and_then(Value::as_u64)?;
|
||||
let end =
|
||||
end.and_then(Value::as_u64)?;
|
||||
|
||||
if (start as usize) <= track_index && track_index <= end as usize {
|
||||
if (start as usize) <= track_index
|
||||
&& track_index <= end as usize
|
||||
{
|
||||
return Some(interval.clone());
|
||||
}
|
||||
}
|
||||
|
|
@ -227,18 +407,25 @@ impl IntervalsClient {
|
|||
}
|
||||
|
||||
/// OAuth methods retained for the normal multi-user flow.
|
||||
pub fn oauth_authorize_url(&self, state: &str) -> Result<String> {
|
||||
pub fn oauth_authorize_url(
|
||||
&self,
|
||||
state: &str,
|
||||
) -> Result<String> {
|
||||
let client_id = self
|
||||
.config
|
||||
.intervals_client_id
|
||||
.as_deref()
|
||||
.context("INTERVALS_CLIENT_ID is not configured")?;
|
||||
.context(
|
||||
"INTERVALS_CLIENT_ID is not configured",
|
||||
)?;
|
||||
|
||||
let redirect_uri = self
|
||||
.config
|
||||
.intervals_redirect_uri
|
||||
.as_deref()
|
||||
.context("INTERVALS_REDIRECT_URI is not configured")?;
|
||||
.context(
|
||||
"INTERVALS_REDIRECT_URI is not configured",
|
||||
)?;
|
||||
|
||||
Ok(format!(
|
||||
"{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}",
|
||||
|
|
@ -250,22 +437,32 @@ impl IntervalsClient {
|
|||
))
|
||||
}
|
||||
|
||||
pub async fn exchange_code(&self, code: &str) -> Result<Value> {
|
||||
pub async fn exchange_code(
|
||||
&self,
|
||||
code: &str,
|
||||
) -> Result<Value> {
|
||||
let client_id = self
|
||||
.config
|
||||
.intervals_client_id
|
||||
.as_deref()
|
||||
.context("INTERVALS_CLIENT_ID is not configured")?;
|
||||
.context(
|
||||
"INTERVALS_CLIENT_ID is not configured",
|
||||
)?;
|
||||
|
||||
let client_secret = self
|
||||
.config
|
||||
.intervals_client_secret
|
||||
.as_deref()
|
||||
.context("INTERVALS_CLIENT_SECRET is not configured")?;
|
||||
.context(
|
||||
"INTERVALS_CLIENT_SECRET is not configured",
|
||||
)?;
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(format!("{}/api/oauth/token", self.base_url()))
|
||||
.post(format!(
|
||||
"{}/api/oauth/token",
|
||||
self.base_url()
|
||||
))
|
||||
.form(&[
|
||||
("client_id", client_id),
|
||||
("client_secret", client_secret),
|
||||
|
|
@ -273,11 +470,15 @@ impl IntervalsClient {
|
|||
])
|
||||
.send()
|
||||
.await
|
||||
.context("OAuth token request failed")?;
|
||||
.context(
|
||||
"OAuth token request failed",
|
||||
)?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
let body = response.text().await?;
|
||||
let body = response
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!(
|
||||
|
|
@ -290,11 +491,20 @@ impl IntervalsClient {
|
|||
Ok(serde_json::from_str(&body)?)
|
||||
}
|
||||
|
||||
pub fn token_data(response: &Value) -> Result<(String, String, String, String)> {
|
||||
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")?
|
||||
.context(
|
||||
"OAuth response has no access_token",
|
||||
)?
|
||||
.to_string();
|
||||
|
||||
let scope = response
|
||||
|
|
@ -305,7 +515,9 @@ impl IntervalsClient {
|
|||
|
||||
let athlete = response
|
||||
.get("athlete")
|
||||
.context("OAuth response has no athlete")?;
|
||||
.context(
|
||||
"OAuth response has no athlete",
|
||||
)?;
|
||||
|
||||
let athlete_id = athlete
|
||||
.get("id")
|
||||
|
|
@ -321,27 +533,62 @@ impl IntervalsClient {
|
|||
|
||||
let display_name = athlete
|
||||
.get("name")
|
||||
.or_else(|| athlete.get("display_name"))
|
||||
.or_else(|| {
|
||||
athlete.get("display_name")
|
||||
})
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Intervals.icu athlete")
|
||||
.unwrap_or(
|
||||
"Intervals.icu athlete",
|
||||
)
|
||||
.to_string();
|
||||
|
||||
Ok((access_token, scope, athlete_id, display_name))
|
||||
Ok((
|
||||
access_token,
|
||||
scope,
|
||||
athlete_id,
|
||||
display_name,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_datetime(value: &str) -> Result<DateTime<Utc>> {
|
||||
if let Ok(value) = DateTime::parse_from_rfc3339(value) {
|
||||
return Ok(value.with_timezone(&Utc));
|
||||
fn parse_datetime(
|
||||
value: &str,
|
||||
) -> Result<DateTime<Utc>> {
|
||||
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) =
|
||||
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::<Utc>::from_naive_utc_and_offset(value, Utc));
|
||||
if let Ok(value) =
|
||||
NaiveDateTime::parse_from_str(
|
||||
value,
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
{
|
||||
return Ok(
|
||||
DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
value,
|
||||
Utc,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Err(anyhow!("cannot parse activity timestamp: {}", value))
|
||||
Err(anyhow!(
|
||||
"cannot parse activity timestamp: {}",
|
||||
value
|
||||
))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue