Skip to main content

topcoat_core/
base_url.rs

1//! The base URL an application is publicly reachable at.
2
3use core::fmt;
4use std::str::FromStr;
5
6use crate::context::{Cx, try_app_context};
7
8/// The absolute URL an application is publicly reachable at, like
9/// `https://example.com`.
10///
11/// Relative URLs work anywhere within the site, but rendered content that
12/// leaves it (e.g. links and images in emails, feeds, or sitemaps) needs
13/// the absolute form, resolved against this base. A base URL is an `http` or
14/// `https` URL with a host and an optional path prefix (for applications
15/// mounted under one, like `https://example.com/app`), and no query or
16/// fragment. The string is parsed at construction, so every value of this
17/// type holds a well-formed base.
18///
19/// Register one on the router builder with `.base_url(...)`, read it back
20/// with [`base_url`] or [`try_base_url`], and resolve paths against it with
21/// [`join`](BaseUrl::join):
22///
23/// ```
24/// use topcoat::context::BaseUrl;
25///
26/// let base = BaseUrl::new("https://example.com")?;
27/// assert_eq!(
28///     base.join("/assets/logo.png"),
29///     "https://example.com/assets/logo.png"
30/// );
31/// # Ok::<(), topcoat::context::BaseUrlError>(())
32/// ```
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct BaseUrl {
35    /// The normalized base: lowercase scheme, authority, and path prefix,
36    /// without a trailing slash.
37    url: String,
38}
39
40impl BaseUrl {
41    /// Parses a base URL.
42    ///
43    /// The scheme and any trailing slash are normalized, so
44    /// `https://example.com/app/` and `https://example.com/app` are the
45    /// same base.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`BaseUrlError`] if the string is not an absolute `http` or
50    /// `https` URL, or carries a query string or fragment.
51    pub fn new(url: impl AsRef<str>) -> Result<BaseUrl, BaseUrlError> {
52        let url = url.as_ref();
53        // `http::Uri` silently discards fragments, so reject them upfront.
54        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    /// Resolves a root-relative path into an absolute URL.
77    ///
78    /// The path is taken as relative to the application root whether or not
79    /// it starts with a slash: `base.join("/assets/logo.png")` and
80    /// `base.join("assets/logo.png")` produce the same URL. A query string
81    /// on the path is carried through.
82    #[must_use]
83    pub fn join(&self, path: &str) -> String {
84        format!("{}/{}", self.url, path.trim_start_matches('/'))
85    }
86
87    /// The base URL as a string, without a trailing slash.
88    #[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/// The reason a string was rejected as a base URL.
139#[derive(Debug, thiserror::Error)]
140pub enum BaseUrlError {
141    /// The string is not a valid URL.
142    #[error("invalid base URL: {0}")]
143    Invalid(#[source] http::uri::InvalidUri),
144    /// The URL has no scheme or host, like `example.com/app` or `/app`.
145    #[error("base URL must be absolute, like `https://example.com`")]
146    NotAbsolute,
147    /// The scheme is neither `http` nor `https`.
148    #[error("base URL scheme must be `http` or `https`")]
149    UnsupportedScheme,
150    /// The URL carries a query string, which a base cannot have.
151    #[error("base URL cannot have a query string")]
152    HasQuery,
153    /// The URL carries a fragment, which a base cannot have.
154    #[error("base URL cannot have a fragment")]
155    HasFragment,
156}
157
158/// Returns the [`BaseUrl`] registered on the router.
159///
160/// # Panics
161///
162/// Panics if no base URL has been registered. Register one on the router
163/// builder with `.base_url(...)`.
164///
165/// # Examples
166///
167/// ```rust
168/// use topcoat::context::{Cx, base_url};
169///
170/// fn logo_url(cx: &Cx) -> String {
171///     base_url(cx).join("/assets/logo.png")
172/// }
173/// ```
174#[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/// Returns the [`BaseUrl`] registered on the router, or `None` if none has
186/// been registered.
187#[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}