177 lines
6.2 KiB
Rust
177 lines
6.2 KiB
Rust
|
|
//! 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<f32>, 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::<f32>().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<Rendered, Error> {
|
||
|
|
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<String> {
|
||
|
|
["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<String> =
|
||
|
|
["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");
|
||
|
|
}
|
||
|
|
}
|