1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat: expose archive server APIs

This commit is contained in:
TheGeneralist 2026-06-01 22:49:10 +02:00
parent 1634026e5b
commit ed68a53c0b
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
5 changed files with 163 additions and 8 deletions

2
Cargo.lock generated
View file

@ -110,6 +110,7 @@ dependencies = [
"hex",
"regex",
"rusqlite",
"serde",
"serde_json",
"sha3",
"uuid",
@ -126,6 +127,7 @@ dependencies = [
"tempfile",
"tokio",
"toml",
"tower",
"tower-http",
]

View file

@ -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

View file

@ -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<EntryArtifactSummary>,
}
#[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<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct RunSummary {
pub run_uid: String,
pub started_at: String,

View file

@ -14,3 +14,4 @@ tower-http.workspace = true
[dev-dependencies]
tempfile.workspace = true
tower.workspace = true

View file

@ -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<ServerRegistry>,
}
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<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);
}
}