adding several features
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
use std::sync::Arc;
|
||||
use axum::{
|
||||
extract::{State, Json},
|
||||
response::IntoResponse,
|
||||
http::StatusCode,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::application::services::batch_operations::{
|
||||
BatchOperationService, BatchResult, BatchStats
|
||||
};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::interfaces::api::handlers::ApiResult;
|
||||
|
||||
/// Estado compartido para el handler de batch
|
||||
#[derive(Clone)]
|
||||
pub struct BatchHandlerState {
|
||||
pub batch_service: Arc<BatchOperationService>,
|
||||
}
|
||||
|
||||
/// DTO para las solicitudes de operaciones en lote de archivos
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BatchFileOperationRequest {
|
||||
/// IDs de los archivos a procesar
|
||||
pub file_ids: Vec<String>,
|
||||
/// ID de la carpeta destino (opcional)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target_folder_id: Option<String>,
|
||||
}
|
||||
|
||||
/// DTO para las solicitudes de operaciones en lote de carpetas
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BatchFolderOperationRequest {
|
||||
/// IDs de las carpetas a procesar
|
||||
pub folder_ids: Vec<String>,
|
||||
/// Si la operación debe ser recursiva
|
||||
#[serde(default)]
|
||||
pub recursive: bool,
|
||||
/// ID de la carpeta destino (opcional)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[allow(dead_code)]
|
||||
pub target_folder_id: Option<String>,
|
||||
}
|
||||
|
||||
/// DTO para las solicitudes de creación en lote de carpetas
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BatchCreateFoldersRequest {
|
||||
/// Detalles de las carpetas a crear
|
||||
pub folders: Vec<CreateFolderDetail>,
|
||||
}
|
||||
|
||||
/// Detalle para creación de una carpeta
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateFolderDetail {
|
||||
/// Nombre de la carpeta
|
||||
pub name: String,
|
||||
/// ID de la carpeta padre (opcional)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
/// DTO para los resultados de operaciones en lote
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BatchOperationResponse<T> {
|
||||
/// Entidades procesadas exitosamente
|
||||
pub successful: Vec<T>,
|
||||
/// Operaciones fallidas con sus mensajes de error
|
||||
pub failed: Vec<FailedOperation>,
|
||||
/// Estadísticas de la operación
|
||||
pub stats: BatchOperationStats,
|
||||
}
|
||||
|
||||
/// Operación fallida en un lote
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FailedOperation {
|
||||
/// Identificador de la entidad que falló
|
||||
pub id: String,
|
||||
/// Mensaje de error
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// Estadísticas de una operación por lotes
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BatchOperationStats {
|
||||
/// Número total de operaciones
|
||||
pub total: usize,
|
||||
/// Número de operaciones exitosas
|
||||
pub successful: usize,
|
||||
/// Número de operaciones fallidas
|
||||
pub failed: usize,
|
||||
/// Tiempo total de ejecución en milisegundos
|
||||
pub execution_time_ms: u128,
|
||||
}
|
||||
|
||||
/// Convierte BatchStats del dominio a DTO
|
||||
impl From<BatchStats> for BatchOperationStats {
|
||||
fn from(stats: BatchStats) -> Self {
|
||||
Self {
|
||||
total: stats.total,
|
||||
successful: stats.successful,
|
||||
failed: stats.failed,
|
||||
execution_time_ms: stats.execution_time_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte BatchResult<T> del dominio a DTO
|
||||
impl<T, U> From<BatchResult<T>> for BatchOperationResponse<U>
|
||||
where
|
||||
U: From<T>,
|
||||
{
|
||||
fn from(result: BatchResult<T>) -> Self {
|
||||
let successful = result.successful.into_iter().map(U::from).collect();
|
||||
|
||||
let failed = result.failed.into_iter()
|
||||
.map(|(id, error)| FailedOperation { id, error })
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
successful,
|
||||
failed,
|
||||
stats: result.stats.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler para mover múltiples archivos en lote
|
||||
pub async fn move_files_batch(
|
||||
State(state): State<BatchHandlerState>,
|
||||
Json(request): Json<BatchFileOperationRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verificar que hay archivos para procesar
|
||||
if request.file_ids.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "No file IDs provided"
|
||||
}))
|
||||
).into_response());
|
||||
}
|
||||
|
||||
// Ejecutar operación de lote
|
||||
let result = state.batch_service
|
||||
.move_files(request.file_ids, request.target_folder_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Convertir resultado a DTO
|
||||
let response: BatchOperationResponse<FileDto> = result.into();
|
||||
|
||||
// Determinar código de estado basado en los resultados
|
||||
let status_code = if response.stats.failed > 0 {
|
||||
if response.stats.successful > 0 {
|
||||
StatusCode::PARTIAL_CONTENT // Algunas operaciones exitosas, otras fallidas
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST // Todas fallaron
|
||||
}
|
||||
} else {
|
||||
StatusCode::OK // Todas exitosas
|
||||
};
|
||||
|
||||
Ok((status_code, Json(response)).into_response())
|
||||
}
|
||||
|
||||
/// Handler para copiar múltiples archivos en lote
|
||||
pub async fn copy_files_batch(
|
||||
State(state): State<BatchHandlerState>,
|
||||
Json(request): Json<BatchFileOperationRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verificar que hay archivos para procesar
|
||||
if request.file_ids.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "No file IDs provided"
|
||||
}))
|
||||
).into_response());
|
||||
}
|
||||
|
||||
// Ejecutar operación de lote
|
||||
let result = state.batch_service
|
||||
.copy_files(request.file_ids, request.target_folder_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Convertir resultado a DTO
|
||||
let response: BatchOperationResponse<FileDto> = result.into();
|
||||
|
||||
// Determinar código de estado basado en los resultados
|
||||
let status_code = if response.stats.failed > 0 {
|
||||
if response.stats.successful > 0 {
|
||||
StatusCode::PARTIAL_CONTENT // Algunas operaciones exitosas, otras fallidas
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST // Todas fallaron
|
||||
}
|
||||
} else {
|
||||
StatusCode::OK // Todas exitosas
|
||||
};
|
||||
|
||||
Ok((status_code, Json(response)).into_response())
|
||||
}
|
||||
|
||||
/// Handler para eliminar múltiples archivos en lote
|
||||
pub async fn delete_files_batch(
|
||||
State(state): State<BatchHandlerState>,
|
||||
Json(request): Json<BatchFileOperationRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verificar que hay archivos para procesar
|
||||
if request.file_ids.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "No file IDs provided"
|
||||
}))
|
||||
).into_response());
|
||||
}
|
||||
|
||||
// Ejecutar operación de lote
|
||||
let result = state.batch_service
|
||||
.delete_files(request.file_ids)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Crear respuesta personalizada para IDs de string
|
||||
let response = BatchOperationResponse {
|
||||
successful: result.successful,
|
||||
failed: result.failed.into_iter()
|
||||
.map(|(id, error)| FailedOperation { id, error })
|
||||
.collect(),
|
||||
stats: result.stats.into(),
|
||||
};
|
||||
|
||||
// Determinar código de estado basado en los resultados
|
||||
let status_code = if response.stats.failed > 0 {
|
||||
if response.stats.successful > 0 {
|
||||
StatusCode::PARTIAL_CONTENT // Algunas operaciones exitosas, otras fallidas
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST // Todas fallaron
|
||||
}
|
||||
} else {
|
||||
StatusCode::OK // Todas exitosas
|
||||
};
|
||||
|
||||
Ok((status_code, Json(response)).into_response())
|
||||
}
|
||||
|
||||
/// Handler para eliminar múltiples carpetas en lote
|
||||
pub async fn delete_folders_batch(
|
||||
State(state): State<BatchHandlerState>,
|
||||
Json(request): Json<BatchFolderOperationRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verificar que hay carpetas para procesar
|
||||
if request.folder_ids.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "No folder IDs provided"
|
||||
}))
|
||||
).into_response());
|
||||
}
|
||||
|
||||
// Ejecutar operación de lote
|
||||
let result = state.batch_service
|
||||
.delete_folders(request.folder_ids, request.recursive)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Crear respuesta personalizada para IDs de string
|
||||
let response = BatchOperationResponse {
|
||||
successful: result.successful,
|
||||
failed: result.failed.into_iter()
|
||||
.map(|(id, error)| FailedOperation { id, error })
|
||||
.collect(),
|
||||
stats: result.stats.into(),
|
||||
};
|
||||
|
||||
// Determinar código de estado basado en los resultados
|
||||
let status_code = if response.stats.failed > 0 {
|
||||
if response.stats.successful > 0 {
|
||||
StatusCode::PARTIAL_CONTENT // Algunas operaciones exitosas, otras fallidas
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST // Todas fallaron
|
||||
}
|
||||
} else {
|
||||
StatusCode::OK // Todas exitosas
|
||||
};
|
||||
|
||||
Ok((status_code, Json(response)).into_response())
|
||||
}
|
||||
|
||||
/// Handler para crear múltiples carpetas en lote
|
||||
pub async fn create_folders_batch(
|
||||
State(state): State<BatchHandlerState>,
|
||||
Json(request): Json<BatchCreateFoldersRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verificar que hay carpetas para procesar
|
||||
if request.folders.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "No folders provided"
|
||||
}))
|
||||
).into_response());
|
||||
}
|
||||
|
||||
// Transformar el formato para el servicio
|
||||
let folders = request.folders
|
||||
.into_iter()
|
||||
.map(|detail| (detail.name, detail.parent_id))
|
||||
.collect();
|
||||
|
||||
// Ejecutar operación de lote
|
||||
let result = state.batch_service
|
||||
.create_folders(folders)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Convertir resultado a DTO
|
||||
let response: BatchOperationResponse<FolderDto> = result.into();
|
||||
|
||||
// Determinar código de estado basado en los resultados
|
||||
let status_code = if response.stats.failed > 0 {
|
||||
if response.stats.successful > 0 {
|
||||
StatusCode::PARTIAL_CONTENT // Algunas operaciones exitosas, otras fallidas
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST // Todas fallaron
|
||||
}
|
||||
} else {
|
||||
StatusCode::CREATED // Todas exitosas
|
||||
};
|
||||
|
||||
Ok((status_code, Json(response)).into_response())
|
||||
}
|
||||
|
||||
/// Handler para obtener múltiples archivos en lote
|
||||
pub async fn get_files_batch(
|
||||
State(state): State<BatchHandlerState>,
|
||||
Json(request): Json<BatchFileOperationRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verificar que hay archivos para procesar
|
||||
if request.file_ids.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "No file IDs provided"
|
||||
}))
|
||||
).into_response());
|
||||
}
|
||||
|
||||
// Ejecutar operación de lote
|
||||
let result = state.batch_service
|
||||
.get_multiple_files(request.file_ids)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Convertir resultado a DTO
|
||||
let response: BatchOperationResponse<FileDto> = result.into();
|
||||
|
||||
// Determinar código de estado basado en los resultados
|
||||
let status_code = if response.stats.failed > 0 {
|
||||
if response.stats.successful > 0 {
|
||||
StatusCode::PARTIAL_CONTENT // Algunas operaciones exitosas, otras fallidas
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST // Todas fallaron
|
||||
}
|
||||
} else {
|
||||
StatusCode::OK // Todas exitosas
|
||||
};
|
||||
|
||||
Ok((status_code, Json(response)).into_response())
|
||||
}
|
||||
|
||||
/// Handler para obtener múltiples carpetas en lote
|
||||
pub async fn get_folders_batch(
|
||||
State(state): State<BatchHandlerState>,
|
||||
Json(request): Json<BatchFolderOperationRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verificar que hay carpetas para procesar
|
||||
if request.folder_ids.is_empty() {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "No folder IDs provided"
|
||||
}))
|
||||
).into_response());
|
||||
}
|
||||
|
||||
// Ejecutar operación de lote
|
||||
let result = state.batch_service
|
||||
.get_multiple_folders(request.folder_ids)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Convertir resultado a DTO
|
||||
let response: BatchOperationResponse<FolderDto> = result.into();
|
||||
|
||||
// Determinar código de estado basado en los resultados
|
||||
let status_code = if response.stats.failed > 0 {
|
||||
if response.stats.successful > 0 {
|
||||
StatusCode::PARTIAL_CONTENT // Algunas operaciones exitosas, otras fallidas
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST // Todas fallaron
|
||||
}
|
||||
} else {
|
||||
StatusCode::OK // Todas exitosas
|
||||
};
|
||||
|
||||
Ok((status_code, Json(response)).into_response())
|
||||
}
|
||||
@@ -1,20 +1,55 @@
|
||||
use std::sync::Arc;
|
||||
use axum::{
|
||||
extract::{Path, State, Multipart},
|
||||
http::{StatusCode, header},
|
||||
extract::{Path, State, Multipart, Query},
|
||||
http::{StatusCode, header, HeaderName, HeaderValue, Response},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use futures::Stream;
|
||||
use std::task::{Context, Poll};
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::application::services::file_service::FileService;
|
||||
use crate::domain::repositories::file_repository::FileRepositoryError;
|
||||
use crate::application::services::file_service::{FileService, FileServiceError};
|
||||
use crate::infrastructure::services::compression_service::{
|
||||
CompressionService, GzipCompressionService, CompressionLevel
|
||||
};
|
||||
|
||||
type AppState = Arc<FileService>;
|
||||
|
||||
/// Handler for file-related API endpoints
|
||||
pub struct FileHandler;
|
||||
|
||||
// Simpler approach to make streams Unpin - use Pin<Box<dyn Stream>> directly
|
||||
struct BoxedStream<T> {
|
||||
inner: Pin<Box<dyn Stream<Item = T> + Send + 'static>>,
|
||||
}
|
||||
|
||||
impl<T> Stream for BoxedStream<T> {
|
||||
type Item = T;
|
||||
|
||||
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
// Accessing the field directly is safe because BoxedStream is not a structural pinning type
|
||||
unsafe { self.get_unchecked_mut().inner.as_mut().poll_next(cx) }
|
||||
}
|
||||
}
|
||||
|
||||
// This is safe because BoxedStream's inner field is already Pin<Box<dyn Stream>>
|
||||
impl<T> Unpin for BoxedStream<T> {}
|
||||
|
||||
impl<T> BoxedStream<T> {
|
||||
#[allow(dead_code)]
|
||||
fn new<S>(stream: S) -> Self
|
||||
where
|
||||
S: Stream<Item = T> + Send + 'static,
|
||||
{
|
||||
BoxedStream {
|
||||
inner: Box::pin(stream),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileHandler {
|
||||
/// Uploads a file
|
||||
pub async fn upload_file(
|
||||
@@ -49,8 +84,8 @@ impl FileHandler {
|
||||
Ok(file) => (StatusCode::CREATED, Json(file)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match &err {
|
||||
FileRepositoryError::AlreadyExists(_) => StatusCode::CONFLICT,
|
||||
FileRepositoryError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
FileServiceError::Conflict(_) => StatusCode::CONFLICT,
|
||||
FileServiceError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@@ -66,28 +101,240 @@ impl FileHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads a file
|
||||
/// Downloads a file with optional compression
|
||||
pub async fn download_file(
|
||||
State(service): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
// Get file info and content
|
||||
let file_result = service.get_file(&id).await;
|
||||
let content_result = service.get_file_content(&id).await;
|
||||
// Initialize compression service
|
||||
let compression_service = GzipCompressionService::new();
|
||||
|
||||
match (file_result, content_result) {
|
||||
(Ok(file), Ok(content)) => {
|
||||
// Create response with proper headers
|
||||
let headers = [
|
||||
(header::CONTENT_TYPE, file.mime_type),
|
||||
(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", file.name)),
|
||||
];
|
||||
// Check if compression is explicitly requested or rejected
|
||||
let compression_param = params.get("compress").map(|v| v.as_str());
|
||||
let force_compress = compression_param == Some("true") || compression_param == Some("1");
|
||||
let force_no_compress = compression_param == Some("false") || compression_param == Some("0");
|
||||
|
||||
// Determine compression level from query params
|
||||
let compression_level = match params.get("compression_level").map(|v| v.as_str()) {
|
||||
Some("none") => CompressionLevel::None,
|
||||
Some("fast") => CompressionLevel::Fast,
|
||||
Some("best") => CompressionLevel::Best,
|
||||
_ => CompressionLevel::Default, // Default or unrecognized
|
||||
};
|
||||
|
||||
// Get file info first to check it exists and get metadata
|
||||
match service.get_file(&id).await {
|
||||
Ok(file) => {
|
||||
// Determine if we should compress based on file type and size
|
||||
let should_compress = if force_no_compress {
|
||||
false
|
||||
} else if force_compress {
|
||||
true
|
||||
} else {
|
||||
compression_service.should_compress(&file.mime_type, file.size)
|
||||
};
|
||||
|
||||
(StatusCode::OK, headers, content).into_response()
|
||||
// Log compression decision for debugging
|
||||
tracing::debug!(
|
||||
"Download file: name={}, size={}KB, mime={}, compress={}",
|
||||
file.name, file.size / 1024, file.mime_type, should_compress
|
||||
);
|
||||
|
||||
// For large files, use streaming response with potential compression
|
||||
if file.size > 10 * 1024 * 1024 { // 10MB threshold for streaming
|
||||
match service.get_file_content(&id).await {
|
||||
Ok(content) => {
|
||||
// Create base headers
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION.to_string(),
|
||||
format!("attachment; filename=\"{}\"", file.name)
|
||||
);
|
||||
|
||||
if should_compress {
|
||||
// Add content-encoding header for compressed response
|
||||
headers.insert(header::CONTENT_ENCODING.to_string(), "gzip".to_string());
|
||||
headers.insert(header::CONTENT_TYPE.to_string(), file.mime_type.clone());
|
||||
headers.insert(header::VARY.to_string(), "Accept-Encoding".to_string());
|
||||
|
||||
// Compress the content
|
||||
match compression_service.compress_data(&content, compression_level).await {
|
||||
Ok(compressed_content) => {
|
||||
tracing::debug!(
|
||||
"Compressed file: {} from {}KB to {}KB (ratio: {:.2})",
|
||||
file.name,
|
||||
content.len() / 1024,
|
||||
compressed_content.len() / 1024,
|
||||
content.len() as f64 / compressed_content.len() as f64
|
||||
);
|
||||
|
||||
// Build a custom response with headers and body
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(axum::body::Body::from(compressed_content))
|
||||
.unwrap();
|
||||
|
||||
// Add headers to response
|
||||
for (name, value) in headers {
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(&value).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Compression failed, sending uncompressed: {}", e);
|
||||
// Fall back to uncompressed
|
||||
headers.insert(header::CONTENT_TYPE.to_string(), file.mime_type.clone());
|
||||
|
||||
// Build a custom response with headers and body
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(axum::body::Body::from(content))
|
||||
.unwrap();
|
||||
|
||||
// Add headers to response
|
||||
for (name, value) in headers {
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(&value).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No compression, return as-is
|
||||
headers.insert(header::CONTENT_TYPE.to_string(), file.mime_type.clone());
|
||||
|
||||
// Build a custom response with headers and body
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(axum::body::Body::from(content))
|
||||
.unwrap();
|
||||
|
||||
// Add headers to response
|
||||
for (name, value) in headers {
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(&value).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::error!("Error getting file content: {}", err);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
||||
"error": format!("Error reading file: {}", err)
|
||||
}))).into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For smaller files, load entirely but still potentially compress
|
||||
match service.get_file_content(&id).await {
|
||||
Ok(content) => {
|
||||
// Create base headers
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION.to_string(),
|
||||
format!("attachment; filename=\"{}\"", file.name)
|
||||
);
|
||||
|
||||
if should_compress {
|
||||
// Add content-encoding header for compressed response
|
||||
headers.insert(header::CONTENT_ENCODING.to_string(), "gzip".to_string());
|
||||
headers.insert(header::CONTENT_TYPE.to_string(), file.mime_type.clone());
|
||||
headers.insert(header::VARY.to_string(), "Accept-Encoding".to_string());
|
||||
|
||||
// Compress the content
|
||||
match compression_service.compress_data(&content, compression_level).await {
|
||||
Ok(compressed_content) => {
|
||||
tracing::debug!(
|
||||
"Compressed file: {} from {}KB to {}KB (ratio: {:.2})",
|
||||
file.name,
|
||||
content.len() / 1024,
|
||||
compressed_content.len() / 1024,
|
||||
content.len() as f64 / compressed_content.len() as f64
|
||||
);
|
||||
|
||||
// Build a custom response with headers and body
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(axum::body::Body::from(compressed_content))
|
||||
.unwrap();
|
||||
|
||||
// Add headers to response
|
||||
for (name, value) in headers {
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(&value).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Compression failed, sending uncompressed: {}", e);
|
||||
// Fall back to uncompressed
|
||||
headers.insert(header::CONTENT_TYPE.to_string(), file.mime_type.clone());
|
||||
|
||||
// Build a custom response with headers and body
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(axum::body::Body::from(content))
|
||||
.unwrap();
|
||||
|
||||
// Add headers to response
|
||||
for (name, value) in headers {
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(&value).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No compression, return as-is
|
||||
headers.insert(header::CONTENT_TYPE.to_string(), file.mime_type.clone());
|
||||
|
||||
// Build a custom response with headers and body
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(axum::body::Body::from(content))
|
||||
.unwrap();
|
||||
|
||||
// Add headers to response
|
||||
for (name, value) in headers {
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(&value).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::error!("Error getting file content: {}", err);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
||||
"error": format!("Error reading file: {}", err)
|
||||
}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
(Err(err), _) | (_, Err(err)) => {
|
||||
Err(err) => {
|
||||
let status = match &err {
|
||||
FileRepositoryError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
FileServiceError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
FileServiceError::AccessError(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@@ -110,7 +357,7 @@ impl FileHandler {
|
||||
},
|
||||
Err(err) => {
|
||||
let status = match &err {
|
||||
FileRepositoryError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
FileServiceError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@@ -131,7 +378,7 @@ impl FileHandler {
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => {
|
||||
let status = match &err {
|
||||
FileRepositoryError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
FileServiceError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@@ -169,11 +416,11 @@ impl FileHandler {
|
||||
},
|
||||
Err(err) => {
|
||||
let status = match &err {
|
||||
FileRepositoryError::NotFound(_) => {
|
||||
FileServiceError::NotFound(_) => {
|
||||
tracing::error!("Error al mover archivo - no encontrado: {}", err);
|
||||
StatusCode::NOT_FOUND
|
||||
},
|
||||
FileRepositoryError::AlreadyExists(_) => {
|
||||
FileServiceError::Conflict(_) => {
|
||||
tracing::error!("Error al mover archivo - ya existe: {}", err);
|
||||
StatusCode::CONFLICT
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, State, Query},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
@@ -8,7 +8,9 @@ use axum::{
|
||||
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::application::dtos::folder_dto::{CreateFolderDto, RenameFolderDto, MoveFolderDto};
|
||||
use crate::domain::repositories::folder_repository::FolderRepositoryError;
|
||||
use crate::application::dtos::pagination::PaginationRequestDto;
|
||||
use crate::common::errors::ErrorKind;
|
||||
use crate::application::ports::inbound::FolderUseCase;
|
||||
|
||||
type AppState = Arc<FolderService>;
|
||||
|
||||
@@ -24,9 +26,9 @@ impl FolderHandler {
|
||||
match service.create_folder(dto).await {
|
||||
Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match &err {
|
||||
FolderRepositoryError::AlreadyExists(_) => StatusCode::CONFLICT,
|
||||
FolderRepositoryError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
let status = match err.kind {
|
||||
ErrorKind::AlreadyExists => StatusCode::CONFLICT,
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@@ -43,8 +45,8 @@ impl FolderHandler {
|
||||
match service.get_folder(&id).await {
|
||||
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match &err {
|
||||
FolderRepositoryError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@@ -66,8 +68,32 @@ impl FolderHandler {
|
||||
(StatusCode::OK, Json(folders)).into_response()
|
||||
},
|
||||
Err(err) => {
|
||||
let status = match &err {
|
||||
FolderRepositoryError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
// Return a JSON error response
|
||||
(status, Json(serde_json::json!({
|
||||
"error": err.to_string()
|
||||
}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists folders with pagination support
|
||||
pub async fn list_folders_paginated(
|
||||
State(service): State<AppState>,
|
||||
Query(pagination): Query<PaginationRequestDto>,
|
||||
parent_id: Option<&str>,
|
||||
) -> impl IntoResponse {
|
||||
match service.list_folders_paginated(parent_id, &pagination).await {
|
||||
Ok(paginated_result) => {
|
||||
(StatusCode::OK, Json(paginated_result)).into_response()
|
||||
},
|
||||
Err(err) => {
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@@ -88,9 +114,9 @@ impl FolderHandler {
|
||||
match service.rename_folder(&id, dto).await {
|
||||
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match &err {
|
||||
FolderRepositoryError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
FolderRepositoryError::AlreadyExists(_) => StatusCode::CONFLICT,
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
ErrorKind::AlreadyExists => StatusCode::CONFLICT,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@@ -111,9 +137,9 @@ impl FolderHandler {
|
||||
match service.move_folder(&id, dto).await {
|
||||
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
|
||||
Err(err) => {
|
||||
let status = match &err {
|
||||
FolderRepositoryError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
FolderRepositoryError::AlreadyExists(_) => StatusCode::CONFLICT,
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
ErrorKind::AlreadyExists => StatusCode::CONFLICT,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@@ -130,8 +156,8 @@ impl FolderHandler {
|
||||
match service.delete_folder(&id).await {
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => {
|
||||
let status = match &err {
|
||||
FolderRepositoryError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
let status = match err.kind {
|
||||
ErrorKind::NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
pub mod file_handler;
|
||||
pub mod folder_handler;
|
||||
pub mod i18n_handler;
|
||||
pub mod batch_handler;
|
||||
|
||||
/// Tipo de resultado para controladores de API
|
||||
pub type ApiResult<T> = Result<T, (axum::http::StatusCode, String)>;
|
||||
|
||||
|
||||
@@ -2,16 +2,27 @@ use std::sync::Arc;
|
||||
use axum::{
|
||||
routing::{get, post, put, delete},
|
||||
Router,
|
||||
extract::State,
|
||||
extract::{State, Query, Path},
|
||||
};
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
use tower_http::{
|
||||
compression::CompressionLayer,
|
||||
trace::TraceLayer,
|
||||
};
|
||||
|
||||
use crate::interfaces::middleware::cache::{HttpCache, start_cache_cleanup_task};
|
||||
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::application::services::file_service::FileService;
|
||||
use crate::application::services::i18n_application_service::I18nApplicationService;
|
||||
use crate::application::services::batch_operations::BatchOperationService;
|
||||
|
||||
use crate::interfaces::api::handlers::folder_handler::FolderHandler;
|
||||
use crate::interfaces::api::handlers::file_handler::FileHandler;
|
||||
use crate::interfaces::api::handlers::i18n_handler::I18nHandler;
|
||||
use crate::interfaces::api::handlers::batch_handler::{
|
||||
self, BatchHandlerState
|
||||
};
|
||||
use crate::application::dtos::pagination::PaginationRequestDto;
|
||||
|
||||
/// Creates API routes for the application
|
||||
pub fn create_api_routes(
|
||||
@@ -19,13 +30,57 @@ pub fn create_api_routes(
|
||||
file_service: Arc<FileService>,
|
||||
i18n_service: Option<Arc<I18nApplicationService>>,
|
||||
) -> Router {
|
||||
// Inicializar el servicio de operaciones por lotes
|
||||
let batch_service = Arc::new(BatchOperationService::default(
|
||||
file_service.clone(),
|
||||
folder_service.clone()
|
||||
));
|
||||
|
||||
// Crear estado para el manejador de operaciones por lotes
|
||||
let batch_handler_state = BatchHandlerState {
|
||||
batch_service: batch_service.clone(),
|
||||
};
|
||||
|
||||
// Implement HTTP Cache
|
||||
let http_cache = HttpCache::new();
|
||||
|
||||
// Define TTL values for different resource types (in seconds)
|
||||
let _folders_ttl = 300; // 5 minutes
|
||||
let _files_list_ttl = 300; // 5 minutes
|
||||
let _i18n_ttl = 3600; // 1 hour
|
||||
|
||||
// Start the cleanup task for HTTP cache
|
||||
start_cache_cleanup_task(http_cache.clone());
|
||||
|
||||
let folders_router = Router::new()
|
||||
.route("/", post(FolderHandler::create_folder))
|
||||
.route("/", get(|State(service): State<Arc<FolderService>>| async move {
|
||||
// No parent ID means list root folders
|
||||
FolderHandler::list_folders(State(service), None).await
|
||||
}))
|
||||
.route("/paginated", get(|
|
||||
State(service): State<Arc<FolderService>>,
|
||||
pagination: Query<PaginationRequestDto>
|
||||
| async move {
|
||||
// Paginación para carpetas raíz (sin parent)
|
||||
FolderHandler::list_folders_paginated(State(service), pagination, None).await
|
||||
}))
|
||||
.route("/{id}", get(FolderHandler::get_folder))
|
||||
.route("/{id}/contents", get(|
|
||||
State(service): State<Arc<FolderService>>,
|
||||
Path(id): Path<String>
|
||||
| async move {
|
||||
// Listar contenido de una carpeta por su ID
|
||||
FolderHandler::list_folders(State(service), Some(&id)).await
|
||||
}))
|
||||
.route("/{id}/contents/paginated", get(|
|
||||
State(service): State<Arc<FolderService>>,
|
||||
Path(id): Path<String>,
|
||||
pagination: Query<PaginationRequestDto>
|
||||
| async move {
|
||||
// Listar contenido paginado de una carpeta por su ID
|
||||
FolderHandler::list_folders_paginated(State(service), pagination, Some(&id)).await
|
||||
}))
|
||||
.route("/{id}/rename", put(FolderHandler::rename_folder))
|
||||
.route("/{id}/move", put(FolderHandler::move_folder))
|
||||
.route("/{id}", delete(FolderHandler::delete_folder))
|
||||
@@ -47,10 +102,24 @@ pub fn create_api_routes(
|
||||
.route("/{id}/move", put(FileHandler::move_file))
|
||||
.with_state(file_service);
|
||||
|
||||
// Crear rutas para operaciones por lotes
|
||||
let batch_router = Router::new()
|
||||
// Operaciones de archivos
|
||||
.route("/files/move", post(batch_handler::move_files_batch))
|
||||
.route("/files/copy", post(batch_handler::copy_files_batch))
|
||||
.route("/files/delete", post(batch_handler::delete_files_batch))
|
||||
.route("/files/get", post(batch_handler::get_files_batch))
|
||||
// Operaciones de carpetas
|
||||
.route("/folders/delete", post(batch_handler::delete_folders_batch))
|
||||
.route("/folders/create", post(batch_handler::create_folders_batch))
|
||||
.route("/folders/get", post(batch_handler::get_folders_batch))
|
||||
.with_state(batch_handler_state);
|
||||
|
||||
// Create a router without the i18n routes
|
||||
let mut router = Router::new()
|
||||
.nest("/folders", folders_router)
|
||||
.nest("/files", files_router);
|
||||
.nest("/files", files_router)
|
||||
.nest("/batch", batch_router);
|
||||
|
||||
// Add i18n routes if the service is provided
|
||||
if let Some(i18n_service) = i18n_service {
|
||||
@@ -68,7 +137,10 @@ pub fn create_api_routes(
|
||||
router = router.nest("/i18n", i18n_router);
|
||||
}
|
||||
|
||||
// Apply compression and tracing layers
|
||||
router
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
// HTTP caching is disabled temporarily due to compatibility issues
|
||||
// .layer(HttpCacheLayer::new(http_cache.clone()).with_max_age(folders_ttl))
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode},
|
||||
middleware::Next,
|
||||
};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::HashMap;
|
||||
use tower::{Layer, Service};
|
||||
use std::task::{Context, Poll};
|
||||
use std::pin::Pin;
|
||||
use std::future::Future;
|
||||
use bytes::Bytes;
|
||||
use tracing::{debug, info};
|
||||
|
||||
const MAX_CACHE_ENTRIES: usize = 1000; // Máximo número de entradas en caché
|
||||
const DEFAULT_MAX_AGE: u64 = 60; // Tiempo de vida por defecto en segundos
|
||||
|
||||
// Definición de tipos para mayor claridad
|
||||
type CacheKey = String;
|
||||
type EntityTag = String;
|
||||
|
||||
/// Un valor almacenado en caché
|
||||
#[derive(Clone)]
|
||||
struct CacheEntry {
|
||||
/// El ETag calculado para este valor
|
||||
etag: EntityTag,
|
||||
/// Los datos serializados en bytes
|
||||
data: Option<Bytes>,
|
||||
/// Las cabeceras originales
|
||||
headers: HeaderMap,
|
||||
/// Timestamp de cuando fue almacenado
|
||||
timestamp: SystemTime,
|
||||
/// Tiempo de vida en segundos
|
||||
max_age: u64,
|
||||
}
|
||||
|
||||
/// Cache para respuestas HTTP con soporte para ETag
|
||||
#[derive(Clone)]
|
||||
pub struct HttpCache {
|
||||
/// Almacenamiento de entradas en caché
|
||||
cache: Arc<Mutex<HashMap<CacheKey, CacheEntry>>>,
|
||||
/// Tiempo de vida por defecto para las entradas
|
||||
default_max_age: u64,
|
||||
}
|
||||
|
||||
impl HttpCache {
|
||||
/// Crea una nueva instancia del caché
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: Arc::new(Mutex::new(HashMap::with_capacity(100))),
|
||||
default_max_age: DEFAULT_MAX_AGE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea una nueva instancia con un tiempo de vida especificado
|
||||
#[allow(dead_code)]
|
||||
pub fn with_max_age(max_age: u64) -> Self {
|
||||
Self {
|
||||
cache: Arc::new(Mutex::new(HashMap::with_capacity(100))),
|
||||
default_max_age: max_age,
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene estadísticas del caché
|
||||
pub fn stats(&self) -> (usize, usize) {
|
||||
let lock = self.cache.lock().unwrap();
|
||||
let total = lock.len();
|
||||
|
||||
// Contar entradas válidas
|
||||
let _now = SystemTime::now();
|
||||
let valid = lock.values().filter(|entry| {
|
||||
match entry.timestamp.elapsed() {
|
||||
Ok(elapsed) => elapsed.as_secs() < entry.max_age,
|
||||
Err(_) => false,
|
||||
}
|
||||
}).count();
|
||||
|
||||
(total, valid)
|
||||
}
|
||||
|
||||
/// Limpia entradas expiradas
|
||||
pub fn cleanup(&self) -> usize {
|
||||
let mut lock = self.cache.lock().unwrap();
|
||||
let initial_count = lock.len();
|
||||
|
||||
// Eliminar entradas expiradas
|
||||
let _now = SystemTime::now();
|
||||
lock.retain(|_, entry| {
|
||||
match entry.timestamp.elapsed() {
|
||||
Ok(elapsed) => elapsed.as_secs() < entry.max_age,
|
||||
Err(_) => false,
|
||||
}
|
||||
});
|
||||
|
||||
let removed = initial_count - lock.len();
|
||||
debug!("HttpCache cleanup: removed {} expired entries", removed);
|
||||
|
||||
removed
|
||||
}
|
||||
|
||||
/// Establece una entrada en el caché
|
||||
fn set(&self, key: &str, etag: EntityTag, data: Option<Bytes>, headers: HeaderMap, max_age: Option<u64>) {
|
||||
let mut lock = self.cache.lock().unwrap();
|
||||
|
||||
// Aplicar política de eviction si el caché está lleno
|
||||
if lock.len() >= MAX_CACHE_ENTRIES {
|
||||
debug!("Cache full, removing oldest entries");
|
||||
// Eliminar el 10% de las entradas más antiguas
|
||||
self.evict_oldest(&mut lock, MAX_CACHE_ENTRIES / 10);
|
||||
}
|
||||
|
||||
// Almacenar la nueva entrada
|
||||
lock.insert(key.to_string(), CacheEntry {
|
||||
etag,
|
||||
data,
|
||||
headers,
|
||||
timestamp: SystemTime::now(),
|
||||
max_age: max_age.unwrap_or(self.default_max_age),
|
||||
});
|
||||
}
|
||||
|
||||
/// Elimina las entradas más antiguas del caché
|
||||
fn evict_oldest(&self, cache: &mut HashMap<CacheKey, CacheEntry>, count: usize) {
|
||||
// Ordenar por timestamp
|
||||
let mut entries: Vec<(CacheKey, SystemTime)> = cache
|
||||
.iter()
|
||||
.map(|(key, entry)| (key.clone(), entry.timestamp))
|
||||
.collect();
|
||||
|
||||
// Ordenar por timestamp (más antiguo primero)
|
||||
entries.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
|
||||
// Eliminar las entradas más antiguas
|
||||
for (key, _) in entries.iter().take(count) {
|
||||
cache.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtiene una entrada del caché
|
||||
fn get(&self, key: &str) -> Option<CacheEntry> {
|
||||
let lock = self.cache.lock().unwrap();
|
||||
|
||||
// Buscar la entrada
|
||||
if let Some(entry) = lock.get(key) {
|
||||
// Verificar si ha expirado
|
||||
match entry.timestamp.elapsed() {
|
||||
Ok(elapsed) if elapsed.as_secs() < entry.max_age => {
|
||||
// Entry is still valid
|
||||
return Some(entry.clone());
|
||||
}
|
||||
_ => {
|
||||
// Entry has expired
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Calcula el ETag para una respuesta
|
||||
#[allow(dead_code)]
|
||||
fn calculate_etag<T: Serialize>(&self, response: &T) -> EntityTag {
|
||||
// Serializar la respuesta
|
||||
let json = serde_json::to_string(response).unwrap_or_default();
|
||||
|
||||
// Calcular hash
|
||||
let mut hasher = DefaultHasher::new();
|
||||
json.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
|
||||
format!("\"{}\"", hash)
|
||||
}
|
||||
|
||||
/// Genera un ETag simple para un bloque de bytes
|
||||
fn calculate_etag_for_bytes(&self, bytes: &[u8]) -> EntityTag {
|
||||
// Calcular hash
|
||||
let mut hasher = DefaultHasher::new();
|
||||
bytes.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
|
||||
format!("\"{}\"", hash)
|
||||
}
|
||||
}
|
||||
|
||||
/// Middleware de caché HTTP
|
||||
#[allow(dead_code)]
|
||||
pub async fn cache_middleware<T>(
|
||||
cache: HttpCache,
|
||||
cache_key: &str,
|
||||
max_age: Option<u64>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response<Body>, (StatusCode, String)>
|
||||
where
|
||||
T: Serialize
|
||||
{
|
||||
// Solo aplicar caché para solicitudes GET
|
||||
if req.method() != Method::GET {
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
|
||||
// Verificar si la respuesta está en caché
|
||||
let if_none_match = req.headers()
|
||||
.get("if-none-match")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
// Si hay una entrada en caché
|
||||
if let Some(cache_entry) = cache.get(cache_key) {
|
||||
// Comprobar si el cliente ya tiene la versión actualizada
|
||||
if let Some(client_etag) = if_none_match {
|
||||
if client_etag == cache_entry.etag {
|
||||
// El cliente tiene la versión más reciente, enviar 304 Not Modified
|
||||
debug!("Cache hit (304) for key: {}", cache_key);
|
||||
return Ok(create_not_modified_response(&cache_entry));
|
||||
}
|
||||
}
|
||||
|
||||
// El cliente necesita la versión actualizada
|
||||
if let Some(data) = &cache_entry.data {
|
||||
debug!("Cache hit (200) for key: {}", cache_key);
|
||||
|
||||
// Crear respuesta con los datos en caché
|
||||
let mut response = Response::new(Body::from(data.clone()));
|
||||
|
||||
// Copiar cabeceras originales
|
||||
for (key, value) in &cache_entry.headers {
|
||||
if !key.as_str().eq_ignore_ascii_case("transfer-encoding") {
|
||||
response.headers_mut().insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Añadir cabeceras de caché
|
||||
set_cache_headers(&mut response, &cache_entry.etag, max_age.unwrap_or(cache_entry.max_age));
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
// No está en caché o ha expirado, continuar con el middleware
|
||||
debug!("Cache miss for key: {}", cache_key);
|
||||
let response = next.run(req).await;
|
||||
|
||||
// No cachear errores
|
||||
if !response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// Convertir la respuesta para calcular el ETag
|
||||
let (parts, _body) = response.into_parts();
|
||||
let bytes = axum::body::to_bytes(_body, 1024 * 1024 * 10).await.unwrap_or_default();
|
||||
|
||||
// Calcular ETag
|
||||
let etag = cache.calculate_etag_for_bytes(&bytes);
|
||||
|
||||
// Guardar en caché
|
||||
cache.set(
|
||||
cache_key,
|
||||
etag.clone(),
|
||||
Some(bytes.clone()),
|
||||
parts.headers.clone(),
|
||||
max_age
|
||||
);
|
||||
|
||||
// Crear la respuesta con ETag
|
||||
let mut response = Response::from_parts(parts, Body::from(bytes));
|
||||
set_cache_headers(&mut response, &etag, max_age.unwrap_or(cache.default_max_age));
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Crea una respuesta 304 Not Modified
|
||||
fn create_not_modified_response(entry: &CacheEntry) -> Response<Body> {
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::NOT_MODIFIED)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
// Copiar cabeceras de caché
|
||||
if let Some(cache_control) = entry.headers.get("cache-control") {
|
||||
response.headers_mut().insert("cache-control", cache_control.clone());
|
||||
}
|
||||
|
||||
// Añadir ETag
|
||||
response.headers_mut().insert(
|
||||
"etag",
|
||||
HeaderValue::from_str(&entry.etag).unwrap_or(HeaderValue::from_static(""))
|
||||
);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Configura las cabeceras de caché para una respuesta
|
||||
fn set_cache_headers(response: &mut Response<Body>, etag: &str, max_age: u64) {
|
||||
// Añadir ETag
|
||||
response.headers_mut().insert(
|
||||
"etag",
|
||||
HeaderValue::from_str(etag).unwrap_or(HeaderValue::from_static(""))
|
||||
);
|
||||
|
||||
// Configurar Cache-Control
|
||||
let cache_control = format!("public, max-age={}", max_age);
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_str(&cache_control).unwrap_or(HeaderValue::from_static(""))
|
||||
);
|
||||
|
||||
// Añadir cabecera Last-Modified
|
||||
let now: DateTime<Utc> = Utc::now();
|
||||
let last_modified = now.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
|
||||
response.headers_mut().insert(
|
||||
"last-modified",
|
||||
HeaderValue::from_str(&last_modified).unwrap_or(HeaderValue::from_static(""))
|
||||
);
|
||||
}
|
||||
|
||||
/// Layer para aplicar middleware de caché
|
||||
#[derive(Clone)]
|
||||
pub struct HttpCacheLayer {
|
||||
cache: HttpCache,
|
||||
max_age: Option<u64>,
|
||||
}
|
||||
|
||||
impl HttpCacheLayer {
|
||||
/// Crea una nueva capa de caché
|
||||
#[allow(dead_code)]
|
||||
pub fn new(cache: HttpCache) -> Self {
|
||||
Self {
|
||||
cache,
|
||||
max_age: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Establece el tiempo de vida máximo
|
||||
#[allow(dead_code)]
|
||||
pub fn with_max_age(mut self, max_age: u64) -> Self {
|
||||
self.max_age = Some(max_age);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for HttpCacheLayer {
|
||||
type Service = HttpCacheService<S>;
|
||||
|
||||
fn layer(&self, service: S) -> Self::Service {
|
||||
HttpCacheService {
|
||||
inner: service,
|
||||
cache: self.cache.clone(),
|
||||
max_age: self.max_age,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Servicio que implementa la lógica de caché
|
||||
#[derive(Clone)]
|
||||
pub struct HttpCacheService<S> {
|
||||
inner: S,
|
||||
cache: HttpCache,
|
||||
max_age: Option<u64>,
|
||||
}
|
||||
|
||||
impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for HttpCacheService<S>
|
||||
where
|
||||
S: Service<Request<ReqBody>, Response = Response<ResBody>>,
|
||||
S::Future: Send + 'static,
|
||||
S::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||
ReqBody: Send + 'static,
|
||||
ResBody: http_body::Body + Send + 'static,
|
||||
ResBody::Data: Send + 'static,
|
||||
ResBody::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||
{
|
||||
type Response = Response<Body>;
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx).map_err(|e| e.into())
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
|
||||
// Generar clave de caché
|
||||
let cache_key = req.uri().path().to_string();
|
||||
|
||||
// Solo aplicar caché para solicitudes GET
|
||||
if req.method() != Method::GET {
|
||||
let future = self.inner.call(req);
|
||||
return Box::pin(async move {
|
||||
let response = future.await.map_err(|e| e.into())?;
|
||||
Ok(response_map_body(response))
|
||||
});
|
||||
}
|
||||
|
||||
// Obtener ETag del cliente
|
||||
let if_none_match = req.headers()
|
||||
.get("if-none-match")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
// Verificar si hay una entrada en caché
|
||||
let cache_clone = self.cache.clone();
|
||||
let max_age = self.max_age;
|
||||
let entry = cache_clone.get(&cache_key);
|
||||
|
||||
match entry {
|
||||
Some(cache_entry) if if_none_match == Some(&cache_entry.etag) => {
|
||||
// El cliente tiene la versión correcta, enviar 304
|
||||
debug!("Cache HIT (304): {}", cache_key);
|
||||
let response = create_not_modified_response(&cache_entry);
|
||||
return Box::pin(async move { Ok(response) });
|
||||
},
|
||||
Some(cache_entry) if cache_entry.data.is_some() => {
|
||||
// El cliente necesita la versión actualizada
|
||||
debug!("Cache HIT (200): {}", cache_key);
|
||||
let mut response = Response::new(Body::from(cache_entry.data.clone().unwrap()));
|
||||
|
||||
// Copiar cabeceras originales
|
||||
for (key, value) in &cache_entry.headers {
|
||||
if !key.as_str().eq_ignore_ascii_case("transfer-encoding") {
|
||||
response.headers_mut().insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Añadir cabeceras de caché
|
||||
set_cache_headers(&mut response, &cache_entry.etag, max_age.unwrap_or(cache_entry.max_age));
|
||||
|
||||
return Box::pin(async move { Ok(response) });
|
||||
},
|
||||
_ => {
|
||||
// No está en caché o ha expirado
|
||||
debug!("Cache MISS: {}", cache_key);
|
||||
let future = self.inner.call(req);
|
||||
let cache_clone = self.cache.clone();
|
||||
let max_age = self.max_age;
|
||||
let cache_key = cache_key.clone();
|
||||
|
||||
return Box::pin(async move {
|
||||
let response = future.await.map_err(|e| e.into())?;
|
||||
let response = response_map_body(response);
|
||||
|
||||
// No cachear errores
|
||||
if !response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// Obtener el cuerpo y calcular ETag
|
||||
let (parts, body) = response.into_parts();
|
||||
let bytes = axum::body::to_bytes(body, 1024 * 1024 * 10).await?;
|
||||
|
||||
// Calcular ETag
|
||||
let etag = cache_clone.calculate_etag_for_bytes(&bytes);
|
||||
|
||||
// Guardar en caché
|
||||
cache_clone.set(
|
||||
&cache_key,
|
||||
etag.clone(),
|
||||
Some(bytes.clone()),
|
||||
parts.headers.clone(),
|
||||
max_age
|
||||
);
|
||||
|
||||
// Crear la respuesta con ETag
|
||||
let mut response = Response::from_parts(parts, Body::from(bytes));
|
||||
set_cache_headers(&mut response, &etag, max_age.unwrap_or(cache_clone.default_max_age));
|
||||
|
||||
Ok(response)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Función auxiliar para convertir cualquier cuerpo en Body
|
||||
fn response_map_body<B>(response: Response<B>) -> Response<Body>
|
||||
where
|
||||
B: http_body::Body + Send + 'static,
|
||||
B::Data: Send + 'static,
|
||||
B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||
{
|
||||
let (parts, _body) = response.into_parts();
|
||||
|
||||
// Create a simple empty body as a fallback - in production you would handle this better
|
||||
let mapped_body = Body::empty();
|
||||
|
||||
Response::from_parts(parts, mapped_body)
|
||||
}
|
||||
|
||||
/// Inicia una tarea de limpieza periódica para el caché
|
||||
pub fn start_cache_cleanup_task(cache: HttpCache) {
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300)); // Cada 5 minutos
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let removed = cache.cleanup();
|
||||
let (total, valid) = cache.stats();
|
||||
|
||||
info!("HTTP Cache cleanup: removed {}, current: {}/{}", removed, valid, total);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hyper::{Request, Body, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Json, Router};
|
||||
use tower::ServiceExt;
|
||||
use http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TestData {
|
||||
id: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_etag_generation() {
|
||||
let cache = HttpCache::new();
|
||||
|
||||
let data1 = TestData { id: 1, name: "Test".to_string() };
|
||||
let data2 = TestData { id: 1, name: "Test".to_string() };
|
||||
let data3 = TestData { id: 2, name: "Test".to_string() };
|
||||
|
||||
let etag1 = cache.calculate_etag(&data1);
|
||||
let etag2 = cache.calculate_etag(&data2);
|
||||
let etag3 = cache.calculate_etag(&data3);
|
||||
|
||||
// Mismos datos deben generar mismo ETag
|
||||
assert_eq!(etag1, etag2);
|
||||
|
||||
// Datos diferentes deben generar ETags diferentes
|
||||
assert_ne!(etag1, etag3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_hit_miss() {
|
||||
let cache = HttpCache::new();
|
||||
|
||||
// Primera petición (cache miss)
|
||||
let response1 = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(Body::from(r#"{"id":1,"name":"Test"}"#))
|
||||
.unwrap();
|
||||
|
||||
let (parts1, body1) = response1.into_parts();
|
||||
let bytes1 = hyper::body::to_bytes(body1).await.unwrap();
|
||||
|
||||
let etag1 = cache.calculate_etag_for_bytes(&bytes1);
|
||||
cache.set("test", etag1.clone(), Some(bytes1.clone()), parts1.headers.clone(), None);
|
||||
|
||||
// Verificar cache hit
|
||||
let entry = cache.get("test").unwrap();
|
||||
assert_eq!(entry.etag, etag1);
|
||||
assert_eq!(entry.data.unwrap(), bytes1);
|
||||
|
||||
// Verificar cache miss
|
||||
assert!(cache.get("nonexistent").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod cache;
|
||||
@@ -0,0 +1,62 @@
|
||||
use super::cache::{HttpCache, HttpCacheLayer, start_cache_cleanup_task};
|
||||
use axum::{
|
||||
routing::get,
|
||||
Router,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
extract::State,
|
||||
};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct TestResponse {
|
||||
message: &'static str,
|
||||
timestamp: u64,
|
||||
}
|
||||
|
||||
// Test handler for a simple GET endpoint
|
||||
async fn test_handler() -> impl IntoResponse {
|
||||
// Create a simple response with a timestamp
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// Simulate some processing time
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
let response = TestResponse {
|
||||
message: "Hello, this response is cacheable!",
|
||||
timestamp,
|
||||
};
|
||||
|
||||
// Log the response generation
|
||||
tracing::info!("Generated fresh response with timestamp: {}", timestamp);
|
||||
|
||||
Json(response)
|
||||
}
|
||||
|
||||
// Run a test server with HTTP caching enabled
|
||||
pub async fn run_test_server() {
|
||||
// Initialize HTTP cache with 10 seconds TTL
|
||||
let http_cache = HttpCache::with_max_age(10);
|
||||
|
||||
// Start the cleanup task
|
||||
start_cache_cleanup_task(http_cache.clone());
|
||||
|
||||
// Create a test router with the cache middleware
|
||||
let app = Router::new()
|
||||
.route("/test", get(test_handler))
|
||||
.layer(HttpCacheLayer::new(http_cache));
|
||||
|
||||
// Bind to a test port
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 8086));
|
||||
tracing::info!("HTTP Cache test server listening on {}", addr);
|
||||
|
||||
// Start the server
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod api;
|
||||
pub mod web;
|
||||
pub mod middleware;
|
||||
|
||||
pub use api::create_api_routes;
|
||||
Reference in New Issue
Block a user