Skip to main content

fraiseql_server/routes/storage/
mod.rs

1//! Storage REST API routes.
2//!
3//! Provides object storage endpoints mounted at `/storage/v1/`:
4//!
5//! | Method | Path | Operation |
6//! |--------|------|-----------|
7//! | `POST`   | `/storage/v1/object/*key` | Upload object |
8//! | `GET`    | `/storage/v1/object/*key` | Download object |
9//! | `DELETE` | `/storage/v1/object/*key` | Delete object |
10//! | `GET`    | `/storage/v1/object/sign/*key` | Generate presigned URL |
11//!
12//! Routes are only mounted when a storage backend has been attached via
13//! [`Server::with_storage`](crate::server::Server::with_storage).
14
15use std::{sync::Arc, time::Duration};
16
17use axum::{
18    Router,
19    body::Bytes,
20    extract::{Path, Query, State},
21    http::{HeaderMap, StatusCode, header},
22    response::{IntoResponse, Response},
23    routing::{get, post},
24};
25use fraiseql_error::FileError;
26use serde::{Deserialize, Serialize};
27use tracing::warn;
28
29use crate::storage::{StorageBackend, validate_key};
30
31/// Default maximum upload size: 100 `MiB`.
32pub const DEFAULT_MAX_UPLOAD_BYTES: usize = 100 * 1024 * 1024;
33
34/// Shared state for all storage route handlers.
35#[derive(Clone)]
36pub struct StorageRouteState {
37    /// The configured storage backend (local, S3, GCS, Azure, …).
38    pub backend:          Arc<dyn StorageBackend>,
39    /// Maximum allowed upload body size in bytes.
40    ///
41    /// Requests that exceed this limit are rejected with HTTP 413 before the
42    /// body is forwarded to the backend, preventing memory exhaustion on the
43    /// server when large files are sent.
44    pub max_upload_bytes: usize,
45    /// Optional key prefix prepended to every storage key.
46    ///
47    /// Used for per-tenant isolation: set this to the tenant's ID so that
48    /// tenant A's keys (`"tenantA/file.txt"`) are disjoint from tenant B's
49    /// (`"tenantB/file.txt"`).  When `None`, keys are used as-is.
50    pub tenant_prefix:    Option<String>,
51}
52
53impl StorageRouteState {
54    /// Create state with the given backend and the default 100 `MiB` upload limit.
55    #[must_use]
56    pub fn new(backend: Arc<dyn StorageBackend>) -> Self {
57        Self {
58            backend,
59            max_upload_bytes: DEFAULT_MAX_UPLOAD_BYTES,
60            tenant_prefix: None,
61        }
62    }
63
64    /// Override the maximum upload size.
65    #[must_use]
66    pub const fn with_max_upload_bytes(mut self, bytes: usize) -> Self {
67        self.max_upload_bytes = bytes;
68        self
69    }
70
71    /// Set a tenant key prefix for per-tenant object isolation.
72    ///
73    /// Every storage key is prefixed with `{prefix}/` before being forwarded
74    /// to the backend, ensuring that tenants cannot access each other's objects
75    /// even if they share the same bucket.
76    #[must_use]
77    pub fn with_tenant_prefix(mut self, prefix: impl Into<String>) -> Self {
78        self.tenant_prefix = Some(prefix.into());
79        self
80    }
81}
82
83// ── Response types ────────────────────────────────────────────────────────────
84
85/// Body returned by a successful upload.
86#[derive(Serialize)]
87struct UploadResponse {
88    /// The key under which the object was stored.
89    key: String,
90}
91
92/// Body returned by a successful presigned-URL request.
93#[derive(Serialize)]
94struct PresignedUrlResponse {
95    /// Time-limited URL that grants direct access to the object.
96    url:        String,
97    /// How long the URL remains valid, in seconds.
98    expires_in: u64,
99}
100
101/// Body returned for all error responses.
102#[derive(Serialize)]
103struct ErrorBody {
104    /// Human-readable error message.
105    error: String,
106    /// Stable machine-readable error code.
107    code:  &'static str,
108}
109
110// ── Error mapping ─────────────────────────────────────────────────────────────
111
112/// Convert a [`FileError`] into an HTTP error response.
113fn file_error_response(err: &FileError) -> Response {
114    let status = match err {
115        FileError::NotFound { .. } => StatusCode::NOT_FOUND,
116        FileError::TooLarge { .. } | FileError::QuotaExceeded => StatusCode::PAYLOAD_TOO_LARGE,
117        FileError::InvalidType { .. } | FileError::MimeMismatch { .. } => {
118            StatusCode::UNSUPPORTED_MEDIA_TYPE
119        },
120        _ => StatusCode::INTERNAL_SERVER_ERROR,
121    };
122    let body = serde_json::to_string(&ErrorBody {
123        error: err.to_string(),
124        code:  err.error_code(),
125    })
126    .unwrap_or_default();
127    (status, [(header::CONTENT_TYPE, "application/json")], body).into_response()
128}
129
130// ── Key helpers ───────────────────────────────────────────────────────────────
131
132/// Combine an optional tenant prefix with a raw key.
133///
134/// When `prefix` is `Some("tenantA")` and `key` is `"file.txt"`, the result
135/// is `"tenantA/file.txt"`.  When `prefix` is `None`, the key is returned
136/// unchanged.
137fn prefixed_key(prefix: Option<&str>, key: &str) -> String {
138    match prefix {
139        Some(p) => format!("{p}/{key}"),
140        None => key.to_owned(),
141    }
142}
143
144// ── Handlers ──────────────────────────────────────────────────────────────────
145
146/// `POST /storage/v1/object/*key` — upload an object.
147///
148/// Reads the entire request body and stores it at `key` in the configured
149/// backend. Rejects bodies larger than [`StorageRouteState::max_upload_bytes`]
150/// with HTTP 413.
151///
152/// The `Content-Type` header is forwarded to the backend and stored as the
153/// object's MIME type (falls back to `application/octet-stream` when absent).
154pub async fn upload_handler(
155    State(state): State<StorageRouteState>,
156    Path(key): Path<String>,
157    headers: HeaderMap,
158    body: Bytes,
159) -> Response {
160    if let Err(e) = validate_key(&key) {
161        return file_error_response(&e);
162    }
163
164    if body.len() > state.max_upload_bytes {
165        return file_error_response(&FileError::TooLarge {
166            size: body.len(),
167            max:  state.max_upload_bytes,
168        });
169    }
170
171    let content_type = headers
172        .get(header::CONTENT_TYPE)
173        .and_then(|v| v.to_str().ok())
174        .unwrap_or("application/octet-stream")
175        .to_string();
176
177    let full_key = prefixed_key(state.tenant_prefix.as_deref(), &key);
178
179    match state.backend.upload(&full_key, &body, &content_type).await {
180        Ok(stored_key) => {
181            (StatusCode::OK, axum::Json(UploadResponse { key: stored_key })).into_response()
182        },
183        Err(e) => file_error_response(&e),
184    }
185}
186
187/// `GET /storage/v1/object/*key` — download an object.
188///
189/// Returns the object bytes with `Content-Type: application/octet-stream`.
190pub async fn download_handler(
191    State(state): State<StorageRouteState>,
192    Path(key): Path<String>,
193) -> Response {
194    if let Err(e) = validate_key(&key) {
195        return file_error_response(&e);
196    }
197
198    let full_key = prefixed_key(state.tenant_prefix.as_deref(), &key);
199
200    match state.backend.download(&full_key).await {
201        Ok(data) => (StatusCode::OK, [(header::CONTENT_TYPE, "application/octet-stream")], data)
202            .into_response(),
203        Err(e) => file_error_response(&e),
204    }
205}
206
207/// `DELETE /storage/v1/object/*key` — delete an object.
208///
209/// Returns HTTP 204 on success.
210pub async fn delete_handler(
211    State(state): State<StorageRouteState>,
212    Path(key): Path<String>,
213) -> Response {
214    if let Err(e) = validate_key(&key) {
215        return file_error_response(&e);
216    }
217
218    let full_key = prefixed_key(state.tenant_prefix.as_deref(), &key);
219
220    match state.backend.delete(&full_key).await {
221        Ok(()) => StatusCode::NO_CONTENT.into_response(),
222        Err(e) => file_error_response(&e),
223    }
224}
225
226/// Query parameters for the presigned-URL endpoint.
227#[derive(Deserialize)]
228pub struct SignQuery {
229    /// URL expiry in seconds (default: 3 600 s = 1 hour).
230    #[serde(default = "default_expiry_secs")]
231    expiry_secs: u64,
232}
233
234const fn default_expiry_secs() -> u64 {
235    3600
236}
237
238/// `GET /storage/v1/object/sign/*key` — generate a presigned URL.
239///
240/// Returns a time-limited URL granting direct access to the object without
241/// requiring credentials.  Not all backends support presigned URLs; those that
242/// do not return HTTP 500 with `code: "file_storage_error"`.
243pub async fn presigned_url_handler(
244    State(state): State<StorageRouteState>,
245    Path(key): Path<String>,
246    Query(params): Query<SignQuery>,
247) -> Response {
248    if let Err(e) = validate_key(&key) {
249        return file_error_response(&e);
250    }
251
252    let expiry = Duration::from_secs(params.expiry_secs);
253    let full_key = prefixed_key(state.tenant_prefix.as_deref(), &key);
254
255    match state.backend.presigned_url(&full_key, expiry).await {
256        Ok(url) => (
257            StatusCode::OK,
258            axum::Json(PresignedUrlResponse {
259                url,
260                expires_in: params.expiry_secs,
261            }),
262        )
263            .into_response(),
264        Err(e) => {
265            warn!(key = %key, error = %e, "Presigned URL generation failed");
266            file_error_response(&e)
267        },
268    }
269}
270
271// ── Router ────────────────────────────────────────────────────────────────────
272
273/// Build the storage sub-router and attach `state` to all routes.
274///
275/// Register this router with [`Router::merge`] after the main application
276/// router is built.  The routes use the `/storage/v1/` prefix.
277///
278/// **Route registration order matters for axum wildcard matching**: the sign
279/// route (`/sign/{*key}`) is registered before the generic object route
280/// (`/{*key}`) so that axum's static-segment-wins rule resolves correctly.
281pub fn storage_router(state: StorageRouteState) -> Router {
282    Router::new()
283        // Sign route must come before the generic wildcard route.
284        .route("/storage/v1/object/sign/{*key}", get(presigned_url_handler))
285        .route(
286            "/storage/v1/object/{*key}",
287            post(upload_handler).get(download_handler).delete(delete_handler),
288        )
289        .with_state(state)
290}
291
292// ── Tests ─────────────────────────────────────────────────────────────────────
293
294#[cfg(test)]
295mod tests;