feat: config crate with legacy env names
This commit is contained in:
13
crates/config/Cargo.toml
Normal file
13
crates/config/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "config"
|
||||
edition.workspace = true
|
||||
version = "0.1.0"
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
figment.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
figment = { workspace = true, features = ["test"] }
|
||||
29
crates/config/src/app_config.rs
Normal file
29
crates/config/src/app_config.rs
Normal 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,
|
||||
}
|
||||
38
crates/config/src/auth_config.rs
Normal file
38
crates/config/src/auth_config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
16
crates/config/src/database_config.rs
Normal file
16
crates/config/src/database_config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
11
crates/config/src/errors.rs
Normal file
11
crates/config/src/errors.rs
Normal 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>),
|
||||
}
|
||||
29
crates/config/src/events_config.rs
Normal file
29
crates/config/src/events_config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
10
crates/config/src/federation_config.rs
Normal file
10
crates/config/src/federation_config.rs
Normal 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,
|
||||
}
|
||||
11
crates/config/src/image_conversion_config.rs
Normal file
11
crates/config/src/image_conversion_config.rs
Normal 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>,
|
||||
}
|
||||
79
crates/config/src/lenient.rs
Normal file
79
crates/config/src/lenient.rs
Normal 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
28
crates/config/src/lib.rs
Normal 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
139
crates/config/src/loader.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
43
crates/config/src/metadata_config.rs
Normal file
43
crates/config/src/metadata_config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
23
crates/config/src/nats_config.rs
Normal file
23
crates/config/src/nats_config.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
33
crates/config/src/server_config.rs
Normal file
33
crates/config/src/server_config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
41
crates/config/src/storage_config.rs
Normal file
41
crates/config/src/storage_config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
9
crates/config/src/wrapup_config.rs
Normal file
9
crates/config/src/wrapup_config.rs
Normal 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>,
|
||||
}
|
||||
379
crates/config/tests/loader_test.rs
Normal file
379
crates/config/tests/loader_test.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
#![allow(clippy::result_large_err)]
|
||||
|
||||
use config::{ConfigError, load};
|
||||
use figment::Jail;
|
||||
|
||||
fn jail_with(jail: &mut Jail, vars: &[(&str, &str)]) {
|
||||
jail.clear_env();
|
||||
jail.set_env("DATABASE_URL", "sqlite://test.db");
|
||||
for (name, value) in vars {
|
||||
jail.set_env(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! env_sets {
|
||||
($test:ident, $var:literal, $value:literal, |$c:ident| $assert:expr) => {
|
||||
#[test]
|
||||
fn $test() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail_with(jail, &[($var, $value)]);
|
||||
let $c = load().expect("config loads");
|
||||
assert!($assert, "{} not applied", $var);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
env_sets!(
|
||||
allow_registration_env_sets_allow_registration,
|
||||
"ALLOW_REGISTRATION",
|
||||
"true",
|
||||
|c| c.server.allow_registration
|
||||
);
|
||||
env_sets!(
|
||||
base_url_env_sets_base_url,
|
||||
"BASE_URL",
|
||||
"https://x.example/",
|
||||
|c| c.server.base_url == "https://x.example"
|
||||
);
|
||||
env_sets!(
|
||||
cors_origins_env_sets_cors_origins,
|
||||
"CORS_ORIGINS",
|
||||
"https://a.example,https://b.example",
|
||||
|c| c.server.cors_origins == "https://a.example,https://b.example"
|
||||
);
|
||||
env_sets!(
|
||||
database_url_env_sets_url,
|
||||
"DATABASE_URL",
|
||||
"sqlite://other.db",
|
||||
|c| c.database.url == "sqlite://other.db"
|
||||
);
|
||||
env_sets!(
|
||||
event_bus_backend_env_sets_bus_backend,
|
||||
"EVENT_BUS_BACKEND",
|
||||
"nats",
|
||||
|c| c.events.bus_backend == "nats"
|
||||
);
|
||||
env_sets!(
|
||||
event_channel_buffer_env_sets_channel_buffer,
|
||||
"EVENT_CHANNEL_BUFFER",
|
||||
"7",
|
||||
|c| c.events.channel_buffer == 7
|
||||
);
|
||||
env_sets!(
|
||||
event_queue_batch_size_env_sets_queue_batch_size,
|
||||
"EVENT_QUEUE_BATCH_SIZE",
|
||||
"11",
|
||||
|c| c.events.queue_batch_size == 11
|
||||
);
|
||||
env_sets!(
|
||||
event_queue_max_attempts_env_sets_queue_max_attempts,
|
||||
"EVENT_QUEUE_MAX_ATTEMPTS",
|
||||
"9",
|
||||
|c| c.events.queue_max_attempts == 9
|
||||
);
|
||||
env_sets!(
|
||||
event_queue_poll_interval_ms_env_sets_queue_poll_interval_ms,
|
||||
"EVENT_QUEUE_POLL_INTERVAL_MS",
|
||||
"250",
|
||||
|c| c.events.queue_poll_interval_ms == 250
|
||||
);
|
||||
env_sets!(
|
||||
federation_debug_env_sets_debug,
|
||||
"FEDERATION_DEBUG",
|
||||
"1",
|
||||
|c| c.federation.debug
|
||||
);
|
||||
env_sets!(host_env_sets_host, "HOST", "127.0.0.1", |c| c.server.host
|
||||
== "127.0.0.1");
|
||||
env_sets!(port_env_sets_port, "PORT", "8080", |c| c.server.port
|
||||
== 8080);
|
||||
env_sets!(
|
||||
image_conversion_enabled_env_sets_enabled,
|
||||
"IMAGE_CONVERSION_ENABLED",
|
||||
"true",
|
||||
|c| c.image_conversion.enabled
|
||||
);
|
||||
env_sets!(
|
||||
image_conversion_format_env_sets_format,
|
||||
"IMAGE_CONVERSION_FORMAT",
|
||||
"webp",
|
||||
|c| c.image_conversion.format.as_deref() == Some("webp")
|
||||
);
|
||||
env_sets!(
|
||||
image_storage_backend_env_sets_backend,
|
||||
"IMAGE_STORAGE_BACKEND",
|
||||
"s3",
|
||||
|c| c.storage.backend == "s3"
|
||||
);
|
||||
env_sets!(
|
||||
image_storage_path_env_sets_path,
|
||||
"IMAGE_STORAGE_PATH",
|
||||
"/data/img",
|
||||
|c| c.storage.path == "/data/img"
|
||||
);
|
||||
env_sets!(
|
||||
jwt_secret_env_sets_jwt_secret,
|
||||
"JWT_SECRET",
|
||||
"s3cret",
|
||||
|c| c.auth.jwt_secret.as_deref() == Some("s3cret")
|
||||
);
|
||||
env_sets!(
|
||||
jwt_ttl_seconds_env_sets_jwt_ttl_seconds,
|
||||
"JWT_TTL_SECONDS",
|
||||
"60",
|
||||
|c| c.auth.jwt_ttl_seconds == 60
|
||||
);
|
||||
env_sets!(
|
||||
minio_access_key_id_env_sets_access_key_id,
|
||||
"MINIO_ACCESS_KEY_ID",
|
||||
"ak",
|
||||
|c| c.storage.access_key_id.as_deref() == Some("ak")
|
||||
);
|
||||
env_sets!(minio_bucket_env_sets_bucket, "MINIO_BUCKET", "b", |c| c
|
||||
.storage
|
||||
.bucket
|
||||
.as_deref()
|
||||
== Some("b"));
|
||||
env_sets!(
|
||||
minio_endpoint_env_sets_endpoint,
|
||||
"MINIO_ENDPOINT",
|
||||
"http://minio:9000",
|
||||
|c| c.storage.endpoint.as_deref() == Some("http://minio:9000")
|
||||
);
|
||||
env_sets!(minio_region_env_sets_region, "MINIO_REGION", "eu", |c| c
|
||||
.storage
|
||||
.region
|
||||
== "eu");
|
||||
env_sets!(
|
||||
minio_secret_access_key_env_sets_secret_access_key,
|
||||
"MINIO_SECRET_ACCESS_KEY",
|
||||
"sk",
|
||||
|c| c.storage.secret_access_key.as_deref() == Some("sk")
|
||||
);
|
||||
env_sets!(
|
||||
nats_consumer_name_env_sets_consumer_name,
|
||||
"NATS_CONSUMER_NAME",
|
||||
"w2",
|
||||
|c| c.nats.consumer_name == "w2"
|
||||
);
|
||||
env_sets!(nats_mode_env_sets_mode, "NATS_MODE", "core", |c| c
|
||||
.nats
|
||||
.mode
|
||||
== "core");
|
||||
env_sets!(
|
||||
nats_stream_name_env_sets_stream_name,
|
||||
"NATS_STREAM_NAME",
|
||||
"S",
|
||||
|c| c.nats.stream_name == "S"
|
||||
);
|
||||
env_sets!(
|
||||
nats_subject_prefix_env_sets_subject_prefix,
|
||||
"NATS_SUBJECT_PREFIX",
|
||||
"p.e",
|
||||
|c| c.nats.subject_prefix == "p.e"
|
||||
);
|
||||
env_sets!(nats_url_env_sets_url, "NATS_URL", "nats://n:4222", |c| c
|
||||
.nats
|
||||
.url
|
||||
.as_deref()
|
||||
== Some("nats://n:4222"));
|
||||
env_sets!(
|
||||
omdb_api_key_env_sets_omdb_api_key,
|
||||
"OMDB_API_KEY",
|
||||
"ok",
|
||||
|c| c.metadata.omdb_api_key.as_deref() == Some("ok")
|
||||
);
|
||||
env_sets!(
|
||||
poster_fetch_timeout_seconds_env_sets_poster_fetch_timeout_seconds,
|
||||
"POSTER_FETCH_TIMEOUT_SECONDS",
|
||||
"5",
|
||||
|c| c.metadata.poster_fetch_timeout_seconds == 5
|
||||
);
|
||||
env_sets!(rate_limit_env_sets_rate_limit, "RATE_LIMIT", "120", |c| c
|
||||
.server
|
||||
.rate_limit
|
||||
== 120);
|
||||
env_sets!(
|
||||
refresh_ttl_seconds_env_sets_refresh_ttl_seconds,
|
||||
"REFRESH_TTL_SECONDS",
|
||||
"99",
|
||||
|c| c.auth.refresh_ttl_seconds == 99
|
||||
);
|
||||
env_sets!(
|
||||
secure_cookies_env_sets_secure_cookies,
|
||||
"SECURE_COOKIES",
|
||||
"true",
|
||||
|c| c.server.secure_cookies
|
||||
);
|
||||
env_sets!(
|
||||
tmdb_api_key_env_sets_tmdb_api_key,
|
||||
"TMDB_API_KEY",
|
||||
"tk",
|
||||
|c| c.metadata.tmdb_api_key.as_deref() == Some("tk")
|
||||
);
|
||||
env_sets!(wrapup_bg_dir_env_sets_bg_dir, "WRAPUP_BG_DIR", "/bg", |c| c
|
||||
.wrapup
|
||||
.bg_dir
|
||||
.as_deref()
|
||||
== Some("/bg"));
|
||||
env_sets!(
|
||||
wrapup_font_path_env_sets_font_path,
|
||||
"WRAPUP_FONT_PATH",
|
||||
"/f.ttf",
|
||||
|c| c.wrapup.font_path.as_deref() == Some("/f.ttf")
|
||||
);
|
||||
env_sets!(
|
||||
wrapup_logo_path_env_sets_logo_path,
|
||||
"WRAPUP_LOGO_PATH",
|
||||
"/l.png",
|
||||
|c| c.wrapup.logo_path.as_deref() == Some("/l.png")
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn numeric_looking_string_values_stay_strings() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail_with(jail, &[("JWT_SECRET", "12345"), ("TMDB_API_KEY", "1")]);
|
||||
let c = load().expect("config loads");
|
||||
assert_eq!(c.auth.jwt_secret.as_deref(), Some("12345"));
|
||||
assert_eq!(c.metadata.tmdb_api_key.as_deref(), Some("1"));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_previous_env_readers() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail_with(jail, &[]);
|
||||
let c = load().expect("config loads");
|
||||
assert!(!c.server.allow_registration);
|
||||
assert_eq!(c.server.base_url, "http://localhost:3000");
|
||||
assert_eq!(c.server.cors_origins, "");
|
||||
assert_eq!(c.server.host, "0.0.0.0");
|
||||
assert_eq!(c.server.port, 3000);
|
||||
assert_eq!(c.server.rate_limit, 60);
|
||||
assert!(!c.server.secure_cookies);
|
||||
assert_eq!(c.database.url, "sqlite://test.db");
|
||||
assert_eq!(c.auth.jwt_secret, None);
|
||||
assert_eq!(c.auth.jwt_ttl_seconds, 900);
|
||||
assert_eq!(c.auth.refresh_ttl_seconds, 2_592_000);
|
||||
assert_eq!(c.events.bus_backend, "db");
|
||||
assert_eq!(c.events.channel_buffer, 128);
|
||||
assert_eq!(c.events.queue_poll_interval_ms, 500);
|
||||
assert_eq!(c.events.queue_batch_size, 10);
|
||||
assert_eq!(c.events.queue_max_attempts, 5);
|
||||
assert_eq!(c.nats.url, None);
|
||||
assert_eq!(c.nats.mode, "jetstream");
|
||||
assert_eq!(c.nats.subject_prefix, "movies-diary.events");
|
||||
assert_eq!(c.nats.stream_name, "MOVIES_DIARY_EVENTS");
|
||||
assert_eq!(c.nats.consumer_name, "worker");
|
||||
assert_eq!(c.storage.backend, "local");
|
||||
assert_eq!(c.storage.path, "./images");
|
||||
assert_eq!(c.storage.endpoint, None);
|
||||
assert_eq!(c.storage.access_key_id, None);
|
||||
assert_eq!(c.storage.secret_access_key, None);
|
||||
assert_eq!(c.storage.bucket, None);
|
||||
assert_eq!(c.storage.region, "minio");
|
||||
assert!(!c.image_conversion.enabled);
|
||||
assert_eq!(c.image_conversion.format, None);
|
||||
assert_eq!(c.metadata.tmdb_api_key, None);
|
||||
assert_eq!(c.metadata.omdb_api_key, None);
|
||||
assert_eq!(c.metadata.poster_fetch_timeout_seconds, 30);
|
||||
assert!(!c.federation.debug);
|
||||
assert_eq!(c.wrapup.font_path, None);
|
||||
assert_eq!(c.wrapup.logo_path, None);
|
||||
assert_eq!(c.wrapup.bg_dir, None);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_file_is_overridden_by_env() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail_with(jail, &[("PORT", "9000")]);
|
||||
jail.create_file(
|
||||
"config.toml",
|
||||
"[server]\nport = 1234\nhost = \"10.0.0.1\"\n[auth]\njwt_secret = \"from-file\"\n",
|
||||
)?;
|
||||
let c = load().expect("config loads");
|
||||
assert_eq!(c.server.port, 9000, "env wins over file");
|
||||
assert_eq!(c.server.host, "10.0.0.1", "file wins over default");
|
||||
assert_eq!(c.auth.jwt_secret.as_deref(), Some("from-file"));
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_file_env_names_an_alternative_file() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail_with(jail, &[("CONFIG_FILE", "alt.toml")]);
|
||||
jail.create_file("config.toml", "[server]\nport = 1\n")?;
|
||||
jail.create_file("alt.toml", "[server]\nport = 2\n")?;
|
||||
assert_eq!(load().expect("config loads").server.port, 2);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_named_config_file_is_an_error() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail_with(jail, &[("CONFIG_FILE", "nope.toml")]);
|
||||
let err = load().expect_err("named file is missing");
|
||||
assert!(matches!(err, ConfigError::NamedFileMissing(_)), "{err}");
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_database_url_is_an_error_naming_the_variable() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail.clear_env();
|
||||
let err = load().expect_err("DATABASE_URL is required");
|
||||
assert!(err.to_string().contains("DATABASE_URL"), "{err}");
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_numeric_env_value_fails_naming_the_variable() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail_with(jail, &[("RATE_LIMIT", "abc")]);
|
||||
let err = load().expect_err("RATE_LIMIT=abc is malformed");
|
||||
assert!(err.to_string().contains("RATE_LIMIT"), "{err}");
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_registration_accepts_true_and_1_only() {
|
||||
for (value, expected) in [
|
||||
("true", true),
|
||||
("1", true),
|
||||
("false", false),
|
||||
("0", false),
|
||||
("yes", false),
|
||||
("TRUE", false),
|
||||
] {
|
||||
Jail::expect_with(|jail| {
|
||||
jail_with(jail, &[("ALLOW_REGISTRATION", value)]);
|
||||
let c = load().expect("config loads");
|
||||
assert_eq!(c.server.allow_registration, expected, "value {value:?}");
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_cookies_and_image_conversion_accept_true_only() {
|
||||
Jail::expect_with(|jail| {
|
||||
jail_with(
|
||||
jail,
|
||||
&[("SECURE_COOKIES", "1"), ("IMAGE_CONVERSION_ENABLED", "1")],
|
||||
);
|
||||
let c = load().expect("config loads");
|
||||
assert!(!c.server.secure_cookies);
|
||||
assert!(!c.image_conversion.enabled);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user