use std::{sync::Arc, time::Duration};
use axum::{
Router,
body::Bytes,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
routing::{get, post},
};
use fraiseql_error::FileError;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::storage::{StorageBackend, validate_key};
pub const DEFAULT_MAX_UPLOAD_BYTES: usize = 100 * 1024 * 1024;
#[derive(Clone)]
pub struct StorageRouteState {
pub backend: Arc<dyn StorageBackend>,
pub max_upload_bytes: usize,
pub tenant_prefix: Option<String>,
}
impl StorageRouteState {
#[must_use]
pub fn new(backend: Arc<dyn StorageBackend>) -> Self {
Self {
backend,
max_upload_bytes: DEFAULT_MAX_UPLOAD_BYTES,
tenant_prefix: None,
}
}
#[must_use]
pub const fn with_max_upload_bytes(mut self, bytes: usize) -> Self {
self.max_upload_bytes = bytes;
self
}
#[must_use]
pub fn with_tenant_prefix(mut self, prefix: impl Into<String>) -> Self {
self.tenant_prefix = Some(prefix.into());
self
}
}
#[derive(Serialize)]
struct UploadResponse {
key: String,
}
#[derive(Serialize)]
struct PresignedUrlResponse {
url: String,
expires_in: u64,
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
code: &'static str,
}
fn file_error_response(err: &FileError) -> Response {
let status = match err {
FileError::NotFound { .. } => StatusCode::NOT_FOUND,
FileError::TooLarge { .. } | FileError::QuotaExceeded => StatusCode::PAYLOAD_TOO_LARGE,
FileError::InvalidType { .. } | FileError::MimeMismatch { .. } => {
StatusCode::UNSUPPORTED_MEDIA_TYPE
},
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = serde_json::to_string(&ErrorBody {
error: err.to_string(),
code: err.error_code(),
})
.unwrap_or_default();
(status, [(header::CONTENT_TYPE, "application/json")], body).into_response()
}
fn prefixed_key(prefix: Option<&str>, key: &str) -> String {
match prefix {
Some(p) => format!("{p}/{key}"),
None => key.to_owned(),
}
}
pub async fn upload_handler(
State(state): State<StorageRouteState>,
Path(key): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
if let Err(e) = validate_key(&key) {
return file_error_response(&e);
}
if body.len() > state.max_upload_bytes {
return file_error_response(&FileError::TooLarge {
size: body.len(),
max: state.max_upload_bytes,
});
}
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let full_key = prefixed_key(state.tenant_prefix.as_deref(), &key);
match state.backend.upload(&full_key, &body, &content_type).await {
Ok(stored_key) => {
(StatusCode::OK, axum::Json(UploadResponse { key: stored_key })).into_response()
},
Err(e) => file_error_response(&e),
}
}
pub async fn download_handler(
State(state): State<StorageRouteState>,
Path(key): Path<String>,
) -> Response {
if let Err(e) = validate_key(&key) {
return file_error_response(&e);
}
let full_key = prefixed_key(state.tenant_prefix.as_deref(), &key);
match state.backend.download(&full_key).await {
Ok(data) => (StatusCode::OK, [(header::CONTENT_TYPE, "application/octet-stream")], data)
.into_response(),
Err(e) => file_error_response(&e),
}
}
pub async fn delete_handler(
State(state): State<StorageRouteState>,
Path(key): Path<String>,
) -> Response {
if let Err(e) = validate_key(&key) {
return file_error_response(&e);
}
let full_key = prefixed_key(state.tenant_prefix.as_deref(), &key);
match state.backend.delete(&full_key).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => file_error_response(&e),
}
}
#[derive(Deserialize)]
pub struct SignQuery {
#[serde(default = "default_expiry_secs")]
expiry_secs: u64,
}
const fn default_expiry_secs() -> u64 {
3600
}
pub async fn presigned_url_handler(
State(state): State<StorageRouteState>,
Path(key): Path<String>,
Query(params): Query<SignQuery>,
) -> Response {
if let Err(e) = validate_key(&key) {
return file_error_response(&e);
}
let expiry = Duration::from_secs(params.expiry_secs);
let full_key = prefixed_key(state.tenant_prefix.as_deref(), &key);
match state.backend.presigned_url(&full_key, expiry).await {
Ok(url) => (
StatusCode::OK,
axum::Json(PresignedUrlResponse {
url,
expires_in: params.expiry_secs,
}),
)
.into_response(),
Err(e) => {
warn!(key = %key, error = %e, "Presigned URL generation failed");
file_error_response(&e)
},
}
}
pub fn storage_router(state: StorageRouteState) -> Router {
Router::new()
.route("/storage/v1/object/sign/{*key}", get(presigned_url_handler))
.route(
"/storage/v1/object/{*key}",
post(upload_handler).get(download_handler).delete(delete_handler),
)
.with_state(state)
}
#[cfg(test)]
mod tests;