feat: config crate with legacy env names

This commit is contained in:
2026-10-25 21:00:42 +01:00
parent ab4e4c3138
commit 8bdf55cfe1
20 changed files with 1025 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
use serde::Deserialize;
use crate::{
AuthConfig, DatabaseConfig, EventsConfig, FederationConfig, ImageConversionConfig,
MetadataConfig, NatsConfig, ServerConfig, StorageConfig, WrapUpConfig,
};
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
#[serde(default)]
pub server: ServerConfig,
pub database: DatabaseConfig,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub events: EventsConfig,
#[serde(default)]
pub nats: NatsConfig,
#[serde(default)]
pub storage: StorageConfig,
#[serde(default)]
pub image_conversion: ImageConversionConfig,
#[serde(default)]
pub metadata: MetadataConfig,
#[serde(default)]
pub federation: FederationConfig,
#[serde(default)]
pub wrapup: WrapUpConfig,
}

View File

@@ -0,0 +1,38 @@
use std::fmt;
use serde::Deserialize;
use crate::lenient;
#[derive(Clone, Deserialize)]
#[serde(default)]
pub struct AuthConfig {
pub jwt_secret: Option<String>,
#[serde(deserialize_with = "lenient::number")]
pub jwt_ttl_seconds: u64,
#[serde(deserialize_with = "lenient::number")]
pub refresh_ttl_seconds: u64,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
jwt_secret: None,
jwt_ttl_seconds: 900,
refresh_ttl_seconds: 2_592_000,
}
}
}
impl fmt::Debug for AuthConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AuthConfig")
.field(
"jwt_secret",
&self.jwt_secret.as_ref().map(|_| "<redacted>"),
)
.field("jwt_ttl_seconds", &self.jwt_ttl_seconds)
.field("refresh_ttl_seconds", &self.refresh_ttl_seconds)
.finish()
}
}

View File

@@ -0,0 +1,16 @@
use std::fmt;
use serde::Deserialize;
#[derive(Clone, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
}
impl fmt::Debug for DatabaseConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DatabaseConfig")
.field("url", &"<redacted>")
.finish()
}
}

View File

@@ -0,0 +1,11 @@
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("configuration file {0} was named by CONFIG_FILE but does not exist")]
NamedFileMissing(String),
#[error("{0} is required but not set")]
Missing(&'static str),
#[error("invalid value for {variable}: {reason}")]
Invalid { variable: String, reason: String },
#[error("could not read configuration: {0}")]
Unreadable(#[from] Box<figment::Error>),
}

View File

@@ -0,0 +1,29 @@
use serde::Deserialize;
use crate::lenient;
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct EventsConfig {
pub bus_backend: String,
#[serde(deserialize_with = "lenient::number")]
pub channel_buffer: usize,
#[serde(deserialize_with = "lenient::number")]
pub queue_poll_interval_ms: u64,
#[serde(deserialize_with = "lenient::number")]
pub queue_batch_size: i64,
#[serde(deserialize_with = "lenient::number")]
pub queue_max_attempts: i32,
}
impl Default for EventsConfig {
fn default() -> Self {
Self {
bus_backend: "db".into(),
channel_buffer: 128,
queue_poll_interval_ms: 500,
queue_batch_size: 10,
queue_max_attempts: 5,
}
}
}

View File

@@ -0,0 +1,10 @@
use serde::Deserialize;
use crate::lenient;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct FederationConfig {
#[serde(deserialize_with = "lenient::true_or_one")]
pub debug: bool,
}

View File

@@ -0,0 +1,11 @@
use serde::Deserialize;
use crate::lenient;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct ImageConversionConfig {
#[serde(deserialize_with = "lenient::true_only")]
pub enabled: bool,
pub format: Option<String>,
}

View File

@@ -0,0 +1,79 @@
//! Deserializers for values that arrive as strings from the environment but
//! may be native numbers/bools in a TOML file.
use std::fmt;
use std::marker::PhantomData;
use std::str::FromStr;
use serde::Deserializer;
use serde::de::{self, Visitor};
struct Number<T>(PhantomData<T>);
impl<T: FromStr> Visitor<'_> for Number<T> {
type Value = T;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a number")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<T, E> {
v.trim()
.parse()
.map_err(|_| E::custom("expected a number in range"))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<T, E> {
self.visit_str(&v.to_string())
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<T, E> {
self.visit_str(&v.to_string())
}
}
pub fn number<'de, D, T>(d: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: FromStr,
{
d.deserialize_any(Number(PhantomData))
}
struct Flag {
accepts_one: bool,
}
impl Visitor<'_> for Flag {
type Value = bool;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a boolean")
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
Ok(v)
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> {
Ok(v == "true" || (self.accepts_one && v == "1"))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> {
Ok(self.accepts_one && v == 1)
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> {
Ok(self.accepts_one && v == 1)
}
}
/// `true` only for `true` or `1` (anything else is false, like the old readers).
pub fn true_or_one<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
d.deserialize_any(Flag { accepts_one: true })
}
/// `true` only for `true`.
pub fn true_only<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
d.deserialize_any(Flag { accepts_one: false })
}

