Skip to main content

kellnr_common/
base_url.rs

1use std::fmt;
2
3use thiserror::Error;
4use url::Url;
5
6/// An http or https URL whose path ends in a slash, the base of the URLs of
7/// an upstream service like the crates.io index.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct BaseUrl(Url);
10
11#[derive(Debug, Clone, PartialEq, Eq, Error)]
12pub enum BaseUrlError {
13    #[error("must be an http or https URL, got {0}")]
14    NotHttp(Url),
15}
16
17impl BaseUrl {
18    /// Accepts `url` with or without a trailing slash.
19    pub fn new(mut url: Url) -> Result<Self, BaseUrlError> {
20        if !matches!(url.scheme(), "http" | "https") {
21            return Err(BaseUrlError::NotHttp(url));
22        }
23        if !url.path().ends_with('/') {
24            let path = format!("{}/", url.path());
25            url.set_path(&path);
26        }
27        Ok(Self(url))
28    }
29
30    /// Appends the `/` separated segments of `path` to the base URL. Unlike
31    /// [`Url::join`], this keeps every segment of the base URL: a leading
32    /// slash, `.` and `..` segments in `path` are ignored, and characters
33    /// like `?` and `#` are percent-encoded.
34    pub fn join(&self, path: &str) -> Url {
35        let mut url = self.0.clone();
36        url.set_query(None);
37        url.set_fragment(None);
38        url.path_segments_mut()
39            .expect("http and https URLs have a path")
40            .pop_if_empty()
41            .extend(path.trim_start_matches('/').split('/'));
42        url
43    }
44
45    pub fn as_url(&self) -> &Url {
46        &self.0
47    }
48
49    pub fn as_str(&self) -> &str {
50        self.0.as_str()
51    }
52}
53
54impl TryFrom<Url> for BaseUrl {
55    type Error = BaseUrlError;
56
57    fn try_from(url: Url) -> Result<Self, Self::Error> {
58        Self::new(url)
59    }
60}
61
62impl fmt::Display for BaseUrl {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        self.0.fmt(f)
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    fn base(url: &str) -> BaseUrl {
73        BaseUrl::new(Url::parse(url).unwrap()).unwrap()
74    }
75
76    #[test]
77    fn new_appends_a_trailing_slash() {
78        assert_eq!(
79            base("https://mirror.example.com/crates").as_str(),
80            "https://mirror.example.com/crates/"
81        );
82        assert_eq!(
83            base("https://mirror.example.com/crates/").as_str(),
84            "https://mirror.example.com/crates/"
85        );
86        assert_eq!(
87            base("http://mirror.example.com").as_str(),
88            "http://mirror.example.com/"
89        );
90    }
91
92    #[test]
93    fn new_rejects_urls_that_are_not_http() {
94        for url in [
95            "sparse+https://index.crates.io/",
96            "ftp://example.com/",
97            "file:///tmp/",
98        ] {
99            assert!(
100                matches!(
101                    BaseUrl::new(Url::parse(url).unwrap()),
102                    Err(BaseUrlError::NotHttp(_))
103                ),
104                "{url}"
105            );
106        }
107    }
108
109    /// `Url::join` drops the last segment of a base URL without trailing
110    /// slash, and all segments for a path with a leading slash.
111    #[test]
112    fn join_keeps_all_path_segments() {
113        let base = base("https://mirror.example.com/api/v1/crates");
114        assert_eq!(
115            base.join("serde").as_str(),
116            "https://mirror.example.com/api/v1/crates/serde"
117        );
118        assert_eq!(
119            base.join("/serde").as_str(),
120            "https://mirror.example.com/api/v1/crates/serde"
121        );
122        assert_eq!(
123            base.join("serde/1.0.0/download").as_str(),
124            "https://mirror.example.com/api/v1/crates/serde/1.0.0/download"
125        );
126        assert_eq!(
127            base.join("../../serde").as_str(),
128            "https://mirror.example.com/api/v1/crates/serde"
129        );
130    }
131
132    #[test]
133    fn join_matches_url_join_for_crate_paths() {
134        let base = base("https://index.crates.io/");
135        for path in ["se/rd/serde", "3/s/syn", "1/a", "serde/serde-1.0.0.crate"] {
136            assert_eq!(base.join(path), base.as_url().join(path).unwrap(), "{path}");
137        }
138    }
139
140    #[test]
141    fn join_encodes_query_and_fragment_characters() {
142        let base = base("https://index.crates.io/?x=1");
143        assert_eq!(
144            base.join("a?b#c").as_str(),
145            "https://index.crates.io/a%3Fb%23c"
146        );
147    }
148}