From 4cdde568f66fab1ffd39c1f96b95a997ebe85b34 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 02:23:20 +0200 Subject: [PATCH] infra-wiring: DbPool enum + Config from env --- Cargo.toml | 2 +- crates/infra-wiring/Cargo.toml | 16 ++ crates/infra-wiring/src/lib.rs | 293 +++++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 crates/infra-wiring/Cargo.toml create mode 100644 crates/infra-wiring/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 088dbd1..5122de2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/domain", "crates/application", "crates/api-types"] +members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring"] exclude = ["k-tv-backend", "k-tv-frontend"] resolver = "2" diff --git a/crates/infra-wiring/Cargo.toml b/crates/infra-wiring/Cargo.toml new file mode 100644 index 0000000..0c66571 --- /dev/null +++ b/crates/infra-wiring/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "infra-wiring" +version = "0.1.0" +edition = "2024" + +[features] +default = ["sqlite"] +sqlite = ["sqlx/sqlite"] +postgres = ["sqlx/postgres"] + +[dependencies] +sqlx = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } diff --git a/crates/infra-wiring/src/lib.rs b/crates/infra-wiring/src/lib.rs new file mode 100644 index 0000000..219ba8d --- /dev/null +++ b/crates/infra-wiring/src/lib.rs @@ -0,0 +1,293 @@ +//! Infra-wiring crate — shared `DbPool` enum and `Config` struct. +//! +//! Breaks dependency cycles between adapter crates and the presentation layer +//! by owning pool creation, migration running, and env-var config loading. + +use std::env; +use std::path::PathBuf; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Errors that can occur when building a [`Config`]. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("missing required env var: {0}")] + Missing(&'static str), + + #[error("invalid value for {key}: {reason}")] + Invalid { + key: &'static str, + reason: String, + }, +} + +/// Errors from pool creation or migration. +#[derive(Debug, thiserror::Error)] +pub enum DbError { + #[error("unsupported database URL scheme: {0}")] + UnsupportedScheme(String), + + #[error(transparent)] + Sqlx(#[from] sqlx::Error), + + #[error(transparent)] + Migrate(#[from] sqlx::migrate::MigrateError), +} + +// --------------------------------------------------------------------------- +// DbPool +// --------------------------------------------------------------------------- + +/// Feature-gated database pool — one variant per supported backend. +#[derive(Debug, Clone)] +pub enum DbPool { + #[cfg(feature = "sqlite")] + Sqlite(sqlx::SqlitePool), + + #[cfg(feature = "postgres")] + Postgres(sqlx::PgPool), +} + +impl DbPool { + /// Create a pool by auto-detecting the scheme of `database_url`. + /// + /// * URLs starting with `sqlite:` → `SqlitePool` + /// * URLs starting with `postgres:` / `postgresql:` → `PgPool` + pub async fn connect(database_url: &str) -> Result { + let scheme = database_url + .split(':') + .next() + .unwrap_or("") + .to_lowercase(); + + match scheme.as_str() { + #[cfg(feature = "sqlite")] + "sqlite" => { + let pool = sqlx::SqlitePool::connect(database_url).await?; + Ok(Self::Sqlite(pool)) + } + #[cfg(feature = "postgres")] + "postgres" | "postgresql" => { + let pool = sqlx::PgPool::connect(database_url).await?; + Ok(Self::Postgres(pool)) + } + other => Err(DbError::UnsupportedScheme(other.to_string())), + } + } + + /// Run the embedded migrations for the detected backend. + pub async fn run_migrations(&self) -> Result<(), DbError> { + match self { + #[cfg(feature = "sqlite")] + Self::Sqlite(pool) => { + sqlx::migrate!("../../k-tv-backend/migrations_sqlite") + .run(pool) + .await?; + } + #[cfg(feature = "postgres")] + Self::Postgres(_pool) => { + // TODO: add postgres migrations directory and enable + tracing::warn!("postgres migrations not yet available"); + } + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +/// Source of dynamic config values (env-only vs database-backed overrides). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigSource { + Env, + Db, +} + +/// Application configuration loaded from environment variables. +#[derive(Debug, Clone)] +pub struct Config { + pub config_source: ConfigSource, + + // Core + pub database_url: String, + pub host: String, + pub port: u16, + pub log_level: String, + pub is_production: bool, + pub base_url: String, + + // HTTP + pub cors_origins: Vec, + pub cookie_secret: String, + pub secure_cookie: bool, + + // Connection pool + pub db_max_connections: u32, + pub db_min_connections: u32, + + // Auth – JWT + pub jwt_secret: Option, + pub jwt_issuer: Option, + pub jwt_audience: Option, + pub jwt_expiry_hours: u64, + pub jwt_refresh_expiry_days: u64, + pub allow_registration: bool, + + // Auth – OIDC + pub oidc_issuer_url: Option, + pub oidc_client_id: Option, + pub oidc_client_secret: Option, + pub oidc_redirect_url: Option, + pub oidc_resource_id: Option, + + // Jellyfin media provider + pub jellyfin_url: Option, + pub jellyfin_api_key: Option, + pub jellyfin_user_id: Option, + + // Local-files provider + pub local_files_dir: Option, + + // Transcoding + pub transcode_dir: Option, + pub transcode_cleanup_ttl_hours: u32, +} + +impl Config { + /// Build config from environment variables. + /// + /// Uses sensible defaults where possible; returns `ConfigError` only when + /// a required variable is missing and has no default. + pub fn from_env() -> Result { + let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); + + let port: u16 = env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(3000); + + let database_url = + env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:data.db?mode=rwc".to_string()); + + let log_level = env::var("LOG_LEVEL") + .or_else(|_| env::var("RUST_LOG")) + .unwrap_or_else(|_| "info".to_string()); + + let cookie_secret = env::var("COOKIE_SECRET").unwrap_or_else(|_| { + "k-template-cookie-secret-key-must-be-at-least-64-bytes-long!!".to_string() + }); + + let cors_origins: Vec = env::var("CORS_ALLOWED_ORIGINS") + .unwrap_or_else(|_| "http://localhost:5173".to_string()) + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + let secure_cookie = env::var("SECURE_COOKIE") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(false); + + let db_max_connections = env::var("DB_MAX_CONNECTIONS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(5); + + let db_min_connections = env::var("DB_MIN_CONNECTIONS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1); + + // JWT + let jwt_secret = env::var("JWT_SECRET").ok(); + let jwt_issuer = env::var("JWT_ISSUER").ok(); + let jwt_audience = env::var("JWT_AUDIENCE").ok(); + let jwt_expiry_hours = env::var("JWT_EXPIRY_HOURS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(24); + let jwt_refresh_expiry_days = env::var("JWT_REFRESH_EXPIRY_DAYS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(30); + + // OIDC + let oidc_issuer_url = env::var("OIDC_ISSUER").ok(); + let oidc_client_id = env::var("OIDC_CLIENT_ID").ok(); + let oidc_client_secret = env::var("OIDC_CLIENT_SECRET").ok(); + let oidc_redirect_url = env::var("OIDC_REDIRECT_URL").ok(); + let oidc_resource_id = env::var("OIDC_RESOURCE_ID").ok(); + + let is_production = env::var("PRODUCTION") + .or_else(|_| env::var("RUST_ENV")) + .map(|v| { + let v = v.to_lowercase(); + v == "production" || v == "1" || v == "true" + }) + .unwrap_or(false); + + let allow_registration = env::var("ALLOW_REGISTRATION") + .map(|v| !(v == "false" || v == "0")) + .unwrap_or(true); + + // Jellyfin + let jellyfin_url = env::var("JELLYFIN_BASE_URL").ok(); + let jellyfin_api_key = env::var("JELLYFIN_API_KEY").ok(); + let jellyfin_user_id = env::var("JELLYFIN_USER_ID").ok(); + + // Local files + let local_files_dir = env::var("LOCAL_FILES_DIR").ok().map(PathBuf::from); + + // Transcoding + let transcode_dir = env::var("TRANSCODE_DIR").ok().map(PathBuf::from); + let transcode_cleanup_ttl_hours = env::var("TRANSCODE_CLEANUP_TTL_HOURS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(24); + + let base_url = + env::var("BASE_URL").unwrap_or_else(|_| format!("http://localhost:{}", port)); + + let config_source = match env::var("CONFIG_SOURCE").as_deref() { + Ok("db") | Ok("DB") => ConfigSource::Db, + _ => ConfigSource::Env, + }; + + Ok(Self { + config_source, + database_url, + host, + port, + log_level, + is_production, + base_url, + cors_origins, + cookie_secret, + secure_cookie, + db_max_connections, + db_min_connections, + jwt_secret, + jwt_issuer, + jwt_audience, + jwt_expiry_hours, + jwt_refresh_expiry_days, + allow_registration, + oidc_issuer_url, + oidc_client_id, + oidc_client_secret, + oidc_redirect_url, + oidc_resource_id, + jellyfin_url, + jellyfin_api_key, + jellyfin_user_id, + local_files_dir, + transcode_dir, + transcode_cleanup_ttl_hours, + }) + } +}