branch: main
presentation.rs
22718 bytesRaw
use serde::Serialize;
use std::fmt;
use worker::{Request, Response, Result};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Representation {
Html,
Json,
Markdown,
}
impl Representation {
pub fn format_value(self) -> &'static str {
match self {
Self::Html => "html",
Self::Json => "json",
Self::Markdown => "md",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MarkdownMediaType {
Markdown,
Plain,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct NegotiatedRepresentation {
representation: Representation,
markdown_media_type: MarkdownMediaType,
vary_accept: bool,
}
impl NegotiatedRepresentation {
pub fn representation(self) -> Representation {
self.representation
}
pub fn query_format_value(self) -> &'static str {
match (self.representation, self.markdown_media_type) {
(Representation::Markdown, MarkdownMediaType::Plain) => "text",
_ => self.representation.format_value(),
}
}
pub fn vary_accept(self) -> bool {
self.vary_accept
}
pub fn response_content_type(self) -> &'static str {
match self.representation {
Representation::Html => "text/html; charset=utf-8",
Representation::Json => "application/json; charset=utf-8",
Representation::Markdown => match self.markdown_media_type {
MarkdownMediaType::Markdown => "text/markdown; charset=utf-8",
MarkdownMediaType::Plain => "text/plain; charset=utf-8",
},
}
}
pub fn preferred_accept_value(self) -> &'static str {
match self.representation {
Representation::Html => "text/html",
Representation::Json => "application/json",
Representation::Markdown => match self.markdown_media_type {
MarkdownMediaType::Markdown => "text/markdown",
MarkdownMediaType::Plain => "text/plain",
},
}
}
fn new(representation: Representation) -> Self {
Self {
representation,
markdown_media_type: MarkdownMediaType::Markdown,
vary_accept: false,
}
}
}
#[derive(Debug)]
pub enum NegotiationError {
UnsupportedFormat {
requested: String,
supported: Vec<Representation>,
},
NotAcceptable {
accept: String,
supported: Vec<Representation>,
},
}
impl NegotiationError {
pub fn into_response(self) -> Result<Response> {
match self {
Self::UnsupportedFormat {
requested,
supported,
} => Response::error(
format!(
"Requested format '{requested}' is not available here. Supported formats: {}",
supported_formats(&supported)
),
406,
),
Self::NotAcceptable { accept, supported } => {
let mut resp = Response::error(
format!(
"Accept '{accept}' is not available here. Supported formats: {}",
supported_formats(&supported)
),
406,
)?;
add_vary_accept(&mut resp)?;
Ok(resp)
}
}
}
}
pub fn preferred_representation(
req: &Request,
supported: &[Representation],
default: Representation,
) -> std::result::Result<NegotiatedRepresentation, NegotiationError> {
let default = if supported.contains(&default) {
default
} else {
supported.first().copied().unwrap_or(default)
};
let url = req.url().map_err(|_| NegotiationError::NotAcceptable {
accept: "invalid URL".to_string(),
supported: supported.to_vec(),
})?;
if let Some(raw_format) = url
.query_pairs()
.find(|(k, _)| k == "format")
.map(|(_, v)| v.to_string())
{
let mut selection =
parse_query_format(&raw_format).ok_or_else(|| NegotiationError::UnsupportedFormat {
requested: raw_format.clone(),
supported: supported.to_vec(),
})?;
if !supported.contains(&selection.representation) {
return Err(NegotiationError::UnsupportedFormat {
requested: raw_format,
supported: supported.to_vec(),
});
}
selection.vary_accept = false;
return Ok(selection);
}
let accept = req.headers().get("Accept").ok().flatten();
let accept = match accept {
Some(value) if !value.trim().is_empty() => value,
_ => return Ok(NegotiatedRepresentation::new(default)),
};
let tokens = parse_accept(&accept);
let selection = best_accept_match(&tokens, supported, default).ok_or_else(|| {
NegotiationError::NotAcceptable {
accept,
supported: supported.to_vec(),
}
})?;
Ok(selection)
}
pub fn finalize_response(
mut resp: Response,
selection: &NegotiatedRepresentation,
) -> Result<Response> {
if selection.vary_accept() {
add_vary_accept(&mut resp)?;
}
Ok(resp)
}
pub fn markdown_response(body: &str, selection: &NegotiatedRepresentation) -> Result<Response> {
let mut resp = Response::ok(body.to_string())?;
resp.headers_mut()
.set("Content-Type", selection.response_content_type())?;
resp.headers_mut().set("Cache-Control", "no-cache")?;
finalize_response(resp, selection)
}
pub fn append_format(path: &str, representation: Representation) -> String {
append_format_value(path, representation.format_value())
}
#[allow(dead_code)]
pub fn append_negotiated_format(path: &str, selection: NegotiatedRepresentation) -> String {
append_format_value(path, selection.query_format_value())
}
fn append_format_value(path: &str, format_value: &str) -> String {
let (path_without_fragment, fragment) = match path.split_once('#') {
Some((before, after)) => (before, Some(after)),
None => (path, None),
};
let (base, query) = match path_without_fragment.split_once('?') {
Some((before, after)) => (before, Some(after)),
None => (path_without_fragment, None),
};
let mut params: Vec<String> = query
.into_iter()
.flat_map(|q| q.split('&'))
.filter_map(|segment| {
if segment.is_empty() {
return None;
}
let key = segment.split('=').next().unwrap_or("");
if key == "format" {
return None;
}
Some(segment.to_string())
})
.collect();
params.push(format!("format={}", format_value));
let mut output = String::from(base);
output.push('?');
output.push_str(¶ms.join("&"));
if let Some(fragment) = fragment {
output.push('#');
output.push_str(fragment);
}
output
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum ActionMethod {
Get,
Post,
Put,
Delete,
}
impl fmt::Display for ActionMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self {
Self::Get => "GET",
Self::Post => "POST",
Self::Put => "PUT",
Self::Delete => "DELETE",
};
f.write_str(value)
}
}
#[allow(dead_code)]
#[derive(Clone, Debug, Serialize)]
pub struct ActionField {
pub name: String,
#[serde(default, skip_serializing_if = "is_false")]
pub required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[allow(dead_code)]
impl ActionField {
pub fn required(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
required: true,
description: Some(description.into()),
}
}
pub fn optional(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
required: false,
description: Some(description.into()),
}
}
}
#[allow(dead_code)]
#[derive(Clone, Debug, Serialize)]
pub struct Action {
pub method: ActionMethod,
pub path: String,
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<ActionField>,
#[serde(skip_serializing_if = "Option::is_none")]
pub requires: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effect: Option<String>,
}
#[allow(dead_code)]
impl Action {
pub fn new(
method: ActionMethod,
path: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
method,
path: path.into(),
description: description.into(),
fields: Vec::new(),
requires: None,
effect: None,
}
}
pub fn get(path: impl Into<String>, description: impl Into<String>) -> Self {
Self::new(ActionMethod::Get, path, description)
}
pub fn post(path: impl Into<String>, description: impl Into<String>) -> Self {
Self::new(ActionMethod::Post, path, description)
}
pub fn put(path: impl Into<String>, description: impl Into<String>) -> Self {
Self::new(ActionMethod::Put, path, description)
}
pub fn delete(path: impl Into<String>, description: impl Into<String>) -> Self {
Self::new(ActionMethod::Delete, path, description)
}
pub fn with_fields(mut self, fields: Vec<ActionField>) -> Self {
self.fields = fields;
self
}
pub fn with_requires(mut self, requires: impl Into<String>) -> Self {
self.requires = Some(requires.into());
self
}
pub fn with_effect(mut self, effect: impl Into<String>) -> Self {
self.effect = Some(effect.into());
self
}
}
#[derive(Clone, Debug, Serialize)]
pub struct Hint {
pub text: String,
}
impl Hint {
pub fn new(text: impl Into<String>) -> Self {
Self { text: text.into() }
}
}
pub fn text_navigation_hint(selection: NegotiatedRepresentation) -> Hint {
Hint::new(format!(
"GET paths below omit `?format`. Keep `Accept: {}` to stay in this text view, or append `?format={}` when following a path without headers.",
selection.preferred_accept_value(),
selection.query_format_value(),
))
}
pub fn render_actions_section(actions: &[Action]) -> String {
if actions.is_empty() {
return String::new();
}
let mut section = String::from("\n\n## Actions\n");
for action in actions {
section.push_str(&render_action_line(action));
section.push('\n');
}
section
}
pub fn render_hints_section(hints: &[Hint]) -> String {
if hints.is_empty() {
return String::new();
}
let mut section = String::from("\n## Hints\n");
for hint in hints {
section.push_str("- ");
section.push_str(&hint.text);
section.push('\n');
}
section
}
#[derive(Clone, Debug)]
struct AcceptToken {
media_type: String,
q: u16,
}
#[derive(Clone, Copy, Debug)]
struct AcceptCandidate {
representation: Representation,
markdown_media_type: MarkdownMediaType,
q: u16,
specificity: u8,
token_index: usize,
supported_index: usize,
is_default: bool,
}
fn best_accept_match(
tokens: &[AcceptToken],
supported: &[Representation],
default: Representation,
) -> Option<NegotiatedRepresentation> {
let mut best: Option<AcceptCandidate> = None;
for (supported_index, representation) in supported.iter().copied().enumerate() {
let Some((q, specificity, token_index, markdown_media_type)) =
best_token_for_representation(representation, tokens)
else {
continue;
};
let candidate = AcceptCandidate {
representation,
markdown_media_type,
q,
specificity,
token_index,
supported_index,
is_default: representation == default,
};
let replace = match best {
Some(current) => is_better_accept_candidate(candidate, current),
None => true,
};
if replace {
best = Some(candidate);
}
}
best.map(|candidate| NegotiatedRepresentation {
representation: candidate.representation,
markdown_media_type: candidate.markdown_media_type,
vary_accept: true,
})
}
fn best_token_for_representation(
representation: Representation,
tokens: &[AcceptToken],
) -> Option<(u16, u8, usize, MarkdownMediaType)> {
let mut best: Option<(u16, u8, usize, MarkdownMediaType)> = None;
for (token_index, token) in tokens.iter().enumerate() {
if token.q == 0 {
continue;
}
let Some((specificity, markdown_media_type)) =
match_representation(representation, &token.media_type)
else {
continue;
};
let candidate = (token.q, specificity, token_index, markdown_media_type);
let replace = match best {
Some(current) => {
candidate.0 > current.0
|| (candidate.0 == current.0 && candidate.1 > current.1)
|| (candidate.0 == current.0
&& candidate.1 == current.1
&& candidate.2 < current.2)
}
None => true,
};
if replace {
best = Some(candidate);
}
}
best
}
fn is_better_accept_candidate(candidate: AcceptCandidate, current: AcceptCandidate) -> bool {
candidate.q > current.q
|| (candidate.q == current.q && candidate.specificity > current.specificity)
|| (candidate.q == current.q
&& candidate.specificity == current.specificity
&& candidate.token_index < current.token_index)
|| (candidate.q == current.q
&& candidate.specificity == current.specificity
&& candidate.token_index == current.token_index
&& candidate.is_default
&& !current.is_default)
|| (candidate.q == current.q
&& candidate.specificity == current.specificity
&& candidate.token_index == current.token_index
&& candidate.is_default == current.is_default
&& candidate.supported_index < current.supported_index)
}
fn parse_query_format(value: &str) -> Option<NegotiatedRepresentation> {
let value = value.trim().to_ascii_lowercase();
match value.as_str() {
"html" => Some(NegotiatedRepresentation::new(Representation::Html)),
"json" => Some(NegotiatedRepresentation::new(Representation::Json)),
"md" | "markdown" => Some(NegotiatedRepresentation::new(Representation::Markdown)),
"text" | "txt" | "plain" => Some(NegotiatedRepresentation {
representation: Representation::Markdown,
markdown_media_type: MarkdownMediaType::Plain,
vary_accept: false,
}),
_ => None,
}
}
fn parse_accept(header: &str) -> Vec<AcceptToken> {
header
.split(',')
.filter_map(|raw| {
let mut parts = raw.split(';');
let media_type = parts.next()?.trim().to_ascii_lowercase();
if media_type.is_empty() {
return None;
}
let mut q = 1000;
for param in parts {
let Some((key, value)) = param.split_once('=') else {
continue;
};
if key.trim().eq_ignore_ascii_case("q") {
q = parse_quality(value.trim()).unwrap_or(0);
}
}
Some(AcceptToken { media_type, q })
})
.collect()
}
fn parse_quality(value: &str) -> Option<u16> {
let parsed = value.parse::<f32>().ok()?;
if !(0.0..=1.0).contains(&parsed) {
return None;
}
Some((parsed * 1000.0).round() as u16)
}
fn match_representation(
representation: Representation,
media_type: &str,
) -> Option<(u8, MarkdownMediaType)> {
match representation {
Representation::Html => match media_type {
"text/html" => Some((3, MarkdownMediaType::Markdown)),
"text/*" => Some((1, MarkdownMediaType::Markdown)),
"*/*" => Some((0, MarkdownMediaType::Markdown)),
_ => None,
},
Representation::Json => match media_type {
"application/json" => Some((3, MarkdownMediaType::Markdown)),
"application/*" => Some((1, MarkdownMediaType::Markdown)),
"*/*" => Some((0, MarkdownMediaType::Markdown)),
_ => None,
},
Representation::Markdown => match media_type {
"text/markdown" => Some((3, MarkdownMediaType::Markdown)),
"text/plain" => Some((3, MarkdownMediaType::Plain)),
"text/*" => Some((1, MarkdownMediaType::Markdown)),
"*/*" => Some((0, MarkdownMediaType::Markdown)),
_ => None,
},
}
}
fn render_action_line(action: &Action) -> String {
let mut line = format!(
"- {} `{}` - {}",
action.method, action.path, action.description
);
if !action.fields.is_empty() {
line.push_str("; fields: ");
for (idx, field) in action.fields.iter().enumerate() {
if idx > 0 {
line.push_str(", ");
}
line.push('`');
line.push_str(&field.name);
line.push('`');
if !field.required {
line.push_str(" (optional)");
}
if let Some(description) = &field.description {
line.push_str(" - ");
line.push_str(description);
}
}
}
if let Some(requires) = &action.requires {
line.push_str("; requires ");
line.push_str(requires);
}
if let Some(effect) = &action.effect {
line.push_str("; ");
line.push_str(effect);
}
line
}
fn supported_formats(formats: &[Representation]) -> String {
formats
.iter()
.map(|format| format.format_value())
.collect::<Vec<_>>()
.join(", ")
}
fn add_vary_accept(resp: &mut Response) -> Result<()> {
let current = resp.headers().get("Vary")?.unwrap_or_default();
if current
.split(',')
.any(|value| value.trim().eq_ignore_ascii_case("Accept"))
{
return Ok(());
}
let next = if current.trim().is_empty() {
"Accept".to_string()
} else {
format!("{}, Accept", current)
};
resp.headers_mut().set("Vary", &next)?;
Ok(())
}
fn is_false(value: &bool) -> bool {
!*value
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_format_parses_supported_aliases() {
let html = parse_query_format(" HTML ").expect("html format");
assert_eq!(html.representation(), Representation::Html);
assert_eq!(html.query_format_value(), "html");
assert_eq!(html.response_content_type(), "text/html; charset=utf-8");
assert!(!html.vary_accept());
let markdown = parse_query_format("markdown").expect("markdown format");
assert_eq!(markdown.representation(), Representation::Markdown);
assert_eq!(markdown.query_format_value(), "md");
assert_eq!(
markdown.response_content_type(),
"text/markdown; charset=utf-8"
);
let plain = parse_query_format("text").expect("plain text format");
assert_eq!(plain.representation(), Representation::Markdown);
assert_eq!(plain.query_format_value(), "text");
assert_eq!(plain.response_content_type(), "text/plain; charset=utf-8");
assert!(parse_query_format("yaml").is_none());
}
#[test]
fn parse_accept_reads_quality_values_and_invalid_entries() {
let tokens =
parse_accept("text/plain; q=0.5, application/json;q=1, text/html;q=bogus, */*;q=0");
assert_eq!(tokens.len(), 4);
assert_eq!(tokens[0].media_type, "text/plain");
assert_eq!(tokens[0].q, 500);
assert_eq!(tokens[1].media_type, "application/json");
assert_eq!(tokens[1].q, 1000);
assert_eq!(tokens[2].media_type, "text/html");
assert_eq!(tokens[2].q, 0);
assert_eq!(tokens[3].media_type, "*/*");
assert_eq!(tokens[3].q, 0);
}
#[test]
fn best_accept_match_prefers_plain_text_markdown_when_exactly_requested() {
let tokens = parse_accept("text/*;q=0.7, text/plain;q=0.7");
let selection = best_accept_match(
&tokens,
&[Representation::Html, Representation::Markdown],
Representation::Html,
)
.expect("selection");
assert_eq!(selection.representation(), Representation::Markdown);
assert_eq!(
selection.response_content_type(),
"text/plain; charset=utf-8"
);
assert_eq!(selection.preferred_accept_value(), "text/plain");
assert!(selection.vary_accept());
}
#[test]
fn best_accept_match_uses_default_representation_for_wildcard_ties() {
let tokens = parse_accept("*/*");
let selection = best_accept_match(
&tokens,
&[Representation::Html, Representation::Markdown],
Representation::Html,
)
.expect("selection");
assert_eq!(selection.representation(), Representation::Html);
assert_eq!(
selection.response_content_type(),
"text/html; charset=utf-8"
);
}
#[test]
fn best_accept_match_returns_none_when_no_supported_media_type_matches() {
let tokens = parse_accept("application/json");
let selection = best_accept_match(
&tokens,
&[Representation::Html, Representation::Markdown],
Representation::Html,
);
assert!(selection.is_none());
}
#[test]
fn append_format_value_replaces_existing_format_and_preserves_fragment() {
assert_eq!(
append_format_value("/alice/repo/tree/main/src?foo=1&format=html#L42", "md"),
"/alice/repo/tree/main/src?foo=1&format=md#L42"
);
assert_eq!(
append_format_value("/alice/repo/?ref=main", "text"),
"/alice/repo/?ref=main&format=text"
);
}
}