Skip to main content

webserver_base/webserver/
pages.rs

1//! Page declarations that produce both the route table and the sitemap.
2//!
3//! A page is declared once, so the two cannot drift, and leaving one out of the
4//! sitemap is something you type ([`Pages::unlisted`]) rather than forget.
5//!
6//! Only pages live here — API routes, WebSocket upgrades and static file
7//! services stay ordinary axum.
8
9use std::sync::Arc;
10
11use axum::extract::State;
12use axum::response::{IntoResponse, Response};
13use axum::routing::{MethodRouter, get};
14use chrono::{DateTime, Utc};
15use serde::Serialize;
16use serde_json::Value;
17use tracing::error;
18
19use crate::sitemap::SitemapUrl;
20use crate::templates::PageTemplateData;
21
22use super::error::WebServerError;
23use super::state::WebServerState;
24
25/// Whether a page appears in `sitemap.xml`.
26#[derive(Debug, Clone)]
27enum Listing {
28    Listed(Vec<SitemapUrl>),
29    Unlisted,
30}
31
32/// How a page produces its response.
33enum PageKind<S> {
34    /// Pure declaration — no handler function exists.
35    Static {
36        page: Arc<PageTemplateData>,
37        data: Arc<Value>,
38    },
39    /// An ordinary axum handler.
40    Dynamic(Box<MethodRouter<WebServerState<S>>>),
41}
42
43struct PageEntry<S> {
44    path: String,
45    kind: PageKind<S>,
46    listing: Listing,
47}
48
49/// The pages a site serves.
50#[derive(Default)]
51pub struct Pages<S = ()> {
52    entries: Vec<PageEntry<S>>,
53}
54
55impl<S> Pages<S>
56where
57    S: Clone + Send + Sync + 'static,
58{
59    /// An empty declaration.
60    #[must_use]
61    pub fn new() -> Self {
62        Self {
63            entries: Vec::new(),
64        }
65    }
66
67    /// A page with no logic: no handler function exists.
68    ///
69    /// Its route comes from the page's own
70    /// [`page_url`](PageTemplateData::page_url), and `data` is serialized once
71    /// at boot. Anything that varies per request needs
72    /// [`Pages::dynamic_page`].
73    ///
74    /// # Panics
75    ///
76    /// If `data` cannot be serialized.
77    #[must_use]
78    pub fn static_page<A>(mut self, page: PageTemplateData, data: A) -> Self
79    where
80        A: Serialize,
81    {
82        let path: String = page.page_url().to_string();
83        let value: Value = serde_json::to_value(data).unwrap_or_else(|error| {
84            panic!("static page `{path}` has unserializable data: {error}")
85        });
86
87        self.entries.push(PageEntry {
88            listing: Listing::Listed(vec![SitemapUrl::new(&path)]),
89            path,
90            kind: PageKind::Static {
91                page: Arc::new(page),
92                data: Arc::new(value),
93            },
94        });
95        self
96    }
97
98    /// A page backed by an ordinary axum handler. For a parameterised path use
99    /// [`Pages::dynamic_page_group`].
100    #[must_use]
101    pub fn dynamic_page(
102        mut self,
103        path: impl Into<String>,
104        handler: MethodRouter<WebServerState<S>>,
105    ) -> Self {
106        let path: String = path.into();
107        self.entries.push(PageEntry {
108            listing: Listing::Listed(vec![SitemapUrl::new(&path)]),
109            path,
110            kind: PageKind::Dynamic(Box::new(handler)),
111        });
112        self
113    }
114
115    /// One handler serving many URLs, with those URLs supplied for the sitemap.
116    ///
117    /// The list is built where the route is declared, so adding a blog post
118    /// cannot leave the sitemap behind.
119    #[must_use]
120    pub fn dynamic_page_group<I>(
121        mut self,
122        path: impl Into<String>,
123        handler: MethodRouter<WebServerState<S>>,
124        urls: I,
125    ) -> Self
126    where
127        I: IntoIterator<Item = SitemapUrl>,
128    {
129        self.entries.push(PageEntry {
130            path: path.into(),
131            kind: PageKind::Dynamic(Box::new(handler)),
132            listing: Listing::Listed(urls.into_iter().collect()),
133        });
134        self
135    }
136
137    /// Keeps the most recently declared page out of the sitemap.
138    #[must_use]
139    pub fn unlisted(mut self) -> Self {
140        if let Some(entry) = self.entries.last_mut() {
141            entry.listing = Listing::Unlisted;
142        }
143        self
144    }
145
146    /// Sets `<lastmod>` on the most recently declared page.
147    #[must_use]
148    pub fn with_last_modified(mut self, last_modified: DateTime<Utc>) -> Self {
149        self.map_last_urls(|url| url.with_last_modified(last_modified));
150        self
151    }
152
153    /// Adds `<image:image>` entries to the most recently declared page.
154    #[must_use]
155    pub fn extend_images<I, T>(mut self, images: I) -> Self
156    where
157        I: IntoIterator<Item = T>,
158        T: Into<String>,
159    {
160        let images: Vec<String> = images.into_iter().map(Into::into).collect();
161        self.map_last_urls(|url| url.extend_images(images.iter().cloned()));
162        self
163    }
164
165    fn map_last_urls<F>(&mut self, mut transform: F)
166    where
167        F: FnMut(SitemapUrl) -> SitemapUrl,
168    {
169        if let Some(entry) = self.entries.last_mut()
170            && let Listing::Listed(urls) = &mut entry.listing
171        {
172            for url in urls.iter_mut() {
173                *url = transform(url.clone());
174            }
175        }
176    }
177
178    /// How many pages were declared.
179    #[must_use]
180    pub fn len(&self) -> usize {
181        self.entries.len()
182    }
183
184    /// Whether nothing was declared.
185    #[must_use]
186    pub fn is_empty(&self) -> bool {
187        self.entries.is_empty()
188    }
189
190    /// Every URL that belongs in the sitemap.
191    ///
192    /// # Errors
193    ///
194    /// [`WebServerError::DynamicPagePathHasParameters`] if a listed page's path
195    /// contains a route parameter, which cannot be resolved to one URL.
196    /// Every asset path the declared pages reference.
197    ///
198    /// Static pages only: a dynamic page builds its `PageTemplateData` per
199    /// request, so there is nothing to inspect at boot.
200    #[must_use]
201    pub fn declared_assets(&self) -> Vec<String> {
202        self.entries
203            .iter()
204            .filter_map(|entry| match &entry.kind {
205                PageKind::Static { page, .. } => Some(page.declared_assets()),
206                PageKind::Dynamic(_) => None,
207            })
208            .flatten()
209            .collect()
210    }
211
212    /// Every listed page as a sitemap entry.
213    ///
214    /// # Errors
215    ///
216    /// [`WebServerError::DynamicPagePathHasParameters`] if a listed page's path
217    /// contains a route parameter, which cannot become a single sitemap URL.
218    pub fn sitemap_urls(&self) -> Result<Vec<SitemapUrl>, WebServerError> {
219        let mut urls: Vec<SitemapUrl> = Vec::new();
220
221        for entry in &self.entries {
222            let Listing::Listed(entry_urls) = &entry.listing else {
223                continue;
224            };
225            for url in entry_urls {
226                if has_route_parameter(url.path()) {
227                    return Err(WebServerError::DynamicPagePathHasParameters {
228                        path: entry.path.clone(),
229                    });
230                }
231                urls.push(url.clone());
232            }
233        }
234
235        Ok(urls)
236    }
237
238    /// The 404 declaration, if one was made.
239    /// Turns every declaration into routes.
240    pub(super) fn into_router(self) -> axum::Router<WebServerState<S>> {
241        let mut router: axum::Router<WebServerState<S>> = axum::Router::new();
242
243        for entry in self.entries {
244            let method_router: MethodRouter<WebServerState<S>> = match entry.kind {
245                PageKind::Static { page, data } => static_page_handler(page, data),
246                PageKind::Dynamic(handler) => *handler,
247            };
248            router = router.route(&entry.path, method_router);
249        }
250
251        router
252    }
253}
254
255/// Builds the handler for a page that has no handler function.
256fn static_page_handler<S>(
257    page: Arc<PageTemplateData>,
258    data: Arc<Value>,
259) -> MethodRouter<WebServerState<S>>
260where
261    S: Clone + Send + Sync + 'static,
262{
263    get(move |State(state): State<WebServerState<S>>| {
264        let page: Arc<PageTemplateData> = Arc::clone(&page);
265        let data: Arc<Value> = Arc::clone(&data);
266        async move { render_or_500(&state, &page, &data) }
267    })
268}
269
270/// Renders a declared page, turning a failure into a 500.
271pub(super) fn render_or_500<S>(
272    state: &WebServerState<S>,
273    page: &PageTemplateData,
274    data: &Value,
275) -> Response {
276    match state.render(page, data) {
277        Ok(html) => html.into_response(),
278        Err(error) => {
279            error!("failed to render `{}`: {error}", page.page_url());
280            error.into_response()
281        }
282    }
283}
284
285/// Whether an axum path pattern matches more than one URL.
286fn has_route_parameter(path: &str) -> bool {
287    path.contains(':') || path.contains('*') || path.contains('{')
288}
289
290#[cfg(test)]
291mod tests {
292
293    use chrono::TimeZone;
294
295    use crate::sitemap::SitemapUrl;
296    use crate::templates::PageTemplateData;
297    use crate::webserver::error::WebServerError;
298
299    use super::{Pages, has_route_parameter};
300
301    #[test]
302    fn a_static_page_takes_its_route_from_its_page_url() {
303        let pages: Pages = Pages::new()
304            .static_page(PageTemplateData::new("home", "Home", "/"), ())
305            .static_page(PageTemplateData::new("music", "Music", "/music"), ());
306
307        let expected_len: usize = 2;
308        let actual_len: usize = pages.len();
309        assert_eq!(expected_len, actual_len);
310
311        let expected_paths: Vec<String> = vec![String::from("/"), String::from("/music")];
312        let actual_paths: Vec<String> = pages
313            .sitemap_urls()
314            .expect("no parameters")
315            .iter()
316            .map(|url| url.path().to_string())
317            .collect();
318        assert_eq!(expected_paths, actual_paths);
319    }
320
321    #[test]
322    fn unlisted_keeps_a_page_routed_but_out_of_the_sitemap() {
323        let pages: Pages = Pages::new()
324            .static_page(PageTemplateData::new("home", "Home", "/"), ())
325            .static_page(PageTemplateData::new("secret", "Secret", "/secret"), ())
326            .unlisted();
327
328        let expected_routes: usize = 2;
329        let actual_routes: usize = pages.len();
330        assert_eq!(expected_routes, actual_routes);
331
332        let expected_listed: Vec<String> = vec![String::from("/")];
333        let actual_listed: Vec<String> = pages
334            .sitemap_urls()
335            .expect("no parameters")
336            .iter()
337            .map(|url| url.path().to_string())
338            .collect();
339        assert_eq!(expected_listed, actual_listed);
340    }
341
342    #[test]
343    fn a_group_lists_the_urls_it_was_given_not_its_pattern() {
344        let pages: Pages = Pages::new().dynamic_page_group(
345            "/blog/{slug}",
346            axum::routing::get(|| async { "post" }),
347            [
348                SitemapUrl::new("/blog/first"),
349                SitemapUrl::new("/blog/second"),
350            ],
351        );
352
353        let expected: Vec<String> = vec![String::from("/blog/first"), String::from("/blog/second")];
354        let actual: Vec<String> = pages
355            .sitemap_urls()
356            .expect("concrete urls")
357            .iter()
358            .map(|url| url.path().to_string())
359            .collect();
360        assert_eq!(expected, actual);
361    }
362
363    #[test]
364    fn a_parameterised_path_cannot_be_declared_as_a_single_page() {
365        let pages: Pages =
366            Pages::new().dynamic_page("/blog/{slug}", axum::routing::get(|| async { "post" }));
367
368        let error: WebServerError = pages
369            .sitemap_urls()
370            .expect_err("one pattern is not one url");
371        assert!(matches!(
372            error,
373            WebServerError::DynamicPagePathHasParameters { ref path } if path == "/blog/{slug}"
374        ));
375    }
376
377    #[test]
378    fn a_parameterised_path_is_fine_once_it_is_unlisted() {
379        let pages: Pages = Pages::new()
380            .dynamic_page(
381                "/og/{version}/{file}",
382                axum::routing::get(|| async { "card" }),
383            )
384            .unlisted();
385
386        let expected: usize = 0;
387        let actual: usize = pages.sitemap_urls().expect("unlisted").len();
388        assert_eq!(expected, actual);
389    }
390
391    #[test]
392    fn sitemap_modifiers_apply_to_the_page_just_declared() {
393        let modified = chrono::Utc
394            .with_ymd_and_hms(2026, 3, 4, 5, 6, 7)
395            .single()
396            .expect("a real instant");
397
398        let pages: Pages = Pages::new()
399            .static_page(PageTemplateData::new("home", "Home", "/"), ())
400            .static_page(PageTemplateData::new("blog", "Blog", "/blog"), ())
401            .with_last_modified(modified);
402
403        let urls: Vec<SitemapUrl> = pages.sitemap_urls().expect("no parameters");
404
405        assert_eq!(None, urls[0].last_modified());
406        assert_eq!(Some(modified), urls[1].last_modified());
407
408        let expected_path: String = String::from("/blog");
409        let actual_path: String = urls[1].path().to_string();
410        assert_eq!(expected_path, actual_path);
411    }
412
413    #[test]
414    fn route_parameters_are_recognised_in_every_axum_spelling() {
415        assert!(has_route_parameter("/blog/{slug}"));
416        assert!(has_route_parameter("/blog/{slug}"));
417        assert!(has_route_parameter("/files/{*path}"));
418        assert!(!has_route_parameter("/blog"));
419        assert!(!has_route_parameter("/"));
420    }
421
422    #[test]
423    fn declarations_actually_build_a_router() {
424        // Route syntax is validated when axum registers the path, not when this
425        // module compiles — so the router has to actually be built here.
426        let pages: Pages = Pages::new()
427            .static_page(PageTemplateData::new("home", "Home", "/"), ())
428            .dynamic_page("/blog", axum::routing::get(|| async { "index" }))
429            .dynamic_page_group(
430                "/blog/{slug}",
431                axum::routing::get(|| async { "post" }),
432                [SitemapUrl::new("/blog/first")],
433            )
434            .unlisted();
435
436        let _router: axum::Router<crate::webserver::WebServerState> = pages.into_router();
437    }
438
439    #[test]
440    fn a_declaration_starts_empty() {
441        let pages: Pages = Pages::new();
442
443        let expected: bool = true;
444        let actual: bool = pages.is_empty();
445        assert_eq!(expected, actual);
446    }
447
448    #[test]
449    fn declared_assets_are_collected_from_static_pages_for_boot_validation() {
450        let pages: Pages = Pages::new().static_page(
451            PageTemplateData::new("blog", "Blog", "/blog")
452                .extend_style_sheets(["static/stylesheet/blog.css"])
453                .with_social_image("static/image/social/blog.webp"),
454            (),
455        );
456
457        let declared: Vec<String> = pages.declared_assets();
458        assert!(declared.contains(&String::from("static/stylesheet/blog.css")));
459        assert!(declared.contains(&String::from("static/image/social/blog.webp")));
460    }
461}