feat: serve rendered pages over http, spartan and nex
This commit is contained in:
parent
46f12b10da
commit
2f38b8b929
30 changed files with 2896 additions and 90 deletions
176
bin/src/proto/negotiate.rs
Normal file
176
bin/src/proto/negotiate.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
//! 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue