use axum::Router;
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::get;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use uuid::Uuid;
use crate::api::extract::Query;
use crate::api::response::ApiResponse;
use crate::api::{contenido, del_motor};
use crate::db::entities::Repositorio;
use crate::error::AppError;
use crate::rustic::{Acceso, ResumenDeSnapshot};
use crate::state::AppState;
pub const LA_VENTANA: usize = 200;
pub fn router() -> Router<AppState> {
Router::new().route("/api/v1/snapshots", get(listado))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConsultaDelCable {
pub repository_id: Option<String>,
}
pub async fn listado(
State(estado): State<AppState>,
Query(consulta): Query<ConsultaDelCable>,
) -> Result<ApiResponse, AppError> {
let fila = repositorio_del_explorador(&estado, consulta).await?;
let instantaneas = estado
.motor
.listar_snapshots(acceso_del_repositorio(&fila), LA_VENTANA)
.await
.map_err(del_motor)?;
let truncated = instantaneas.len() >= LA_VENTANA;
let vista = ListadoDelCable::desde(&instantaneas, truncated)?;
Ok(ApiResponse::new(
StatusCode::OK,
"instantáneas listadas",
contenido(&vista)?,
))
}
async fn repositorio_del_explorador(
estado: &AppState,
consulta: ConsultaDelCable,
) -> Result<Repositorio, AppError> {
let Some(crudo) = consulta.repository_id.as_deref() else {
return Err(AppError::InvalidInput(
"el parámetro `repository_id` de la consulta es obligatorio: el explorador mira un \
repositorio cada vez"
.to_owned(),
));
};
let id = Uuid::parse_str(crudo).map_err(|_| {
AppError::InvalidInput(
"el parámetro `repository_id` de la consulta no tiene la forma de un UUID".to_owned(),
)
})?;
estado.db.repositorios.leer(&id).await?.ok_or_else(|| {
AppError::NotFound("no existe ningún repositorio con ese identificador".to_owned())
})
}
fn acceso_del_repositorio(fila: &Repositorio) -> Acceso {
Acceso {
endpoint: fila.endpoint.clone(),
bucket: fila.bucket.clone(),
region: fila.region.clone(),
access_key_id: fila.access_key_id.clone(),
secret_access_key: fila.secret_access_key.clone(),
passphrase: fila.passphrase.clone(),
}
}
#[derive(Debug, Serialize)]
struct ListadoDelCable {
snapshots: Vec<SnapshotDelCable>,
truncated: bool,
}
impl ListadoDelCable {
fn desde(instantaneas: &[ResumenDeSnapshot], truncated: bool) -> Result<Self, AppError> {
let snapshots = instantaneas
.iter()
.map(SnapshotDelCable::desde)
.collect::<Result<_, _>>()?;
Ok(Self {
snapshots,
truncated,
})
}
}
#[derive(Debug, Serialize)]
struct SnapshotDelCable {
id: String,
time: String,
paths: Vec<String>,
label: Option<String>,
files: u64,
bytes: u64,
}
impl SnapshotDelCable {
fn desde(resumen: &ResumenDeSnapshot) -> Result<Self, AppError> {
Ok(Self {
id: resumen.id.clone(),
time: fecha(resumen.fecha)?,
paths: resumen.rutas.clone(),
label: resumen.etiqueta.clone(),
files: resumen.ficheros,
bytes: resumen.bytes,
})
}
}
fn fecha(fecha: OffsetDateTime) -> Result<String, AppError> {
fecha.format(&Rfc3339).map_err(|error| {
AppError::Other(format!(
"la fecha de la instantánea no se pudo formatear: {error}"
))
})
}
#[cfg(test)]
mod tests {
use axum::Router;
use axum::http::StatusCode;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::test_support::{
ETIQUETA_AJENA, ETIQUETA_SEMBRADA, PASSPHRASE_DE_PRUEBA, body_json, directorio_de_prueba,
get, post_json, router_desarrollo, sembrar_snapshot_ajeno, sembrar_snapshots,
};
const UN_UUID: &str = "00000000-0000-4000-8000-000000000000";
const LA_VENTANA: usize = 200;
fn cuerpo_de_alta(nombre: &str, ruta: &str) -> serde_json::Value {
serde_json::json!({
"name": nombre,
"endpoint": ruta,
"bucket": format!("cubo-{nombre}"),
"region": "eu-west-1",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "AKIAIOSFODNN7EXAMPLE-secreto-del-explorador",
"passphrase": PASSPHRASE_DE_PRUEBA,
})
}
async fn alta_de_repositorio(router: &Router, nombre: &str, ruta: &str) -> String {
let respuesta = post_json(
router.clone(),
"/api/v1/repositories",
&cuerpo_de_alta(nombre, ruta),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"el repositorio del explorador tiene que darse de alta —el listado sale de él y no de la \
base— y el alta contestó {status} con {cuerpo}"
);
cuerpo["data"]["id"]
.as_str()
.expect("el alta del repositorio tiene que devolver su identificador")
.to_owned()
}
fn comprobar_elemento(elemento: &serde_json::Value, donde: &str) {
assert!(
elemento["id"].as_str().is_some_and(|id| !id.is_empty()),
"el elemento {donde} no trae su identificador: {elemento}"
);
let fecha = elemento["time"]
.as_str()
.unwrap_or_else(|| panic!("el elemento {donde} no trae la fecha: {elemento}"));
OffsetDateTime::parse(fecha, &Rfc3339).unwrap_or_else(|error| {
panic!("la fecha del elemento {donde} tiene que ser RFC3339 y llegó `{fecha}`: {error}")
});
let rutas = elemento["paths"]
.as_array()
.unwrap_or_else(|| panic!("el elemento {donde} no trae sus rutas: {elemento}"));
assert!(
!rutas.is_empty() && rutas.iter().all(serde_json::Value::is_string),
"el elemento {donde} tiene que traer al menos una ruta y todas como cadenas: {elemento}"
);
assert!(
elemento["label"]
.as_str()
.is_some_and(|etiqueta| !etiqueta.is_empty()),
"la instantánea del elemento {donde} se sembró con etiqueta y llegó sin ella: {elemento}"
);
assert!(
elemento["files"]
.as_u64()
.is_some_and(|ficheros| ficheros >= 1),
"el resumen del elemento {donde} tiene que contar al menos un fichero: {elemento}"
);
assert!(
elemento["bytes"].as_u64().is_some_and(|bytes| bytes >= 1),
"el resumen del elemento {donde} tiene que contar los bytes que la copia procesó: \
{elemento}"
);
}
#[tokio::test]
async fn sin_el_parametro_el_listado_contesta_400_nombrandolo() {
let respuesta = get(router_desarrollo().await, "/api/v1/snapshots").await;
let status = respuesta.status();
assert_ne!(
status,
StatusCode::NOT_FOUND,
"una petición a la que le falta el parámetro no es una ruta ausente"
);
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"sin `repository_id` el listado no puede saber qué repositorio mirar y tiene que contestar \
400; contestó {status}"
);
let cuerpo = body_json(respuesta).await;
assert_eq!(cuerpo["status"], 400, "la envoltura del 400: {cuerpo}");
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|motivo| motivo.contains("repository_id")),
"el motivo del 400 tiene que nombrar el parámetro que falta, y llegó {cuerpo}"
);
}
#[tokio::test]
async fn un_repository_id_que_no_es_un_uuid_contesta_400() {
let respuesta = get(
router_desarrollo().await,
"/api/v1/snapshots?repository_id=no-es-un-uuid",
)
.await;
let status = respuesta.status();
assert_ne!(
status,
StatusCode::NOT_FOUND,
"un identificador mal formado no es un recurso ausente"
);
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un `repository_id` que no es un UUID tiene que contestar 400; contestó {status}"
);
let cuerpo = body_json(respuesta).await;
assert_eq!(cuerpo["status"], 400, "la envoltura del 400: {cuerpo}");
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|motivo| !motivo.is_empty()),
"el 400 tiene que traer un motivo: {cuerpo}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn un_repositorio_que_no_existe_contesta_404() {
let router = router_desarrollo().await;
let destino = directorio_de_prueba();
let ruta = destino.ruta().to_string_lossy().into_owned();
sembrar_snapshots(
destino.ruta(),
PASSPHRASE_DE_PRUEBA,
&["2026-01-01T10:00:00Z"],
);
let existe = alta_de_repositorio(&router, "explorador-1-29c", &ruta).await;
let respuesta = get(
router.clone(),
&format!("/api/v1/snapshots?repository_id={existe}"),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"el listado de un repositorio que existe tiene que contestar 200, y contestó {status} con \
{cuerpo}"
);
let respuesta = get(
router,
&format!("/api/v1/snapshots?repository_id={UN_UUID}"),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_ne!(
status,
StatusCode::INTERNAL_SERVER_ERROR,
"un repositorio que no existe no es un fallo del servidor: contestó {status} con {cuerpo}"
);
assert_eq!(
status,
StatusCode::NOT_FOUND,
"un repositorio que no existe es un recurso ausente y no una petición mal formada; contestó \
{status} con {cuerpo}"
);
assert_eq!(cuerpo["status"], 404, "la envoltura del 404: {cuerpo}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cada_elemento_trae_sus_seis_datos_y_el_listado_sale_del_repositorio() {
let router = router_desarrollo().await;
let destino = directorio_de_prueba();
let ruta = destino.ruta().to_string_lossy().into_owned();
let sembradas = sembrar_snapshots(
destino.ruta(),
PASSPHRASE_DE_PRUEBA,
&["2026-01-01T10:00:00Z", "2026-01-02T10:00:00Z"],
);
let (ajena, _nodo_ajeno) = sembrar_snapshot_ajeno(destino.ruta(), PASSPHRASE_DE_PRUEBA);
let repositorio = alta_de_repositorio(&router, "explorador-1-29d", &ruta).await;
let respuesta = get(
router,
&format!("/api/v1/snapshots?repository_id={repositorio}"),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"el listado de un repositorio sembrado tiene que contestar 200, y contestó {status} con \
{cuerpo}"
);
let listado = cuerpo["data"]["snapshots"].as_array().unwrap_or_else(|| {
panic!("el listado tiene que traer sus elementos en `data.snapshots`: {cuerpo}")
});
assert_eq!(
listado.len(),
sembradas.identificadores.len() + 1,
"el repositorio tiene las dos instantáneas sembradas y la ajena, y el listado trajo {}: \
{cuerpo}",
listado.len()
);
for (indice, elemento) in listado.iter().enumerate() {
comprobar_elemento(elemento, &format!("en la posición {indice}"));
}
let fechas: Vec<OffsetDateTime> = listado
.iter()
.map(|elemento| {
let fecha = elemento["time"]
.as_str()
.unwrap_or_else(|| panic!("un elemento llegó sin fecha: {elemento}"));
OffsetDateTime::parse(fecha, &Rfc3339).unwrap_or_else(|error| {
panic!("la fecha `{fecha}` del listado no es RFC3339: {error}")
})
})
.collect();
assert!(
fechas.windows(2).all(|par| par[0] > par[1]),
"el explorador promete el orden de la más reciente a la más antigua, y llegó {fechas:?}: \
un listado desordenado enseña las copias al revés"
);
assert_eq!(
cuerpo["data"]["truncated"], false,
"un listado por debajo de la ventana no puede venir recortado: {cuerpo}"
);
let identificadores: Vec<&str> = listado
.iter()
.filter_map(|elemento| elemento["id"].as_str())
.collect();
let etiquetas: Vec<&str> = listado
.iter()
.filter_map(|elemento| elemento["label"].as_str())
.collect();
for sembrada in &sembradas.identificadores {
assert!(
identificadores.contains(&sembrada.as_str()),
"la instantánea sembrada `{sembrada}` no está en el listado: {cuerpo}"
);
}
assert!(
identificadores.contains(&ajena.as_str()),
"la instantánea ajena `{ajena}` no está en el listado, y el listado tiene que salir del \
repositorio y no del histórico de ejecuciones: {cuerpo}"
);
assert!(
etiquetas.contains(&ETIQUETA_SEMBRADA) && etiquetas.contains(&ETIQUETA_AJENA),
"las instantáneas del repositorio tienen que llegar con su etiqueta —`{ETIQUETA_SEMBRADA}` \
y `{ETIQUETA_AJENA}`— y llegaron {etiquetas:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn el_listado_esta_acotado_a_la_ventana_e_indica_que_hay_mas() {
let router = router_desarrollo().await;
let destino = directorio_de_prueba();
let ruta = destino.ruta().to_string_lossy().into_owned();
let fechas: Vec<String> = (0..=LA_VENTANA)
.map(|indice| format!("2026-01-{:02}T{:02}:00:00Z", 1 + indice / 24, indice % 24))
.collect();
let referencias: Vec<&str> = fechas.iter().map(String::as_str).collect();
sembrar_snapshots(destino.ruta(), PASSPHRASE_DE_PRUEBA, &referencias);
let repositorio = alta_de_repositorio(&router, "explorador-1-29e", &ruta).await;
let respuesta = get(
router,
&format!("/api/v1/snapshots?repository_id={repositorio}"),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"el listado tiene que contestar 200 aunque tenga más instantáneas que la ventana; contestó \
{status} con {cuerpo}"
);
let listado = cuerpo["data"]["snapshots"]
.as_array()
.unwrap_or_else(|| panic!("el listado tiene que traer sus elementos: {cuerpo}"));
assert!(
listado.len() <= LA_VENTANA,
"el listado devolvió {} elementos y no puede superar la ventana de {LA_VENTANA}: {cuerpo}",
listado.len()
);
assert_eq!(
listado.len(),
LA_VENTANA,
"el repositorio tiene {} instantáneas y la ventana de {LA_VENTANA} tiene que llenarse: \
{cuerpo}",
fechas.len()
);
assert_eq!(
cuerpo["data"]["truncated"], true,
"con más instantáneas que la ventana, la respuesta tiene que decir que hay más: {cuerpo}"
);
}
}