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]
180 pub fn len(&self) -> usize {
181 self.entries.len()
182 }
183
184 #[must_use]
186 pub fn is_empty(&self) -> bool {
187 self.entries.is_empty()
188 }
189
190 #[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 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 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
255fn 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
270pub(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
285fn 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 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}