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;