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
121
Cargo.lock
generated
121
Cargo.lock
generated
|
|
@ -17,6 +17,15 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
|
|
@ -49,6 +58,12 @@ version = "1.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
|
|
@ -168,6 +183,25 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
|
|
@ -542,6 +576,30 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
|
|
@ -810,6 +868,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"noisebell-common",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
|
|
@ -862,6 +921,15 @@ version = "0.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
|
|
@ -1973,12 +2041,65 @@ dependencies = [
|
|||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -106,25 +106,11 @@ async fn handle_status(
|
|||
|
||||
let embed = match resp {
|
||||
Ok(resp) if resp.status().is_success() => match resp.json::<CacheStatusResponse>().await {
|
||||
Ok(data) => {
|
||||
let mut embed = build_embed(
|
||||
data.status,
|
||||
data.since.unwrap_or(unix_now()),
|
||||
&state.image_base_url,
|
||||
);
|
||||
|
||||
let mut fields = Vec::new();
|
||||
if let Some(ts) = data.since {
|
||||
fields.push(("Since", format_timestamp(ts), true));
|
||||
}
|
||||
if let Some(ts) = data.last_checked {
|
||||
fields.push(("Last Checked", format_timestamp(ts), true));
|
||||
}
|
||||
if !fields.is_empty() {
|
||||
embed = embed.fields(fields);
|
||||
}
|
||||
embed
|
||||
}
|
||||
Ok(data) => build_embed(
|
||||
data.status,
|
||||
data.since.unwrap_or(unix_now()),
|
||||
&state.image_base_url,
|
||||
),
|
||||
Err(e) => {
|
||||
error!(error = %e, "failed to parse status response");
|
||||
CreateEmbed::new()
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ pub struct CacheStatusResponse {
|
|||
pub status: DoorStatus,
|
||||
pub since: Option<u64>,
|
||||
pub last_checked: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub human_readable: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -177,11 +179,13 @@ mod tests {
|
|||
status: DoorStatus::Closed,
|
||||
since: Some(123),
|
||||
last_checked: Some(456),
|
||||
human_readable: "We've been closed since Thursday, January 1, 1970 at 12:02 AM UTC, which was 2 minutes and 3 seconds ago. Last checked Thursday, January 1, 1970 at 12:07 AM UTC, which was 7 minutes and 36 seconds ago.".into(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&response).unwrap();
|
||||
assert_eq!(json["status"], "closed");
|
||||
assert_eq!(json["since"], 123);
|
||||
assert_eq!(json["last_checked"], 456);
|
||||
assert_eq!(json["human_readable"], response.human_readable);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue