pub const DEFAULT_RPC_PORT: u16 = 15503;
pub const DEFAULT_HTTP_PORT: u16 = 15002;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Endpoint {
Rpc {
host: String,
port: u16,
},
Rest {
url: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseError {
#[error("endpoint URL is empty")]
Empty,
#[error("unsupported URL scheme '{scheme}'; expected 'vectorizer', 'http', or 'https'")]
UnsupportedScheme {
scheme: String,
},
#[error("invalid authority in URL '{raw}': {reason}")]
InvalidAuthority {
raw: String,
reason: String,
},
#[error(
"URL carries credentials in the userinfo section; \
pass credentials to the HELLO handshake instead of embedding them in the URL"
)]
CredentialsInUrl,
}
pub fn parse_endpoint(url: &str) -> Result<Endpoint, ParseError> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(ParseError::Empty);
}
if let Some((scheme, rest)) = trimmed.split_once("://") {
let scheme_lower = scheme.to_ascii_lowercase();
match scheme_lower.as_str() {
"vectorizer" => parse_rpc_authority(rest),
"http" | "https" => parse_rest(scheme_lower.as_str(), rest, trimmed),
_ => Err(ParseError::UnsupportedScheme {
scheme: scheme.to_owned(),
}),
}
} else {
parse_rpc_authority(trimmed)
}
}
fn parse_rpc_authority(authority: &str) -> Result<Endpoint, ParseError> {
if authority.is_empty() {
return Err(ParseError::InvalidAuthority {
raw: authority.to_owned(),
reason: "missing host".to_owned(),
});
}
if authority.contains('@') {
return Err(ParseError::CredentialsInUrl);
}
let host_port = authority.split(['/', '?', '#']).next().unwrap_or(authority);
if host_port.is_empty() {
return Err(ParseError::InvalidAuthority {
raw: authority.to_owned(),
reason: "missing host".to_owned(),
});
}
let (host, port) = if let Some(idx) = host_port.rfind(':') {
if host_port.starts_with('[') {
let close = host_port
.find(']')
.ok_or_else(|| ParseError::InvalidAuthority {
raw: authority.to_owned(),
reason: "unterminated IPv6 literal '['".to_owned(),
})?;
let host_part = &host_port[..=close];
let after_bracket = &host_port[close + 1..];
if after_bracket.is_empty() {
(host_part.to_owned(), DEFAULT_RPC_PORT)
} else if let Some(port_str) = after_bracket.strip_prefix(':') {
let port = port_str
.parse::<u16>()
.map_err(|e| ParseError::InvalidAuthority {
raw: authority.to_owned(),
reason: format!("invalid port: {e}"),
})?;
(host_part.to_owned(), port)
} else {
return Err(ParseError::InvalidAuthority {
raw: authority.to_owned(),
reason: format!("expected ':<port>' after IPv6 literal, got '{after_bracket}'"),
});
}
} else {
let host = &host_port[..idx];
let port_str = &host_port[idx + 1..];
if host.is_empty() {
return Err(ParseError::InvalidAuthority {
raw: authority.to_owned(),
reason: "missing host before ':<port>'".to_owned(),
});
}
let port = port_str
.parse::<u16>()
.map_err(|e| ParseError::InvalidAuthority {
raw: authority.to_owned(),
reason: format!("invalid port: {e}"),
})?;
(host.to_owned(), port)
}
} else {
(host_port.to_owned(), DEFAULT_RPC_PORT)
};
Ok(Endpoint::Rpc { host, port })
}
fn parse_rest(scheme: &str, rest: &str, raw: &str) -> Result<Endpoint, ParseError> {
if rest.is_empty() {
return Err(ParseError::InvalidAuthority {
raw: raw.to_owned(),
reason: "missing host".to_owned(),
});
}
if rest.contains('@') {
return Err(ParseError::CredentialsInUrl);
}
let url = format!("{scheme}://{rest}");
Ok(Endpoint::Rest { url })
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn rpc_with_explicit_host_and_port() {
let ep = parse_endpoint("vectorizer://example.com:9000").unwrap();
assert_eq!(
ep,
Endpoint::Rpc {
host: "example.com".into(),
port: 9000,
}
);
}
#[test]
fn rpc_without_port_defaults_to_15503() {
let ep = parse_endpoint("vectorizer://example.com").unwrap();
assert_eq!(
ep,
Endpoint::Rpc {
host: "example.com".into(),
port: DEFAULT_RPC_PORT,
}
);
assert_eq!(DEFAULT_RPC_PORT, 15503);
}
#[test]
fn bare_host_port_without_scheme_is_rpc() {
let ep = parse_endpoint("localhost:15503").unwrap();
assert_eq!(
ep,
Endpoint::Rpc {
host: "localhost".into(),
port: 15503,
}
);
}
#[test]
fn http_url_routes_to_rest_endpoint() {
let ep = parse_endpoint("http://localhost:15002").unwrap();
assert_eq!(
ep,
Endpoint::Rest {
url: "http://localhost:15002".into(),
}
);
let ep = parse_endpoint("https://api.example.com").unwrap();
assert_eq!(
ep,
Endpoint::Rest {
url: "https://api.example.com".into(),
}
);
}
#[test]
fn unsupported_scheme_is_rejected_by_name() {
let err = parse_endpoint("ftp://server.example.com").unwrap_err();
match err {
ParseError::UnsupportedScheme { scheme } => assert_eq!(scheme, "ftp"),
other => panic!("expected UnsupportedScheme, got {other:?}"),
}
}
#[test]
fn empty_string_is_rejected() {
let err = parse_endpoint("").unwrap_err();
assert_eq!(err, ParseError::Empty);
let err = parse_endpoint(" ").unwrap_err();
assert_eq!(err, ParseError::Empty);
}
#[test]
fn url_with_userinfo_credentials_is_rejected() {
let err = parse_endpoint("vectorizer://user:pass@host:15503").unwrap_err();
assert_eq!(err, ParseError::CredentialsInUrl);
let err = parse_endpoint("https://user:secret@api.example.com").unwrap_err();
assert_eq!(err, ParseError::CredentialsInUrl);
}
#[test]
fn malformed_port_is_rejected() {
let err = parse_endpoint("vectorizer://host:not-a-port").unwrap_err();
match err {
ParseError::InvalidAuthority { raw, reason } => {
assert!(raw.contains("host:not-a-port"));
assert!(reason.contains("invalid port"), "got reason: {reason}");
}
other => panic!("expected InvalidAuthority, got {other:?}"),
}
}
#[test]
fn ipv6_literal_with_port_works() {
let ep = parse_endpoint("vectorizer://[::1]:15503").unwrap();
assert_eq!(
ep,
Endpoint::Rpc {
host: "[::1]".into(),
port: 15503,
}
);
}
#[test]
fn ipv6_literal_without_port_defaults() {
let ep = parse_endpoint("vectorizer://[::1]").unwrap();
assert_eq!(
ep,
Endpoint::Rpc {
host: "[::1]".into(),
port: DEFAULT_RPC_PORT,
}
);
}
}