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}