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}