fraiseql_storage/backend/
mod.rs1use std::time::Duration;
8
9use chrono::{DateTime, Utc};
10#[cfg(feature = "aws-s3")]
11use fraiseql_error::FraiseQLError;
12use fraiseql_error::{FileError, Result};
13use serde::{Deserialize, Serialize};
14
15pub mod local;
16pub mod types;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PresignedUrl {
24 pub url: String,
26 pub expires_at: DateTime<Utc>,
28 pub method: String,
30}
31
32impl PresignedUrl {
33 #[must_use]
41 pub fn new(url: String, expires_at: DateTime<Utc>, method: &str) -> Self {
42 Self {
43 url,
44 expires_at,
45 method: method.to_uppercase(),
46 }
47 }
48}
49
50#[cfg(feature = "aws-s3")]
58#[allow(async_fn_in_trait)] pub trait PresignCapable {
60 async fn presign_put(
75 &self,
76 key: &str,
77 content_type: &str,
78 expires_in: Duration,
79 ) -> Result<PresignedUrl>;
80
81 async fn presign_get(&self, key: &str, expires_in: Duration) -> Result<PresignedUrl>;
95}
96
97#[cfg(feature = "aws-s3")]
98pub mod s3;
99
100#[cfg(feature = "gcs")]
101pub mod gcs;
102
103#[cfg(feature = "azure-blob")]
104pub mod azure;
105
106#[cfg(test)]
107mod tests;
108
109pub use local::LocalBackend;
110
111#[cfg(feature = "azure-blob")]
112pub use self::azure::AzureBackend;
113#[cfg(feature = "gcs")]
114pub use self::gcs::GcsBackend;
115#[cfg(feature = "aws-s3")]
116pub use self::s3::S3Backend;
117
118#[non_exhaustive]
127pub enum StorageBackend {
128 Local(LocalBackend),
130 #[cfg(feature = "aws-s3")]
132 S3(S3Backend),
133 #[cfg(feature = "aws-s3")]
135 Hetzner(S3Backend),
136 #[cfg(feature = "aws-s3")]
138 Scaleway(S3Backend),
139 #[cfg(feature = "aws-s3")]
141 Ovh(S3Backend),
142 #[cfg(feature = "aws-s3")]
144 Exoscale(S3Backend),
145 #[cfg(feature = "aws-s3")]
147 Backblaze(S3Backend),
148 #[cfg(feature = "aws-s3")]
150 R2(S3Backend),
151 #[cfg(feature = "gcs")]
153 Gcs(GcsBackend),
154 #[cfg(feature = "azure-blob")]
156 Azure(AzureBackend),
157}
158
159impl StorageBackend {
160 pub async fn upload(&self, key: &str, data: &[u8], content_type: &str) -> Result<String> {
166 match self {
167 Self::Local(b) => b.upload(key, data, content_type).await,
168 #[cfg(feature = "aws-s3")]
169 Self::S3(b)
170 | Self::Hetzner(b)
171 | Self::Scaleway(b)
172 | Self::Ovh(b)
173 | Self::Exoscale(b)
174 | Self::Backblaze(b)
175 | Self::R2(b) => b.upload(key, data, content_type).await,
176 #[cfg(feature = "gcs")]
177 Self::Gcs(b) => b.upload(key, data, content_type).await,
178 #[cfg(feature = "azure-blob")]
179 Self::Azure(b) => b.upload(key, data, content_type).await,
180 }
181 }
182
183 pub async fn download(&self, key: &str) -> Result<Vec<u8>> {
190 match self {
191 Self::Local(b) => b.download(key).await,
192 #[cfg(feature = "aws-s3")]
193 Self::S3(b)
194 | Self::Hetzner(b)
195 | Self::Scaleway(b)
196 | Self::Ovh(b)
197 | Self::Exoscale(b)
198 | Self::Backblaze(b)
199 | Self::R2(b) => b.download(key).await,
200 #[cfg(feature = "gcs")]
201 Self::Gcs(b) => b.download(key).await,
202 #[cfg(feature = "azure-blob")]
203 Self::Azure(b) => b.download(key).await,
204 }
205 }
206
207 pub async fn delete(&self, key: &str) -> Result<()> {
213 match self {
214 Self::Local(b) => b.delete(key).await,
215 #[cfg(feature = "aws-s3")]
216 Self::S3(b)
217 | Self::Hetzner(b)
218 | Self::Scaleway(b)
219 | Self::Ovh(b)
220 | Self::Exoscale(b)
221 | Self::Backblaze(b)
222 | Self::R2(b) => b.delete(key).await,
223 #[cfg(feature = "gcs")]
224 Self::Gcs(b) => b.delete(key).await,
225 #[cfg(feature = "azure-blob")]
226 Self::Azure(b) => b.delete(key).await,
227 }
228 }
229
230 pub async fn exists(&self, key: &str) -> Result<bool> {
236 match self {
237 Self::Local(b) => b.exists(key).await,
238 #[cfg(feature = "aws-s3")]
239 Self::S3(b)
240 | Self::Hetzner(b)
241 | Self::Scaleway(b)
242 | Self::Ovh(b)
243 | Self::Exoscale(b)
244 | Self::Backblaze(b)
245 | Self::R2(b) => b.exists(key).await,
246 #[cfg(feature = "gcs")]
247 Self::Gcs(b) => b.exists(key).await,
248 #[cfg(feature = "azure-blob")]
249 Self::Azure(b) => b.exists(key).await,
250 }
251 }
252
253 pub async fn presigned_url(&self, key: &str, expiry: Duration) -> Result<String> {
260 match self {
261 Self::Local(b) => b.presigned_url(key, expiry).await,
262 #[cfg(feature = "aws-s3")]
263 Self::S3(b)
264 | Self::Hetzner(b)
265 | Self::Scaleway(b)
266 | Self::Ovh(b)
267 | Self::Exoscale(b)
268 | Self::Backblaze(b)
269 | Self::R2(b) => b.presigned_url(key, expiry).await,
270 #[cfg(feature = "gcs")]
271 Self::Gcs(b) => b.presigned_url(key, expiry).await,
272 #[cfg(feature = "azure-blob")]
273 Self::Azure(b) => b.presigned_url(key, expiry).await,
274 }
275 }
276
277 #[cfg(feature = "aws-s3")]
284 pub async fn presign_put(
285 &self,
286 key: &str,
287 content_type: &str,
288 expires_in: Duration,
289 ) -> Result<PresignedUrl> {
290 match self {
291 Self::S3(b)
292 | Self::Hetzner(b)
293 | Self::Scaleway(b)
294 | Self::Ovh(b)
295 | Self::Exoscale(b)
296 | Self::Backblaze(b)
297 | Self::R2(b) => b.presign_put(key, content_type, expires_in).await,
298 _ => Err(FraiseQLError::File(FileError::Unsupported {
299 message: "presigned PUT not supported by this backend".to_string(),
300 })),
301 }
302 }
303
304 #[cfg(feature = "aws-s3")]
311 pub async fn presign_get(&self, key: &str, expires_in: Duration) -> Result<PresignedUrl> {
312 match self {
313 Self::S3(b)
314 | Self::Hetzner(b)
315 | Self::Scaleway(b)
316 | Self::Ovh(b)
317 | Self::Exoscale(b)
318 | Self::Backblaze(b)
319 | Self::R2(b) => b.presign_get(key, expires_in).await,
320 _ => Err(FraiseQLError::File(FileError::Unsupported {
321 message: "presigned GET not supported by this backend".to_string(),
322 })),
323 }
324 }
325
326 pub async fn list(
332 &self,
333 prefix: &str,
334 cursor: Option<&str>,
335 limit: usize,
336 ) -> Result<types::ListResult> {
337 match self {
338 Self::Local(b) => b.list(prefix, cursor, limit).await,
339 #[cfg(feature = "aws-s3")]
340 Self::S3(b)
341 | Self::Hetzner(b)
342 | Self::Scaleway(b)
343 | Self::Ovh(b)
344 | Self::Exoscale(b)
345 | Self::Backblaze(b)
346 | Self::R2(b) => b.list(prefix, cursor, limit).await,
347 #[cfg(feature = "gcs")]
348 Self::Gcs(b) => b.list(prefix, cursor, limit).await,
349 #[cfg(feature = "azure-blob")]
350 Self::Azure(b) => b.list(prefix, cursor, limit).await,
351 }
352 }
353}
354
355pub fn validate_key(key: &str) -> Result<()> {
362 if key.is_empty() {
363 return Err(fraiseql_error::FraiseQLError::File(FileError::InvalidKey {
364 message: "Storage key must not be empty".to_string(),
365 }));
366 }
367 if key.contains("..") || key.starts_with('/') || key.starts_with('\\') {
368 return Err(fraiseql_error::FraiseQLError::File(FileError::InvalidKey {
369 message: "Invalid storage key: must be a relative path without '..'".to_string(),
370 }));
371 }
372 Ok(())
373}
374
375#[cfg(any(feature = "aws-s3", test))]
380#[allow(dead_code)] fn default_s3_endpoint(backend: &str, region: Option<&str>) -> Option<String> {
382 match backend {
383 "r2" => {
384 None
386 },
387 "hetzner" => {
388 let r = region.unwrap_or("fsn1");
389 Some(format!("https://{r}.your-objectstorage.com"))
390 },
391 "scaleway" => {
392 let r = region.unwrap_or("fr-par");
393 Some(format!("https://s3.{r}.scw.cloud"))
394 },
395 "ovh" => {
396 let r = region.unwrap_or("gra");
397 Some(format!("https://s3.{r}.perf.cloud.ovh.net"))
398 },
399 "exoscale" => {
400 let r = region.unwrap_or("de-fra-1");
401 Some(format!("https://sos-{r}.exo.io"))
402 },
403 "backblaze" => {
404 let r = region.unwrap_or("us-west-004");
406 Some(format!("https://s3.{r}.backblazeb2.com"))
407 },
408 _ => None,
409 }
410}
411
412fn config_err(message: impl Into<String>) -> fraiseql_error::FraiseQLError {
414 fraiseql_error::FraiseQLError::File(FileError::Backend {
415 message: message.into(),
416 source: None,
417 })
418}
419
420pub async fn create_backend(config: &crate::config::StorageConfig) -> Result<StorageBackend> {
432 let backend_name = config.backend.as_str();
433
434 match backend_name {
435 "local" => {
436 let path = config
437 .path
438 .as_deref()
439 .ok_or_else(|| config_err("Local storage backend requires 'path' configuration"))?;
440 Ok(StorageBackend::Local(LocalBackend::new(path)))
441 },
442 #[cfg(feature = "aws-s3")]
443 "s3" => {
444 let bucket = config.bucket.as_deref().ok_or_else(|| {
445 config_err("AWS S3 storage backend requires 'bucket' configuration")
446 })?;
447 let endpoint = config.endpoint.as_deref().map(str::to_owned);
448 let backend =
449 S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
450 Ok(StorageBackend::S3(backend))
451 },
452 #[cfg(feature = "aws-s3")]
453 "hetzner" => {
454 let bucket = config.bucket.as_deref().ok_or_else(|| {
455 config_err("Hetzner Object Storage requires 'bucket' configuration")
456 })?;
457 let endpoint = config
458 .endpoint
459 .as_deref()
460 .map(str::to_owned)
461 .or_else(|| default_s3_endpoint("hetzner", config.region.as_deref()));
462 let backend =
463 S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
464 Ok(StorageBackend::Hetzner(backend))
465 },
466 #[cfg(feature = "aws-s3")]
467 "scaleway" => {
468 let bucket = config.bucket.as_deref().ok_or_else(|| {
469 config_err("Scaleway Object Storage requires 'bucket' configuration")
470 })?;
471 let endpoint = config
472 .endpoint
473 .as_deref()
474 .map(str::to_owned)
475 .or_else(|| default_s3_endpoint("scaleway", config.region.as_deref()));
476 let backend =
477 S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
478 Ok(StorageBackend::Scaleway(backend))
479 },
480 #[cfg(feature = "aws-s3")]
481 "ovh" => {
482 let bucket = config
483 .bucket
484 .as_deref()
485 .ok_or_else(|| config_err("OVH Object Storage requires 'bucket' configuration"))?;
486 let endpoint = config
487 .endpoint
488 .as_deref()
489 .map(str::to_owned)
490 .or_else(|| default_s3_endpoint("ovh", config.region.as_deref()));
491 let backend =
492 S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
493 Ok(StorageBackend::Ovh(backend))
494 },
495 #[cfg(feature = "aws-s3")]
496 "exoscale" => {
497 let bucket = config.bucket.as_deref().ok_or_else(|| {
498 config_err("Exoscale Object Storage requires 'bucket' configuration")
499 })?;
500 let endpoint = config
501 .endpoint
502 .as_deref()
503 .map(str::to_owned)
504 .or_else(|| default_s3_endpoint("exoscale", config.region.as_deref()));
505 let backend =
506 S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
507 Ok(StorageBackend::Exoscale(backend))
508 },
509 #[cfg(feature = "aws-s3")]
510 "backblaze" => {
511 let bucket = config.bucket.as_deref().ok_or_else(|| {
512 config_err("Backblaze B2 storage requires 'bucket' configuration")
513 })?;
514 let endpoint = config
515 .endpoint
516 .as_deref()
517 .map(str::to_owned)
518 .or_else(|| default_s3_endpoint("backblaze", config.region.as_deref()));
519 let backend =
520 S3Backend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
521 Ok(StorageBackend::Backblaze(backend))
522 },
523 #[cfg(feature = "aws-s3")]
524 "r2" => {
525 let bucket = config
526 .bucket
527 .as_deref()
528 .ok_or_else(|| config_err("Cloudflare R2 requires 'bucket' configuration"))?;
529 let endpoint = config.endpoint.as_deref().ok_or_else(|| {
530 config_err("Cloudflare R2 requires 'endpoint' configuration (account ID in URL)")
531 })?;
532 let backend = S3Backend::new(bucket, config.region.as_deref(), Some(endpoint)).await;
533 Ok(StorageBackend::R2(backend))
534 },
535 #[cfg(feature = "gcs")]
536 "gcs" => {
537 let bucket = config
538 .bucket
539 .as_deref()
540 .ok_or_else(|| config_err("GCS storage backend requires 'bucket' configuration"))?;
541 let backend = GcsBackend::new(bucket)?;
542 Ok(StorageBackend::Gcs(backend))
543 },
544 #[cfg(feature = "azure-blob")]
545 "azure" => {
546 let container = config.bucket.as_deref().ok_or_else(|| {
547 config_err("Azure Blob storage requires 'bucket' (container) configuration")
548 })?;
549 let account = config.account_name.as_deref().ok_or_else(|| {
550 config_err("Azure Blob storage requires 'account_name' configuration")
551 })?;
552 let backend = AzureBackend::new(account, container)?;
553 Ok(StorageBackend::Azure(backend))
554 },
555 #[cfg(not(feature = "aws-s3"))]
556 "s3" | "hetzner" | "scaleway" | "ovh" | "exoscale" | "backblaze" | "r2" => {
557 Err(config_err("S3-compatible storage backends require the 'aws-s3' feature"))
558 },
559 #[cfg(not(feature = "gcs"))]
560 "gcs" => Err(config_err("GCS storage backend requires the 'gcs' feature")),
561 #[cfg(not(feature = "azure-blob"))]
562 "azure" => Err(config_err("Azure Blob storage backend requires the 'azure-blob' feature")),
563 other => Err(config_err(format!("Unknown storage backend: {other}"))),
564 }
565}