itsybitsy/bin/src/proto/negotiate.rs

177 lines
6.2 KiB
Rust
Raw Normal View History

//! 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(&registry(), &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(&registry(), &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(&registry(), &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");
}
}