fraiseql_server/server_config/
storage.rs1use 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
22const STORAGE_METADATA_POOL_MAX: u32 = 5;
26
27#[derive(Debug, Clone)]
30pub struct ResolvedStorage {
31 pub backend: StorageConfig,
33 pub bucket: BucketConfig,
36}
37
38pub 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 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
95pub 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
143fn 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}