mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat: add db and multi-archive web UI foundation (#8)
* Add SQLite metadata database support * Implement archive metadata database * chore: let's guess cargoHash because there's something wrong with nixpkgs! * Gate test-only database helpers behind cfg(test) * Fix archive database row identity * Use serde for archive metadata JSON * Finalize archive runs at command level * Handle archive command errors without panics * Cover tweet entry metadata recording * Document static regex invariants * docs: add web UI design spec * docs: add web UI implementation plan * chore: move cli into workspace crate * chore: track workspace crates directory * refactor: extract archive core crate * refactor: add core archive opening APIs * refactor: rename taxonomy model to tags * feat: add archive query APIs * feat: add web server registry * feat: expose archive server APIs * feat: add archive table web UI * fix: complete web UI smoke path * docs: add architecture mental model * docs: remove private superpowers plans * nix: split cli and server packages * chore: remove PLAN.md
This commit is contained in:
parent
cc380ec5ba
commit
b56c969624
25 changed files with 3928 additions and 171 deletions
168
crates/archivr-server/src/routes.rs
Normal file
168
crates/archivr-server/src/routes.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use archivr_core::{archive, database};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
use crate::registry::{MountedArchive, ServerRegistry};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
registry: Arc<ServerRegistry>,
|
||||
}
|
||||
|
||||
pub fn app(registry: ServerRegistry) -> Router {
|
||||
let state = AppState {
|
||||
registry: Arc::new(registry),
|
||||
};
|
||||
let static_dir = static_dir();
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route("/api/archives", get(list_archives))
|
||||
.route("/api/archives/:archive_id/entries", get(list_entries))
|
||||
.route(
|
||||
"/api/archives/:archive_id/entries/:entry_uid",
|
||||
get(entry_detail),
|
||||
)
|
||||
.route("/api/archives/:archive_id/runs", get(list_runs))
|
||||
.nest_service("/assets", ServeDir::new(&static_dir))
|
||||
.fallback_service(ServeFile::new(static_dir.join("index.html")))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn static_dir() -> PathBuf {
|
||||
std::env::var_os("ARCHIVR_STATIC_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static"))
|
||||
}
|
||||
|
||||
async fn list_archives(State(state): State<AppState>) -> Json<Vec<MountedArchive>> {
|
||||
Json(state.registry.archives.clone())
|
||||
}
|
||||
|
||||
async fn list_entries(
|
||||
State(state): State<AppState>,
|
||||
Path(archive_id): Path<String>,
|
||||
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
Ok(Json(archive::list_root_entries(&conn)?))
|
||||
}
|
||||
|
||||
async fn entry_detail(
|
||||
State(state): State<AppState>,
|
||||
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||
) -> Result<Json<archive::EntryDetail>, ApiError> {
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let detail = archive::get_entry_detail(&conn, &entry_uid)?
|
||||
.ok_or(ApiError::not_found("entry not found"))?;
|
||||
Ok(Json(detail))
|
||||
}
|
||||
|
||||
async fn list_runs(
|
||||
State(state): State<AppState>,
|
||||
Path(archive_id): Path<String>,
|
||||
) -> Result<Json<Vec<archive::RunSummary>>, ApiError> {
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
Ok(Json(archive::list_runs(&conn)?))
|
||||
}
|
||||
|
||||
fn mounted_archive<'a>(
|
||||
state: &'a AppState,
|
||||
archive_id: &str,
|
||||
) -> Result<&'a MountedArchive, ApiError> {
|
||||
state
|
||||
.registry
|
||||
.archives
|
||||
.iter()
|
||||
.find(|archive| archive.id == archive_id)
|
||||
.ok_or(ApiError::not_found("archive not found"))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn not_found(message: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<E> for ApiError
|
||||
where
|
||||
E: Into<anyhow::Error>,
|
||||
{
|
||||
fn from(error: E) -> Self {
|
||||
let error = error.into();
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: format!("{error:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, self.message).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn archives_endpoint_lists_mounted_archives() {
|
||||
let registry = ServerRegistry {
|
||||
archives: vec![MountedArchive {
|
||||
id: "personal".to_string(),
|
||||
label: "Personal".to_string(),
|
||||
archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"),
|
||||
}],
|
||||
};
|
||||
let response = app(registry)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/archives")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_archive_returns_404() {
|
||||
let response = app(ServerRegistry::default())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/archives/missing/entries")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue