use serde::{Deserialize, Serialize};
use crate::auth::{AuthMode, DEFAULT_FORWARDED_USER_HEADER};
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
pub struct EnvValue {
pub env: String,
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
#[serde(untagged)]
pub enum ConfigValue {
Value(String),
Environment(EnvValue),
}
impl ConfigValue {
pub fn value(&self) -> Option<String> {
match self {
ConfigValue::Value(value) => Some(value.clone()),
ConfigValue::Environment(env) => std::env::var(&env.env).ok(),
}
}
}
pub const DEFAULT_HOST: &str = "0.0.0.0";
pub const DEFAULT_PORT: u16 = 8080;
pub const DEFAULT_BASE_PATH: &str = "/storage-proxy";
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct Config {
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub base_path: Option<String>,
pub upstream: UpstreamConfig,
#[serde(default)]
pub auth: AuthConfig,
}
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct UpstreamConfig {
pub base_url: String,
#[serde(default)]
pub token: Option<ConfigValue>,
}
#[derive(Debug, Deserialize, Serialize, Default, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct AuthConfig {
#[serde(default)]
pub mode: AuthKind,
#[serde(default)]
pub forwarded_user_header: Option<String>,
#[serde(default)]
pub allow_missing_identity: bool,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum AuthKind {
#[default]
Anonymous,
ReverseProxy,
}
impl AuthConfig {
pub fn resolved_forwarded_header(&self) -> String {
self.forwarded_user_header
.clone()
.unwrap_or_else(|| DEFAULT_FORWARDED_USER_HEADER.to_string())
}
pub fn to_mode(&self) -> AuthMode {
match self.mode {
AuthKind::Anonymous => AuthMode::Anonymous,
AuthKind::ReverseProxy => AuthMode::ReverseProxy {
header: self
.forwarded_user_header
.clone()
.unwrap_or_else(|| DEFAULT_FORWARDED_USER_HEADER.to_string()),
allow_missing: self.allow_missing_identity,
},
}
}
}
impl Config {
pub fn load(path: &str) -> Result<Self, String> {
let p = std::path::Path::new(path);
if !p.exists() {
return Err(format!("config file not found at {}", p.display()));
}
let contents =
std::fs::read_to_string(p).map_err(|e| format!("reading config `{path}`: {e}"))?;
serde_yml::from_str(&contents).map_err(|e| format!("parsing config `{path}`: {e}"))
}
pub fn resolved_host(&self) -> &str {
self.host.as_deref().unwrap_or(DEFAULT_HOST)
}
pub fn resolved_port(&self) -> u16 {
self.port.unwrap_or(DEFAULT_PORT)
}
pub fn resolved_base_path(&self) -> String {
let raw = self.base_path.as_deref().unwrap_or(DEFAULT_BASE_PATH);
let trimmed = raw.trim().trim_matches('/');
if trimmed.is_empty() {
String::new()
} else {
format!("/{trimmed}")
}
}
pub fn health_url(&self) -> String {
let host = match self.resolved_host() {
"0.0.0.0" | "" | "::" => "127.0.0.1",
other => other,
};
format!("http://{host}:{}/health", self.resolved_port())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minimal_config_defaults() {
let yaml = r#"
upstream:
base_url: "http://uc:8080/api/2.1/unity-catalog/"
"#;
let cfg: Config = serde_yml::from_str(yaml).unwrap();
assert_eq!(cfg.resolved_host(), "0.0.0.0");
assert_eq!(cfg.resolved_port(), 8080);
assert_eq!(cfg.resolved_base_path(), "/storage-proxy");
assert!(cfg.upstream.token.is_none());
assert_eq!(cfg.auth.mode, AuthKind::Anonymous);
assert_eq!(cfg.auth.to_mode(), AuthMode::Anonymous);
}
#[test]
fn base_path_normalization() {
for raw in ["storage-proxy", "/storage-proxy", "/storage-proxy/"] {
let cfg = Config {
host: None,
port: None,
base_path: Some(raw.to_string()),
upstream: UpstreamConfig {
base_url: "http://uc/".into(),
token: None,
},
auth: AuthConfig::default(),
};
assert_eq!(cfg.resolved_base_path(), "/storage-proxy", "input {raw:?}");
}
let cfg = Config {
host: None,
port: None,
base_path: Some(String::new()),
upstream: UpstreamConfig {
base_url: "http://uc/".into(),
token: None,
},
auth: AuthConfig::default(),
};
assert_eq!(cfg.resolved_base_path(), "");
}
#[test]
fn reverse_proxy_auth_roundtrips_and_maps() {
let yaml = r#"
upstream:
base_url: "http://uc:8080/api/2.1/unity-catalog/"
token:
env: "UC_TOKEN"
auth:
mode: reverse-proxy
forwarded-user-header: "x-user"
allow-missing-identity: true
"#;
let cfg: Config = serde_yml::from_str(yaml).unwrap();
assert_eq!(
cfg.upstream.token,
Some(ConfigValue::Environment(EnvValue {
env: "UC_TOKEN".into()
}))
);
assert_eq!(
cfg.auth.to_mode(),
AuthMode::ReverseProxy {
header: "x-user".into(),
allow_missing: true
}
);
let reparsed: Config = serde_yml::from_str(&serde_yml::to_string(&cfg).unwrap()).unwrap();
assert_eq!(reparsed, cfg);
}
#[test]
fn reverse_proxy_defaults_to_standard_header() {
let yaml = r#"
upstream:
base_url: "http://uc/"
auth:
mode: reverse-proxy
"#;
let cfg: Config = serde_yml::from_str(yaml).unwrap();
assert_eq!(
cfg.auth.to_mode(),
AuthMode::ReverseProxy {
header: DEFAULT_FORWARDED_USER_HEADER.into(),
allow_missing: false
}
);
}
#[test]
fn health_url_maps_wildcard_host_to_loopback() {
let cfg = Config {
host: Some("0.0.0.0".into()),
port: Some(9000),
base_path: None,
upstream: UpstreamConfig {
base_url: "http://uc/".into(),
token: None,
},
auth: AuthConfig::default(),
};
assert_eq!(cfg.health_url(), "http://127.0.0.1:9000/health");
}
}