fraiseql_storage/service/mod.rs
1//! Policy-enforcing wrapper around storage backends.
2//!
3//! `BucketService` validates size limits and MIME type restrictions
4//! before delegating to the underlying storage backend.
5
6use std::time::Duration;
7
8use fraiseql_error::{FileError, FraiseQLError, Result};
9
10use super::backend::validate_key;
11use crate::{
12 backend::{StorageBackend, types::ListResult},
13 config::BucketConfig,
14};
15
16/// A bucket-aware storage service that enforces policies.
17///
18/// Wraps a `StorageBackend` with a `BucketConfig` to validate
19/// upload size and MIME type restrictions before delegating operations.
20pub struct BucketService {
21 backend: StorageBackend,
22 config: BucketConfig,
23}
24
25impl BucketService {
26 /// Creates a new bucket service.
27 #[must_use]
28 pub const fn new(backend: StorageBackend, config: BucketConfig) -> Self {
29 Self { backend, config }
30 }
31
32 /// Returns a reference to the bucket configuration.
33 #[must_use]
34 pub const fn config(&self) -> &BucketConfig {
35 &self.config
36 }
37
38 /// Uploads data with policy validation.
39 ///
40 /// Validates:
41 /// - Key is safe (no path traversal)
42 /// - Data size does not exceed configured limit
43 /// - Content type is in allowed MIME types list
44 ///
45 /// # Errors
46 ///
47 /// Returns `FraiseQLError::File` if validation fails or upload fails.
48 pub async fn upload(&self, key: &str, data: &[u8], content_type: &str) -> Result<String> {
49 validate_key(key)?;
50
51 // Validate size limit
52 if let Some(max_bytes) = self.config.max_object_bytes {
53 // Reason: data.len() is bounded by axum body limits + this check; the cast to
54 // u64 is wider than usize on all supported targets, so no truncation can occur.
55 #[allow(clippy::cast_possible_truncation)]
56 let actual = data.len() as u64;
57 if actual > max_bytes {
58 return Err(FraiseQLError::File(FileError::SizeLimitExceeded {
59 message: format!("Upload exceeds maximum object size of {max_bytes} bytes"),
60 limit: Some(max_bytes),
61 actual: Some(actual),
62 }));
63 }
64 }
65
66 // Validate MIME type
67 if let Some(ref allowed) = self.config.allowed_mime_types {
68 let is_allowed = allowed.iter().any(|m| m == content_type || m == "*/*");
69 if !is_allowed {
70 return Err(FraiseQLError::File(FileError::MimeTypeNotAllowed {
71 message: format!(
72 "Content type '{content_type}' is not allowed for this bucket"
73 ),
74 mime: Some(content_type.to_string()),
75 }));
76 }
77 }
78
79 self.backend.upload(key, data, content_type).await
80 }
81
82 /// Downloads data without policy validation (policies only apply to upload).
83 ///
84 /// # Errors
85 ///
86 /// Returns `FraiseQLError::File` if download fails.
87 pub async fn download(&self, key: &str) -> Result<Vec<u8>> {
88 self.backend.download(key).await
89 }
90
91 /// Deletes an object.
92 ///
93 /// # Errors
94 ///
95 /// Returns `FraiseQLError::File` if deletion fails.
96 pub async fn delete(&self, key: &str) -> Result<()> {
97 self.backend.delete(key).await
98 }
99
100 /// Checks if an object exists.
101 ///
102 /// # Errors
103 ///
104 /// Returns `FraiseQLError::File` on backend communication errors.
105 pub async fn exists(&self, key: &str) -> Result<bool> {
106 self.backend.exists(key).await
107 }
108
109 /// Lists objects in the bucket by prefix.
110 ///
111 /// # Errors
112 ///
113 /// Returns `FraiseQLError::File` if listing fails.
114 pub async fn list(
115 &self,
116 prefix: &str,
117 cursor: Option<&str>,
118 limit: usize,
119 ) -> Result<ListResult> {
120 self.backend.list(prefix, cursor, limit).await
121 }
122
123 /// Generates a presigned URL for direct access to an object.
124 ///
125 /// # Errors
126 ///
127 /// Returns `FraiseQLError::File` if presigned URLs are not supported
128 /// by the backend or if generation fails.
129 pub async fn presigned_url(&self, key: &str, expiry: Duration) -> Result<String> {
130 self.backend.presigned_url(key, expiry).await
131 }
132}
133
134#[cfg(test)]
135mod tests;