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