Skip to main content

haystack_server/
content.rs

1//! Content negotiation — parse Accept header to pick codec, decode request body.
2
3use haystack_core::codecs::{CodecError, codec_for};
4use haystack_core::data::HGrid;
5
6/// Default MIME type when no Accept header is provided or no supported type is found.
7const DEFAULT_MIME: &str = "text/zinc";
8
9/// Supported MIME types in preference order.
10const SUPPORTED: &[&str] = &[
11    "text/zinc",
12    "application/json",
13    "text/trio",
14    "application/json;v=3",
15];
16
17/// Parse an Accept header and return the best supported MIME type.
18///
19/// Returns `"text/zinc"` when the header is empty, `*/*`, or contains
20/// no recognized MIME type.
21pub fn parse_accept(accept_header: &str) -> &'static str {
22    let accept = accept_header.trim();
23    if accept.is_empty() || accept == "*/*" {
24        return DEFAULT_MIME;
25    }
26
27    // Parse weighted entries: "text/zinc;q=0.9, application/json;q=1.0"
28    let mut candidates: Vec<(&str, f32)> = Vec::new();
29
30    for part in accept.split(',') {
31        let part = part.trim();
32        let mut segments = part.splitn(2, ';');
33        let mime = segments.next().unwrap_or("").trim();
34
35        // Check for q= parameter
36        let quality = segments
37            .next()
38            .and_then(|params| {
39                for param in params.split(';') {
40                    let param = param.trim();
41                    if let Some(q_val) = param.strip_prefix("q=") {
42                        return q_val.trim().parse::<f32>().ok();
43                    }
44                }
45                None
46            })
47            .unwrap_or(1.0);
48
49        // Handle application/json;v=3 specially: need to check the original part
50        if mime == "application/json" && part.contains("v=3") {
51            candidates.push(("application/json;v=3", quality));
52        } else if mime == "*/*" {
53            candidates.push((DEFAULT_MIME, quality));
54        } else {
55            candidates.push((mime, quality));
56        }
57    }
58
59    // Sort by quality descending, then pick the first supported
60    candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
61
62    for (mime, _) in &candidates {
63        for supported in SUPPORTED {
64            if mime == supported {
65                return supported;
66            }
67        }
68    }
69
70    DEFAULT_MIME
71}
72
73/// Decode a request body into an HGrid using the given Content-Type.
74///
75/// Falls back to `"text/zinc"` if the content type is not recognized.
76pub fn decode_request_grid(body: &str, content_type: &str) -> Result<HGrid, CodecError> {
77    let mime = normalize_content_type(content_type);
78    let codec = codec_for(mime)
79        .unwrap_or_else(|| codec_for(DEFAULT_MIME).expect("default codec must exist"));
80    codec.decode_grid(body)
81}
82
83/// Encode an HGrid for the response using the best Accept type.
84///
85/// Returns `(body, content_type)`.
86pub fn encode_response_grid(
87    grid: &HGrid,
88    accept: &str,
89) -> Result<(String, &'static str), CodecError> {
90    let mime = parse_accept(accept);
91    let codec = codec_for(mime)
92        .unwrap_or_else(|| codec_for(DEFAULT_MIME).expect("default codec must exist"));
93    let body = codec.encode_grid(grid)?;
94    // Return the static mime type string that matches what we used
95    for supported in SUPPORTED {
96        if *supported == mime {
97            return Ok((body, supported));
98        }
99    }
100    Ok((body, DEFAULT_MIME))
101}
102
103/// Normalize a Content-Type header to a bare MIME type for codec lookup.
104fn normalize_content_type(content_type: &str) -> &str {
105    let ct = content_type.trim();
106    if ct.is_empty() {
107        return DEFAULT_MIME;
108    }
109    // Handle "application/json; v=3" or "application/json;v=3"
110    if ct.starts_with("application/json") && ct.contains("v=3") {
111        return "application/json;v=3";
112    }
113    // Strip any parameters like charset
114    let base = ct.split(';').next().unwrap_or(ct).trim();
115    // Verify it is a supported type
116    for supported in SUPPORTED {
117        if base == *supported {
118            return supported;
119        }
120    }
121    DEFAULT_MIME
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn parse_accept_empty() {
130        assert_eq!(parse_accept(""), "text/zinc");
131    }
132
133    #[test]
134    fn parse_accept_wildcard() {
135        assert_eq!(parse_accept("*/*"), "text/zinc");
136    }
137
138    #[test]
139    fn parse_accept_json() {
140        assert_eq!(parse_accept("application/json"), "application/json");
141    }
142
143    #[test]
144    fn parse_accept_zinc() {
145        assert_eq!(parse_accept("text/zinc"), "text/zinc");
146    }
147
148    #[test]
149    fn parse_accept_trio() {
150        assert_eq!(parse_accept("text/trio"), "text/trio");
151    }
152
153    #[test]
154    fn parse_accept_json_v3() {
155        assert_eq!(parse_accept("application/json;v=3"), "application/json;v=3");
156    }
157
158    #[test]
159    fn parse_accept_unsupported_falls_back() {
160        assert_eq!(parse_accept("text/html"), "text/zinc");
161    }
162
163    #[test]
164    fn parse_accept_multiple_with_quality() {
165        assert_eq!(
166            parse_accept("text/zinc;q=0.5, application/json;q=1.0"),
167            "application/json"
168        );
169    }
170
171    #[test]
172    fn normalize_content_type_empty() {
173        assert_eq!(normalize_content_type(""), "text/zinc");
174    }
175
176    #[test]
177    fn normalize_content_type_json_v3() {
178        assert_eq!(
179            normalize_content_type("application/json; v=3"),
180            "application/json;v=3"
181        );
182    }
183
184    #[test]
185    fn normalize_content_type_with_charset() {
186        assert_eq!(
187            normalize_content_type("text/zinc; charset=utf-8"),
188            "text/zinc"
189        );
190    }
191
192    #[test]
193    fn decode_request_grid_empty_zinc() {
194        // Empty zinc grid: "ver:\"3.0\"\nempty\n"
195        let result = decode_request_grid("ver:\"3.0\"\nempty\n", "text/zinc");
196        assert!(result.is_ok());
197    }
198
199    #[test]
200    fn encode_response_grid_default() {
201        let grid = HGrid::new();
202        let result = encode_response_grid(&grid, "");
203        assert!(result.is_ok());
204        let (_, content_type) = result.unwrap();
205        assert_eq!(content_type, "text/zinc");
206    }
207}