Skip to main content

motorcortex_rust/
url.rs

1//! Free helpers for the Motorcortex URL convention.
2//!
3//! The server can be reached two ways:
4//! 1. Two distinct ports on the same host — Req/Rep and Pub/Sub each
5//!    get their own: `scheme://host:req_port:sub_port`.
6//! 2. One host, no ports — typical behind an nginx reverse proxy that
7//!    routes `/mcx_req` and `/mcx_sub` paths to the two backends:
8//!    `scheme://host` is expanded to `scheme://host/mcx_req` and
9//!    `scheme://host/mcx_sub`.
10//!
11//! `parse_url` returns `(req_url, sub_url)` for either form. Supports
12//! hostnames, IPv4, and IPv6 (bracketed) hosts.
13
14use crate::error::{MotorcortexError, Result};
15
16/// Default path endpoints appended when no ports are present —
17/// matches motorcortex-python's `parseUrl` fallback.
18const DEFAULT_REQ_PATH: &str = "/mcx_req";
19const DEFAULT_SUB_PATH: &str = "/mcx_sub";
20
21/// Split a Motorcortex URL into `(req_url, sub_url)`.
22///
23/// Two input forms are accepted:
24/// - `scheme://host:req_port:sub_port` → each port attached to its
25///   own copy of the host.
26/// - `scheme://host` → `/mcx_req` / `/mcx_sub` appended, matching the
27///   nginx reverse-proxy convention used by motorcortex-python.
28///
29/// Examples:
30///
31/// ```
32/// use motorcortex_rust::parse_url;
33/// let (req, sub) = parse_url("wss://host:5568:5567").unwrap();
34/// assert_eq!(req, "wss://host:5568");
35/// assert_eq!(sub, "wss://host:5567");
36///
37/// let (req, sub) = parse_url("wss://host").unwrap();
38/// assert_eq!(req, "wss://host/mcx_req");
39/// assert_eq!(sub, "wss://host/mcx_sub");
40/// ```
41///
42/// Errors with [`MotorcortexError::Connection`] if the scheme is
43/// missing, an IPv6 bracket is unclosed, only one port is given, or
44/// either port isn't parseable as a `u16`.
45pub fn parse_url(s: &str) -> Result<(String, String)> {
46    let scheme_end = s
47        .find("://")
48        .ok_or_else(|| invalid("missing `scheme://` prefix"))?;
49    let scheme_prefix = &s[..scheme_end + 3]; // includes `://`
50    let after = &s[scheme_end + 3..];
51
52    // Split host from the port tail. Anything after the host (the
53    // closing `]` for IPv6, or the first `:` otherwise) is treated as
54    // the tail; an empty tail means "no ports — use default paths".
55    let (host, port_tail) = if let Some(rest) = after.strip_prefix('[') {
56        let close = rest
57            .find(']')
58            .ok_or_else(|| invalid("IPv6 host missing closing `]`"))?;
59        let host = &after[..=close + 1]; // `[` + inner + `]`
60        let tail = &rest[close + 1..]; // everything after `]`
61        (host, tail)
62    } else {
63        match after.find(':') {
64            Some(i) => (&after[..i], &after[i..]),
65            None => (after, ""),
66        }
67    };
68
69    if port_tail.is_empty() {
70        return Ok((
71            format!("{scheme_prefix}{host}{DEFAULT_REQ_PATH}"),
72            format!("{scheme_prefix}{host}{DEFAULT_SUB_PATH}"),
73        ));
74    }
75
76    let ports = port_tail
77        .strip_prefix(':')
78        .ok_or_else(|| invalid("expected `:` before ports"))?;
79    let colon = ports
80        .find(':')
81        .ok_or_else(|| invalid("need two ports separated by `:`"))?;
82    let req_port = &ports[..colon];
83    let sub_port = &ports[colon + 1..];
84
85    req_port
86        .parse::<u16>()
87        .map_err(|_| invalid(&format!("req_port {req_port:?} is not a valid u16")))?;
88    sub_port
89        .parse::<u16>()
90        .map_err(|_| invalid(&format!("sub_port {sub_port:?} is not a valid u16")))?;
91
92    Ok((
93        format!("{scheme_prefix}{host}:{req_port}"),
94        format!("{scheme_prefix}{host}:{sub_port}"),
95    ))
96}
97
98fn invalid(msg: &str) -> MotorcortexError {
99    MotorcortexError::Connection(format!("parse_url: {msg}"))
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn hostname_with_both_ports() {
108        let (r, s) = parse_url("wss://host:5568:5567").unwrap();
109        assert_eq!(r, "wss://host:5568");
110        assert_eq!(s, "wss://host:5567");
111    }
112
113    #[test]
114    fn ipv4_with_both_ports() {
115        let (r, s) = parse_url("wss://192.168.1.10:5568:5567").unwrap();
116        assert_eq!(r, "wss://192.168.1.10:5568");
117        assert_eq!(s, "wss://192.168.1.10:5567");
118    }
119
120    #[test]
121    fn ipv6_loopback_with_both_ports() {
122        let (r, s) = parse_url("wss://[::1]:5568:5567").unwrap();
123        assert_eq!(r, "wss://[::1]:5568");
124        assert_eq!(s, "wss://[::1]:5567");
125    }
126
127    #[test]
128    fn ipv6_full_address() {
129        let (r, s) =
130            parse_url("wss://[2001:db8::8a2e:370:7334]:5568:5567").unwrap();
131        assert_eq!(r, "wss://[2001:db8::8a2e:370:7334]:5568");
132        assert_eq!(s, "wss://[2001:db8::8a2e:370:7334]:5567");
133    }
134
135    #[test]
136    fn tcp_scheme() {
137        let (r, s) = parse_url("tcp://host:5568:5567").unwrap();
138        assert_eq!(r, "tcp://host:5568");
139        assert_eq!(s, "tcp://host:5567");
140    }
141
142    #[test]
143    fn missing_scheme_errors() {
144        let err = parse_url("host:5568:5567").unwrap_err();
145        assert!(matches!(err, MotorcortexError::Connection(ref m) if m.contains("scheme")));
146    }
147
148    #[test]
149    fn hostname_no_ports_uses_default_paths() {
150        let (r, s) = parse_url("wss://host").unwrap();
151        assert_eq!(r, "wss://host/mcx_req");
152        assert_eq!(s, "wss://host/mcx_sub");
153    }
154
155    #[test]
156    fn ipv4_no_ports_uses_default_paths() {
157        let (r, s) = parse_url("wss://192.168.2.100").unwrap();
158        assert_eq!(r, "wss://192.168.2.100/mcx_req");
159        assert_eq!(s, "wss://192.168.2.100/mcx_sub");
160    }
161
162    #[test]
163    fn ipv6_no_ports_uses_default_paths() {
164        let (r, s) = parse_url("wss://[::1]").unwrap();
165        assert_eq!(r, "wss://[::1]/mcx_req");
166        assert_eq!(s, "wss://[::1]/mcx_sub");
167    }
168
169    #[test]
170    fn ipv6_link_local_no_ports_uses_default_paths() {
171        let (r, _) = parse_url("wss://[fe80::1]").unwrap();
172        assert_eq!(r, "wss://[fe80::1]/mcx_req");
173    }
174
175    #[test]
176    fn single_port_errors() {
177        // Only one port — we need two.
178        let err = parse_url("wss://host:5568").unwrap_err();
179        assert!(matches!(err, MotorcortexError::Connection(ref m) if m.contains("two ports")));
180    }
181
182    #[test]
183    fn non_numeric_port_errors() {
184        let err = parse_url("wss://host:5568:abc").unwrap_err();
185        assert!(matches!(err, MotorcortexError::Connection(ref m) if m.contains("sub_port")));
186    }
187
188    #[test]
189    fn ipv6_unclosed_bracket_errors() {
190        let err = parse_url("wss://[::1:5568:5567").unwrap_err();
191        assert!(matches!(err, MotorcortexError::Connection(ref m) if m.contains("closing")));
192    }
193
194    #[test]
195    fn ipv6_missing_colon_after_bracket_errors() {
196        // `]` followed by something other than `:`.
197        let err = parse_url("wss://[::1]5568:5567").unwrap_err();
198        assert!(matches!(err, MotorcortexError::Connection(ref m) if m.contains(":")));
199    }
200
201    #[test]
202    fn port_above_u16_max_errors() {
203        let err = parse_url("wss://host:70000:5567").unwrap_err();
204        assert!(matches!(err, MotorcortexError::Connection(ref m) if m.contains("req_port")));
205    }
206}