Skip to main content

modo_upload/
config.rs

1use serde::Deserialize;
2
3/// Storage backend selector.
4#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
5#[serde(rename_all = "lowercase")]
6pub enum StorageBackend {
7    /// Local filesystem storage (default).
8    #[default]
9    Local,
10    /// S3-compatible object storage (requires the `opendal` feature).
11    S3,
12}
13
14/// Upload configuration, deserialized from YAML via `modo::config::load()`.
15///
16/// The `s3` field is only available when the `opendal` feature is enabled.
17/// Irrelevant fields are silently ignored for the active backend.
18#[derive(Debug, Clone, Deserialize)]
19#[serde(default)]
20pub struct UploadConfig {
21    /// Which storage backend to use.
22    pub backend: StorageBackend,
23    /// Local directory for file uploads.
24    pub path: String,
25    /// Default max file size when no per-field `#[upload(max_size)]` is set.
26    /// Human-readable: "10mb", "500kb". None disables the default limit.
27    pub max_file_size: Option<String>,
28    /// S3 configuration (only available with the `opendal` feature).
29    #[cfg(feature = "opendal")]
30    pub s3: S3Config,
31}
32
33impl Default for UploadConfig {
34    fn default() -> Self {
35        Self {
36            backend: StorageBackend::default(),
37            path: "./uploads".to_string(),
38            max_file_size: Some("10mb".to_string()),
39            #[cfg(feature = "opendal")]
40            s3: S3Config::default(),
41        }
42    }
43}
44
45impl UploadConfig {
46    /// Validate configuration at startup. Panics if `max_file_size` is set but
47    /// parses to zero or is not a valid size string.
48    ///
49    /// Call this during application startup (e.g., in the storage factory) to
50    /// fail fast rather than discovering bad config at request time.
51    pub fn validate(&self) {
52        if let Some(ref size_str) = self.max_file_size {
53            let bytes = modo::config::parse_size(size_str).unwrap_or_else(|e| {
54                panic!("invalid max_file_size \"{size_str}\": {e}");
55            });
56            assert!(
57                bytes > 0,
58                "max_file_size must be greater than 0, got \"{size_str}\""
59            );
60        }
61    }
62}
63
64/// S3-compatible storage configuration.
65#[cfg(feature = "opendal")]
66#[derive(Debug, Clone, Default, Deserialize)]
67#[serde(default)]
68pub struct S3Config {
69    /// S3 bucket name.
70    pub bucket: String,
71    /// AWS region.
72    pub region: String,
73    /// Custom endpoint URL (for S3-compatible services like MinIO).
74    pub endpoint: String,
75    /// AWS access key ID.
76    pub access_key_id: String,
77    /// AWS secret access key.
78    pub secret_access_key: String,
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn default_config_is_valid() {
87        // Should not panic
88        let config = UploadConfig::default();
89        config.validate();
90    }
91
92    #[test]
93    #[should_panic(expected = "max_file_size")]
94    fn rejects_zero_max_file_size() {
95        let config = UploadConfig {
96            max_file_size: Some("0".to_string()),
97            ..Default::default()
98        };
99        config.validate();
100    }
101
102    #[test]
103    #[should_panic(expected = "max_file_size")]
104    fn rejects_zero_bytes_max_file_size() {
105        let config = UploadConfig {
106            max_file_size: Some("0mb".to_string()),
107            ..Default::default()
108        };
109        config.validate();
110    }
111
112    #[test]
113    #[should_panic(expected = "max_file_size")]
114    fn rejects_unparseable_max_file_size() {
115        let config = UploadConfig {
116            max_file_size: Some("not-a-size".to_string()),
117            ..Default::default()
118        };
119        config.validate();
120    }
121
122    #[test]
123    fn none_max_file_size_is_valid() {
124        let config = UploadConfig {
125            max_file_size: None,
126            ..Default::default()
127        };
128        config.validate();
129    }
130}