thunder/client/
endpoint.rs1use crate::wire::Config;
13
14use crate::client::error::ClientError;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Endpoint {
19 pub host: String,
21 pub port: u16,
23}
24
25pub fn parse_endpoint(input: &str, config: &Config) -> Result<Endpoint, ClientError> {
36 let input = input.trim();
37 if let Some((scheme, rest)) = input.split_once("://") {
38 let scheme = scheme.to_ascii_lowercase();
39 if scheme == "http" || scheme == "https" {
40 return Err(invalid(format!(
41 "'{input}' is an HTTP URL and Thunder is RPC-only — use the application's HTTP \
42 client for REST endpoints, or pass an RPC endpoint such as \
43 'scheme://host:port' or bare 'host:port'"
44 )));
45 }
46 if scheme != config.scheme {
47 let configured = config.scheme;
48 return Err(invalid(format!(
49 "endpoint scheme '{scheme}' does not match this client's configured scheme \
50 '{configured}' — set the scheme on the Config, or use bare 'host:port'"
51 )));
52 }
53 let rest = rest.strip_suffix('/').unwrap_or(rest);
54 if rest.contains('/') {
55 return Err(invalid(format!(
56 "endpoint '{input}' must not carry a path — expected {scheme}://host[:port]"
57 )));
58 }
59 let (host, port) = split_host_port(rest)?;
60 Ok(Endpoint {
61 host,
62 port: port.unwrap_or(config.default_port),
63 })
64 } else {
65 let (host, port) = split_host_port(input)?;
66 let port = port.ok_or_else(|| {
67 invalid(format!(
68 "bare endpoint '{input}' needs an explicit port ('host:port') — only \
69 scheme-prefixed endpoints resolve a registry default port"
70 ))
71 })?;
72 Ok(Endpoint { host, port })
73 }
74}
75
76fn split_host_port(s: &str) -> Result<(String, Option<u16>), ClientError> {
78 if s.is_empty() {
79 return Err(invalid("endpoint host is empty".to_owned()));
80 }
81 if let Some(inner) = s.strip_prefix('[') {
82 let (host, tail) = inner
83 .split_once(']')
84 .ok_or_else(|| invalid(format!("unterminated '[' in endpoint host '{s}'")))?;
85 if host.is_empty() {
86 return Err(invalid("endpoint host is empty".to_owned()));
87 }
88 return match tail {
89 "" => Ok((host.to_owned(), None)),
90 t => {
91 let port = t.strip_prefix(':').ok_or_else(|| {
92 invalid(format!("expected ':port' after ']' in endpoint '{s}'"))
93 })?;
94 Ok((host.to_owned(), Some(parse_port(port, s)?)))
95 }
96 };
97 }
98 match s.rsplit_once(':') {
99 Some((head, _)) if head.contains(':') => Ok((s.to_owned(), None)),
101 Some((host, port)) => {
102 if host.is_empty() {
103 return Err(invalid("endpoint host is empty".to_owned()));
104 }
105 Ok((host.to_owned(), Some(parse_port(port, s)?)))
106 }
107 None => Ok((s.to_owned(), None)),
108 }
109}
110
111fn parse_port(port: &str, whole: &str) -> Result<u16, ClientError> {
112 port.parse::<u16>()
113 .map_err(|_| invalid(format!("invalid port '{port}' in endpoint '{whole}'")))
114}
115
116fn invalid(message: String) -> ClientError {
117 ClientError::Connection { message }
118}
119
120#[cfg(test)]
121#[allow(clippy::unwrap_used, clippy::expect_used)]
122mod tests {
123 use super::*;
124
125 fn app() -> Config {
128 Config::standard().scheme("myapp").port(9000)
129 }
130
131 #[test]
132 fn the_configured_scheme_resolves_the_configured_default_port() {
133 let ep = parse_endpoint("myapp://db.example.com", &app()).unwrap();
136 assert_eq!(ep.host, "db.example.com");
137 assert_eq!(ep.port, 9000);
138 }
139
140 #[test]
141 fn any_application_can_pick_any_scheme_without_a_thunder_release() {
142 let future = Config::standard()
145 .scheme("something-new-in-2030")
146 .port(4242);
147 let ep = parse_endpoint("something-new-in-2030://host", &future).unwrap();
148 assert_eq!(ep.port, 4242);
149 }
150
151 #[test]
152 fn explicit_port_wins_over_default() {
153 let ep = parse_endpoint("myapp://10.0.0.7:9999", &app()).unwrap();
154 assert_eq!(
155 ep,
156 Endpoint {
157 host: "10.0.0.7".to_owned(),
158 port: 9999
159 }
160 );
161 }
162
163 #[test]
164 fn bare_host_port_is_accepted_rpc_implied() {
165 let ep = parse_endpoint("localhost:15501", &app()).unwrap();
166 assert_eq!(
167 ep,
168 Endpoint {
169 host: "localhost".to_owned(),
170 port: 15501
171 }
172 );
173 }
174
175 #[test]
176 fn bare_host_port_works_even_with_no_scheme_configured() {
177 let ep = parse_endpoint("localhost:15501", &Config::standard()).unwrap();
180 assert_eq!(ep.port, 15501);
181 }
182
183 #[test]
184 fn bare_host_without_port_is_rejected() {
185 let err = parse_endpoint("localhost", &app()).unwrap_err();
186 assert!(matches!(err, ClientError::Connection { .. }));
187 }
188
189 #[test]
190 fn http_and_https_are_rejected_with_pointer_to_http_client() {
191 for url in ["http://vec.example.com:8080", "https://vec.example.com"] {
192 let err = parse_endpoint(url, &app()).unwrap_err();
193 let ClientError::Connection { message } = err else {
194 panic!("expected the connection class, got {err:?}");
195 };
196 assert!(
197 message.contains("RPC-only") && message.contains("HTTP client"),
198 "rejection must point at the application's HTTP client: {message}"
199 );
200 }
201 }
202
203 #[test]
204 fn a_scheme_other_than_the_configured_one_is_rejected() {
205 let err = parse_endpoint("redis://h:1", &app()).unwrap_err();
206 let ClientError::Connection { message } = err else {
207 panic!("expected the connection class");
208 };
209 assert!(
210 message.contains("redis") && message.contains("myapp"),
211 "the mismatch must name both the given and the configured scheme: {message}"
212 );
213 }
214
215 #[test]
216 fn ipv6_literals_parse_with_and_without_brackets() {
217 let ep = parse_endpoint("[::1]:8080", &app()).unwrap();
218 assert_eq!(
219 ep,
220 Endpoint {
221 host: "::1".to_owned(),
222 port: 8080
223 }
224 );
225 let ep = parse_endpoint("myapp://[fe80::1]", &app()).unwrap();
226 assert_eq!(ep.host, "fe80::1");
227 assert_eq!(ep.port, 9000);
228 }
229
230 #[test]
231 fn trailing_slash_is_tolerated_but_paths_are_not() {
232 let ep = parse_endpoint("myapp://h/", &app()).unwrap();
233 assert_eq!(ep.port, 9000);
234 assert!(parse_endpoint("myapp://h/db", &app()).is_err());
235 }
236
237 #[test]
238 fn invalid_ports_are_rejected() {
239 assert!(parse_endpoint("host:99999", &app()).is_err());
240 assert!(parse_endpoint("host:abc", &app()).is_err());
241 }
242
243 #[test]
244 fn empty_host_is_rejected() {
245 assert!(parse_endpoint("myapp://:1234", &app()).is_err());
246 assert!(parse_endpoint(":1234", &app()).is_err());
247 }
248}