28
crates/config/src/lib.rs Normal file
View File

@@ -0,0 +1,28 @@
mod app_config;
mod auth_config;
mod database_config;
mod errors;
mod events_config;
mod federation_config;
mod image_conversion_config;
mod lenient;
mod loader;
mod metadata_config;
mod nats_config;
mod server_config;
mod storage_config;
mod wrapup_config;
pub use app_config::AppConfig;
pub use auth_config::AuthConfig;
pub use database_config::DatabaseConfig;
pub use errors::ConfigError;
pub use events_config::EventsConfig;
pub use federation_config::FederationConfig;
pub use image_conversion_config::ImageConversionConfig;
pub use loader::load;
pub use metadata_config::MetadataConfig;
pub use nats_config::NatsConfig;
pub use server_config::ServerConfig;
pub use storage_config::StorageConfig;
pub use wrapup_config::WrapUpConfig;

139
crates/config/src/loader.rs Normal file
View File

@@ -0,0 +1,139 @@
use std::path::Path;
use figment::providers::{Format, Toml};
use figment::value::{Dict, Map, Value};
use figment::{Figment, Metadata, Profile, Provider};
use crate::AppConfig;
use crate::errors::ConfigError;
const DEFAULT_CONFIG_FILE: &str = "config.toml";
const CONFIG_FILE_VARIABLE: &str = "CONFIG_FILE";
/// Every environment variable the application has always honoured, mapped to
/// its `section.field` key. No prefix: the names are the public contract.
const LEGACY_ENV: &[(&str, &str)] = &[
("ALLOW_REGISTRATION", "server.allow_registration"),
("BASE_URL", "server.base_url"),
("CORS_ORIGINS", "server.cors_origins"),
("DATABASE_URL", "database.url"),
("EVENT_BUS_BACKEND", "events.bus_backend"),
("EVENT_CHANNEL_BUFFER", "events.channel_buffer"),
("EVENT_QUEUE_BATCH_SIZE", "events.queue_batch_size"),
("EVENT_QUEUE_MAX_ATTEMPTS", "events.queue_max_attempts"),
(
"EVENT_QUEUE_POLL_INTERVAL_MS",
"events.queue_poll_interval_ms",
),
("FEDERATION_DEBUG", "federation.debug"),
("HOST", "server.host"),
("PORT", "server.port"),
("IMAGE_CONVERSION_ENABLED", "image_conversion.enabled"),
("IMAGE_CONVERSION_FORMAT", "image_conversion.format"),
("IMAGE_STORAGE_BACKEND", "storage.backend"),
("IMAGE_STORAGE_PATH", "storage.path"),
("JWT_SECRET", "auth.jwt_secret"),
("JWT_TTL_SECONDS", "auth.jwt_ttl_seconds"),
("MINIO_ACCESS_KEY_ID", "storage.access_key_id"),
("MINIO_BUCKET", "storage.bucket"),
("MINIO_ENDPOINT", "storage.endpoint"),
("MINIO_REGION", "storage.region"),
("MINIO_SECRET_ACCESS_KEY", "storage.secret_access_key"),
("NATS_CONSUMER_NAME", "nats.consumer_name"),
("NATS_MODE", "nats.mode"),
("NATS_STREAM_NAME", "nats.stream_name"),
("NATS_SUBJECT_PREFIX", "nats.subject_prefix"),
("NATS_URL", "nats.url"),
("OMDB_API_KEY", "metadata.omdb_api_key"),
(
"POSTER_FETCH_TIMEOUT_SECONDS",
"metadata.poster_fetch_timeout_seconds",
),
("RATE_LIMIT", "server.rate_limit"),
("REFRESH_TTL_SECONDS", "auth.refresh_ttl_seconds"),
("SECURE_COOKIES", "server.secure_cookies"),
("TMDB_API_KEY", "metadata.tmdb_api_key"),
("WRAPUP_BG_DIR", "wrapup.bg_dir"),
("WRAPUP_FONT_PATH", "wrapup.font_path"),
("WRAPUP_LOGO_PATH", "wrapup.logo_path"),
];
/// Reads the legacy variables as plain strings; typing is left to the config
/// structs so that e.g. `JWT_SECRET=12345` stays a string.
struct LegacyEnv;
impl Provider for LegacyEnv {
fn metadata(&self) -> Metadata {
Metadata::named("environment variables")
}
fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
let mut sections: Dict = Dict::new();
for (variable, key) in LEGACY_ENV {
let Some(raw) = std::env::var_os(variable) else {
continue;
};
let value = raw
.into_string()
.map_err(|_| figment::Error::from(format!("{variable} is not valid unicode")))?;
let (section, field) = key.split_once('.').expect("legacy keys are section.field");
let entry = sections
.entry(section.to_owned())
.or_insert_with(|| Value::from(Dict::new()));
if let Value::Dict(_, dict) = entry {
dict.insert(field.to_owned(), Value::from(value));
}
}
Ok(Profile::Default.collect(sections))
}
}
pub fn load() -> Result<AppConfig, ConfigError> {
let named_file = std::env::var(CONFIG_FILE_VARIABLE).ok();
let path = named_file.as_deref().unwrap_or(DEFAULT_CONFIG_FILE);
if named_file.is_some() && !Path::new(path).exists() {
return Err(ConfigError::NamedFileMissing(path.to_owned()));
}
let figment = Figment::new().merge(Toml::file(path)).merge(LegacyEnv);
if figment.find_value("database.url").is_err() {
return Err(ConfigError::Missing("DATABASE_URL"));
}
let mut config: AppConfig = figment.extract().map_err(into_config_error)?;
config.server.base_url = config.server.base_url.trim_end_matches('/').to_owned();
announce(path);
Ok(config)
}
fn into_config_error(error: figment::Error) -> ConfigError {
let key = error.path.join(".");
match LEGACY_ENV.iter().find(|(_, k)| *k == key) {
Some((variable, _)) if std::env::var_os(variable).is_some() => ConfigError::Invalid {
variable: (*variable).to_owned(),
reason: error.kind.to_string(),
},
_ => ConfigError::Unreadable(Box::new(error)),
}
}
fn announce(path: &str) {
if Path::new(path).exists() {
tracing::info!(path, "loaded configuration from file");
} else {
tracing::info!("no configuration file found, using defaults");
}
let overriding: Vec<&str> = LEGACY_ENV
.iter()
.map(|(variable, _)| *variable)
.filter(|variable| std::env::var_os(variable).is_some())
.collect();
if !overriding.is_empty() {
tracing::info!(variables = ?overriding, "configuration overridden from the environment");
}
}

