//! Choosing an output format from an HTTP `Accept` header. //! //! The one invariant: a wildcard never selects a format the client did not name //! outright. A browser sends `Accept: text/html, application/xhtml+xml;q=0.9, //! */*;q=0.8`, and that `*/*` must not be read as willingness to receive WML. //! Only a literal media type counts as a match. use itsybitsy_core::render::Registry; /// Pick a format id from `accept`, out of the listener's `formats`. /// /// `formats` is in preference order; the last entry is the fallback used when the /// client named nothing this listener can serve, which is the ordinary case for a /// browser. pub fn choose<'a>(registry: &Registry, formats: &'a [String], accept: Option<&str>) -> &'a str { let fallback = formats.last().map(String::as_str).unwrap_or(""); let Some(accept) = accept else { return fallback }; let ranges = parse(accept); let mut best: Option<(&'a str, f32, usize)> = None; for (index, id) in formats.iter().enumerate() { let Some(renderer) = registry.get(id) else { continue }; let media_type = base_type(renderer.media_type()); // Literal matches only: a wildcard range is ignored entirely. let Some(quality) = ranges .iter() .filter(|(range, _)| *range == media_type) .map(|(_, q)| *q) .fold(None, |best: Option, q| Some(best.map_or(q, |b: f32| b.max(q)))) else { continue; }; if quality <= 0.0 { continue; } // A tie on quality is broken by the listener's own preference order. let better = best.is_none_or(|(_, best_q, best_index)| { quality > best_q || (quality == best_q && index < best_index) }); if better { best = Some((formats[index].as_str(), quality, index)); } } best.map(|(id, _, _)| id).unwrap_or(fallback) } /// `(media type, quality)` pairs, with parameters other than `q` discarded. fn parse(accept: &str) -> Vec<(String, f32)> { accept .split(',') .filter_map(|range| { let mut parts = range.split(';'); let media_type = parts.next()?.trim().to_ascii_lowercase(); if media_type.is_empty() { return None; } let quality = parts .find_map(|param| { let (name, value) = param.split_once('=')?; (name.trim().eq_ignore_ascii_case("q")).then(|| value.trim()) }) .and_then(|value| value.parse::().ok()) .unwrap_or(1.0); Some((media_type, quality)) }) .collect() } /// A renderer's media type without its parameters, for comparison against a range. fn base_type(media_type: &str) -> String { media_type.split(';').next().unwrap_or(media_type).trim().to_ascii_lowercase() } #[cfg(test)] mod tests { use std::sync::Arc; use itsybitsy_core::Error; use itsybitsy_core::ir::Doc; use itsybitsy_core::render::{RenderCtx, Rendered, Renderer}; use super::*; struct Stub(&'static str, &'static str); impl Renderer for Stub { fn id(&self) -> &'static str { self.0 } fn media_type(&self) -> &'static str { self.1 } fn render(&self, _doc: &Doc, _ctx: &RenderCtx<'_>) -> Result { Ok(Rendered::body(Vec::new())) } } fn registry() -> Registry { let mut registry = Registry::new(); registry.insert(Arc::new(Stub("wml", "text/vnd.wap.wml; charset=utf-8"))).unwrap(); registry .insert(Arc::new(Stub("xhtmlmp", "application/vnd.wap.xhtml+xml; charset=utf-8"))) .unwrap(); registry.insert(Arc::new(Stub("html", "text/html; charset=utf-8"))).unwrap(); registry } fn formats() -> Vec { ["wml", "xhtmlmp", "html"].iter().map(|s| s.to_string()).collect() } #[track_caller] fn choose_with(accept: Option<&str>) -> String { choose(®istry(), &formats(), accept).to_string() } #[test] fn a_wildcard_never_selects_a_format_the_client_did_not_name() { // The invariant the whole module exists for. assert_eq!(choose_with(Some("*/*")), "html"); assert_eq!(choose_with(Some("text/*")), "html"); assert_eq!(choose_with(Some("text/html,application/xhtml+xml;q=0.9,*/*;q=0.8")), "html"); } #[test] fn no_header_at_all_falls_back() { assert_eq!(choose_with(None), "html"); assert_eq!(choose_with(Some("")), "html"); } #[test] fn a_literal_media_type_is_honoured() { assert_eq!(choose_with(Some("text/vnd.wap.wml")), "wml"); assert_eq!(choose_with(Some("application/vnd.wap.xhtml+xml")), "xhtmlmp"); assert_eq!(choose_with(Some("text/html")), "html"); } #[test] fn a_media_type_with_parameters_still_matches() { assert_eq!(choose_with(Some("text/vnd.wap.wml; charset=utf-8")), "wml"); assert_eq!(choose_with(Some("TEXT/VND.WAP.WML")), "wml"); } #[test] fn quality_decides_between_two_named_formats() { assert_eq!(choose_with(Some("text/vnd.wap.wml;q=0.5,text/html;q=0.9")), "html"); assert_eq!(choose_with(Some("text/vnd.wap.wml;q=0.9,text/html;q=0.5")), "wml"); } #[test] fn a_tie_breaks_by_the_listeners_preference_order() { assert_eq!(choose_with(Some("text/html,text/vnd.wap.wml")), "wml"); let reversed: Vec = ["html", "xhtmlmp", "wml"].iter().map(|s| s.to_string()).collect(); assert_eq!(choose(®istry(), &reversed, Some("text/html,text/vnd.wap.wml")), "html"); } #[test] fn a_zero_quality_refuses_that_format() { assert_eq!(choose_with(Some("text/vnd.wap.wml;q=0")), "html"); } #[test] fn a_format_this_listener_does_not_serve_is_not_chosen() { let only_html = vec!["html".to_string()]; assert_eq!(choose(®istry(), &only_html, Some("text/vnd.wap.wml")), "html"); } #[test] fn a_malformed_header_falls_back_rather_than_failing() { assert_eq!(choose_with(Some(";;;")), "html"); assert_eq!(choose_with(Some("text/html;q=abc")), "html"); } }