dev api key
This commit is contained in:
parent
06c837b244
commit
89091a03d3
11 changed files with 791 additions and 762 deletions
448
src/intervals.rs
448
src/intervals.rs
|
|
@ -1,6 +1,6 @@
|
|||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Client;
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::Config;
|
||||
|
|
@ -19,274 +19,266 @@ impl IntervalsClient {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn oauth_authorize_url(&self, state: &str) -> String {
|
||||
fn base_url(&self) -> &str {
|
||||
self.config.intervals_base_url.trim_end_matches('/')
|
||||
}
|
||||
|
||||
fn api_key(&self) -> Result<&str> {
|
||||
self.config.require_api_key()
|
||||
}
|
||||
|
||||
fn api_url(&self, path: &str) -> String {
|
||||
format!(
|
||||
"{}/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}",
|
||||
self.config.intervals_base_url,
|
||||
urlencoding::encode(&self.config.intervals_client_id),
|
||||
urlencoding::encode(&self.config.intervals_redirect_uri),
|
||||
urlencoding::encode("ACTIVITY:READ"),
|
||||
urlencoding::encode(state),
|
||||
"{}/api/v1/{}",
|
||||
self.base_url(),
|
||||
path.trim_start_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn exchange_code(&self, code: &str) -> Result<Value> {
|
||||
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> {
|
||||
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 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}"));
|
||||
|
||||
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":[...]},
|
||||
/// ...
|
||||
/// ]
|
||||
pub async fn streams(&self, activity_id: &str) -> Result<Value> {
|
||||
let url = self.api_url(&format!("activity/{activity_id}/streams.json"));
|
||||
|
||||
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;
|
||||
|
||||
let url = format!(
|
||||
"{}/api/oauth/token",
|
||||
self.config.intervals_base_url
|
||||
"{}?oldest={}&newest={}",
|
||||
self.api_url(&format!("athlete/{athlete_id}/activities")),
|
||||
urlencoding::encode(oldest),
|
||||
urlencoding::encode(newest),
|
||||
);
|
||||
|
||||
Ok(self
|
||||
.client
|
||||
.post(url)
|
||||
.form(&[
|
||||
("client_id", self.config.intervals_client_id.as_str()),
|
||||
(
|
||||
"client_secret",
|
||||
self.config.intervals_client_secret.as_str(),
|
||||
),
|
||||
("code", code),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?)
|
||||
self.get_json(url).await
|
||||
}
|
||||
|
||||
pub async fn activity(
|
||||
&self,
|
||||
access_token: &str,
|
||||
activity_id: &str,
|
||||
) -> Result<Value> {
|
||||
let url = format!(
|
||||
"{}/api/v1/activity/{}",
|
||||
self.config.intervals_base_url,
|
||||
activity_id
|
||||
);
|
||||
|
||||
Ok(self
|
||||
.client
|
||||
.get(url)
|
||||
.bearer_auth(access_token)
|
||||
.query(&[("intervals", "true")])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()
|
||||
.context("Intervals.icu activity request failed")?
|
||||
.json()
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn streams(
|
||||
&self,
|
||||
access_token: &str,
|
||||
activity_id: &str,
|
||||
) -> Result<Value> {
|
||||
let url = format!(
|
||||
"{}/api/v1/activity/{}/streams.json",
|
||||
self.config.intervals_base_url,
|
||||
activity_id
|
||||
);
|
||||
|
||||
Ok(self
|
||||
.client
|
||||
.get(url)
|
||||
.bearer_auth(access_token)
|
||||
.query(&[("types", "time,latlng")])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()
|
||||
.context("Intervals.icu streams request failed")?
|
||||
.json()
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub fn activity_url(&self, activity_id: &str) -> String {
|
||||
format!(
|
||||
"{}/activities/{}",
|
||||
self.config.intervals_base_url,
|
||||
activity_id
|
||||
)
|
||||
}
|
||||
|
||||
pub fn interval_url(
|
||||
&self,
|
||||
activity_id: &str,
|
||||
interval_id: i64,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}/activities/{}?interval={}",
|
||||
self.config.intervals_base_url,
|
||||
activity_id,
|
||||
interval_id
|
||||
)
|
||||
}
|
||||
|
||||
pub fn stream_values(
|
||||
streams: &Value,
|
||||
wanted: &str,
|
||||
) -> Option<Vec<Value>> {
|
||||
/// 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 entry in array {
|
||||
if entry.get("type")
|
||||
.and_then(Value::as_str)
|
||||
== Some(wanted)
|
||||
{
|
||||
return entry
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.cloned();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(object) = streams.as_object() {
|
||||
if let Some(value) = object.get(wanted) {
|
||||
if let Some(data) = value.get("data").and_then(Value::as_array) {
|
||||
return Some(data.clone());
|
||||
}
|
||||
// Also support a dictionary-like response:
|
||||
//
|
||||
// {
|
||||
// "time": [...],
|
||||
// "latlng": [...]
|
||||
// }
|
||||
streams.get(stream_type).and_then(Value::as_array)
|
||||
}
|
||||
|
||||
if let Some(data) = value.as_array() {
|
||||
return Some(data.clone());
|
||||
}
|
||||
/// Extract the activity start timestamp.
|
||||
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"))?;
|
||||
|
||||
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.
|
||||
///
|
||||
/// 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")?;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub fn activity_start(activity: &Value) -> Result<DateTime<Utc>> {
|
||||
if let Some(value) = activity
|
||||
.get("start_date")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
return Ok(value.parse()?);
|
||||
/// OAuth methods are intentionally retained so we can
|
||||
/// re-enable multi-user OAuth later.
|
||||
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")?;
|
||||
|
||||
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<Value> {
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(value) = activity
|
||||
.get("start_date_local")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
let naive = chrono::NaiveDateTime::parse_from_str(
|
||||
value,
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
)?;
|
||||
|
||||
return Ok(
|
||||
naive
|
||||
.and_utc()
|
||||
);
|
||||
}
|
||||
|
||||
Err(anyhow!("activity contains no usable start_date"))
|
||||
Ok(serde_json::from_str(&body)?)
|
||||
}
|
||||
|
||||
pub fn icu_intervals(activity: &Value) -> Vec<Value> {
|
||||
activity
|
||||
.get("icu_intervals")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn matching_interval(
|
||||
activity: &Value,
|
||||
track_index: usize,
|
||||
) -> Option<Value> {
|
||||
let intervals = Self::icu_intervals(activity);
|
||||
|
||||
intervals
|
||||
.into_iter()
|
||||
.filter(|interval| {
|
||||
let start = interval
|
||||
.get("start_index")
|
||||
.and_then(Value::as_u64);
|
||||
|
||||
let end = interval
|
||||
.get("end_index")
|
||||
.and_then(Value::as_u64);
|
||||
|
||||
match (start, end) {
|
||||
(Some(start), Some(end)) =>
|
||||
(start as usize) <= track_index
|
||||
&& track_index < end as usize,
|
||||
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
.min_by_key(|interval| {
|
||||
let start = interval
|
||||
.get("start_index")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(u64::MAX);
|
||||
|
||||
let end = interval
|
||||
.get("end_index")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(u64::MAX);
|
||||
|
||||
end.saturating_sub(start)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn interval_metrics(
|
||||
interval: &Value,
|
||||
) -> (Option<f64>, Option<f64>, Option<i64>) {
|
||||
let watts = interval
|
||||
.get("average_watts")
|
||||
.and_then(Value::as_f64);
|
||||
|
||||
let hr = interval
|
||||
.get("average_heartrate")
|
||||
.and_then(Value::as_f64);
|
||||
|
||||
let id = interval
|
||||
.get("id")
|
||||
.and_then(Value::as_i64);
|
||||
|
||||
(watts, hr, id)
|
||||
}
|
||||
|
||||
pub fn token_data(
|
||||
response: &Value,
|
||||
) -> Result<(String, String, String, String)> {
|
||||
let token = response
|
||||
pub fn token_data(response: &Value) -> Result<(String, String, String, String)> {
|
||||
let access_token = response
|
||||
.get("access_token")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("OAuth response has no access_token"))?;
|
||||
.context("OAuth response has no access_token")?
|
||||
.to_string();
|
||||
|
||||
let scope = response
|
||||
.get("scope")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
.to_string();
|
||||
|
||||
let athlete = response
|
||||
.get("athlete")
|
||||
.ok_or_else(|| anyhow!("OAuth response has no athlete"))?;
|
||||
.context("OAuth response has no athlete")?;
|
||||
|
||||
let athlete_id = athlete
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("OAuth response has no athlete.id"))?;
|
||||
.or_else(|| athlete.get("id").and_then(Value::as_i64).map(|_| ""))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let athlete_name = athlete
|
||||
let display_name = athlete
|
||||
.get("name")
|
||||
.or_else(|| athlete.get("display_name"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(athlete_id);
|
||||
.unwrap_or("Intervals.icu athlete")
|
||||
.to_string();
|
||||
|
||||
Ok((
|
||||
token.to_owned(),
|
||||
scope,
|
||||
athlete_id.to_owned(),
|
||||
athlete_name.to_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn _json_example() -> Value {
|
||||
json!({
|
||||
"scope": "ACTIVITY:READ"
|
||||
})
|
||||
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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
Err(anyhow!("cannot parse activity timestamp: {}", value))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue