Skip to main content

fraiseql_server/server_config/
storage.rs

1//! Resolution of `[storage.<name>]` config sections into the storage runtime
2//! types.
3//!
4//! [`resolve_storage_section`] validates the configured storage section(s) and
5//! maps the single supported section into the
6//! [`fraiseql_storage::config::StorageConfig`] (backend connection) and
7//! [`fraiseql_storage::config::BucketConfig`] (logical-bucket policy) the storage
8//! runtime needs. It is pure and IO-free so it can be unit-tested without a
9//! database; the actual backend construction and metadata wiring happen in the
10//! binary boot path.
11
12use std::{collections::HashMap, sync::Arc};
13
14use fraiseql_storage::{
15    StorageMetadataRepo, StorageRlsEvaluator, StorageState,
16    config::{BucketAccess, BucketConfig, StorageConfig},
17};
18use sqlx::postgres::PgPoolOptions;
19
20use super::{ServerConfig, StorageSectionConfig};
21
22/// Maximum size of the dedicated connection pool used for storage object
23/// metadata. Storage is metadata-light (one row per object operation), so a
24/// small pool is sufficient and keeps startup cheap.
25const STORAGE_METADATA_POOL_MAX: u32 = 5;
26
27/// A `[storage.<name>]` section resolved into the types the storage runtime
28/// needs.
29#[derive(Debug, Clone)]
30pub struct ResolvedStorage {
31    /// Backend connection config passed to `fraiseql_storage::create_backend`.
32    pub backend: StorageConfig,
33    /// Logical-bucket access policy. `bucket.name` is the section key and the
34    /// bucket name used in the URL path.
35    pub bucket:  BucketConfig,
36}
37
38/// Resolve the configured storage section into a [`ResolvedStorage`].
39///
40/// Returns `Ok(None)` when no `[storage.<name>]` section is configured.
41///
42/// # Errors
43///
44/// Returns an error message when:
45/// - more than one `[storage.<name>]` section is configured (the binary currently supports a single
46///   backend), or
47/// - a section's `access` value is not `"private"` or `"public_read"`.
48pub fn resolve_storage_section(config: &ServerConfig) -> Result<Option<ResolvedStorage>, String> {
49    resolve_from_map(&config.storage)
50}
51
52fn resolve_from_map(
53    storage: &HashMap<String, StorageSectionConfig>,
54) -> Result<Option<ResolvedStorage>, String> {
55    // 0 sections → None; exactly 1 → resolve; >1 → error. Iterating once handles
56    // all three without an `unwrap`/`expect` on the single-element case.
57    let mut iter = storage.iter();
58    let Some((name, section)) = iter.next() else {
59        return Ok(None);
60    };
61    if iter.next().is_some() {
62        let mut names: Vec<&str> = storage.keys().map(String::as_str).collect();
63        names.sort_unstable();
64        return Err(format!(
65            "multiple [storage.<name>] sections configured ({}); the fraiseql-server binary \
66             currently supports a single storage backend — configure exactly one [storage.<name>].",
67            names.join(", "),
68        ));
69    }
70
71    let access = parse_access(section.access.as_deref())?;
72
73    let backend = StorageConfig {
74        backend:      section.backend.clone(),
75        path:         section.path.clone(),
76        bucket:       section.bucket.clone(),
77        region:       section.region.clone(),
78        endpoint:     section.endpoint.clone(),
79        project_id:   section.project_id.clone(),
80        account_name: section.account_name.clone(),
81    };
82
83    let bucket = BucketConfig {
84        name: name.clone(),
85        max_object_bytes: section.max_object_bytes,
86        allowed_mime_types: section.allowed_mime_types.clone(),
87        access,
88        transform_presets: None,
89        serve_inline: section.serve_inline.unwrap_or(false),
90    };
91
92    Ok(Some(ResolvedStorage { backend, bucket }))
93}
94
95/// Build the storage runtime [`StorageState`] from the configured
96/// `[storage.<name>]` section, or `Ok(None)` when storage is not configured.
97///
98/// Connects a small dedicated PostgreSQL pool from `config.database_url`,
99/// ensures the object-metadata table exists (idempotent DDL), constructs the
100/// backend, and assembles the state. Object storage via the binary is
101/// PostgreSQL-only because [`StorageMetadataRepo`] requires a `sqlx::PgPool`.
102///
103/// # Errors
104///
105/// Returns an error message when the storage section is invalid (see
106/// [`resolve_storage_section`]), the metadata database cannot be reached, the
107/// metadata table cannot be created, or the backend cannot be constructed (for
108/// example, a backend whose Cargo feature is not compiled in).
109pub async fn build_storage_state(config: &ServerConfig) -> Result<Option<StorageState>, String> {
110    let Some(resolved) = resolve_storage_section(config)? else {
111        return Ok(None);
112    };
113    let bucket_name = resolved.bucket.name.clone();
114
115    let pool = PgPoolOptions::new()
116        .max_connections(STORAGE_METADATA_POOL_MAX)
117        .connect(&config.database_url)
118        .await
119        .map_err(|e| {
120            format!("storage: failed to connect to PostgreSQL for object metadata: {e}")
121        })?;
122
123    sqlx::raw_sql(fraiseql_storage::migrations::storage_migration_sql())
124        .execute(&pool)
125        .await
126        .map_err(|e| format!("storage: failed to ensure the object-metadata table exists: {e}"))?;
127
128    let backend = fraiseql_storage::create_backend(&resolved.backend).await.map_err(|e| {
129        format!("storage: failed to create backend for bucket '{bucket_name}': {e}")
130    })?;
131
132    let mut buckets = HashMap::new();
133    buckets.insert(bucket_name, resolved.bucket);
134
135    Ok(Some(StorageState {
136        backend:  Arc::new(backend),
137        metadata: Arc::new(StorageMetadataRepo::new(pool)),
138        rls:      StorageRlsEvaluator::new(),
139        buckets:  Arc::new(buckets),
140    }))
141}
142
143/// Parse the optional per-bucket `access` policy. Defaults to the secure
144/// [`BucketAccess::Private`] policy when unset.
145fn parse_access(access: Option<&str>) -> Result<BucketAccess, String> {
146    let Some(value) = access else {
147        return Ok(BucketAccess::Private);
148    };
149    match value.to_ascii_lowercase().as_str() {
150        "private" => Ok(BucketAccess::Private),
151        "public_read" | "public-read" => Ok(BucketAccess::PublicRead),
152        other => Err(format!(
153            "invalid storage access policy {other:?}; expected \"private\" or \"public_read\""
154        )),
155    }
156}