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_bytes, content_type)`.
86pub fn encode_response_grid(
87    grid: &HGrid,
88    accept: &str,
89) -> Result<(Vec<u8>, &'static str), CodecError> {
90    let mime = parse_accept(accept);
91
92    let codec = codec_for(mime)
93        .unwrap_or_else(|| codec_for(DEFAULT_MIME).expect("default codec must exist"));
94    let body = codec.encode_grid(grid)?;
95    // Return the static mime type string that matches what we used
96    for supported in SUPPORTED {
97        if *supported == mime {
98            return Ok((body.into_bytes(), supported));
99        }
100    }
101    Ok((body.into_bytes(), DEFAULT_MIME))
102}
103
104/// Normalize a Content-Type header to a bare MIME type for codec lookup.
105fn normalize_content_type(content_type: &str) -> &str {
106    let ct = content_type.trim();
107    if ct.is_empty() {
108        return DEFAULT_MIME;
109    }
110    // Handle "application/json; v=3" or "application/json;v=3"
111    if ct.starts_with("application/json") && ct.contains("v=3") {
112        return "application/json;v=3";
113    }
114    // Strip any parameters like charset
115    let base = ct.split(';').next().unwrap_or(ct).trim();
116    // Verify it is a supported type
117    for supported in SUPPORTED {
118        if base == *supported {
119            return supported;
120        }
121    }
122    DEFAULT_MIME
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn parse_accept_empty() {
131        assert_eq!(parse_accept(""), "text/zinc");
132    }
133
134    #[test]
135    fn parse_accept_wildcard() {
136        assert_eq!(parse_accept("*/*"), "text/zinc");
137    }
138
139    #[test]
140    fn parse_accept_json() {
141        assert_eq!(parse_accept("application/json"), "application/json");
142    }
143
144    #[test]
145    fn parse_accept_zinc() {
146        assert_eq!(parse_accept("text/zinc"), "text/zinc");
147    }
148
149    #[test]
150    fn parse_accept_trio() {
151        assert_eq!(parse_accept("text/trio"), "text/trio");
152    }
153
154    #[test]
155    fn parse_accept_json_v3() {
156        assert_eq!(parse_accept("application/json;v=3"), "application/json;v=3");
157    }
158
159    #[test]
160    fn parse_accept_unsupported_falls_back() {
161        assert_eq!(parse_accept("text/html"), "text/zinc");
162    }
163
164    #[test]
165    fn parse_accept_multiple_with_quality() {
166        assert_eq!(
167            parse_accept("text/zinc;q=0.5, application/json;q=1.0"),
168            "application/json"
169        );
170    }
171
172    #[test]
173    fn normalize_content_type_empty() {
174        assert_eq!(normalize_content_type(""), "text/zinc");
175    }
176
177    #[test]
178    fn normalize_content_type_json_v3() {
179        assert_eq!(
180            normalize_content_type("application/json; v=3"),
181            "application/json;v=3"
182        );
183    }
184
185    #[test]
186    fn normalize_content_type_with_charset() {
187        assert_eq!(
188            normalize_content_type("text/zinc; charset=utf-8"),
189            "text/zinc"
190        );
191    }
192
193    #[test]
194    fn decode_request_grid_empty_zinc() {
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}