use crate::wire::Config;
use crate::client::error::ClientError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint {
pub host: String,
pub port: u16,
}
pub fn parse_endpoint(input: &str, config: &Config) -> Result<Endpoint, ClientError> {
let input = input.trim();
if let Some((scheme, rest)) = input.split_once("://") {
let scheme = scheme.to_ascii_lowercase();
if scheme == "http" || scheme == "https" {
return Err(invalid(format!(
"'{input}' is an HTTP URL and Thunder is RPC-only — use the application's HTTP \
client for REST endpoints, or pass an RPC endpoint such as \
'scheme://host:port' or bare 'host:port'"
)));
}
if scheme != config.scheme {
let configured = config.scheme;
return Err(invalid(format!(
"endpoint scheme '{scheme}' does not match this client's configured scheme \
'{configured}' — set the scheme on the Config, or use bare 'host:port'"
)));
}
let rest = rest.strip_suffix('/').unwrap_or(rest);
if rest.contains('/') {
return Err(invalid(format!(
"endpoint '{input}' must not carry a path — expected {scheme}://host[:port]"
)));
}
let (host, port) = split_host_port(rest)?;
Ok(Endpoint {
host,
port: port.unwrap_or(config.default_port),
})
} else {
let (host, port) = split_host_port(input)?;
let port = port.ok_or_else(|| {
invalid(format!(
"bare endpoint '{input}' needs an explicit port ('host:port') — only \
scheme-prefixed endpoints resolve a registry default port"
))
})?;
Ok(Endpoint { host, port })
}
}
fn split_host_port(s: &str) -> Result<(String, Option<u16>), ClientError> {
if s.is_empty() {
return Err(invalid("endpoint host is empty".to_owned()));
}
if let Some(inner) = s.strip_prefix('[') {
let (host, tail) = inner
.split_once(']')
.ok_or_else(|| invalid(format!("unterminated '[' in endpoint host '{s}'")))?;
if host.is_empty() {
return Err(invalid("endpoint host is empty".to_owned()));
}
return match tail {
"" => Ok((host.to_owned(), None)),
t => {
let port = t.strip_prefix(':').ok_or_else(|| {
invalid(format!("expected ':port' after ']' in endpoint '{s}'"))
})?;
Ok((host.to_owned(), Some(parse_port(port, s)?)))
}
};
}
match s.rsplit_once(':') {
Some((head, _)) if head.contains(':') => Ok((s.to_owned(), None)),
Some((host, port)) => {
if host.is_empty() {
return Err(invalid("endpoint host is empty".to_owned()));
}
Ok((host.to_owned(), Some(parse_port(port, s)?)))
}
None => Ok((s.to_owned(), None)),
}
}
fn parse_port(port: &str, whole: &str) -> Result<u16, ClientError> {
port.parse::<u16>()
.map_err(|_| invalid(format!("invalid port '{port}' in endpoint '{whole}'")))
}
fn invalid(message: String) -> ClientError {
ClientError::Connection { message }
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn app() -> Config {
Config::standard().scheme("myapp").port(9000)
}
#[test]
fn the_configured_scheme_resolves_the_configured_default_port() {
let ep = parse_endpoint("myapp://db.example.com", &app()).unwrap();
assert_eq!(ep.host, "db.example.com");
assert_eq!(ep.port, 9000);
}
#[test]
fn any_application_can_pick_any_scheme_without_a_thunder_release() {
let future = Config::standard()
.scheme("something-new-in-2030")
.port(4242);
let ep = parse_endpoint("something-new-in-2030://host", &future).unwrap();
assert_eq!(ep.port, 4242);
}
#[test]
fn explicit_port_wins_over_default() {
let ep = parse_endpoint("myapp://10.0.0.7:9999", &app()).unwrap();
assert_eq!(
ep,
Endpoint {
host: "10.0.0.7".to_owned(),
port: 9999
}
);
}
#[test]
fn bare_host_port_is_accepted_rpc_implied() {
let ep = parse_endpoint("localhost:15501", &app()).unwrap();
assert_eq!(
ep,
Endpoint {
host: "localhost".to_owned(),
port: 15501
}
);
}
#[test]
fn bare_host_port_works_even_with_no_scheme_configured() {
let ep = parse_endpoint("localhost:15501", &Config::standard()).unwrap();
assert_eq!(ep.port, 15501);
}
#[test]
fn bare_host_without_port_is_rejected() {
let err = parse_endpoint("localhost", &app()).unwrap_err();
assert!(matches!(err, ClientError::Connection { .. }));
}
#[test]
fn http_and_https_are_rejected_with_pointer_to_http_client() {
for url in ["http://vec.example.com:8080", "https://vec.example.com"] {
let err = parse_endpoint(url, &app()).unwrap_err();
let ClientError::Connection { message } = err else {
panic!("expected the connection class, got {err:?}");
};
assert!(
message.contains("RPC-only") && message.contains("HTTP client"),
"rejection must point at the application's HTTP client: {message}"
);
}
}
#[test]
fn a_scheme_other_than_the_configured_one_is_rejected() {
let err = parse_endpoint("redis://h:1", &app()).unwrap_err();
let ClientError::Connection { message } = err else {
panic!("expected the connection class");
};
assert!(
message.contains("redis") && message.contains("myapp"),
"the mismatch must name both the given and the configured scheme: {message}"
);
}
#[test]
fn ipv6_literals_parse_with_and_without_brackets() {
let ep = parse_endpoint("[::1]:8080", &app()).unwrap();
assert_eq!(
ep,
Endpoint {
host: "::1".to_owned(),
port: 8080
}
);
let ep = parse_endpoint("myapp://[fe80::1]", &app()).unwrap();
assert_eq!(ep.host, "fe80::1");
assert_eq!(ep.port, 9000);
}
#[test]
fn trailing_slash_is_tolerated_but_paths_are_not() {
let ep = parse_endpoint("myapp://h/", &app()).unwrap();
assert_eq!(ep.port, 9000);
assert!(parse_endpoint("myapp://h/db", &app()).is_err());
}
#[test]
fn invalid_ports_are_rejected() {
assert!(parse_endpoint("host:99999", &app()).is_err());
assert!(parse_endpoint("host:abc", &app()).is_err());
}
#[test]
fn empty_host_is_rejected() {
assert!(parse_endpoint("myapp://:1234", &app()).is_err());
assert!(parse_endpoint(":1234", &app()).is_err());
}
}