Skip to main content

lino_rest_api/
media_type.rs

1//! Media types and content negotiation (specification section 2).
2//!
3//! Negotiation follows RFC 9110 section 12.5.1: the `Accept` header is parsed
4//! into ranges with quality values, the ranges are ordered by quality and then
5//! by specificity, and the first range that matches a representation the server
6//! can produce wins.
7
8/// Readable, indented Links Notation. The default representation.
9pub const LINO_CONTENT_TYPE: &str = "text/lino";
10
11/// Readable Links Notation restricted to one line per value.
12pub const LINO_LINE_CONTENT_TYPE: &str = "text/lino-line";
13
14/// Type-tagged, base64 Links Notation that preserves object identity.
15pub const LINO_COMPACT_CONTENT_TYPE: &str = "text/lino-compact";
16
17/// JSON fallback for clients that cannot speak Links Notation.
18pub const JSON_CONTENT_TYPE: &str = "application/json";
19
20/// Problem details served as Links Notation.
21pub const LINO_PROBLEM_CONTENT_TYPE: &str = "application/problem+lino";
22
23/// Media type of RFC 9457 problem details expressed as JSON.
24pub const JSON_PROBLEM_CONTENT_TYPE: &str = "application/problem+json";
25
26/// Every representation a server produces, most preferred first.
27pub const SUPPORTED_MEDIA_TYPES: [&str; 4] = [
28    LINO_CONTENT_TYPE,
29    LINO_LINE_CONTENT_TYPE,
30    LINO_COMPACT_CONTENT_TYPE,
31    JSON_CONTENT_TYPE,
32];
33
34/// One range of an `Accept` header.
35#[derive(Debug, Clone, PartialEq)]
36pub struct AcceptRange {
37    /// The media range itself, lower-cased.
38    pub media_type: String,
39    /// Quality value, 1.0 when absent.
40    pub quality: f64,
41    /// 0 for `*/*`, 1 for `type/*`, 2 for a concrete type.
42    pub specificity: u8,
43}
44
45/// Add `charset=utf-8` to a media type so that bytes are unambiguous.
46pub fn with_charset(media_type: &str) -> String {
47    format!("{media_type}; charset=utf-8")
48}
49
50/// Strip parameters and normalise case, turning a header value into a media type.
51///
52/// Returns an empty string when the header is absent.
53pub fn parse_content_type(header_value: Option<&str>) -> String {
54    match header_value {
55        None => String::new(),
56        Some(value) => value
57            .split(';')
58            .next()
59            .unwrap_or("")
60            .trim()
61            .to_ascii_lowercase(),
62    }
63}
64
65/// Parse an `Accept` header into ranges ordered by preference.
66pub fn parse_accept(header_value: Option<&str>) -> Vec<AcceptRange> {
67    let header = header_value.unwrap_or("").trim();
68    if header.is_empty() {
69        return vec![AcceptRange {
70            media_type: "*/*".to_string(),
71            quality: 1.0,
72            specificity: 0,
73        }];
74    }
75
76    let mut ranges: Vec<(usize, AcceptRange)> = Vec::new();
77    for (index, part) in header.split(',').enumerate() {
78        let mut pieces = part.split(';');
79        let media_type = pieces.next().unwrap_or("").trim().to_ascii_lowercase();
80        if media_type.is_empty() {
81            continue;
82        }
83
84        let mut quality = 1.0;
85        for parameter in pieces {
86            let (name, value) = match parameter.split_once('=') {
87                Some((name, value)) => (name, value),
88                None => (parameter, ""),
89            };
90            if name.trim().eq_ignore_ascii_case("q") {
91                quality = value.trim().parse::<f64>().unwrap_or(0.0);
92            }
93        }
94
95        let specificity = if media_type == "*/*" {
96            0
97        } else if media_type.ends_with("/*") {
98            1
99        } else {
100            2
101        };
102
103        ranges.push((
104            index,
105            AcceptRange {
106                media_type,
107                quality,
108                specificity,
109            },
110        ));
111    }
112
113    ranges.sort_by(|left, right| {
114        right
115            .1
116            .quality
117            .partial_cmp(&left.1.quality)
118            .unwrap_or(std::cmp::Ordering::Equal)
119            .then(right.1.specificity.cmp(&left.1.specificity))
120            .then(left.0.cmp(&right.0))
121    });
122    ranges.into_iter().map(|entry| entry.1).collect()
123}
124
125/// Test whether an `Accept` range matches a concrete media type.
126fn range_matches(range_type: &str, media_type: &str) -> bool {
127    if range_type == "*/*" || range_type == media_type {
128        return true;
129    }
130    match range_type.strip_suffix('*') {
131        Some(prefix) => media_type.starts_with(prefix),
132        None => false,
133    }
134}
135
136/// Select the representation to produce for a request.
137///
138/// Returns `None` when nothing the server can produce is acceptable, which the
139/// caller answers with `406 Not Acceptable`.
140pub fn negotiate_media_type(accept_header: Option<&str>, supported: &[String]) -> Option<String> {
141    for accepted in parse_accept(accept_header) {
142        if accepted.quality <= 0.0 {
143            continue;
144        }
145        for media_type in supported {
146            if range_matches(&accepted.media_type, media_type) {
147                return Some(media_type.clone());
148            }
149        }
150    }
151    None
152}
153
154/// Every representation a server produces, as owned strings.
155pub fn supported_media_types() -> Vec<String> {
156    SUPPORTED_MEDIA_TYPES
157        .iter()
158        .map(|entry| entry.to_string())
159        .collect()
160}
161
162/// Resolve a media type to the representation it is encoded as.
163///
164/// Problem-details types carry the same syntax as their base representation, so
165/// they decode with the same codec.
166pub fn normalize_media_type(media_type: &str) -> &str {
167    match media_type {
168        LINO_PROBLEM_CONTENT_TYPE => LINO_CONTENT_TYPE,
169        JSON_PROBLEM_CONTENT_TYPE => JSON_CONTENT_TYPE,
170        other => other,
171    }
172}
173
174/// Test whether a request body media type can be decoded.
175pub fn is_decodable_media_type(media_type: &str) -> bool {
176    SUPPORTED_MEDIA_TYPES.contains(&normalize_media_type(media_type))
177}
178
179/// Problem-details media type matching a representation.
180pub fn problem_media_type(media_type: &str) -> &str {
181    match media_type {
182        LINO_CONTENT_TYPE => LINO_PROBLEM_CONTENT_TYPE,
183        JSON_CONTENT_TYPE => JSON_PROBLEM_CONTENT_TYPE,
184        other => other,
185    }
186}