use std::borrow::Cow;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::matcher::Pattern;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct GatewayConfig {
pub upstreams: BTreeMap<String, Upstream>,
pub routes: Vec<GatewayRoute>,
}
impl GatewayConfig {
pub fn is_enabled(&self) -> bool {
!self.routes.is_empty()
}
pub fn match_route(&self, path: &str) -> Option<&GatewayRoute> {
self.routes.iter().find(|route| {
Pattern::compile(&route.matches)
.map(|pattern| pattern.is_match(path))
.unwrap_or(false)
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Upstream {
pub target: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub targets: Vec<String>,
#[serde(skip_serializing_if = "LbPolicy::is_default")]
pub lb: LbPolicy,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub regions: BTreeMap<String, crate::geo::Region>,
#[serde(skip_serializing_if = "crate::geo::RegionMap::is_empty")]
pub region_map: crate::geo::RegionMap,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_region_header: Option<String>,
#[serde(skip_serializing_if = "is_zero")]
pub max_retries: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub passive_health: Option<PassiveHealth>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_health: Option<ActiveHealth>,
#[serde(skip_serializing_if = "Option::is_none")]
pub discover: Option<Discovery>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compute: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub host_header: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strip_prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub connect_timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "is_false")]
pub tls_insecure: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub read_buffer_bytes: Option<u64>,
#[serde(skip_serializing_if = "HeaderOps::is_empty")]
pub header_up: HeaderOps,
#[serde(skip_serializing_if = "HeaderOps::is_empty")]
pub header_down: HeaderOps,
}
fn is_false(b: &bool) -> bool {
!*b
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LbPolicy {
#[default]
RoundRobin,
Random,
Nearest,
}
impl LbPolicy {
fn is_default(&self) -> bool {
matches!(self, Self::RoundRobin)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PassiveHealth {
pub max_fails: u32,
pub fail_timeout_ms: u64,
}
impl Default for PassiveHealth {
fn default() -> Self {
Self {
max_fails: 3,
fail_timeout_ms: 10_000,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ActiveHealth {
pub path: String,
pub interval_ms: u64,
pub timeout_ms: u64,
pub healthy_threshold: u32,
pub unhealthy_threshold: u32,
pub expected_status: u16,
}
impl Default for ActiveHealth {
fn default() -> Self {
Self {
path: "/".to_string(),
interval_ms: 10_000,
timeout_ms: 2_000,
healthy_threshold: 2,
unhealthy_threshold: 3,
expected_status: 200,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Discovery {
pub host: String,
pub port: u16,
pub scheme: String,
pub refresh_secs: u64,
}
impl Default for Discovery {
fn default() -> Self {
Self {
host: String::new(),
port: 0,
scheme: "http".to_string(),
refresh_secs: 30,
}
}
}
impl Upstream {
pub fn static_backends(&self) -> Vec<&str> {
if !self.targets.is_empty() {
self.targets.iter().map(String::as_str).collect()
} else if !self.target.is_empty() {
vec![self.target.as_str()]
} else {
Vec::new()
}
}
pub fn forward_path<'a>(&self, request_path: &'a str) -> Cow<'a, str> {
let Some(prefix) = self.strip_prefix.as_deref() else {
return Cow::Borrowed(request_path);
};
let prefix = prefix.trim_end_matches('/');
if prefix.is_empty() {
return Cow::Borrowed(request_path);
}
match request_path.strip_prefix(prefix) {
Some("") => Cow::Borrowed("/"),
Some(rest) if rest.starts_with('/') => Cow::Borrowed(rest),
_ => Cow::Borrowed(request_path),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HeaderOps {
pub set: BTreeMap<String, String>,
pub remove: Vec<String>,
}
impl HeaderOps {
pub fn is_empty(&self) -> bool {
self.set.is_empty() && self.remove.is_empty()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GatewayRoute {
#[serde(rename = "match")]
pub matches: String,
pub upstream: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn upstream(target: &str, strip: Option<&str>) -> Upstream {
Upstream {
target: target.to_string(),
strip_prefix: strip.map(str::to_string),
..Default::default()
}
}
#[test]
fn match_route_first_wins() {
let cfg = GatewayConfig {
upstreams: BTreeMap::from([
("api".to_string(), upstream("http://10.0.0.5:8080", None)),
("app".to_string(), upstream("http://10.0.0.6:3000", None)),
]),
routes: vec![
GatewayRoute {
matches: "/api/**".into(),
upstream: "api".into(),
},
GatewayRoute {
matches: "/**".into(),
upstream: "app".into(),
},
],
};
assert_eq!(cfg.match_route("/api/v1/x").unwrap().upstream, "api");
assert_eq!(cfg.match_route("/anything").unwrap().upstream, "app");
assert!(cfg.is_enabled());
assert!(!GatewayConfig::default().is_enabled());
}
#[test]
fn strip_prefix_only_on_segment_boundary() {
let u = upstream("http://h:1", Some("/app"));
assert_eq!(u.forward_path("/app"), "/");
assert_eq!(u.forward_path("/app/foo/bar"), "/foo/bar");
assert_eq!(u.forward_path("/application"), "/application"); let u2 = upstream("http://h:1", Some("/app/"));
assert_eq!(u2.forward_path("/app/x"), "/x");
let u3 = upstream("http://h:1", None);
assert_eq!(u3.forward_path("/app/x"), "/app/x");
}
#[test]
fn config_round_trips_through_json() {
let cfg = GatewayConfig {
upstreams: BTreeMap::from([(
"api".to_string(),
Upstream {
target: "https://10.0.0.5:8443/base".into(),
host_header: Some("internal.local".into()),
strip_prefix: Some("/api".into()),
connect_timeout_ms: Some(2000),
request_timeout_ms: Some(30000),
tls_insecure: true,
header_up: HeaderOps {
set: BTreeMap::from([("x-svc".to_string(), "boatramp".to_string())]),
remove: vec!["cookie".into()],
},
header_down: HeaderOps::default(),
..Default::default()
},
)]),
routes: vec![GatewayRoute {
matches: "/api/**".into(),
upstream: "api".into(),
}],
};
let json = serde_json::to_string(&cfg).unwrap();
assert_eq!(serde_json::from_str::<GatewayConfig>(&json).unwrap(), cfg);
assert!(json.contains("\"match\":\"/api/**\""));
let single = &serde_json::from_str::<GatewayConfig>(&json)
.unwrap()
.upstreams["api"];
assert_eq!(single.static_backends(), vec!["https://10.0.0.5:8443/base"]);
}
#[test]
fn nearest_upstream_round_trips_with_region_config() {
let up = Upstream {
targets: vec!["http://a".into(), "http://b".into()],
lb: LbPolicy::Nearest,
regions: BTreeMap::from([
("http://a".to_string(), "us-east".to_string()),
("http://b".to_string(), "eu-west".to_string()),
]),
region_map: crate::geo::RegionMap::from_edges([(
"us-east".to_string(),
"eu-west".to_string(),
4,
)]),
client_region_header: Some("fly-region".into()),
max_retries: 2,
..Default::default()
};
let json = serde_json::to_string(&up).unwrap();
assert_eq!(serde_json::from_str::<Upstream>(&json).unwrap(), up);
assert!(json.contains("\"lb\":\"nearest\""));
let plain = serde_json::to_string(&Upstream {
target: "http://x".into(),
..Default::default()
})
.unwrap();
assert!(!plain.contains("region_map"));
assert!(!plain.contains("client_region_header"));
assert!(!plain.contains("\"regions\""));
}
#[test]
fn pool_round_trips_and_resolves_backends() {
let cfg = GatewayConfig {
upstreams: BTreeMap::from([(
"pool".to_string(),
Upstream {
targets: vec!["http://10.0.0.5:8080".into(), "http://10.0.0.6:8080".into()],
lb: LbPolicy::Random,
max_retries: 2,
passive_health: Some(PassiveHealth {
max_fails: 5,
fail_timeout_ms: 30_000,
}),
..Default::default()
},
)]),
routes: vec![GatewayRoute {
matches: "/**".into(),
upstream: "pool".into(),
}],
};
let json = serde_json::to_string(&cfg).unwrap();
let back = serde_json::from_str::<GatewayConfig>(&json).unwrap();
assert_eq!(back, cfg);
assert_eq!(
back.upstreams["pool"].static_backends(),
vec!["http://10.0.0.5:8080", "http://10.0.0.6:8080"]
);
assert!(json.contains("\"lb\":\"random\""));
}
#[test]
fn dns_discovery_round_trips() {
let u = Upstream {
discover: Some(Discovery {
host: "svc.internal".into(),
port: 8080,
scheme: "http".into(),
refresh_secs: 15,
}),
..Default::default()
};
let json = serde_json::to_string(&u).unwrap();
assert_eq!(serde_json::from_str::<Upstream>(&json).unwrap(), u);
assert!(u.static_backends().is_empty());
}
#[test]
fn active_health_round_trips() {
let u = Upstream {
targets: vec!["http://10.0.0.1:80".into(), "http://10.0.0.2:80".into()],
active_health: Some(ActiveHealth {
path: "/healthz".into(),
interval_ms: 5_000,
timeout_ms: 1_000,
healthy_threshold: 2,
unhealthy_threshold: 3,
expected_status: 200,
}),
..Default::default()
};
let json = serde_json::to_string(&u).unwrap();
assert_eq!(serde_json::from_str::<Upstream>(&json).unwrap(), u);
assert!(json.contains("\"path\":\"/healthz\""));
}
#[test]
fn defaults_omit_new_fields_on_the_wire() {
let json = serde_json::to_string(&upstream("http://h:1", None)).unwrap();
for absent in [
"targets",
"lb",
"max_retries",
"passive_health",
"active_health",
"discover",
] {
assert!(
!json.contains(absent),
"default upstream leaked {absent}: {json}"
);
}
}
}