kellnr_common/
base_url.rs1use std::fmt;
2
3use thiserror::Error;
4use url::Url;
5
6#[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 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 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 #[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}