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/// MIME type for Haystack Binary Format (HBF).
10const HBF_MIME: &str = "application/x-haystack-binary";
11
12/// Supported MIME types in preference order.
13const SUPPORTED: &[&str] = &[
14    "text/zinc",
15    "application/json",
16    "text/trio",
17    "application/json;v=3",
18    HBF_MIME,
19];
20
21/// Parse an Accept header and return the best supported MIME type.
22///
23/// Returns `"text/zinc"` when the header is empty, `*/*`, or contains
24/// no recognized MIME type.
25pub fn parse_accept(accept_header: &str) -> &'static str {
26    let accept = accept_header.trim();
27    if accept.is_empty() || accept == "*/*" {
28        return DEFAULT_MIME;
29    }
30
31    // Parse weighted entries: "text/zinc;q=0.9, application/json;q=1.0"
32    let mut candidates: Vec<(&str, f32)> = Vec::new();
33
34    for part in accept.split(',') {
35        let part = part.trim();
36        let mut segments = part.splitn(2, ';');
37        let mime = segments.next().unwrap_or("").trim();
38
39        // Check for q= parameter
40        let quality = segments
41            .next()
42            .and_then(|params| {
43                for param in params.split(';') {
44                    let param = param.trim();
45                    if let Some(q_val) = param.strip_prefix("q=") {
46                        return q_val.trim().parse::<f32>().ok();
47                    }
48                }
49                None
50            })
51            .unwrap_or(1.0);
52
53        // Handle application/json;v=3 specially: need to check the original part
54        if mime == "application/json" && part.contains("v=3") {
55            candidates.push(("application/json;v=3", quality));
56        } else if mime == "*/*" {
57            candidates.push((DEFAULT_MIME, quality));
58        } else {
59            candidates.push((mime, quality));
60        }
61    }
62
63    // Sort by quality descending, then pick the first supported
64    candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
65
66    for (mime, _) in &candidates {
67        for supported in SUPPORTED {
68            if mime == supported {
69                return supported;
70            }
71        }
72    }
73
74    DEFAULT_MIME
75}
76
77/// Decode a request body into an HGrid using the given Content-Type.
78///
79/// Falls back to `"text/zinc"` if the content type is not recognized.
80pub fn decode_request_grid(body: &str, content_type: &str) -> Result<HGrid, CodecError> {
81    let mime = normalize_content_type(content_type);
82    let codec = codec_for(mime)
83        .unwrap_or_else(|| codec_for(DEFAULT_MIME).expect("default codec must exist"));
84    codec.decode_grid(body)
85}
86
87/// Encode an HGrid for the response using the best Accept type.
88///
89/// Returns `(body_bytes, content_type)`. Text codecs produce UTF-8 bytes;
90/// HBF produces raw binary.
91pub fn encode_response_grid(
92    grid: &HGrid,
93    accept: &str,
94) -> Result<(Vec<u8>, &'static str), CodecError> {
95    let mime = parse_accept(accept);
96
97    if mime == HBF_MIME {
98        let bytes = haystack_core::codecs::encode_grid_binary(grid).map_err(CodecError::Encode)?;
99        return Ok((bytes, HBF_MIME));
100    }
101
102    let codec = codec_for(mime)
103        .unwrap_or_else(|| codec_for(DEFAULT_MIME).expect("default codec must exist"));
104    let body = codec.encode_grid(grid)?;
105    // Return the static mime type string that matches what we used
106    for supported in SUPPORTED {
107        if *supported == mime {
108            return Ok((body.into_bytes(), supported));
109        }
110    }
111    Ok((body.into_bytes(), DEFAULT_MIME))
112}
113
114/// Normalize a Content-Type header to a bare MIME type for codec lookup.
115fn normalize_content_type(content_type: &str) -> &str {
116    let ct = content_type.trim();
117    if ct.is_empty() {
118        return DEFAULT_MIME;
119    }
120    // Handle HBF binary format
121    if ct.starts_with(HBF_MIME) {
122        return HBF_MIME;
123    }
124    // Handle "application/json; v=3" or "application/json;v=3"
125    if ct.starts_with("application/json") && ct.contains("v=3") {
126        return "application/json;v=3";
127    }
128    // Strip any parameters like charset
129    let base = ct.split(';').next().unwrap_or(ct).trim();
130    // Verify it is a supported type
131    for supported in SUPPORTED {
132        if base == *supported {
133            return supported;
134        }
135    }
136    DEFAULT_MIME
137}
138
139/// Decode a request body (as raw bytes) into an HGrid using the given Content-Type.
140///
141/// Supports both text-based codecs and the binary HBF codec. Falls back to
142/// `"text/zinc"` if the content type is not recognized.
143pub fn decode_request_grid_bytes(body: &[u8], content_type: &str) -> Result<HGrid, CodecError> {
144    let ct = normalize_content_type(content_type);
145    if ct == HBF_MIME {
146        return haystack_core::codecs::decode_grid_binary(body).map_err(CodecError::Encode);
147    }
148    let text = std::str::from_utf8(body).map_err(|e| CodecError::Encode(e.to_string()))?;
149    decode_request_grid(text, content_type)
150}
151
152/// Encode a grid as a streaming byte iterator: yields header chunk then row chunks.
153///
154/// Returns `(header_bytes, row_batches, content_type)`.
155/// Rows are batched into groups of ~500 to balance streaming granularity against
156/// allocation overhead. For codecs without streaming support (or HBF), the header
157/// contains the full response and `row_batches` is empty.
158#[allow(clippy::type_complexity)]
159pub fn encode_response_streaming(
160    grid: &HGrid,
161    accept: &str,
162) -> Result<(Vec<u8>, Vec<Vec<u8>>, &'static str), CodecError> {
163    let mime = parse_accept(accept);
164
165    // HBF: full binary encode (zstd compression needs the full payload)
166    if mime == HBF_MIME {
167        let bytes = haystack_core::codecs::encode_grid_binary(grid).map_err(CodecError::Encode)?;
168        return Ok((bytes, Vec::new(), HBF_MIME));
169    }
170
171    let codec = codec_for(mime)
172        .unwrap_or_else(|| codec_for(DEFAULT_MIME).expect("default codec must exist"));
173
174    let header = codec.encode_grid_header(grid)?;
175
176    // Batch rows into chunks of 500 to avoid per-row allocation overhead
177    const BATCH_SIZE: usize = 500;
178    let mut batches = Vec::with_capacity(grid.rows.len() / BATCH_SIZE + 1);
179    for chunk in grid.rows.chunks(BATCH_SIZE) {
180        let mut buf = Vec::new();
181        for row in chunk {
182            buf.extend_from_slice(&codec.encode_grid_row(&grid.cols, row)?);
183        }
184        batches.push(buf);
185    }
186
187    let ct = SUPPORTED
188        .iter()
189        .find(|&&s| s == mime)
190        .copied()
191        .unwrap_or(DEFAULT_MIME);
192    Ok((header, batches, ct))
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn parse_accept_empty() {
201        assert_eq!(parse_accept(""), "text/zinc");
202    }
203
204    #[test]
205    fn parse_accept_wildcard() {
206        assert_eq!(parse_accept("*/*"), "text/zinc");
207    }
208
209    #[test]
210    fn parse_accept_json() {
211        assert_eq!(parse_accept("application/json"), "application/json");
212    }
213
214    #[test]
215    fn parse_accept_zinc() {
216        assert_eq!(parse_accept("text/zinc"), "text/zinc");
217    }
218
219    #[test]
220    fn parse_accept_trio() {
221        assert_eq!(parse_accept("text/trio"), "text/trio");
222    }
223
224    #[test]
225    fn parse_accept_json_v3() {
226        assert_eq!(parse_accept("application/json;v=3"), "application/json;v=3");
227    }
228
229    #[test]
230    fn parse_accept_unsupported_falls_back() {
231        assert_eq!(parse_accept("text/html"), "text/zinc");
232    }
233
234    #[test]
235    fn parse_accept_multiple_with_quality() {
236        assert_eq!(
237            parse_accept("text/zinc;q=0.5, application/json;q=1.0"),
238            "application/json"
239        );
240    }
241
242    #[test]
243    fn normalize_content_type_empty() {
244        assert_eq!(normalize_content_type(""), "text/zinc");
245    }
246
247    #[test]
248    fn normalize_content_type_json_v3() {
249        assert_eq!(
250            normalize_content_type("application/json; v=3"),
251            "application/json;v=3"
252        );
253    }
254
255    #[test]
256    fn normalize_content_type_with_charset() {
257        assert_eq!(
258            normalize_content_type("text/zinc; charset=utf-8"),
259            "text/zinc"
260        );
261    }
262
263    #[test]
264    fn decode_request_grid_empty_zinc() {
265        // Empty zinc grid: "ver:\"3.0\"\nempty\n"
266        let result = decode_request_grid("ver:\"3.0\"\nempty\n", "text/zinc");
267        assert!(result.is_ok());
268    }
269
270    #[test]
271    fn encode_response_grid_default() {
272        let grid = HGrid::new();
273        let result = encode_response_grid(&grid, "");
274        assert!(result.is_ok());
275        let (_, content_type) = result.unwrap();
276        assert_eq!(content_type, "text/zinc");
277    }
278
279    #[test]
280    fn parse_accept_hbf() {
281        assert_eq!(parse_accept(HBF_MIME), HBF_MIME);
282    }
283
284    #[test]
285    fn normalize_content_type_hbf() {
286        assert_eq!(normalize_content_type(HBF_MIME), HBF_MIME);
287    }
288
289    #[test]
290    fn encode_decode_hbf_round_trip() {
291        let grid = HGrid::new();
292        let (bytes, ct) = encode_response_grid(&grid, HBF_MIME).unwrap();
293        assert_eq!(ct, HBF_MIME);
294        let decoded = decode_request_grid_bytes(&bytes, HBF_MIME).unwrap();
295        assert!(decoded.is_empty());
296    }
297
298    #[test]
299    fn decode_request_grid_bytes_text_fallback() {
300        let result = decode_request_grid_bytes(b"ver:\"3.0\"\nempty\n", "text/zinc");
301        assert!(result.is_ok());
302    }
303
304    #[test]
305    fn streaming_zinc_matches_full_encode() {
306        use haystack_core::data::{HCol, HDict};
307        use haystack_core::kinds::{HRef, Kind};
308
309        let mut rows = Vec::new();
310        for i in 0..5 {
311            let mut d = HDict::new();
312            d.set(
313                String::from("id"),
314                Kind::Ref(HRef::from_val(format!("r{i}"))),
315            );
316            d.set(String::from("dis"), Kind::Str(format!("Row {i}")));
317            rows.push(d);
318        }
319        let cols = vec![
320            HCol::new(String::from("id")),
321            HCol::new(String::from("dis")),
322        ];
323        let grid = HGrid::from_parts(HDict::new(), cols, rows);
324
325        // Full encode
326        let (full_bytes, ct) = encode_response_grid(&grid, "text/zinc").unwrap();
327        assert_eq!(ct, "text/zinc");
328
329        // Streaming encode
330        let (header, row_chunks, ct2) = encode_response_streaming(&grid, "text/zinc").unwrap();
331        assert_eq!(ct2, "text/zinc");
332
333        let mut streamed = header;
334        for chunk in row_chunks {
335            streamed.extend_from_slice(&chunk);
336        }
337        assert_eq!(full_bytes, streamed);
338    }
339}