use url::Url;
use crate::error::{Error, Result};
use crate::identifier::authority;
#[derive(Clone, Debug, Default)]
pub struct ConnectTo(Vec<(String, String)>);
#[derive(Debug, PartialEq, Eq)]
pub struct Route {
pub url: String,
pub host: Option<String>,
}
impl ConnectTo {
pub fn parse(raw: &str) -> Result<Self> {
raw.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(|entry| {
entry
.split_once('=')
.map(|(from, to)| (from.trim().to_ascii_lowercase(), to.trim().to_string()))
.filter(|(from, to)| !from.is_empty() && !to.is_empty())
.ok_or_else(|| {
Error::Invalid(format!("connect-to entry {entry:?} is not from=to"))
})
})
.collect::<Result<_, _>>()
.map(Self)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn route(&self, url: &str) -> Result<Route> {
let parsed = Url::parse(url).map_err(|e| Error::Url(format!("{url}: {e}")))?;
let from = authority(&parsed).ok_or_else(|| Error::Url(format!("{url} has no host")))?;
let Some((_, to)) = self.0.iter().find(|(candidate, _)| *candidate == from) else {
return Ok(Route {
url: url.to_string(),
host: None,
});
};
let mut rest = parsed.path().to_string();
if let Some(query) = parsed.query() {
rest = format!("{rest}?{query}");
}
Ok(Route {
url: format!("{}{rest}", to.trim_end_matches('/')),
host: Some(from),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_mapped_authority_dials_elsewhere_and_keeps_its_host() {
let connect = ConnectTo::parse(
"ps.acme.localhost=http://host.docker.internal:3400, localhost:3400=http://host.docker.internal:3400/",
)
.unwrap();
assert_eq!(
connect
.route("https://ps.acme.localhost/ps/token?x=1")
.unwrap(),
Route {
url: "http://host.docker.internal:3400/ps/token?x=1".into(),
host: Some("ps.acme.localhost".into()),
}
);
assert_eq!(
connect.route("https://ps.example/token").unwrap(),
Route {
url: "https://ps.example/token".into(),
host: None,
}
);
}
#[test]
fn a_malformed_entry_is_refused() {
assert!(ConnectTo::parse("localhost:3400").is_err());
assert!(ConnectTo::parse("").unwrap().is_empty());
}
}