webserver_base/webserver/
pages.rs1use 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#[derive(Debug, Clone)]
27enum Listing {
28 Listed(Vec<SitemapUrl>),
29 Unlisted,
30}
31
32enum PageKind<S> {
34 Static {
36 page: Arc<PageTemplateData>,
37 data: Arc<Value>,
38 },
39 Dynamic(Box<MethodRouter<WebServerState<S>>>),
41}
42
43struct PageEntry<S> {
44 path: String,
45 kind: PageKind<S>,
46 listing: Listing,
47}
48
49#[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 #[must_use]
61 pub fn new() -> Self {
62 Self {
63 entries: Vec::new(),
64 }
65 }
66
67 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
192 pub fn len(&self) -> usize {
193 self.entries.len()
194 }
195
196 #[must_use]
198 pub fn is_empty(&self) -> bool {
199 self.entries.is_empty()
200 }
201
202 #[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 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 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
267fn 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
282pub(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
297fn 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 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}