use std::path::{Path, PathBuf};
use std::time::Duration;
use autumn_web::config::Env;
use serde::{Deserialize, Serialize};
pub const DEFAULT_SECTION: &str = "clickhouse";
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct ConfigError(pub(crate) String);
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct ClickHouseConfig {
pub url: String,
pub database: String,
pub user: String,
pub password: String,
pub timeout_ms: u64,
pub health_check: bool,
pub inserter_max_rows: u64,
pub inserter_max_bytes: u64,
pub inserter_period_secs: u64,
}
impl Default for ClickHouseConfig {
fn default() -> Self {
Self {
url: "http://localhost:8123".to_owned(),
database: "default".to_owned(),
user: "default".to_owned(),
password: String::new(),
timeout_ms: 5_000,
health_check: true,
inserter_max_rows: 100_000,
inserter_max_bytes: 16 * 1024 * 1024,
inserter_period_secs: 5,
}
}
}
impl std::fmt::Debug for ClickHouseConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClickHouseConfig")
.field("url", &self.url)
.field("database", &self.database)
.field("user", &self.user)
.field("password", &"<redacted>")
.field("timeout_ms", &self.timeout_ms)
.field("health_check", &self.health_check)
.field("inserter_max_rows", &self.inserter_max_rows)
.field("inserter_max_bytes", &self.inserter_max_bytes)
.field("inserter_period_secs", &self.inserter_period_secs)
.finish()
}
}
#[derive(Clone, Copy)]
enum Kind {
Text,
Unsigned,
Bool,
}
const LEAVES: &[(&str, Kind)] = &[
("url", Kind::Text),
("database", Kind::Text),
("user", Kind::Text),
("password", Kind::Text),
("timeout_ms", Kind::Unsigned),
("health_check", Kind::Bool),
("inserter_max_rows", Kind::Unsigned),
("inserter_max_bytes", Kind::Unsigned),
("inserter_period_secs", Kind::Unsigned),
];
const MAX_TIMEOUT_MS: u64 = 86_400_000;
const MAX_BATCH_ROWS: u64 = 10_000_000;
const MAX_BATCH_BYTES: u64 = 1 << 30;
const MAX_PERIOD_SECS: u64 = 86_400;
impl ClickHouseConfig {
pub fn resolve(section: &str) -> Result<Self, ConfigError> {
autumn_web::dotenv::os_env_with_dotenv().map_or_else(
|_| Self::resolve_with_env(section, &autumn_web::config::OsEnv),
|env| Self::resolve_with_env(section, &env),
)
}
pub fn resolve_with_env(section: &str, env: &dyn Env) -> Result<Self, ConfigError> {
let (selected, profile) = active_profile(env);
let mut merged = toml::Table::new();
if let Some(base) = read_toml(&config_file("autumn.toml", env))? {
merge_section(&mut merged, base.get(section), section)?;
for name in inline_profile_names(&profile) {
let inline = base
.get("profile")
.and_then(|p| p.get(name))
.and_then(|p| p.get(section));
merge_section(&mut merged, inline, section)?;
}
}
for name in autumn_web::config::profile_override_file_lookup_names(&profile, &selected) {
if let Some(file) = read_toml(&config_file(&format!("autumn-{name}.toml"), env))? {
merge_section(&mut merged, file.get(section), section)?;
break;
}
}
apply_env(&mut merged, section, env)?;
let config: Self = toml::Value::Table(merged)
.try_into()
.map_err(|err| ConfigError(format!("[{section}]: {err}")))?;
config.validate_section(section)?;
Ok(config)
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.validate_section(DEFAULT_SECTION)
}
pub(crate) fn validate_section(&self, section: &str) -> Result<(), ConfigError> {
let fail = |key: &str, rule: &str| Err(ConfigError(format!("{section}.{key} {rule}")));
if self.url.trim().is_empty() {
return fail("url", "must be an `http://` or `https://` URL");
}
if self.url.trim() != self.url {
return fail("url", "must not start or end with white space");
}
if !self.url_is_http() {
return fail("url", "must be an `http://` or `https://` URL");
}
if self.database.trim().is_empty() {
return fail("database", "must not be empty");
}
if self.database.trim() != self.database {
return fail("database", "must not start or end with white space");
}
if self.user.trim().is_empty() {
return fail("user", "must not be empty");
}
if !(1..=MAX_TIMEOUT_MS).contains(&self.timeout_ms) {
return fail("timeout_ms", "must be from 1 to 86400000 (one day)");
}
if !(1..=MAX_BATCH_ROWS).contains(&self.inserter_max_rows) {
return fail("inserter_max_rows", "must be from 1 to 10000000");
}
if !(1..=MAX_BATCH_BYTES).contains(&self.inserter_max_bytes) {
return fail("inserter_max_bytes", "must be from 1 to 1073741824 (1 GiB)");
}
if !(1..=MAX_PERIOD_SECS).contains(&self.inserter_period_secs) {
return fail("inserter_period_secs", "must be from 1 to 86400 (one day)");
}
Ok(())
}
#[must_use]
pub fn url_is_http(&self) -> bool {
let lower = self.url.trim().to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
#[must_use]
pub const fn timeout(&self) -> Duration {
Duration::from_millis(self.timeout_ms)
}
#[must_use]
pub const fn inserter_period(&self) -> Duration {
Duration::from_secs(self.inserter_period_secs)
}
}
fn active_profile(env: &dyn Env) -> (String, String) {
let selected = ["AUTUMN_ENV", "AUTUMN_PROFILE"]
.iter()
.filter_map(|key| env.var(key).ok())
.map(|value| value.trim().to_owned())
.find(|value| !value.is_empty())
.unwrap_or_else(|| {
let release = env.var("AUTUMN_IS_DEBUG").is_ok_and(|v| v == "0");
if release { "prod" } else { "dev" }.to_owned()
});
let profile =
autumn_web::config::normalize_profile_name(&selected).unwrap_or_else(|| "dev".to_owned());
(selected, profile)
}
fn inline_profile_names(profile: &str) -> Vec<&str> {
match profile {
"prod" => vec!["production", "prod"],
"dev" => vec!["development", "dev"],
other => vec![other],
}
}
fn config_file(name: &str, env: &dyn Env) -> PathBuf {
env.var("AUTUMN_MANIFEST_DIR")
.ok()
.map(|dir| Path::new(&dir).join(name))
.filter(|path| path.exists())
.unwrap_or_else(|| PathBuf::from(name))
}
fn read_toml(path: &Path) -> Result<Option<toml::Table>, ConfigError> {
match std::fs::read_to_string(path) {
Ok(text) => text
.parse::<toml::Table>()
.map(Some)
.map_err(|err| ConfigError(format!("{}: {err}", path.display()))),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(ConfigError(format!("{}: {err}", path.display()))),
}
}
fn merge_section(
into: &mut toml::Table,
layer: Option<&toml::Value>,
section: &str,
) -> Result<(), ConfigError> {
match layer {
None => Ok(()),
Some(toml::Value::Table(table)) => {
deep_merge(into, table);
Ok(())
}
Some(_) => Err(ConfigError(format!("[{section}] must be a table"))),
}
}
fn deep_merge(into: &mut toml::Table, layer: &toml::Table) {
for (key, value) in layer {
match (into.get_mut(key), value) {
(Some(toml::Value::Table(old)), toml::Value::Table(new)) => deep_merge(old, new),
_ => {
into.insert(key.clone(), value.clone());
}
}
}
}
fn apply_env(into: &mut toml::Table, section: &str, env: &dyn Env) -> Result<(), ConfigError> {
let name: String = section
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect();
for (key, kind) in LEAVES {
let name = format!("AUTUMN_{name}__{}", key.to_ascii_uppercase());
let Ok(raw) = env.var(&name) else {
continue;
};
let bad = || ConfigError(format!("{name}: can not read {raw:?}"));
let value = match kind {
Kind::Text => toml::Value::String(raw.clone()),
Kind::Unsigned => {
let value: i64 = raw.trim().parse().map_err(|_| bad())?;
if value < 0 {
return Err(bad());
}
toml::Value::Integer(value)
}
Kind::Bool => match raw.trim() {
"true" | "1" => toml::Value::Boolean(true),
"false" | "0" => toml::Value::Boolean(false),
_ => return Err(bad()),
},
};
into.insert((*key).to_owned(), value);
}
Ok(())
}
#[cfg(test)]
mod tests;