Skip to main content

coil_config/
platform.rs

1use std::collections::BTreeMap;
2use std::env;
3use std::fs;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7use url::Url;
8
9use crate::{
10    AppConfig, AssetsConfig, CacheConfig, ConfigValidationErrors, DatabaseConfig, HttpConfig,
11    JobsConfig, ObservabilityConfig, SecretRef, ServerConfig, StorageConfig, TlsConfig,
12};
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct PlatformConfig {
16    pub app: AppConfig,
17    pub server: ServerConfig,
18    pub http: HttpConfig,
19    pub tls: TlsConfig,
20    #[serde(default)]
21    pub database: DatabaseConfig,
22    pub storage: StorageConfig,
23    pub cache: CacheConfig,
24    #[serde(default)]
25    pub i18n: I18nConfig,
26    #[serde(default)]
27    pub seo: SeoConfig,
28    #[serde(default)]
29    pub sites: Vec<SiteConfig>,
30    pub auth: AuthConfig,
31    pub modules: ModulesConfig,
32    pub wasm: WasmConfig,
33    pub jobs: JobsConfig,
34    pub observability: ObservabilityConfig,
35    pub assets: AssetsConfig,
36}
37
38impl PlatformConfig {
39    pub fn from_toml_str(input: &str) -> Result<Self, ConfigError> {
40        Self::from_toml_str_with_overlays(input, std::iter::empty::<&str>())
41    }
42
43    pub fn from_toml_str_with_overlays<'a>(
44        input: &str,
45        overlays: impl IntoIterator<Item = &'a str>,
46    ) -> Result<Self, ConfigError> {
47        let mut merged: toml::Value = toml::from_str(input)?;
48
49        for overlay in overlays {
50            let overlay_value: toml::Value = toml::from_str(overlay)?;
51            merge_toml_value(&mut merged, overlay_value);
52        }
53
54        let config: Self = merged.try_into()?;
55        config.validate()?;
56        Ok(config)
57    }
58
59    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
60        let contents = fs::read_to_string(path).map_err(ConfigError::Io)?;
61        Self::from_toml_str(&contents)
62    }
63
64    pub fn from_toml_str_with_env_overlays(
65        input: &str,
66        env_vars: &[&str],
67    ) -> Result<Self, ConfigError> {
68        let overlays = env_vars
69            .iter()
70            .filter_map(|var| env::var(var).ok())
71            .collect::<Vec<_>>();
72
73        Self::from_toml_str_with_overlays(input, overlays.iter().map(String::as_str))
74    }
75
76    pub fn render_effective_toml(&self) -> Result<String, toml::ser::Error> {
77        toml::to_string_pretty(self)
78    }
79
80    pub fn site_for_host(&self, host: &str) -> Option<&SiteConfig> {
81        let host = normalize_host(host);
82        self.sites.iter().find(|site| {
83            normalize_host(&site.canonical_host) == host
84                || site.hosts.iter().any(|value| normalize_host(value) == host)
85        })
86    }
87
88    pub fn site_for_id(&self, site_id: &str) -> Option<&SiteConfig> {
89        self.sites.iter().find(|site| site.id == site_id)
90    }
91
92    pub fn default_site(&self) -> Option<&SiteConfig> {
93        self.sites.first()
94    }
95
96    pub fn canonical_host_for_site(&self, site_id: Option<&str>) -> &str {
97        site_id
98            .and_then(|site_id| self.site_for_id(site_id))
99            .or_else(|| self.default_site())
100            .map(|site| site.canonical_host.as_str())
101            .unwrap_or(self.seo.canonical_host.as_str())
102    }
103
104    pub fn default_locale_for_site(&self, site_id: Option<&str>) -> &str {
105        site_id
106            .and_then(|site_id| self.site_for_id(site_id))
107            .or_else(|| self.default_site())
108            .map(|site| site.default_locale.as_str())
109            .unwrap_or(self.i18n.default_locale.as_str())
110    }
111
112    pub fn supported_locales_for_site(&self, site_id: Option<&str>) -> &[String] {
113        site_id
114            .and_then(|site_id| self.site_for_id(site_id))
115            .or_else(|| self.default_site())
116            .map(|site| site.supported_locales.as_slice())
117            .unwrap_or(self.i18n.supported_locales.as_slice())
118    }
119
120    pub fn localized_routes_for_site(&self, site_id: Option<&str>) -> bool {
121        site_id
122            .and_then(|site_id| self.site_for_id(site_id))
123            .or_else(|| self.default_site())
124            .and_then(|site| site.localized_routes)
125            .unwrap_or(self.i18n.localized_routes)
126    }
127}
128
129fn normalize_host(host: &str) -> &str {
130    if let Some(stripped) = host.strip_prefix('[') {
131        if let Some(end) = stripped.find(']') {
132            return &stripped[..end];
133        }
134    }
135
136    match host.rsplit_once(':') {
137        Some((candidate, port))
138            if !candidate.contains(':')
139                && !candidate.is_empty()
140                && port.chars().all(|ch| ch.is_ascii_digit()) =>
141        {
142            candidate
143        }
144        _ => host,
145    }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
149pub struct I18nConfig {
150    #[serde(default)]
151    pub default_locale: String,
152    #[serde(default)]
153    pub supported_locales: Vec<String>,
154    #[serde(default)]
155    pub fallback_locale: String,
156    #[serde(default)]
157    pub localized_routes: bool,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161pub struct SeoConfig {
162    pub canonical_host: String,
163    pub emit_json_ld: bool,
164    #[serde(default = "default_sitemap_enabled")]
165    pub sitemap_enabled: bool,
166}
167
168impl Default for SeoConfig {
169    fn default() -> Self {
170        Self {
171            canonical_host: String::new(),
172            emit_json_ld: false,
173            sitemap_enabled: default_sitemap_enabled(),
174        }
175    }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
179pub struct SiteConfig {
180    pub id: String,
181    pub display_name: String,
182    #[serde(default)]
183    pub brand_name: Option<String>,
184    pub canonical_host: String,
185    #[serde(default)]
186    pub hosts: Vec<String>,
187    pub default_locale: String,
188    pub supported_locales: Vec<String>,
189    #[serde(default)]
190    pub localized_routes: Option<bool>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
194pub struct AuthConfig {
195    pub package: String,
196    pub explain_api: bool,
197    pub tenant_id: i64,
198    #[serde(default)]
199    pub tuple_store_secret: Option<crate::SecretRef>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
203pub struct ModulesConfig {
204    pub enabled: Vec<String>,
205    #[serde(flatten, default)]
206    pub settings: toml::Table,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
210pub struct WasmConfig {
211    pub directory: String,
212    pub default_time_limit_ms: u64,
213    pub allow_network: bool,
214    #[serde(default)]
215    pub secret_bindings: BTreeMap<String, SecretRef>,
216    #[serde(default)]
217    pub outbound_http: Vec<WasmOutboundHttpIntegration>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221pub struct WasmOutboundHttpIntegration {
222    pub integration: String,
223    pub endpoint: Url,
224}
225
226#[derive(Debug, thiserror::Error)]
227pub enum ConfigError {
228    #[error("failed to read config file: {0}")]
229    Io(std::io::Error),
230    #[error("failed to parse config: {0}")]
231    Parse(#[from] toml::de::Error),
232    #[error(transparent)]
233    Validation(#[from] ConfigValidationErrors),
234}
235
236fn default_sitemap_enabled() -> bool {
237    true
238}
239
240fn merge_toml_value(base: &mut toml::Value, overlay: toml::Value) {
241    match (base, overlay) {
242        (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
243            for (key, value) in overlay_table {
244                match base_table.get_mut(&key) {
245                    Some(existing) => merge_toml_value(existing, value),
246                    None => {
247                        base_table.insert(key, value);
248                    }
249                }
250            }
251        }
252        (base_value, overlay_value) => {
253            *base_value = overlay_value;
254        }
255    }
256}