infra-wiring: DbPool enum + Config from env
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[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"]
|
exclude = ["k-tv-backend", "k-tv-frontend"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
16
crates/infra-wiring/Cargo.toml
Normal file
16
crates/infra-wiring/Cargo.toml
Normal file
@@ -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 }
|
||||||
293
crates/infra-wiring/src/lib.rs
Normal file
293
crates/infra-wiring/src/lib.rs
Normal file
@@ -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<Self, DbError> {
|
||||||
|
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<String>,
|
||||||
|
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<String>,
|
||||||
|
pub jwt_issuer: Option<String>,
|
||||||
|
pub jwt_audience: Option<String>,
|
||||||
|
pub jwt_expiry_hours: u64,
|
||||||
|
pub jwt_refresh_expiry_days: u64,
|
||||||
|
pub allow_registration: bool,
|
||||||
|
|
||||||
|
// Auth – OIDC
|
||||||
|
pub oidc_issuer_url: Option<String>,
|
||||||
|
pub oidc_client_id: Option<String>,
|
||||||
|
pub oidc_client_secret: Option<String>,
|
||||||
|
pub oidc_redirect_url: Option<String>,
|
||||||
|
pub oidc_resource_id: Option<String>,
|
||||||
|
|
||||||
|
// Jellyfin media provider
|
||||||
|
pub jellyfin_url: Option<String>,
|
||||||
|
pub jellyfin_api_key: Option<String>,
|
||||||
|
pub jellyfin_user_id: Option<String>,
|
||||||
|
|
||||||
|
// Local-files provider
|
||||||
|
pub local_files_dir: Option<PathBuf>,
|
||||||
|
|
||||||
|
// Transcoding
|
||||||
|
pub transcode_dir: Option<PathBuf>,
|
||||||
|
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<Self, ConfigError> {
|
||||||
|
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<String> = 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user