feat: Add API endpoint for listing oneliners
This commit introduces a new API endpoint `/api/oneliners` that allows authenticated users to retrieve a list of their recent oneliners. The endpoint supports conditional GET requests using the `ETag` header for efficient caching. It also allows specifying a time window for the oneliners via the `hours` query parameter, with sensible defaults and clamping. The implementation includes: - A new route handler `handle_oneliners`. - A new type `OnelinerWatermark` for tracking user-specific timestamps. - Logic for scanning user data directories, filtering by time, and handling deleted cases. - Test cases to cover various scenarios, including caching, time windowing, and edge cases.
This commit is contained in:
@@ -3,6 +3,7 @@ mod case_actions;
|
||||
mod debug;
|
||||
mod health;
|
||||
mod login;
|
||||
mod oneliners;
|
||||
mod upload;
|
||||
pub(crate) mod user_web;
|
||||
pub(crate) mod web;
|
||||
@@ -17,6 +18,7 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/api/health", get(health::handle_health))
|
||||
.route("/api/debug/whoami", get(debug::handle_whoami))
|
||||
.route("/api/upload", post(upload::handle_upload))
|
||||
.route("/api/oneliners", get(oneliners::handle_oneliners))
|
||||
.route("/web/login", get(login::handle_login_page).post(login::handle_login_submit))
|
||||
.route("/web/logout", post(login::handle_logout))
|
||||
.route("/web/cases", get(user_web::handle_my_cases))
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! GET /api/oneliners — list oneliners for the authenticated user.
|
||||
//!
|
||||
//! Time window is caller-controlled via `?hours=N` (default 16, clamped
|
||||
//! to [1, MAX_HOURS]). Each entry carries the case-id, the oneliner
|
||||
//! text (or null if not yet generated), and UTC timestamps for when the
|
||||
//! case started and when its oneliner was last written.
|
||||
//!
|
||||
//! Conditional-GET: the response carries a weak ETag `W/"<unix>-<hours>"`
|
||||
//! derived from the per-user watermark. Clients that send back a matching
|
||||
//! `If-None-Match` get `304 Not Modified` without touching the filesystem.
|
||||
//! The `hours` component is included so a client swapping window sizes
|
||||
//! does not get a stale 304.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::auth::AuthenticatedUser;
|
||||
use crate::error::AppError;
|
||||
use crate::OnelinerWatermark;
|
||||
|
||||
const DEFAULT_HOURS: u32 = 16;
|
||||
const MAX_HOURS: u32 = 168;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OnelinersQuery {
|
||||
hours: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OnelinersResponse {
|
||||
as_of: String,
|
||||
window_hours: u32,
|
||||
oneliners: Vec<OnelinerEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OnelinerEntry {
|
||||
case_id: String,
|
||||
oneliner: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn handle_oneliners(
|
||||
user: AuthenticatedUser,
|
||||
State(watermark): State<OnelinerWatermark>,
|
||||
Query(params): Query<OnelinersQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
let hours = params.hours.unwrap_or(DEFAULT_HOURS).clamp(1, MAX_HOURS);
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let since = now - Duration::hours(hours as i64);
|
||||
|
||||
let current_watermark = watermark.read().await.get(&user.slug).copied();
|
||||
let etag = build_etag(current_watermark, hours);
|
||||
|
||||
if client_has_matching_etag(&headers, &etag) {
|
||||
return Ok(not_modified(&etag));
|
||||
}
|
||||
|
||||
let oneliners = enumerate_recent_oneliners(&user.data_dir, since).await;
|
||||
let body = OnelinersResponse {
|
||||
as_of: now.format(&Rfc3339).unwrap_or_default(),
|
||||
window_hours: hours,
|
||||
oneliners,
|
||||
};
|
||||
|
||||
let mut response = Json(body).into_response();
|
||||
if let Ok(value) = etag.parse() {
|
||||
response.headers_mut().insert(header::ETAG, value);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn build_etag(watermark: Option<OffsetDateTime>, hours: u32) -> String {
|
||||
let seconds = watermark.map(|t| t.unix_timestamp()).unwrap_or(0);
|
||||
format!("W/\"{seconds}-{hours}\"")
|
||||
}
|
||||
|
||||
/// Case-sensitive match against the raw `If-None-Match` header value.
|
||||
/// No wildcard or comma-split logic — the client is our own code sending
|
||||
/// back the exact ETag we handed it.
|
||||
fn client_has_matching_etag(headers: &HeaderMap, etag: &str) -> bool {
|
||||
headers
|
||||
.get(header::IF_NONE_MATCH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|sent| sent == etag)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn not_modified(etag: &str) -> Response {
|
||||
let mut response = StatusCode::NOT_MODIFIED.into_response();
|
||||
if let Ok(value) = etag.parse() {
|
||||
response.headers_mut().insert(header::ETAG, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// Scan `<user_data_dir>/<case_id>/` for cases whose earliest recording
|
||||
/// sits inside the `[since, now]` window. Returns entries sorted by
|
||||
/// `created_at` descending (newest first). Soft-deleted cases and
|
||||
/// non-UUID directories are filtered out.
|
||||
async fn enumerate_recent_oneliners(
|
||||
user_data_dir: &Path,
|
||||
since: OffsetDateTime,
|
||||
) -> Vec<OnelinerEntry> {
|
||||
let mut entries: Vec<OnelinerEntry> = Vec::new();
|
||||
let Ok(mut cases) = tokio::fs::read_dir(user_data_dir).await else {
|
||||
return entries;
|
||||
};
|
||||
while let Ok(Some(case_entry)) = cases.next_entry().await {
|
||||
let case_dir = case_entry.path();
|
||||
if !case_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(case_id) = case_dir.file_name().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if uuid::Uuid::parse_str(case_id).is_err() {
|
||||
continue;
|
||||
}
|
||||
if crate::paths::is_deleted(&case_dir).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(created_mtime) = earliest_m4a_mtime(&case_dir).await else {
|
||||
continue;
|
||||
};
|
||||
let created_at = OffsetDateTime::from(created_mtime);
|
||||
if created_at < since {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (oneliner_text, updated_at) = read_oneliner(&case_dir).await;
|
||||
let Ok(created_at_str) = created_at.format(&Rfc3339) else {
|
||||
warn!(case = %case_dir.display(), "format created_at failed");
|
||||
continue;
|
||||
};
|
||||
let updated_at_str = updated_at.and_then(|t| t.format(&Rfc3339).ok());
|
||||
|
||||
entries.push(OnelinerEntry {
|
||||
case_id: case_id.to_owned(),
|
||||
oneliner: oneliner_text,
|
||||
created_at: created_at_str,
|
||||
updated_at: updated_at_str,
|
||||
});
|
||||
}
|
||||
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
entries
|
||||
}
|
||||
|
||||
/// Return the earliest `.m4a` mtime in `case_dir`. `.m4a.failed` counts —
|
||||
/// a failed recording still marks the birth of the case. Returns None on
|
||||
/// read errors or empty directories.
|
||||
async fn earliest_m4a_mtime(case_dir: &Path) -> Option<SystemTime> {
|
||||
let mut files = tokio::fs::read_dir(case_dir).await.ok()?;
|
||||
let mut earliest: Option<SystemTime> = None;
|
||||
while let Ok(Some(entry)) = files.next_entry().await {
|
||||
let path: PathBuf = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !(name.ends_with(".m4a") || name.ends_with(".m4a.failed")) {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = entry.metadata().await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(mtime) = meta.modified() else {
|
||||
continue;
|
||||
};
|
||||
earliest = match earliest {
|
||||
None => Some(mtime),
|
||||
Some(e) if mtime < e => Some(mtime),
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
earliest
|
||||
}
|
||||
|
||||
/// Read `oneliner.txt` (trimmed, non-empty) plus its mtime. Missing file
|
||||
/// yields `(None, None)`.
|
||||
async fn read_oneliner(case_dir: &Path) -> (Option<String>, Option<OffsetDateTime>) {
|
||||
let path = case_dir.join("oneliner.txt");
|
||||
let text = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if text.is_none() {
|
||||
return (None, None);
|
||||
}
|
||||
let mtime = tokio::fs::metadata(&path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.map(OffsetDateTime::from);
|
||||
(text, mtime)
|
||||
}
|
||||
Reference in New Issue
Block a user