Skip to main content

rustlavel_client/
url.rs

1//! URL parsing for outbound requests.
2
3use rustlavel_core::{Error, Result};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct Url {
7    pub secure: bool,
8    pub host: String,
9    pub port: u16,
10    /// Path and query together, which is exactly what goes on the request line.
11    pub target: String,
12}
13
14impl Url {
15    pub fn parse(input: &str) -> Result<Url> {
16        let (scheme, rest) = input
17            .split_once("://")
18            .ok_or_else(|| Error::msg(format!("`{input}` has no scheme; expected http:// or https://")))?;
19
20        let secure = match scheme.to_ascii_lowercase().as_str() {
21            "https" => true,
22            "http" => false,
23            other => {
24                return Err(Error::msg(format!(
25                    "`{other}` is not a scheme this client speaks; use http or https"
26                )));
27            }
28        };
29
30        let (authority, path) = match rest.find('/') {
31            Some(at) => (&rest[..at], &rest[at..]),
32            None => (rest, "/"),
33        };
34
35        // Credentials in a URL are rejected rather than silently dropped: a
36        // caller who put them there expects them to be sent.
37        if authority.contains('@') {
38            return Err(Error::msg(
39                "credentials in a URL are not supported; send an Authorization header instead"
40                    .to_string(),
41            ));
42        }
43
44        let (host, port) = match authority.rsplit_once(':') {
45            Some((host, port)) => {
46                let port = port
47                    .parse()
48                    .map_err(|_| Error::msg(format!("`{port}` is not a valid port")))?;
49                (host, port)
50            }
51            None => (authority, if secure { 443 } else { 80 }),
52        };
53
54        if host.is_empty() {
55            return Err(Error::msg(format!("`{input}` has no host")));
56        }
57
58        Ok(Url {
59            secure,
60            host: host.to_string(),
61            port,
62            target: if path.is_empty() { "/".to_string() } else { path.to_string() },
63        })
64    }
65
66    /// `host:port`, for connecting and for the `Host` header.
67    pub fn authority(&self) -> String {
68        let default = if self.secure { 443 } else { 80 };
69        if self.port == default {
70            self.host.clone()
71        } else {
72            format!("{}:{}", self.host, self.port)
73        }
74    }
75
76    pub fn socket_address(&self) -> String {
77        format!("{}:{}", self.host, self.port)
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn parses_the_common_shapes() {
87        let url = Url::parse("https://api.anthropic.com/v1/messages").unwrap();
88        assert!(url.secure);
89        assert_eq!(url.host, "api.anthropic.com");
90        assert_eq!(url.port, 443);
91        assert_eq!(url.target, "/v1/messages");
92        assert_eq!(url.authority(), "api.anthropic.com");
93
94        let local = Url::parse("http://127.0.0.1:11434/api/chat?stream=true").unwrap();
95        assert!(!local.secure);
96        assert_eq!(local.port, 11434);
97        assert_eq!(local.target, "/api/chat?stream=true");
98        assert_eq!(local.authority(), "127.0.0.1:11434");
99    }
100
101    #[test]
102    fn a_bare_host_gets_a_root_path() {
103        assert_eq!(Url::parse("https://example.com").unwrap().target, "/");
104    }
105
106    #[test]
107    fn rejects_what_it_cannot_send_correctly() {
108        assert!(Url::parse("example.com/path").is_err());
109        assert!(Url::parse("ftp://example.com").is_err());
110        assert!(Url::parse("https://user:pass@example.com").is_err());
111        assert!(Url::parse("https://example.com:notaport/").is_err());
112    }
113}