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.
158pub fn encode_response_streaming(
159    grid: &HGrid,
160    accept: &str,
161) -> Result<(Vec<u8>, Vec<Vec<u8>>, &'static str), CodecError> {
162    let mime = parse_accept(accept);
163
164    // HBF: full binary encode (zstd compression needs the full payload)
165    if mime == HBF_MIME {
166        let bytes = haystack_core::codecs::encode_grid_binary(grid).map_err(CodecError::Encode)?;
167        return Ok((bytes, Vec::new(), HBF_MIME));
168    }
169
170    let codec = codec_for(mime)
171        .unwrap_or_else(|| codec_for(DEFAULT_MIME).expect("default codec must exist"));
172
173    let header = codec.encode_grid_header(grid)?;
174
175    // Batch rows into chunks of 500 to avoid per-row allocation overhead
176    const BATCH_SIZE: usize = 500;
177    let mut batches = Vec::with_capacity(grid.rows.len() / BATCH_SIZE + 1);
178    for chunk in grid.rows.chunks(BATCH_SIZE) {
179        let mut buf = Vec::new();
180        for row in chunk {
181            buf.extend_from_slice(&codec.encode_grid_row(&grid.cols, row)?);
182        }
183        batches.push(buf);
184    }
185
186    let ct = SUPPORTED
187        .iter()
188        .find(|&&s| s == mime)
189        .copied()
190        .unwrap_or(DEFAULT_MIME);
191    Ok((header, batches, ct))
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn parse_accept_empty() {
200        assert_eq!(parse_accept(""), "text/zinc");
201    }
202
203    #[test]
204    fn parse_accept_wildcard() {
205        assert_eq!(parse_accept("*/*"), "text/zinc");
206    }
207
208    #[test]
209    fn parse_accept_json() {
210        assert_eq!(parse_accept("application/json"), "application/json");
211    }
212
213    #[test]
214    fn parse_accept_zinc() {
215        assert_eq!(parse_accept("text/zinc"), "text/zinc");
216    }
217
218    #[test]
219    fn parse_accept_trio() {
220        assert_eq!(parse_accept("text/trio"), "text/trio");
221    }
222
223    #[test]
224    fn parse_accept_json_v3() {
225        assert_eq!(parse_accept("application/json;v=3"), "application/json;v=3");
226    }
227
228    #[test]
229    fn parse_accept_unsupported_falls_back() {
230        assert_eq!(parse_accept("text/html"), "text/zinc");
231    }
232
233    #[test]
234    fn parse_accept_multiple_with_quality() {
235        assert_eq!(
236            parse_accept("text/zinc;q=0.5, application/json;q=1.0"),
237            "application/json"
238        );
239    }
240
241    #[test]
242    fn normalize_content_type_empty() {
243        assert_eq!(normalize_content_type(""), "text/zinc");
244    }
245
246    #[test]
247    fn normalize_content_type_json_v3() {
248        assert_eq!(
249            normalize_content_type("application/json; v=3"),
250            "application/json;v=3"
251        );
252    }
253
254    #[test]
255    fn normalize_content_type_with_charset() {
256        assert_eq!(
257            normalize_content_type("text/zinc; charset=utf-8"),
258            "text/zinc"
259        );
260    }
261
262    #[test]
263    fn decode_request_grid_empty_zinc() {
264        // Empty zinc grid: "ver:\"3.0\"\nempty\n"
265        let result = decode_request_grid("ver:\"3.0\"\nempty\n", "text/zinc");
266        assert!(result.is_ok());
267    }
268
269    #[test]
270    fn encode_response_grid_default() {
271        let grid = HGrid::new();
272        let result = encode_response_grid(&grid, "");
273        assert!(result.is_ok());
274        let (_, content_type) = result.unwrap();
275        assert_eq!(content_type, "text/zinc");
276    }
277
278    #[test]
279    fn parse_accept_hbf() {
280        assert_eq!(parse_accept(HBF_MIME), HBF_MIME);
281    }
282
283    #[test]
284    fn normalize_content_type_hbf() {
285        assert_eq!(normalize_content_type(HBF_MIME), HBF_MIME);
286    }
287
288    #[test]
289    fn encode_decode_hbf_round_trip() {
290        let grid = HGrid::new();
291        let (bytes, ct) = encode_response_grid(&grid, HBF_MIME).unwrap();
292        assert_eq!(ct, HBF_MIME);
293        let decoded = decode_request_grid_bytes(&bytes, HBF_MIME).unwrap();
294        assert!(decoded.is_empty());
295    }
296
297    #[test]
298    fn decode_request_grid_bytes_text_fallback() {
299        let result = decode_request_grid_bytes(b"ver:\"3.0\"\nempty\n", "text/zinc");
300        assert!(result.is_ok());
301    }
302
303    #[test]
304    fn streaming_zinc_matches_full_encode() {
305        use haystack_core::data::{HCol, HDict};
306        use haystack_core::kinds::{HRef, Kind};
307
308        let mut rows = Vec::new();
309        for i in 0..5 {
310            let mut d = HDict::new();
311            d.set(
312                String::from("id"),
313                Kind::Ref(HRef::from_val(&format!("r{i}"))),
314            );
315            d.set(String::from("dis"), Kind::Str(format!("Row {i}")));
316            rows.push(d);
317        }
318        let cols = vec![
319            HCol::new(String::from("id")),
320            HCol::new(String::from("dis")),
321        ];
322        let grid = HGrid::from_parts(HDict::new(), cols, rows);
323
324        // Full encode
325        let (full_bytes, ct) = encode_response_grid(&grid, "text/zinc").unwrap();
326        assert_eq!(ct, "text/zinc");
327
328        // Streaming encode
329        let (header, row_chunks, ct2) = encode_response_streaming(&grid, "text/zinc").unwrap();
330        assert_eq!(ct2, "text/zinc");
331
332        let mut streamed = header;
333        for chunk in row_chunks {
334            streamed.extend_from_slice(&chunk);
335        }
336        assert_eq!(full_bytes, streamed);
337    }
338}