Skip to main content

pjson_rs/infrastructure/http/
streaming.rs

1//! Advanced streaming implementations for different protocols
2
3use crate::domain::entities::Frame;
4use async_stream::try_stream;
5use axum::{
6    http::{HeaderMap, StatusCode, header},
7    response::Response,
8};
9use futures::{Stream, StreamExt};
10use headers_accept::Accept;
11use mediatype::{MediaType, MediaTypeBuf, Name, names};
12use std::str::FromStr;
13
14/// Streaming format types
15#[derive(Debug, Clone, Copy)]
16pub enum StreamFormat {
17    /// Standard JSON array streaming
18    Json,
19    /// Newline-delimited JSON
20    NdJson,
21    /// Server-Sent Events
22    ServerSentEvents,
23    /// Binary PJS protocol
24    Binary,
25}
26
27/// Maximum number of comma-separated `Accept` entries considered during content
28/// negotiation.
29///
30/// Bounds iteration over untrusted input (project security invariant); entries
31/// beyond this count are ignored, not rejected. Enforced by truncating the raw
32/// header string *before* it reaches [`headers_accept`]'s parser, since that
33/// crate does not itself bound entry count.
34const MAX_ACCEPT_ENTRIES: usize = 16;
35
36/// Non-standard "ndjson" subtype name.
37///
38/// Not present in [`mediatype`]'s built-in IANA registry constants (`x-`
39/// prefixed extension types aren't registered), so it is declared here.
40const X_NDJSON: Name<'static> = Name::new_unchecked("x-ndjson");
41
42/// Server-supported media ranges for this route, paired with the
43/// [`StreamFormat`] each selects.
44///
45/// Order is the tie-break preference used when [`Accept::negotiate`] finds
46/// multiple candidates matching the same `Accept` entry at equal specificity
47/// and `q` (e.g. a bare `*/*` or `application/*`, which match every entry
48/// here equally) — [`StreamFormat::Json`] listed first preserves the
49/// permissive default fallback.
50static SUPPORTED_MEDIA_TYPES: [(MediaType<'static>, StreamFormat); 4] = [
51    (
52        MediaType::new(names::APPLICATION, names::JSON),
53        StreamFormat::Json,
54    ),
55    (
56        MediaType::new(names::TEXT, names::EVENT_STREAM),
57        StreamFormat::ServerSentEvents,
58    ),
59    (
60        MediaType::new(names::APPLICATION, X_NDJSON),
61        StreamFormat::NdJson,
62    ),
63    (
64        MediaType::new(names::APPLICATION, names::OCTET_STREAM),
65        StreamFormat::Binary,
66    ),
67];
68
69/// Whether `media_range` is eligible for negotiation.
70///
71/// Wildcards are restricted to exactly `*/*` and `application/*`
72/// (case-insensitive) — the only two forms this route has ever supported.
73/// Any other range containing a `*` (e.g. `text/*`, `*/x-ndjson`) is rejected
74/// here rather than handed to `headers_accept`, whose wildcard matching is
75/// general RFC 9110 `type/*`/`*/*` matching — broader than this route's
76/// historical, scoped wildcard support, and would otherwise let e.g.
77/// `*/x-ndjson` match every candidate the same as `*/*`. Non-wildcard ranges
78/// are always eligible; an unrecognized concrete type (e.g. `application/xml`)
79/// simply never matches any candidate in [`SUPPORTED_MEDIA_TYPES`] and falls
80/// through to the permissive fallback.
81fn is_supported_media_range(media_range: &str) -> bool {
82    !media_range.contains('*')
83        || media_range.eq_ignore_ascii_case("*/*")
84        || media_range.eq_ignore_ascii_case("application/*")
85}
86
87/// Formats an already-clamped `q` (`[0.0, 1.0]`) to the `<=3` fractional digits
88/// `headers_accept`'s `QValue` grammar requires.
89///
90/// Rounds to the nearest representable value, but never rounds a positive `q`
91/// down to `0.000` — `headers_accept` treats `q=0` as an explicit rejection of
92/// the entry, so floor-rounding e.g. `q=0.0004` to `0.000` would flip a
93/// (barely) acceptable preference into a hard rejection.
94fn format_q(q: f32) -> String {
95    if q <= 0.0 {
96        return "0.000".to_string();
97    }
98    let milli = ((q * 1000.0).round() as u32).max(1);
99    if milli >= 1000 {
100        "1.000".to_string()
101    } else {
102        format!("0.{milli:03}")
103    }
104}
105
106impl StreamFormat {
107    /// Picks a streaming format for the request's `Accept` header.
108    ///
109    /// Negotiation is RFC 9110 §12.5.1-conformant for this route's supported media
110    /// ranges, delegated to [`headers_accept::Accept::negotiate`]: candidates are
111    /// ranked by `q` value first and by media-range specificity as the tiebreaker
112    /// (exact match > `application/*` > `*/*`).
113    ///
114    /// - `q=0` is an explicit rejection of that media range and drops the entry.
115    /// - A `q` parameter that is non-finite (`nan`/`inf`/`-inf`) or otherwise fails
116    ///   to parse as a float drops its entry — a malformed preference is treated
117    ///   as "no preference stated", not "highest preference". This is validated
118    ///   before delegating to `headers_accept`, whose own default treats an
119    ///   unparsable `q` as absent (i.e. `1.0`, the highest priority) rather than
120    ///   dropping the entry.
121    /// - A finite but out-of-range `q` (e.g. `q=5`) is clamped to `[0.0, 1.0]` and
122    ///   the entry is kept — matching the pre-migration hand-rolled parser's
123    ///   `.clamp(0.0, 1.0)` exactly (not dropped; only `q=0`, non-finite, and
124    ///   unparsable `q` are dropped).
125    /// - Wildcard matching is restricted to exactly `*/*` and `application/*`
126    ///   (case-insensitive), which vote for [`Self::Json`] at their own `q`.
127    ///   Any other range containing a `*` (e.g. `text/*`, `*/x-ndjson`) is
128    ///   rejected rather than delegated to `headers_accept`'s more general
129    ///   `type/*`/`*/*` matching, which is broader than this route's
130    ///   historical, scoped wildcard support.
131    /// - Each surviving entry is validated as a well-formed media type
132    ///   independently, before negotiation; a malformed entry (e.g. `garbage!!`)
133    ///   is dropped individually and does not discard the rest of the header —
134    ///   `headers_accept::Accept::from_str` itself fails the *entire* header on a
135    ///   single bad entry, so this crate is never handed anything but
136    ///   already-validated, surviving entries.
137    /// - At most `MAX_ACCEPT_ENTRIES` (16) comma-separated entries are considered;
138    ///   any beyond that bound are silently ignored. This bound is enforced by
139    ///   truncating the raw header string before it reaches `headers_accept`'s
140    ///   parser, since that crate does not itself bound entry count.
141    /// - A missing header, an unparsable header value, or no entry surviving
142    ///   negotiation all fall back to [`Self::Json`].
143    pub fn from_accept_header(headers: &HeaderMap) -> Self {
144        let Some(accept) = headers.get(header::ACCEPT) else {
145            return Self::Json;
146        };
147        let Ok(accept_str) = accept.to_str() else {
148            return Self::Json;
149        };
150
151        let mut sanitized_entries: Vec<String> = Vec::new();
152        for entry in accept_str.split(',').take(MAX_ACCEPT_ENTRIES) {
153            let mut parts = entry.split(';');
154            let media_range = parts.next().unwrap_or("").trim();
155            if media_range.is_empty() || !is_supported_media_range(media_range) {
156                continue;
157            }
158
159            let mut q_str: Option<&str> = None;
160            for param in parts {
161                let mut kv = param.splitn(2, '=');
162                let name = kv.next().unwrap_or("").trim();
163                if name.eq_ignore_ascii_case("q") {
164                    q_str = Some(kv.next().unwrap_or("").trim());
165                    break;
166                }
167            }
168
169            let sanitized = match q_str {
170                None => media_range.to_string(),
171                Some(raw_q) => {
172                    let Ok(q) = raw_q.parse::<f32>() else {
173                        continue;
174                    };
175                    if !q.is_finite() {
176                        continue;
177                    }
178                    format!("{media_range};q={}", format_q(q.clamp(0.0, 1.0)))
179                }
180            };
181
182            // Validate independently, per entry: `Accept::from_str` fails the
183            // whole header on a single malformed entry (S1), so a bad entry must
184            // be dropped here, before the survivors are ever joined together.
185            if MediaTypeBuf::from_str(&sanitized).is_err() {
186                continue;
187            }
188            sanitized_entries.push(sanitized);
189        }
190
191        if sanitized_entries.is_empty() {
192            return Self::Json;
193        }
194        let Ok(accept) = Accept::from_str(&sanitized_entries.join(",")) else {
195            return Self::Json;
196        };
197
198        let Some(best) = accept.negotiate(SUPPORTED_MEDIA_TYPES.iter().map(|(mt, _)| mt)) else {
199            return Self::Json;
200        };
201        SUPPORTED_MEDIA_TYPES
202            .iter()
203            .find(|(mt, _)| mt == best)
204            .map_or(Self::Json, |(_, format)| *format)
205    }
206
207    /// MIME type that corresponds to this streaming format.
208    pub fn content_type(&self) -> &'static str {
209        match self {
210            Self::Json => "application/json",
211            Self::NdJson => "application/x-ndjson",
212            Self::ServerSentEvents => "text/event-stream",
213            Self::Binary => "application/octet-stream",
214        }
215    }
216}
217
218// ---------------------------------------------------------------------------
219// Shared helpers
220// ---------------------------------------------------------------------------
221
222/// Serializes a batch of frames.
223///
224/// Each batch is serialized as newline-delimited JSON objects (one object per
225/// frame). `StreamFormat::Json` and `StreamFormat::NdJson` produce identical
226/// wire bytes; only `content_type()` differs.
227fn format_batch_owned(
228    frames: &[Frame],
229    format: StreamFormat,
230) -> Result<Vec<u8>, StreamTransportError> {
231    match format {
232        // #167: NDJSON-of-objects — one JSON object per line per frame.
233        // Identical wire bytes for Json and NdJson; only content_type() differs.
234        StreamFormat::Json | StreamFormat::NdJson => {
235            let mut out = Vec::new();
236            for frame in frames {
237                out.extend_from_slice(&sonic_rs::to_vec(frame)?);
238                out.push(b'\n');
239            }
240            Ok(out)
241        }
242        StreamFormat::ServerSentEvents => {
243            let mut out = Vec::new();
244            for frame in frames {
245                out.extend_from_slice(b"data: ");
246                out.extend_from_slice(&sonic_rs::to_vec(frame)?);
247                out.extend_from_slice(b"\n\n");
248            }
249            Ok(out)
250        }
251        StreamFormat::Binary => Ok(sonic_rs::to_vec(frames)?),
252    }
253}
254
255// ---------------------------------------------------------------------------
256// BatchFrameStream
257// ---------------------------------------------------------------------------
258
259/// Batch frame stream for improved throughput.
260pub struct BatchFrameStream<S> {
261    inner: S,
262    format: StreamFormat,
263    batch_size: usize,
264}
265
266impl<S> BatchFrameStream<S>
267where
268    S: Stream<Item = Frame> + Unpin + Send + 'static,
269{
270    /// Wrap a frame stream and emit batches of up to `batch_size` frames.
271    pub fn new(stream: S, format: StreamFormat, batch_size: usize) -> Self {
272        Self {
273            inner: stream,
274            format,
275            batch_size,
276        }
277    }
278
279    /// Returns the `Content-Type` that accurately describes what this stream emits.
280    ///
281    /// `BatchFrameStream` serializes each batch as newline-delimited JSON objects,
282    /// so `StreamFormat::Json` is promoted to `application/x-ndjson` — the output
283    /// is not a single well-formed JSON document and must not be advertised as one.
284    pub fn content_type(&self) -> &'static str {
285        match self.format {
286            StreamFormat::Json => "application/x-ndjson",
287            other => other.content_type(),
288        }
289    }
290
291    /// Consume the builder and return a `Stream` of formatted batch payloads.
292    ///
293    /// Each item is one full batch as `Vec<u8>`. For `StreamFormat::Json` and
294    /// `StreamFormat::NdJson` the bytes hold one JSON object per frame, one
295    /// per line (NDJSON-of-objects, #167). The stream item type is binary
296    /// (`Vec<u8>`, not `String`) to leave room for future per-batch
297    /// compression (#226).
298    pub fn into_stream(
299        self,
300    ) -> impl Stream<Item = Result<Vec<u8>, StreamTransportError>> + Send + 'static {
301        let Self {
302            inner,
303            format,
304            batch_size,
305        } = self;
306        try_stream! {
307            let mut batch: Vec<Frame> = Vec::with_capacity(batch_size);
308            futures::pin_mut!(inner);
309
310            while let Some(frame) = inner.next().await {
311                batch.push(frame);
312                if batch.len() >= batch_size {
313                    let bytes = format_batch_owned(&batch, format)?;
314                    batch.clear();
315                    yield bytes;
316                }
317            }
318
319            if !batch.is_empty() {
320                let bytes = format_batch_owned(&batch, format)?;
321                yield bytes;
322            }
323        }
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Stream error types
329// ---------------------------------------------------------------------------
330
331/// Stream error types
332#[derive(Debug, thiserror::Error)]
333pub enum StreamTransportError {
334    /// Frame failed to serialize to JSON.
335    #[error("Serialization error: {0}")]
336    Serialization(#[from] sonic_rs::Error),
337
338    /// Underlying I/O or transport failure.
339    #[error("IO error: {0}")]
340    Io(String),
341
342    /// Internal buffer overflowed before consumers could drain it.
343    #[error("Buffer overflow")]
344    BufferOverflow,
345
346    /// The stream was closed before completing the operation.
347    #[error("Stream closed")]
348    StreamClosed,
349}
350
351// ---------------------------------------------------------------------------
352// Response helper
353// ---------------------------------------------------------------------------
354
355/// Create a response with appropriate headers for the given streaming format.
356///
357/// The stream item type is `Vec<u8>` (binary). This is the canonical type for
358/// both UTF-8 textual formats (`Json`, `NdJson`, `ServerSentEvents`) and binary
359/// payloads (`Binary`, e.g. gzip-compressed output).
360pub fn create_streaming_response<S>(
361    stream: S,
362    format: StreamFormat,
363) -> Result<Response, StreamTransportError>
364where
365    S: Stream<Item = Result<Vec<u8>, StreamTransportError>> + Send + 'static,
366{
367    let body = axum::body::Body::from_stream(stream);
368
369    let mut response = Response::builder()
370        .status(StatusCode::OK)
371        .header(header::CONTENT_TYPE, format.content_type())
372        .header(header::CACHE_CONTROL, "no-cache");
373
374    // No manual `Transfer-Encoding` or `Connection` here: the response body encoder
375    // (hyper) owns transfer framing and connection-management headers, and
376    // applications must not set either by hand. On HTTP/2 both are illegal and
377    // hyper logs a WARN per response when either is present (verified: hyper
378    // `proto/h2/mod.rs:50`) — real, current log spam in any HTTP/2 deployment of
379    // this route, since SSE (which previously set `Connection: keep-alive`) is a
380    // first-class negotiated format here. On HTTP/1.1 a manually-set
381    // `Transfer-Encoding` can also collide with body-length-derived framing in
382    // other code paths; that hazard is latent here (`Body::from_stream` reports
383    // `BodyLength::Unknown`, so nothing on this response path derives a
384    // `Content-Length` to collide with) but is not the reason for this rule — the
385    // encoder owning framing is. `X-Accel-Buffering` is a reverse-proxy hint, not a
386    // connection-management header, and is unaffected by either concern.
387    if let StreamFormat::ServerSentEvents = format {
388        response = response.header("X-Accel-Buffering", "no");
389    }
390
391    response
392        .body(body)
393        .map_err(|e| StreamTransportError::Io(e.to_string()))
394}
395
396/// Create a streaming response with an explicit `Content-Type`.
397///
398/// Use this when the stream's content-type cannot be derived from [`StreamFormat`]
399/// alone — for example, when a [`BatchFrameStream`] promotes `StreamFormat::Json`
400/// to `application/x-ndjson` via [`BatchFrameStream::content_type()`].
401///
402/// # Example
403///
404/// ```rust,no_run
405/// # use pjson_rs::infrastructure::http::streaming::{
406/// #     BatchFrameStream, StreamFormat, create_streaming_response_with_content_type,
407/// # };
408/// # use futures::stream;
409/// # use pjson_rs::domain::entities::Frame;
410/// # async fn example() -> Result<axum::response::Response, Box<dyn std::error::Error>> {
411/// let frames = stream::iter(Vec::<Frame>::new());
412/// let batch = BatchFrameStream::new(frames, StreamFormat::Json, 10);
413/// let content_type = batch.content_type();
414/// let response = create_streaming_response_with_content_type(batch.into_stream(), content_type)?;
415/// # Ok(response)
416/// # }
417/// ```
418pub fn create_streaming_response_with_content_type<S>(
419    stream: S,
420    content_type: &str,
421) -> Result<Response, StreamTransportError>
422where
423    S: Stream<Item = Result<Vec<u8>, StreamTransportError>> + Send + 'static,
424{
425    let body = axum::body::Body::from_stream(stream);
426    Response::builder()
427        .status(StatusCode::OK)
428        .header(header::CONTENT_TYPE, content_type)
429        .header(header::CACHE_CONTROL, "no-cache")
430        .body(body)
431        .map_err(|e| StreamTransportError::Io(e.to_string()))
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::domain::entities::Frame;
438    use crate::domain::value_objects::{JsonData, StreamId};
439    use axum::http::header;
440    use futures::StreamExt;
441    use futures::stream;
442    use std::pin::Pin;
443    use std::task::{Context, Poll};
444
445    fn make_skeleton_frame() -> Frame {
446        Frame::skeleton(StreamId::new(), 1, JsonData::Null)
447    }
448
449    // -----------------------------------------------------------------------
450    // PendingThenReady: adversarial test stream
451    //
452    // Returns `Poll::Pending` exactly `pending_per_item` times before each
453    // item, then `Poll::Ready(Some(item))`. After exhaustion, always returns
454    // `Poll::Ready(None)` (done short-circuit prevents spurious Pending phases
455    // after completion, making it compatible with fused-stream consumers).
456    // -----------------------------------------------------------------------
457
458    struct PendingThenReady<I: Iterator> {
459        iter: I,
460        pending_remaining: usize,
461        pending_per_item: usize,
462        /// Short-circuit: once the inner iterator is exhausted, never return
463        /// Pending again so that fused consumers and select!-driven code work.
464        done: bool,
465    }
466
467    impl<I: Iterator> PendingThenReady<I> {
468        fn new(iter: I, pending_per_item: usize) -> Self {
469            Self {
470                iter,
471                pending_remaining: pending_per_item,
472                pending_per_item,
473                done: false,
474            }
475        }
476    }
477
478    impl<I: Iterator + Unpin> Stream for PendingThenReady<I> {
479        type Item = I::Item;
480
481        fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
482            if self.done {
483                return Poll::Ready(None);
484            }
485            if self.pending_remaining > 0 {
486                self.pending_remaining -= 1;
487                // CRITICAL: re-arm the waker so the executor will poll again.
488                // Without this the stream stalls forever — exactly the pattern
489                // that exposes #166 in hand-rolled poll_next impls.
490                cx.waker().wake_by_ref();
491                return Poll::Pending;
492            }
493            match self.iter.next() {
494                Some(item) => {
495                    self.pending_remaining = self.pending_per_item;
496                    Poll::Ready(Some(item))
497                }
498                None => {
499                    self.done = true;
500                    Poll::Ready(None)
501                }
502            }
503        }
504    }
505
506    // -----------------------------------------------------------------------
507    // Existing tests (updated to use .into_stream())
508    // -----------------------------------------------------------------------
509
510    #[test]
511    fn test_stream_format_detection() {
512        let mut headers = HeaderMap::new();
513        headers.insert(header::ACCEPT, "text/event-stream".parse().unwrap());
514
515        let format = StreamFormat::from_accept_header(&headers);
516        assert!(matches!(format, StreamFormat::ServerSentEvents));
517    }
518
519    /// Each output line must be a valid JSON object (NDJSON-of-objects, #167).
520    #[tokio::test]
521    async fn test_batch_frame_stream_multiple_batches() {
522        let frames: Vec<Frame> = (0..5).map(|_| make_skeleton_frame()).collect();
523        let frame_stream = stream::iter(frames);
524
525        // batch_size=2 → two full batches of 2 and one remainder batch of 1
526        let batch_stream = BatchFrameStream::new(frame_stream, StreamFormat::Json, 2);
527        let collected: Vec<Result<Vec<u8>, StreamTransportError>> =
528            batch_stream.into_stream().collect().await;
529
530        assert_eq!(
531            collected.len(),
532            3,
533            "expected 3 batches for 5 frames with batch_size=2"
534        );
535
536        let mut total_objects = 0usize;
537        for result in &collected {
538            let batch_bytes = result.as_ref().expect("batch should not error");
539            let batch_str = std::str::from_utf8(batch_bytes).expect("uncompressed batch is UTF-8");
540            for line in batch_str.lines() {
541                if line.is_empty() {
542                    continue;
543                }
544                let parsed: serde_json::Value =
545                    serde_json::from_str(line).expect("each line must be valid JSON");
546                assert!(
547                    parsed.is_object(),
548                    "each line must be a JSON object (NDJSON-of-objects), got: {line}"
549                );
550                total_objects += 1;
551            }
552        }
553        assert_eq!(
554            total_objects, 5,
555            "total parsed objects across all batches must equal 5"
556        );
557    }
558
559    // -----------------------------------------------------------------------
560    // New tests using PendingThenReady (#168)
561    // -----------------------------------------------------------------------
562
563    /// `BatchFrameStream` with batch_size=3 over 6 frames must emit exactly 2
564    /// batches, even when the inner stream interleaves `Poll::Pending`.
565    /// The half-batch-on-Pending heuristic (removed) would have emitted more.
566    #[test]
567    fn test_batch_stream_emits_only_full_batches_under_pending() {
568        tokio_test::block_on(async {
569            let frames: Vec<Frame> = (0..6).map(|_| make_skeleton_frame()).collect();
570            let inner = PendingThenReady::new(frames.into_iter(), 2);
571            let batch = BatchFrameStream::new(inner, StreamFormat::Json, 3);
572            let collected: Vec<_> = batch.into_stream().collect().await;
573            assert_eq!(
574                collected.len(),
575                2,
576                "6 frames at batch_size=3 must yield exactly 2 batches"
577            );
578            for r in collected {
579                assert!(r.is_ok());
580            }
581        });
582    }
583
584    /// Validates the r3 wire format for all four `StreamFormat` variants of
585    /// `BatchFrameStream`:
586    /// - `Json` → one JSON object per line per frame (NDJSON-of-objects)
587    /// - `NdJson` → identical bytes to `Json`
588    /// - `ServerSentEvents` → `data: <object>\n\n` per frame
589    /// - `Binary` → single JSON array, no trailing newline
590    #[tokio::test]
591    async fn test_batch_stream_ndjson_objects_per_line() {
592        let make_frames = || -> Vec<Frame> { (0..3).map(|_| make_skeleton_frame()).collect() };
593
594        // Json: one object per line
595        let result_json: Vec<_> =
596            BatchFrameStream::new(stream::iter(make_frames()), StreamFormat::Json, 10)
597                .into_stream()
598                .collect()
599                .await;
600        assert_eq!(result_json.len(), 1);
601        let json_bytes = result_json[0].as_ref().unwrap();
602        let json_str = std::str::from_utf8(json_bytes).unwrap();
603        for line in json_str.lines() {
604            if line.is_empty() {
605                continue;
606            }
607            let v: serde_json::Value = serde_json::from_str(line).unwrap();
608            assert!(v.is_object(), "Json format: each line must be an object");
609        }
610
611        // NdJson: same wire shape as Json — one object per line per frame.
612        let result_ndjson: Vec<_> =
613            BatchFrameStream::new(stream::iter(make_frames()), StreamFormat::NdJson, 10)
614                .into_stream()
615                .collect()
616                .await;
617        assert_eq!(result_ndjson.len(), 1);
618        let ndjson_bytes = result_ndjson[0].as_ref().unwrap();
619        let ndjson_str = std::str::from_utf8(ndjson_bytes).unwrap();
620        for line in ndjson_str.lines() {
621            if line.is_empty() {
622                continue;
623            }
624            let v: serde_json::Value = serde_json::from_str(line).unwrap();
625            assert!(v.is_object(), "NdJson format: each line must be an object");
626        }
627        // Both formats must produce the same number of objects per batch
628        let json_count = json_str.lines().filter(|l| !l.is_empty()).count();
629        let ndjson_count = ndjson_str.lines().filter(|l| !l.is_empty()).count();
630        assert_eq!(
631            json_count, ndjson_count,
632            "Json and NdJson must produce the same object count"
633        );
634
635        // SSE: data: <object>\n\n per frame
636        let result_sse: Vec<_> = BatchFrameStream::new(
637            stream::iter(make_frames()),
638            StreamFormat::ServerSentEvents,
639            10,
640        )
641        .into_stream()
642        .collect()
643        .await;
644        assert_eq!(result_sse.len(), 1);
645        let sse_bytes = result_sse[0].as_ref().unwrap();
646        let sse_str = std::str::from_utf8(sse_bytes).unwrap();
647        let sse_frames: Vec<&str> = sse_str.split("\n\n").filter(|s| !s.is_empty()).collect();
648        assert_eq!(sse_frames.len(), 3);
649        for frame_str in sse_frames {
650            assert!(frame_str.starts_with("data: "));
651            let json_part = &frame_str["data: ".len()..];
652            let v: serde_json::Value = serde_json::from_str(json_part).unwrap();
653            assert!(v.is_object());
654        }
655
656        // Binary: single JSON array
657        let result_binary: Vec<_> =
658            BatchFrameStream::new(stream::iter(make_frames()), StreamFormat::Binary, 10)
659                .into_stream()
660                .collect()
661                .await;
662        assert_eq!(result_binary.len(), 1);
663        let binary_bytes = result_binary[0].as_ref().unwrap();
664        let v: serde_json::Value = serde_json::from_slice(binary_bytes).unwrap();
665        assert!(v.is_array());
666        assert_eq!(v.as_array().unwrap().len(), 3);
667    }
668
669    /// `create_streaming_response_with_content_type` sets the exact content-type
670    /// provided by the caller — specifically `application/x-ndjson` when wrapping
671    /// a `BatchFrameStream` that promotes `StreamFormat::Json`.
672    #[tokio::test]
673    async fn test_create_streaming_response_with_content_type_uses_explicit_type() {
674        let frames: Vec<Frame> = (0..2).map(|_| make_skeleton_frame()).collect();
675        let batch = BatchFrameStream::new(stream::iter(frames), StreamFormat::Json, 10);
676        let expected_ct = batch.content_type();
677        assert_eq!(
678            expected_ct, "application/x-ndjson",
679            "BatchFrameStream with Json format must report application/x-ndjson"
680        );
681
682        let response =
683            create_streaming_response_with_content_type(batch.into_stream(), expected_ct)
684                .expect("response must be built");
685        let ct = response
686            .headers()
687            .get(header::CONTENT_TYPE)
688            .expect("Content-Type header must be present")
689            .to_str()
690            .unwrap();
691        assert_eq!(ct, "application/x-ndjson");
692    }
693
694    /// `create_streaming_response` uses `format.content_type()` — for
695    /// `StreamFormat::Json` this is `application/json`, demonstrating the API gap
696    /// that `create_streaming_response_with_content_type` was introduced to close.
697    #[tokio::test]
698    async fn test_create_streaming_response_uses_format_content_type() {
699        let frames: Vec<Frame> = (0..1).map(|_| make_skeleton_frame()).collect();
700        let batch = BatchFrameStream::new(stream::iter(frames), StreamFormat::Json, 10);
701        let response = create_streaming_response(batch.into_stream(), StreamFormat::Json)
702            .expect("response must be built");
703        let ct = response
704            .headers()
705            .get(header::CONTENT_TYPE)
706            .expect("Content-Type header must be present")
707            .to_str()
708            .unwrap();
709        // Without the new helper, the caller is stuck with application/json.
710        assert_eq!(ct, "application/json");
711    }
712
713    /// `sonic_rs::to_vec` must stay parse-equivalent to `serde_json::to_vec` for
714    /// the range of JSON shapes a serialized [`Frame`] can produce — the two
715    /// encoders are not guaranteed byte-identical (e.g. float formatting), so
716    /// this asserts round-trip equivalence per edge case and records where the
717    /// raw bytes happen to diverge (informational, not a failure).
718    #[test]
719    fn test_sonic_rs_matches_serde_json_semantics() {
720        let cases: Vec<(&str, serde_json::Value)> = vec![
721            ("empty_object", serde_json::json!({})),
722            ("empty_array", serde_json::json!([])),
723            ("null", serde_json::Value::Null),
724            (
725                "unicode_and_escapes",
726                serde_json::json!({
727                    "s": "héllo \"quoted\" \n \t \u{0} emoji \u{1F600} \u{2028}"
728                }),
729            ),
730            (
731                "numbers",
732                serde_json::json!({
733                    "u64_max": u64::MAX,
734                    "i64_min": i64::MIN,
735                    "zero": 0,
736                    "neg_zero_float": -0.0_f64,
737                    "integral_float": 1.0_f64,
738                    "fractional": 1234.567890123_f64,
739                    "small_exp": 1.5e-10_f64,
740                    "large_exp": 1.5e300_f64,
741                }),
742            ),
743            (
744                "nested",
745                serde_json::json!({
746                    "a": [1, 2, {"b": [null, true, false, "x"]}],
747                    "c": {}
748                }),
749            ),
750        ];
751
752        for (name, value) in cases {
753            let serde_bytes = serde_json::to_vec(&value).expect("serde_json must serialize");
754            let sonic_bytes = sonic_rs::to_vec(&value).expect("sonic_rs must serialize");
755
756            let serde_roundtrip: serde_json::Value =
757                serde_json::from_slice(&serde_bytes).expect("serde_json bytes must parse");
758            let sonic_roundtrip: serde_json::Value =
759                serde_json::from_slice(&sonic_bytes).expect("sonic_rs bytes must parse");
760
761            assert_eq!(
762                serde_roundtrip, sonic_roundtrip,
763                "case `{name}`: sonic_rs and serde_json must be semantically equivalent"
764            );
765
766            if serde_bytes != sonic_bytes {
767                eprintln!(
768                    "note: case `{name}` byte output differs (semantically equal) — \
769                     serde_json={:?} sonic_rs={:?}",
770                    String::from_utf8_lossy(&serde_bytes),
771                    String::from_utf8_lossy(&sonic_bytes)
772                );
773            }
774        }
775    }
776}