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    /// Every route path that was declared, listed or not.
179    ///
180    /// Used to prove the feed advertises a page the site actually serves; an
181    /// unlisted page still counts, because it is still routed.
182    #[must_use]
183    pub fn paths(&self) -> Vec<&str> {
184        self.entries
185            .iter()
186            .map(|entry| entry.path.as_str())
187            .collect()
188    }
189
190    /// How many pages were declared.
191    #[must_use]
192    pub fn len(&self) -> usize {
193        self.entries.len()
194    }
195
196    /// Whether nothing was declared.
197    #[must_use]
198    pub fn is_empty(&self) -> bool {
199        self.entries.is_empty()
200    }
201
202    /// Every URL that belongs in the sitemap.
203    ///
204    /// # Errors
205    ///
206    /// [`WebServerError::DynamicPagePathHasParameters`] if a listed page's path
207    /// contains a route parameter, which cannot be resolved to one URL.
208    /// Every asset path the declared pages reference.
209    ///
210    /// Static pages only: a dynamic page builds its `PageTemplateData` per
211    /// request, so there is nothing to inspect at boot.
212    #[must_use]
213    pub fn declared_assets(&self) -> Vec<String> {
214        self.entries
215            .iter()
216            .filter_map(|entry| match &entry.kind {
217                PageKind::Static { page, .. } => Some(page.declared_assets()),
218                PageKind::Dynamic(_) => None,
219            })
220            .flatten()
221            .collect()
222    }
223
224    /// Every listed page as a sitemap entry.
225    ///
226    /// # Errors
227    ///
228    /// [`WebServerError::DynamicPagePathHasParameters`] if a listed page's path
229    /// contains a route parameter, which cannot become a single sitemap URL.
230    pub fn sitemap_urls(&self) -> Result<Vec<SitemapUrl>, WebServerError> {
231        let mut urls: Vec<SitemapUrl> = Vec::new();
232
233        for entry in &self.entries {
234            let Listing::Listed(entry_urls) = &entry.listing else {
235                continue;
236            };
237            for url in entry_urls {
238                if has_route_parameter(url.path()) {
239                    return Err(WebServerError::DynamicPagePathHasParameters {
240                        path: entry.path.clone(),
241                    });
242                }
243                urls.push(url.clone());
244            }
245        }
246
247        Ok(urls)
248    }
249
250    /// The 404 declaration, if one was made.
251    /// Turns every declaration into routes.
252    pub(super) fn into_router(self) -> axum::Router<WebServerState<S>> {
253        let mut router: axum::Router<WebServerState<S>> = axum::Router::new();
254
255        for entry in self.entries {
256            let method_router: MethodRouter<WebServerState<S>> = match entry.kind {
257                PageKind::Static { page, data } => static_page_handler(page, data),
258                PageKind::Dynamic(handler) => *handler,
259            };
260            router = router.route(&entry.path, method_router);
261        }
262
263        router
264    }
265}
266
267/// Builds the handler for a page that has no handler function.
268fn static_page_handler<S>(
269    page: Arc<PageTemplateData>,
270    data: Arc<Value>,
271) -> MethodRouter<WebServerState<S>>
272where
273    S: Clone + Send + Sync + 'static,
274{
275    get(move |State(state): State<WebServerState<S>>| {
276        let page: Arc<PageTemplateData> = Arc::clone(&page);
277        let data: Arc<Value> = Arc::clone(&data);
278        async move { render_or_500(&state, &page, &data) }
279    })
280}
281
282/// Renders a declared page, turning a failure into a 500.
283pub(super) fn render_or_500<S>(
284    state: &WebServerState<S>,
285    page: &PageTemplateData,
286    data: &Value,
287) -> Response {
288    match state.render(page, data) {
289        Ok(html) => html.into_response(),
290        Err(error) => {
291            error!("failed to render `{}`: {error}", page.page_url());
292            error.into_response()
293        }
294    }
295}
296
297/// Whether an axum path pattern matches more than one URL.
298fn has_route_parameter(path: &str) -> bool {
299    path.contains(':') || path.contains('*') || path.contains('{')
300}
301
302#[cfg(test)]
303mod tests {
304
305    use chrono::TimeZone;
306
307    use crate::sitemap::SitemapUrl;
308    use crate::templates::PageTemplateData;
309    use crate::webserver::error::WebServerError;
310
311    use super::{Pages, has_route_parameter};
312
313    #[test]
314    fn a_static_page_takes_its_route_from_its_page_url() {
315        let pages: Pages = Pages::new()
316            .static_page(PageTemplateData::new("home", "Home", "/"), ())
317            .static_page(PageTemplateData::new("music", "Music", "/music"), ());
318
319        let expected_len: usize = 2;
320        let actual_len: usize = pages.len();
321        assert_eq!(expected_len, actual_len);
322
323        let expected_paths: Vec<String> = vec![String::from("/"), String::from("/music")];
324        let actual_paths: Vec<String> = pages
325            .sitemap_urls()
326            .expect("no parameters")
327            .iter()
328            .map(|url| url.path().to_string())
329            .collect();
330        assert_eq!(expected_paths, actual_paths);
331    }
332
333    #[test]
334    fn unlisted_keeps_a_page_routed_but_out_of_the_sitemap() {
335        let pages: Pages = Pages::new()
336            .static_page(PageTemplateData::new("home", "Home", "/"), ())
337            .static_page(PageTemplateData::new("secret", "Secret", "/secret"), ())
338            .unlisted();
339
340        let expected_routes: usize = 2;
341        let actual_routes: usize = pages.len();
342        assert_eq!(expected_routes, actual_routes);
343
344        let expected_listed: Vec<String> = vec![String::from("/")];
345        let actual_listed: Vec<String> = pages
346            .sitemap_urls()
347            .expect("no parameters")
348            .iter()
349            .map(|url| url.path().to_string())
350            .collect();
351        assert_eq!(expected_listed, actual_listed);
352    }
353
354    #[test]
355    fn a_group_lists_the_urls_it_was_given_not_its_pattern() {
356        let pages: Pages = Pages::new().dynamic_page_group(
357            "/blog/{slug}",
358            axum::routing::get(|| async { "post" }),
359            [
360                SitemapUrl::new("/blog/first"),
361                SitemapUrl::new("/blog/second"),
362            ],
363        );
364
365        let expected: Vec<String> = vec![String::from("/blog/first"), String::from("/blog/second")];
366        let actual: Vec<String> = pages
367            .sitemap_urls()
368            .expect("concrete urls")
369            .iter()
370            .map(|url| url.path().to_string())
371            .collect();
372        assert_eq!(expected, actual);
373    }
374
375    #[test]
376    fn a_parameterised_path_cannot_be_declared_as_a_single_page() {
377        let pages: Pages =
378            Pages::new().dynamic_page("/blog/{slug}", axum::routing::get(|| async { "post" }));
379
380        let error: WebServerError = pages
381            .sitemap_urls()
382            .expect_err("one pattern is not one url");
383        assert!(matches!(
384            error,
385            WebServerError::DynamicPagePathHasParameters { ref path } if path == "/blog/{slug}"
386        ));
387    }
388
389    #[test]
390    fn a_parameterised_path_is_fine_once_it_is_unlisted() {
391        let pages: Pages = Pages::new()
392            .dynamic_page(
393                "/og/{version}/{file}",
394                axum::routing::get(|| async { "card" }),
395            )
396            .unlisted();
397
398        let expected: usize = 0;
399        let actual: usize = pages.sitemap_urls().expect("unlisted").len();
400        assert_eq!(expected, actual);
401    }
402
403    #[test]
404    fn sitemap_modifiers_apply_to_the_page_just_declared() {
405        let modified: chrono::DateTime<chrono::Utc> = chrono::Utc
406            .with_ymd_and_hms(2026, 3, 4, 5, 6, 7)
407            .single()
408            .expect("a real instant");
409
410        let pages: Pages = Pages::new()
411            .static_page(PageTemplateData::new("home", "Home", "/"), ())
412            .static_page(PageTemplateData::new("blog", "Blog", "/blog"), ())
413            .with_last_modified(modified);
414
415        let urls: Vec<SitemapUrl> = pages.sitemap_urls().expect("no parameters");
416
417        assert_eq!(None, urls[0].last_modified());
418        assert_eq!(Some(modified), urls[1].last_modified());
419
420        let expected_path: String = String::from("/blog");
421        let actual_path: String = urls[1].path().to_string();
422        assert_eq!(expected_path, actual_path);
423    }
424
425    #[test]
426    fn route_parameters_are_recognised_in_every_axum_spelling() {
427        assert!(has_route_parameter("/blog/{slug}"));
428        assert!(has_route_parameter("/blog/{slug}"));
429        assert!(has_route_parameter("/files/{*path}"));
430        assert!(!has_route_parameter("/blog"));
431        assert!(!has_route_parameter("/"));
432    }
433
434    #[test]
435    fn declarations_actually_build_a_router() {
436        // Route syntax is validated when axum registers the path, not when this
437        // module compiles — so the router has to actually be built here.
438        let pages: Pages = Pages::new()
439            .static_page(PageTemplateData::new("home", "Home", "/"), ())
440            .dynamic_page("/blog", axum::routing::get(|| async { "index" }))
441            .dynamic_page_group(
442                "/blog/{slug}",
443                axum::routing::get(|| async { "post" }),
444                [SitemapUrl::new("/blog/first")],
445            )
446            .unlisted();
447
448        let _router: axum::Router<crate::webserver::WebServerState> = pages.into_router();
449    }
450
451    #[test]
452    fn a_declaration_starts_empty() {
453        let pages: Pages = Pages::new();
454
455        let expected: bool = true;
456        let actual: bool = pages.is_empty();
457        assert_eq!(expected, actual);
458    }
459
460    #[test]
461    fn declared_assets_are_collected_from_static_pages_for_boot_validation() {
462        let pages: Pages = Pages::new().static_page(
463            PageTemplateData::new("blog", "Blog", "/blog")
464                .extend_style_sheets(["static/stylesheet/blog.css"])
465                .with_social_image("static/image/social/blog.webp"),
466            (),
467        );
468
469        let declared: Vec<String> = pages.declared_assets();
470        assert!(declared.contains(&String::from("static/stylesheet/blog.css")));
471        assert!(declared.contains(&String::from("static/image/social/blog.webp")));
472    }
473}