use serde::{Deserialize, Serialize};
pub trait DatabaseUrl {
fn to_url(&self) -> String;
fn db_type(&self) -> &'static str;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbConnection {
#[serde(default = "default_localhost")]
pub host: String,
pub port: u16,
#[serde(default)]
pub user: String,
#[serde(default)]
pub password: String,
#[serde(default)]
pub db: String,
#[serde(default)]
pub params: Option<String>,
}
fn default_localhost() -> String {
"localhost".into()
}
impl DbConnection {
fn from_env_with_defaults(prefix: &str, default_port: u16) -> Self {
Self {
host: std::env::var(format!("{prefix}_HOST")).unwrap_or_else(|_| "localhost".into()),
port: std::env::var(format!("{prefix}_PORT"))
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default_port),
user: std::env::var(format!("{prefix}_USER")).unwrap_or_default(),
password: std::env::var(format!("{prefix}_PASSWORD")).unwrap_or_default(),
db: std::env::var(format!("{prefix}_DB")).unwrap_or_default(),
params: std::env::var(format!("{prefix}_PARAMS")).ok(),
}
}
fn url_with_scheme(&self, scheme: &str) -> String {
let auth = if self.user.is_empty() && self.password.is_empty() {
String::new()
} else if self.password.is_empty() {
format!("{}@", self.user)
} else {
format!("{}:{}@", self.user, self.password)
};
let db_path = if self.db.is_empty() {
String::new()
} else {
format!("/{}", self.db)
};
let params = self
.params
.as_ref()
.map(|p| format!("?{p}"))
.unwrap_or_default();
format!(
"{scheme}://{auth}{}:{}{db_path}{params}",
self.host, self.port
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostgresUrl(pub DbConnection);
impl PostgresUrl {
#[must_use]
pub fn new(host: &str, port: u16, user: &str, password: &str, db: &str) -> Self {
Self(DbConnection {
host: host.into(),
port,
user: user.into(),
password: password.into(),
db: db.into(),
params: None,
})
}
#[must_use]
pub fn from_env(prefix: &str) -> Self {
Self(DbConnection::from_env_with_defaults(prefix, 5432))
}
#[must_use]
pub fn with_params(mut self, params: &str) -> Self {
self.0.params = Some(params.into());
self
}
}
impl DatabaseUrl for PostgresUrl {
fn to_url(&self) -> String {
self.0.url_with_scheme("postgresql")
}
fn db_type(&self) -> &'static str {
"postgresql"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClickHouseUrl(pub DbConnection);
impl ClickHouseUrl {
#[must_use]
pub fn new(host: &str, port: u16, user: &str, password: &str, db: &str) -> Self {
Self(DbConnection {
host: host.into(),
port,
user: user.into(),
password: password.into(),
db: db.into(),
params: None,
})
}
#[must_use]
pub fn from_env(prefix: &str) -> Self {
Self(DbConnection::from_env_with_defaults(prefix, 8123))
}
#[must_use]
pub fn from_env_native(prefix: &str) -> Self {
Self(DbConnection::from_env_with_defaults(prefix, 9000))
}
}
impl DatabaseUrl for ClickHouseUrl {
fn to_url(&self) -> String {
self.0.url_with_scheme("http")
}
fn db_type(&self) -> &'static str {
"clickhouse"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedisUrl(pub DbConnection);
impl RedisUrl {
#[must_use]
pub fn new(host: &str, port: u16, password: &str, db: &str) -> Self {
Self(DbConnection {
host: host.into(),
port,
user: String::new(),
password: password.into(),
db: db.into(),
params: None,
})
}
#[must_use]
pub fn from_env(prefix: &str) -> Self {
Self(DbConnection::from_env_with_defaults(prefix, 6379))
}
}
impl DatabaseUrl for RedisUrl {
fn to_url(&self) -> String {
self.0.url_with_scheme("redis")
}
fn db_type(&self) -> &'static str {
"redis"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MongoUrl(pub DbConnection);
impl MongoUrl {
#[must_use]
pub fn new(host: &str, port: u16, user: &str, password: &str, db: &str) -> Self {
Self(DbConnection {
host: host.into(),
port,
user: user.into(),
password: password.into(),
db: db.into(),
params: None,
})
}
#[must_use]
pub fn from_env(prefix: &str) -> Self {
Self(DbConnection::from_env_with_defaults(prefix, 27017))
}
#[must_use]
pub fn with_params(mut self, params: &str) -> Self {
self.0.params = Some(params.into());
self
}
}
impl DatabaseUrl for MongoUrl {
fn to_url(&self) -> String {
self.0.url_with_scheme("mongodb")
}
fn db_type(&self) -> &'static str {
"mongodb"
}
}
impl std::fmt::Display for PostgresUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"postgresql://{}:***@{}:{}/{}",
self.0.user, self.0.host, self.0.port, self.0.db
)
}
}
impl std::fmt::Display for ClickHouseUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"http://{}:***@{}:{}/{}",
self.0.user, self.0.host, self.0.port, self.0.db
)
}
}
impl std::fmt::Display for RedisUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"redis://***@{}:{}/{}",
self.0.host, self.0.port, self.0.db
)
}
}
impl std::fmt::Display for MongoUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"mongodb://{}:***@{}:{}/{}",
self.0.user, self.0.host, self.0.port, self.0.db
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn postgres_url_with_all_fields() {
let url = PostgresUrl::new("db.prod", 5432, "app", "secret", "mydb");
assert_eq!(url.to_url(), "postgresql://app:secret@db.prod:5432/mydb");
assert_eq!(url.db_type(), "postgresql");
}
#[test]
fn postgres_url_with_params() {
let url = PostgresUrl::new("db.prod", 5432, "app", "secret", "mydb")
.with_params("sslmode=require");
assert_eq!(
url.to_url(),
"postgresql://app:secret@db.prod:5432/mydb?sslmode=require"
);
}
#[test]
fn postgres_url_no_password() {
let url = PostgresUrl::new("db.prod", 5432, "app", "", "mydb");
assert_eq!(url.to_url(), "postgresql://app@db.prod:5432/mydb");
}
#[test]
fn postgres_url_no_auth() {
let url = PostgresUrl::new("db.prod", 5432, "", "", "mydb");
assert_eq!(url.to_url(), "postgresql://db.prod:5432/mydb");
}
#[test]
fn postgres_display_redacts_password() {
let url = PostgresUrl::new("db.prod", 5432, "app", "hunter2", "mydb");
let display = format!("{url}");
assert!(!display.contains("hunter2"));
assert!(display.contains("***"));
}
#[test]
fn clickhouse_http_url() {
let url = ClickHouseUrl::new("ch.prod", 8123, "default", "secret", "dfe");
assert_eq!(url.to_url(), "http://default:secret@ch.prod:8123/dfe");
assert_eq!(url.db_type(), "clickhouse");
}
#[test]
fn redis_url() {
let url = RedisUrl::new("redis.prod", 6379, "secret", "0");
assert_eq!(url.to_url(), "redis://:secret@redis.prod:6379/0");
assert_eq!(url.db_type(), "redis");
}
#[test]
fn redis_url_no_password() {
let url = RedisUrl::new("redis.prod", 6379, "", "0");
assert_eq!(url.to_url(), "redis://redis.prod:6379/0");
}
#[test]
fn redis_display_redacts() {
let url = RedisUrl::new("redis.prod", 6379, "secret123", "0");
let display = format!("{url}");
assert!(!display.contains("secret123"));
}
#[test]
fn mongo_url() {
let url = MongoUrl::new("mongo.prod", 27017, "admin", "secret", "mydb");
assert_eq!(url.to_url(), "mongodb://admin:secret@mongo.prod:27017/mydb");
assert_eq!(url.db_type(), "mongodb");
}
#[test]
fn mongo_url_with_params() {
let url = MongoUrl::new("mongo.prod", 27017, "admin", "secret", "mydb")
.with_params("authSource=admin&replicaSet=rs0");
assert_eq!(
url.to_url(),
"mongodb://admin:secret@mongo.prod:27017/mydb?authSource=admin&replicaSet=rs0"
);
}
#[test]
fn mongo_display_redacts() {
let url = MongoUrl::new("mongo.prod", 27017, "admin", "hunter2", "mydb");
let display = format!("{url}");
assert!(!display.contains("hunter2"));
}
}