1use core::fmt;
4use std::str::FromStr;
5
6use crate::context::{Cx, try_app_context};
7
8#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct BaseUrl {
35 url: String,
38}
39
40impl BaseUrl {
41 pub fn new(url: impl AsRef<str>) -> Result<BaseUrl, BaseUrlError> {
52 let url = url.as_ref();
53 if url.contains('#') {
55 return Err(BaseUrlError::HasFragment);
56 }
57 let uri: http::Uri = url.parse().map_err(BaseUrlError::Invalid)?;
58 let Some(scheme) = uri.scheme_str() else {
59 return Err(BaseUrlError::NotAbsolute);
60 };
61 if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
62 return Err(BaseUrlError::UnsupportedScheme);
63 }
64 let Some(authority) = uri.authority() else {
65 return Err(BaseUrlError::NotAbsolute);
66 };
67 if uri.query().is_some() {
68 return Err(BaseUrlError::HasQuery);
69 }
70 let path = uri.path().trim_end_matches('/');
71 Ok(BaseUrl {
72 url: format!("{}://{authority}{path}", scheme.to_ascii_lowercase()),
73 })
74 }
75
76 #[must_use]
83 pub fn join(&self, path: &str) -> String {
84 format!("{}/{}", self.url, path.trim_start_matches('/'))
85 }
86
87 #[must_use]
89 pub fn as_str(&self) -> &str {
90 &self.url
91 }
92}
93
94impl fmt::Display for BaseUrl {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 f.write_str(&self.url)
97 }
98}
99
100impl FromStr for BaseUrl {
101 type Err = BaseUrlError;
102
103 fn from_str(s: &str) -> Result<BaseUrl, BaseUrlError> {
104 BaseUrl::new(s)
105 }
106}
107
108impl TryFrom<&str> for BaseUrl {
109 type Error = BaseUrlError;
110
111 fn try_from(url: &str) -> Result<BaseUrl, BaseUrlError> {
112 url.parse()
113 }
114}
115
116impl TryFrom<String> for BaseUrl {
117 type Error = BaseUrlError;
118
119 fn try_from(url: String) -> Result<BaseUrl, BaseUrlError> {
120 url.parse()
121 }
122}
123
124impl TryFrom<&String> for BaseUrl {
125 type Error = BaseUrlError;
126
127 fn try_from(url: &String) -> Result<BaseUrl, BaseUrlError> {
128 url.parse()
129 }
130}
131
132impl From<&BaseUrl> for BaseUrl {
133 fn from(base_url: &BaseUrl) -> BaseUrl {
134 base_url.clone()
135 }
136}
137
138#[derive(Debug, thiserror::Error)]
140pub enum BaseUrlError {
141 #[error("invalid base URL: {0}")]
143 Invalid(#[source] http::uri::InvalidUri),
144 #[error("base URL must be absolute, like `https://example.com`")]
146 NotAbsolute,
147 #[error("base URL scheme must be `http` or `https`")]
149 UnsupportedScheme,
150 #[error("base URL cannot have a query string")]
152 HasQuery,
153 #[error("base URL cannot have a fragment")]
155 HasFragment,
156}
157
158#[must_use]
175pub fn base_url(cx: &Cx) -> &BaseUrl {
176 match try_base_url(cx) {
177 Some(base_url) => base_url,
178 None => panic!(
179 "attempted to access the base URL, but none was registered; \
180 register one on the router builder with `.base_url(...)`"
181 ),
182 }
183}
184
185#[must_use]
188pub fn try_base_url(cx: &Cx) -> Option<&BaseUrl> {
189 try_app_context(cx)
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::context::CxTestBuilder;
196
197 #[test]
198 fn accepts_and_normalizes_absolute_http_urls() -> Result<(), BaseUrlError> {
199 let base = BaseUrl::new("https://example.com")?;
200 assert_eq!(base.as_str(), "https://example.com");
201 assert_eq!(base.to_string(), "https://example.com");
202
203 let dev: BaseUrl = "http://localhost:3000".parse()?;
204 assert_eq!(dev.as_str(), "http://localhost:3000");
205
206 let prefixed = BaseUrl::try_from("https://example.com/app/")?;
207 assert_eq!(prefixed.as_str(), "https://example.com/app");
208
209 let uppercase = BaseUrl::new("HTTPS://example.com/")?;
210 assert_eq!(uppercase.as_str(), "https://example.com");
211
212 Ok(())
213 }
214
215 #[test]
216 fn rejects_urls_that_cannot_serve_as_a_base() {
217 assert!(matches!(
218 BaseUrl::new("example.com"),
219 Err(BaseUrlError::NotAbsolute)
220 ));
221 assert!(matches!(
222 BaseUrl::new("/app"),
223 Err(BaseUrlError::NotAbsolute)
224 ));
225 assert!(matches!(
226 BaseUrl::new("ftp://example.com"),
227 Err(BaseUrlError::UnsupportedScheme)
228 ));
229 assert!(matches!(
230 BaseUrl::new("https://example.com?page=1"),
231 Err(BaseUrlError::HasQuery)
232 ));
233 assert!(matches!(
234 BaseUrl::new("https://example.com#top"),
235 Err(BaseUrlError::HasFragment)
236 ));
237 assert!(matches!(
238 BaseUrl::new("https://exa mple.com"),
239 Err(BaseUrlError::Invalid(_))
240 ));
241 }
242
243 #[test]
244 fn joins_paths_below_the_base() -> Result<(), BaseUrlError> {
245 let base = BaseUrl::new("https://example.com")?;
246 assert_eq!(
247 base.join("/assets/logo.png"),
248 "https://example.com/assets/logo.png"
249 );
250 assert_eq!(
251 base.join("assets/logo.png"),
252 "https://example.com/assets/logo.png"
253 );
254 assert_eq!(
255 base.join("/posts?page=2"),
256 "https://example.com/posts?page=2"
257 );
258 assert_eq!(base.join("/"), "https://example.com/");
259
260 let prefixed = BaseUrl::new("https://example.com/app")?;
261 assert_eq!(
262 prefixed.join("/assets/logo.png"),
263 "https://example.com/app/assets/logo.png"
264 );
265
266 Ok(())
267 }
268
269 #[test]
270 fn reads_the_registered_base_url_from_context() -> Result<(), BaseUrlError> {
271 let cx = CxTestBuilder::new()
272 .app_context(BaseUrl::new("https://example.com")?)
273 .build();
274
275 assert_eq!(base_url(&cx).as_str(), "https://example.com");
276 assert_eq!(
277 try_base_url(&cx),
278 Some(&BaseUrl::new("https://example.com")?)
279 );
280
281 Ok(())
282 }
283
284 #[test]
285 fn try_base_url_is_none_when_unregistered() {
286 let cx = Cx::default();
287 assert_eq!(try_base_url(&cx), None);
288 }
289
290 #[test]
291 #[should_panic(expected = "attempted to access the base URL")]
292 fn base_url_panics_when_unregistered() {
293 let cx = Cx::default();
294 let _ = base_url(&cx);
295 }
296}