View File

@@ -0,0 +1,43 @@
use std::fmt;
use serde::Deserialize;
use crate::lenient;
#[derive(Clone, Deserialize)]
#[serde(default)]
pub struct MetadataConfig {
pub tmdb_api_key: Option<String>,
pub omdb_api_key: Option<String>,
#[serde(deserialize_with = "lenient::number")]
pub poster_fetch_timeout_seconds: u64,
}
impl Default for MetadataConfig {
fn default() -> Self {
Self {
tmdb_api_key: None,
omdb_api_key: None,
poster_fetch_timeout_seconds: 30,
}
}
}
impl fmt::Debug for MetadataConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MetadataConfig")
.field(
"tmdb_api_key",
&self.tmdb_api_key.as_ref().map(|_| "<redacted>"),
)
.field(
"omdb_api_key",
&self.omdb_api_key.as_ref().map(|_| "<redacted>"),
)
.field(
"poster_fetch_timeout_seconds",
&self.poster_fetch_timeout_seconds,
)
.finish()
}
}

View File

@@ -0,0 +1,23 @@
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct NatsConfig {
pub url: Option<String>,
pub mode: String,
pub subject_prefix: String,
pub stream_name: String,
pub consumer_name: String,
}
impl Default for NatsConfig {
fn default() -> Self {
Self {
url: None,
mode: "jetstream".into(),
subject_prefix: "movies-diary.events".into(),
stream_name: "MOVIES_DIARY_EVENTS".into(),
consumer_name: "worker".into(),
}
}
}

View File

@@ -0,0 +1,33 @@
use serde::Deserialize;
use crate::lenient;
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
pub host: String,
#[serde(deserialize_with = "lenient::number")]
pub port: u16,
pub base_url: String,
pub cors_origins: String,
#[serde(deserialize_with = "lenient::true_or_one")]
pub allow_registration: bool,
#[serde(deserialize_with = "lenient::true_only")]
pub secure_cookies: bool,
#[serde(deserialize_with = "lenient::number")]
pub rate_limit: u64,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "0.0.0.0".into(),
port: 3000,
base_url: "http://localhost:3000".into(),
cors_origins: String::new(),
allow_registration: false,
secure_cookies: false,
rate_limit: 60,
}
}
}

View File

@@ -0,0 +1,41 @@
use std::fmt;
use serde::Deserialize;
#[derive(Clone, Deserialize)]
#[serde(default)]
pub struct StorageConfig {
pub backend: String,
pub path: String,
pub endpoint: Option<String>,
pub access_key_id: Option<String>,
pub secret_access_key: Option<String>,
pub bucket: Option<String>,
pub region: String,
}
impl Default for StorageConfig {
fn default() -> Self {
Self {
backend: "local".into(),
path: "./images".into(),
endpoint: None,
access_key_id: None,
secret_access_key: None,
bucket: None,
region: "minio".into(),
}
}
}
impl fmt::Debug for StorageConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StorageConfig")
.field("backend", &self.backend)
.field("path", &self.path)
.field("endpoint", &self.endpoint)
.field("bucket", &self.bucket)
.field("region", &self.region)
.finish_non_exhaustive()
}
}

View File

@@ -0,0 +1,9 @@
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct WrapUpConfig {
pub font_path: Option<String>,
pub logo_path: Option<String>,
pub bg_dir: Option<String>,
}