From ed68a53c0b84db73b328a12c788a8f5c2cfaf7de Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:49:10 +0200 Subject: [PATCH] feat: expose archive server APIs --- Cargo.lock | 2 + crates/archivr-core/Cargo.toml | 1 + crates/archivr-core/src/archive.rs | 8 +- crates/archivr-server/Cargo.toml | 1 + crates/archivr-server/src/routes.rs | 159 +++++++++++++++++++++++++++- 5 files changed, 163 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb598fe..5a6b629 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,6 +110,7 @@ dependencies = [ "hex", "regex", "rusqlite", + "serde", "serde_json", "sha3", "uuid", @@ -126,6 +127,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "tower", "tower-http", ] diff --git a/crates/archivr-core/Cargo.toml b/crates/archivr-core/Cargo.toml index 0a1da07..2325fdf 100644 --- a/crates/archivr-core/Cargo.toml +++ b/crates/archivr-core/Cargo.toml @@ -9,6 +9,7 @@ chrono.workspace = true hex.workspace = true regex.workspace = true rusqlite.workspace = true +serde.workspace = true serde_json.workspace = true sha3.workspace = true uuid.workspace = true diff --git a/crates/archivr-core/src/archive.rs b/crates/archivr-core/src/archive.rs index 1b9e2ee..0d507c0 100644 --- a/crates/archivr-core/src/archive.rs +++ b/crates/archivr-core/src/archive.rs @@ -14,7 +14,7 @@ pub struct ArchivePaths { pub name: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct EntrySummary { pub entry_uid: String, pub archived_at: String, @@ -27,7 +27,7 @@ pub struct EntrySummary { pub total_artifact_bytes: i64, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct EntryDetail { pub summary: EntrySummary, pub structured_root_relpath: String, @@ -36,7 +36,7 @@ pub struct EntryDetail { pub artifacts: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct EntryArtifactSummary { pub artifact_role: String, pub storage_area: String, @@ -44,7 +44,7 @@ pub struct EntryArtifactSummary { pub byte_size: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct RunSummary { pub run_uid: String, pub started_at: String, diff --git a/crates/archivr-server/Cargo.toml b/crates/archivr-server/Cargo.toml index 7e40131..b90f678 100644 --- a/crates/archivr-server/Cargo.toml +++ b/crates/archivr-server/Cargo.toml @@ -14,3 +14,4 @@ tower-http.workspace = true [dev-dependencies] tempfile.workspace = true +tower.workspace = true diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 5136717..910cbc9 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -1,7 +1,158 @@ -use axum::{Router, routing::get}; +use std::sync::Arc; -use crate::registry::ServerRegistry; +use archivr_core::{archive, database}; +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; -pub fn app(_registry: ServerRegistry) -> Router { - Router::new().route("/health", get(|| async { "ok" })) +use crate::registry::{MountedArchive, ServerRegistry}; + +#[derive(Clone)] +pub struct AppState { + registry: Arc, +} + +pub fn app(registry: ServerRegistry) -> Router { + let state = AppState { + registry: Arc::new(registry), + }; + + 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)) + .with_state(state) +} + +async fn list_archives(State(state): State) -> Json> { + Json(state.registry.archives.clone()) +} + +async fn list_entries( + State(state): State, + Path(archive_id): Path, +) -> Result>, 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, + Path((archive_id, entry_uid)): Path<(String, String)>, +) -> Result, 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, + Path(archive_id): Path, +) -> Result>, 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 From for ApiError +where + E: Into, +{ + 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); + } }