Skip to main content

shaperail_runtime/storage/
backend.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4/// Metadata associated with a stored file.
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub struct FileMetadata {
7    /// Storage path (relative key within the backend).
8    pub path: String,
9    /// Original filename as uploaded.
10    pub filename: String,
11    /// MIME type (e.g., "image/png").
12    pub mime_type: String,
13    /// File size in bytes.
14    pub size: u64,
15}
16
17/// Errors that can occur during storage operations.
18#[derive(Debug)]
19pub enum StorageError {
20    /// The requested file was not found.
21    NotFound(String),
22    /// File exceeds the maximum allowed size.
23    FileTooLarge { max_bytes: u64, actual_bytes: u64 },
24    /// MIME type is not in the allowed list.
25    InvalidMimeType {
26        mime_type: String,
27        allowed: Vec<String>,
28    },
29    /// Backend I/O or configuration error.
30    Backend(String),
31}
32
33impl fmt::Display for StorageError {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Self::NotFound(path) => write!(f, "File not found: {path}"),
37            Self::FileTooLarge {
38                max_bytes,
39                actual_bytes,
40            } => {
41                write!(
42                    f,
43                    "File too large: {actual_bytes} bytes exceeds limit of {max_bytes} bytes"
44                )
45            }
46            Self::InvalidMimeType { mime_type, allowed } => {
47                write!(f, "Invalid MIME type '{mime_type}', allowed: {allowed:?}")
48            }
49            Self::Backend(msg) => write!(f, "Storage backend error: {msg}"),
50        }
51    }
52}
53
54impl std::error::Error for StorageError {}
55
56impl From<StorageError> for shaperail_core::ShaperailError {
57    fn from(err: StorageError) -> Self {
58        match err {
59            StorageError::NotFound(_) => shaperail_core::ShaperailError::NotFound,
60            StorageError::FileTooLarge {
61                max_bytes,
62                actual_bytes,
63            } => shaperail_core::ShaperailError::Validation(vec![shaperail_core::FieldError {
64                field: "file".to_string(),
65                message: format!(
66                    "File too large: {actual_bytes} bytes exceeds limit of {max_bytes} bytes"
67                ),
68                code: "file_too_large".to_string(),
69            }]),
70            StorageError::InvalidMimeType { mime_type, allowed } => {
71                shaperail_core::ShaperailError::Validation(vec![shaperail_core::FieldError {
72                    field: "file".to_string(),
73                    message: format!("Invalid MIME type '{mime_type}', allowed: {allowed:?}"),
74                    code: "invalid_mime_type".to_string(),
75                }])
76            }
77            StorageError::Backend(msg) => shaperail_core::ShaperailError::Internal(msg),
78        }
79    }
80}
81
82/// Storage backend selected via `SHAPERAIL_STORAGE_BACKEND` env var.
83///
84/// Uses enum dispatch to avoid async trait object complexity.
85pub enum StorageBackend {
86    Local(super::LocalStorage),
87    S3(super::S3Storage),
88    Gcs(super::GcsStorage),
89    Azure(super::AzureStorage),
90}
91
92impl StorageBackend {
93    /// Create a storage backend from an explicit backend name.
94    pub fn from_name(name: &str) -> Result<Self, StorageError> {
95        match name {
96            "local" => Ok(Self::Local(super::LocalStorage::from_env())),
97            "s3" => super::S3Storage::from_env().map(Self::S3),
98            "gcs" => super::GcsStorage::from_env().map(Self::Gcs),
99            "azure" => super::AzureStorage::from_env().map(Self::Azure),
100            other => Err(StorageError::Backend(format!(
101                "Unknown storage backend: '{other}'. Supported: local, s3, gcs, azure"
102            ))),
103        }
104    }
105
106    /// Create a storage backend from the `SHAPERAIL_STORAGE_BACKEND` env var.
107    ///
108    /// Supported values: `local`, `s3`, `gcs`, `azure`.
109    /// Defaults to `local` if not set.
110    pub fn from_env() -> Result<Self, StorageError> {
111        let backend =
112            std::env::var("SHAPERAIL_STORAGE_BACKEND").unwrap_or_else(|_| "local".to_string());
113        Self::from_name(&backend)
114    }
115
116    /// Upload file data to storage under the given `path`.
117    pub async fn upload(
118        &self,
119        path: &str,
120        data: &[u8],
121        mime_type: &str,
122    ) -> Result<FileMetadata, StorageError> {
123        match self {
124            Self::Local(s) => s.upload(path, data, mime_type).await,
125            Self::S3(s) => s.upload(path, data, mime_type).await,
126            Self::Gcs(s) => s.upload(path, data, mime_type).await,
127            Self::Azure(s) => s.upload(path, data, mime_type).await,
128        }
129    }
130
131    /// Download file data from storage at the given `path`.
132    pub async fn download(&self, path: &str) -> Result<Vec<u8>, StorageError> {
133        match self {
134            Self::Local(s) => s.download(path).await,
135            Self::S3(s) => s.download(path).await,
136            Self::Gcs(s) => s.download(path).await,
137            Self::Azure(s) => s.download(path).await,
138        }
139    }
140
141    /// Delete a file from storage at the given `path`.
142    pub async fn delete(&self, path: &str) -> Result<(), StorageError> {
143        match self {
144            Self::Local(s) => s.delete(path).await,
145            Self::S3(s) => s.delete(path).await,
146            Self::Gcs(s) => s.delete(path).await,
147            Self::Azure(s) => s.delete(path).await,
148        }
149    }
150
151    /// Generate a time-limited signed URL for downloading the file.
152    pub async fn signed_url(&self, path: &str, expires_secs: u64) -> Result<String, StorageError> {
153        match self {
154            Self::Local(s) => s.signed_url(path, expires_secs).await,
155            Self::S3(s) => s.signed_url(path, expires_secs).await,
156            Self::Gcs(s) => s.signed_url(path, expires_secs).await,
157            Self::Azure(s) => s.signed_url(path, expires_secs).await,
158        }
159    }
160}