Skip to main content

thunder/client/
endpoint.rs

1//! Endpoint parsing (CLT-070/071).
2//!
3//! Accepts `scheme://host[:port]` where the scheme is **the application's
4//! own**, taken from its [`Config`] — Thunder has no registry of schemes to
5//! consult and no product's parser to fork (PRO-012) — plus bare
6//! `host:port` (RPC implied). `http(s)://` URLs are rejected with a pointer
7//! to the application's HTTP client: Thunder is RPC-only.
8//!
9//! Parse failures use the [`ClientError::Connection`] class — an endpoint
10//! that cannot be parsed is an endpoint that cannot be dialed.
11
12use crate::wire::Config;
13
14use crate::client::error::ClientError;
15
16/// A resolved RPC endpoint: host plus concrete port.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Endpoint {
19    /// Host name or IP literal (IPv6 without brackets).
20    pub host: String,
21    /// Concrete port — explicit, or the config's `default_port`.
22    pub port: u16,
23}
24
25/// Parse an endpoint string against the application's [`Config`] (CLT-070).
26///
27/// Accepted forms:
28/// - `scheme://host[:port]` where `scheme` is `config.scheme`; a missing
29///   port resolves to `config.default_port` (CLT-071).
30/// - bare `host:port` (RPC implied).
31/// - `[v6::addr]:port` / `scheme://[v6::addr][:port]` for IPv6 literals.
32///
33/// `http://` / `https://` are rejected: Thunder is RPC-only; REST
34/// endpoints belong to the application's HTTP client.
35pub 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
76/// Split `host[:port]`, handling bracketed IPv6 literals.
77fn 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        // More than one ':' without brackets: an IPv6 literal, no port.
100        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    /// An application's config — Thunder ships no schemes of its own, so
126    /// the tests bring their own, exactly as an application does.
127    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        // CLT-071: scheme → default port comes from the application's own
134        // config, not from any registry Thunder carries.
135        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        // The whole point of dropping the registry: a scheme Thunder has
143        // never heard of works because the application configured it.
144        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        // Config::standard() has no identity until an application gives it
178        // one; an explicit host:port needs none.
179        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}