feat: add human readable output to the API
This commit is contained in:
parent
f57ecd19aa
commit
183b2c2c88
6 changed files with 221 additions and 20 deletions
|
|
@ -6,6 +6,7 @@ edition = "2021"
|
|||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
axum = "0.8"
|
||||
chrono = "0.4"
|
||||
noisebell-common = { path = "../noisebell-common" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
rusqlite = { version = "0.33", features = ["bundled"] }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use axum::extract::State;
|
|||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use noisebell_common::{validate_bearer, CacheStatusResponse, DoorStatus, WebhookPayload};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info};
|
||||
|
|
@ -35,6 +36,67 @@ fn unix_now() -> u64 {
|
|||
.as_secs()
|
||||
}
|
||||
|
||||
fn format_full_timestamp(ts: u64) -> String {
|
||||
DateTime::from_timestamp(ts as i64, 0)
|
||||
.map(|dt: DateTime<Utc>| dt.format("%A, %B %-d, %Y at %-I:%M:%S %p UTC").to_string())
|
||||
.unwrap_or_else(|| format!("unix timestamp {ts}"))
|
||||
}
|
||||
|
||||
fn format_duration(seconds: u64) -> String {
|
||||
let units = [
|
||||
(86_400, "day"),
|
||||
(3_600, "hour"),
|
||||
(60, "minute"),
|
||||
(1, "second"),
|
||||
];
|
||||
|
||||
let mut remaining = seconds;
|
||||
let mut parts = Vec::new();
|
||||
|
||||
for (unit_seconds, name) in units {
|
||||
if remaining >= unit_seconds {
|
||||
let count = remaining / unit_seconds;
|
||||
remaining %= unit_seconds;
|
||||
let suffix = if count == 1 { "" } else { "s" };
|
||||
parts.push(format!("{count} {name}{suffix}"));
|
||||
}
|
||||
|
||||
if parts.len() == 2 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
"0 seconds".to_string()
|
||||
} else if parts.len() == 1 {
|
||||
parts.remove(0)
|
||||
} else {
|
||||
format!("{} and {}", parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
fn status_summary(status: DoorStatus, since: Option<u64>, last_checked: Option<u64>, now: u64) -> String {
|
||||
let since_text = since
|
||||
.map(|ts| {
|
||||
format!(
|
||||
"We've been {} since {}, which was {} ago.",
|
||||
status,
|
||||
format_full_timestamp(ts),
|
||||
format_duration(now.saturating_sub(ts)),
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| format!("We're currently {}, but the start time is unknown.", status));
|
||||
|
||||
match last_checked {
|
||||
Some(ts) => format!(
|
||||
"{since_text} Last checked {}, which was {} ago.",
|
||||
format_full_timestamp(ts),
|
||||
format_duration(now.saturating_sub(ts)),
|
||||
),
|
||||
None => format!("{since_text} Last checked time is unknown."),
|
||||
}
|
||||
}
|
||||
|
||||
const WEBHOOK_RATE_LIMIT: u32 = 10;
|
||||
const WEBHOOK_RATE_WINDOW_SECS: u64 = 60;
|
||||
|
||||
|
|
@ -127,7 +189,7 @@ pub async fn get_status(
|
|||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<CacheStatusResponse>, StatusCode> {
|
||||
let db = state.db.clone();
|
||||
let status = tokio::task::spawn_blocking(move || {
|
||||
let mut status = tokio::task::spawn_blocking(move || {
|
||||
let conn = db.blocking_lock();
|
||||
db::get_status(&conn)
|
||||
})
|
||||
|
|
@ -138,6 +200,8 @@ pub async fn get_status(
|
|||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
status.human_readable = status_summary(status.status, status.since, status.last_checked, unix_now());
|
||||
|
||||
Ok(Json(status))
|
||||
}
|
||||
|
||||
|
|
@ -205,3 +269,25 @@ pub async fn get_image(State(state): State<Arc<AppState>>) -> Response {
|
|||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_duration_uses_two_units() {
|
||||
assert_eq!(format_duration(57), "57 seconds");
|
||||
assert_eq!(format_duration(125), "2 minutes and 5 seconds");
|
||||
assert_eq!(format_duration(3_723), "1 hour and 2 minutes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_summary_includes_since_and_last_checked() {
|
||||
let summary = status_summary(DoorStatus::Open, Some(1_000), Some(1_125), 1_180);
|
||||
|
||||
assert!(summary.contains("We've been open since"));
|
||||
assert!(summary.contains("which was 3 minutes ago"));
|
||||
assert!(summary.contains("Last checked"));
|
||||
assert!(summary.contains("55 seconds ago"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ pub fn get_status(conn: &Connection) -> Result<CacheStatusResponse> {
|
|||
status: row.state.status_for_api(),
|
||||
since: row.state.since_for_api(),
|
||||
last_checked: row.last_checked,
|
||||
human_readable: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -359,11 +360,13 @@ mod tests {
|
|||
status: DoorStatus::Open,
|
||||
since: Some(1234),
|
||||
last_checked: Some(5678),
|
||||
human_readable: "We've been open since Thursday, January 1, 1970 at 12:20 AM UTC, which was 20 minutes and 34 seconds ago. Last checked Thursday, January 1, 1970 at 1:34 AM UTC, which was 1 hour, 34 minutes and 38 seconds ago.".into(),
|
||||
};
|
||||
let json = serde_json::to_value(&resp).unwrap();
|
||||
assert_eq!(json["status"], "open");
|
||||
assert_eq!(json["since"], 1234);
|
||||
assert_eq!(json["last_checked"], 5678);
|
||||
assert_eq!(json["human_readable"], resp.human_readable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue