Skip to main content

doido_storage/
config.rs

1//! Per-environment storage configuration, loaded from the `storage` section of
2//! `config/<env>.yml` — the analogue of Rails' `config/storage.yml` plus
3//! `config.active_storage.service`.
4//!
5//! ```yaml
6//! storage:
7//!   service: local                 # the active service for this environment
8//!   services:
9//!     local:  { type: disk, root: storage }
10//!     test:   { type: memory }
11//!     amazon: { type: s3, bucket: my-bucket, region: us-east-1 }
12//!     cloudflare:
13//!       type: r2
14//!       bucket: my-bucket
15//!       endpoint: "https://<accountid>.r2.cloudflarestorage.com"
16//!     azure:  { type: azure, container: my-container, account: my-account }
17//! ```
18//!
19//! [`StorageConfig::build`] turns the selected service into a live
20//! `Arc<dyn Service>`. `disk` and `memory` are always available; the `s3`/`r2`
21//! and `azure` backends are behind the `storage-s3` / `storage-azure` cargo
22//! features and produce a clear error when selected without their feature.
23
24use crate::error::StorageError;
25use crate::providers::disk::DiskService;
26use crate::providers::memory::MemoryService;
27use crate::service::Service;
28use doido_core::Result;
29use serde::{Deserialize, Deserializer};
30use std::collections::HashMap;
31use std::sync::Arc;
32
33/// Which storage backend a named service uses.
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub enum ServiceBackend {
36    /// Local filesystem (the default).
37    #[default]
38    Disk,
39    /// In-process memory (dev/test).
40    Memory,
41    /// AWS S3 (or any S3-compatible endpoint). Requires `storage-s3`.
42    S3,
43    /// Cloudflare R2 (S3-compatible). Requires `storage-s3`.
44    R2,
45    /// Azure Blob Storage. Requires `storage-azure`.
46    Azure,
47    /// Google Cloud Storage. Requires `storage-gcs`.
48    Gcs,
49    /// A custom adapter registered via [`crate::register_adapter`], keyed by the
50    /// unrecognized `type` string.
51    Custom(String),
52}
53
54impl ServiceBackend {
55    /// Map a `type` string to a backend, treating anything unrecognized as a
56    /// custom adapter kind.
57    fn from_kind(kind: &str) -> Self {
58        match kind {
59            "disk" => ServiceBackend::Disk,
60            "memory" => ServiceBackend::Memory,
61            "s3" => ServiceBackend::S3,
62            "r2" => ServiceBackend::R2,
63            "azure" => ServiceBackend::Azure,
64            "gcs" | "google" => ServiceBackend::Gcs,
65            other => ServiceBackend::Custom(other.to_string()),
66        }
67    }
68}
69
70impl<'de> Deserialize<'de> for ServiceBackend {
71    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
72        let kind = String::deserialize(deserializer)?;
73        Ok(ServiceBackend::from_kind(&kind))
74    }
75}
76
77/// One named service's settings. Fields are shared across backends; each backend
78/// reads the ones it needs.
79#[derive(Debug, Clone, Default, Deserialize)]
80pub struct ServiceConfig {
81    /// Backend kind. YAML key is `type`.
82    #[serde(default, rename = "type")]
83    pub backend: ServiceBackend,
84    /// Whether objects are publicly readable (affects URL generation).
85    #[serde(default)]
86    pub public: bool,
87
88    /// Disk: root directory (default `storage`).
89    #[serde(default)]
90    pub root: Option<String>,
91
92    /// S3/R2: bucket name.
93    #[serde(default)]
94    pub bucket: Option<String>,
95    /// S3/R2: region (R2 uses `auto`).
96    #[serde(default)]
97    pub region: Option<String>,
98    /// S3/R2: custom endpoint (required for R2 and S3-compatible stores).
99    #[serde(default)]
100    pub endpoint: Option<String>,
101    /// S3/R2: access key id (else read from the environment).
102    #[serde(default)]
103    pub access_key_id: Option<String>,
104    /// S3/R2: secret access key (else read from the environment).
105    #[serde(default)]
106    pub secret_access_key: Option<String>,
107
108    /// Azure: container name.
109    #[serde(default)]
110    pub container: Option<String>,
111    /// Azure: storage account name.
112    #[serde(default)]
113    pub account: Option<String>,
114    /// Azure: account access key (else read from the environment).
115    #[serde(default)]
116    pub access_key: Option<String>,
117
118    /// Any extra keys not matched above — available to custom adapters (e.g. a
119    /// `token` or `api_url` for an external service).
120    #[serde(flatten)]
121    pub options: HashMap<String, serde_norway::Value>,
122}
123
124impl ServiceConfig {
125    /// A custom option read as a string, e.g. `cfg.option_str("token")`.
126    pub fn option_str(&self, key: &str) -> Option<&str> {
127        self.options.get(key).and_then(|v| v.as_str())
128    }
129
130    /// Build this service as a live `Arc<dyn Service>` named `name`.
131    pub async fn build(&self, name: &str) -> Result<Arc<dyn Service>> {
132        match &self.backend {
133            ServiceBackend::Disk => {
134                let root = self.root.clone().unwrap_or_else(|| "storage".to_string());
135                Ok(Arc::new(DiskService::new(name, root).public(self.public)))
136            }
137            ServiceBackend::Memory => Ok(Arc::new(MemoryService::new(name))),
138            ServiceBackend::S3 => self.build_s3(name, false).await,
139            ServiceBackend::R2 => self.build_s3(name, true).await,
140            ServiceBackend::Azure => self.build_azure(name).await,
141            ServiceBackend::Gcs => self.build_gcs(name).await,
142            ServiceBackend::Custom(kind) => crate::registry::build_adapter(kind, name, self),
143        }
144    }
145
146    #[cfg(feature = "storage-s3")]
147    async fn build_s3(&self, name: &str, r2: bool) -> Result<Arc<dyn Service>> {
148        Ok(Arc::new(crate::providers::s3::S3Service::connect(
149            name, self, r2,
150        )?))
151    }
152
153    #[cfg(not(feature = "storage-s3"))]
154    async fn build_s3(&self, _name: &str, r2: bool) -> Result<Arc<dyn Service>> {
155        let kind = if r2 { "r2" } else { "s3" };
156        Err(StorageError::Config(format!(
157            "storage backend '{kind}' selected in config but doido-storage was built \
158             without the `storage-s3` feature"
159        ))
160        .into())
161    }
162
163    #[cfg(feature = "storage-azure")]
164    async fn build_azure(&self, name: &str) -> Result<Arc<dyn Service>> {
165        Ok(Arc::new(
166            crate::providers::azure::AzureBlobService::connect(name, self)?,
167        ))
168    }
169
170    #[cfg(not(feature = "storage-azure"))]
171    async fn build_azure(&self, _name: &str) -> Result<Arc<dyn Service>> {
172        Err(StorageError::Config(
173            "storage backend 'azure' selected in config but doido-storage was built \
174             without the `storage-azure` feature"
175                .to_string(),
176        )
177        .into())
178    }
179
180    #[cfg(feature = "storage-gcs")]
181    async fn build_gcs(&self, name: &str) -> Result<Arc<dyn Service>> {
182        Ok(Arc::new(
183            crate::providers::gcs::GcsService::connect(name, self).await?,
184        ))
185    }
186
187    #[cfg(not(feature = "storage-gcs"))]
188    async fn build_gcs(&self, _name: &str) -> Result<Arc<dyn Service>> {
189        Err(StorageError::Config(
190            "storage backend 'gcs' selected in config but doido-storage was built \
191             without the `storage-gcs` feature"
192                .to_string(),
193        )
194        .into())
195    }
196}
197
198/// The `storage` config section: named services plus the selected one.
199#[derive(Debug, Clone, Default, Deserialize)]
200pub struct StorageConfig {
201    /// Name of the active service. Defaults to the first entry (or a disk service
202    /// rooted at `storage` when no services are configured).
203    #[serde(default)]
204    pub service: Option<String>,
205    /// The named services.
206    #[serde(default)]
207    pub services: HashMap<String, ServiceConfig>,
208}
209
210impl StorageConfig {
211    /// Build the selected service. With no configuration at all, defaults to a
212    /// disk service named `local` rooted at `storage`.
213    pub async fn build(&self) -> Result<Arc<dyn Service>> {
214        if self.services.is_empty() {
215            return Ok(Arc::new(DiskService::new("local", "storage")));
216        }
217        let name = self
218            .service
219            .clone()
220            .or_else(|| self.services.keys().next().cloned())
221            .ok_or_else(|| StorageError::Config("no storage service selected".to_string()))?;
222        self.build_named(&name).await
223    }
224
225    /// Build a specific named service.
226    pub async fn build_named(&self, name: &str) -> Result<Arc<dyn Service>> {
227        let cfg = self
228            .services
229            .get(name)
230            .ok_or_else(|| StorageError::Config(format!("unknown storage service {name:?}")))?;
231        cfg.build(name).await
232    }
233}
234
235/// File shape for `config/<env>.yml`; only the `storage` section is read.
236#[derive(Debug, Clone, Default, Deserialize)]
237pub struct YamlConfig {
238    #[serde(default)]
239    pub storage: StorageConfig,
240}
241
242impl YamlConfig {
243    /// Load `config/<env>.yml` for the current environment.
244    pub fn load() -> std::io::Result<Self> {
245        Self::load_env(doido_core::Environment::get_env())
246    }
247
248    /// Load `config/<env>.yml` for a specific environment.
249    pub fn load_env(env: doido_core::Environment) -> std::io::Result<Self> {
250        let path = format!("config/{}.yml", env.as_str());
251        let contents = std::fs::read_to_string(&path)?;
252        Self::from_yaml(&contents)
253    }
254
255    /// Parse a [`YamlConfig`] from a YAML string.
256    pub fn from_yaml(yaml: &str) -> std::io::Result<Self> {
257        serde_norway::from_str(yaml)
258            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
259    }
260}
261
262/// Load the current environment's [`StorageConfig`], falling back to the default
263/// (disk) when the file is missing or has no `storage` section.
264pub fn load() -> StorageConfig {
265    YamlConfig::load().map(|c| c.storage).unwrap_or_default()
266}