apps: add quiche-client JSON dump option

This change introduces the `--dump-json` option for quiche-client.
When enabled HTTP/3 request headers, response headers and
response body (up to a hard-coded 10KB limit) is written to stdout
once all requests complete or the client times out.

Timed-out requests contain empty values.

The format is HAR-like but simplified.
diff --git a/tools/apps/src/bin/quiche-client.rs b/tools/apps/src/bin/quiche-client.rs
index b2e43d0..7a303c3 100644
--- a/tools/apps/src/bin/quiche-client.rs
+++ b/tools/apps/src/bin/quiche-client.rs
@@ -55,6 +55,7 @@
   --dgram-data DATA        Data to send for certain types of DATAGRAM application protocol [default: quack].
   --dump-packets PATH      Dump the incoming packets as files in the given directory.
   --dump-responses PATH    Dump response payload as files in the given directory.
+  --dump-json              Dump response headers and payload to stdout.
   --no-verify              Don't verify server's certificate.
   --no-grease              Don't send GREASE.
   --cc-algorithm NAME      Specify which congestion control algorithm to use [default: cubic].
@@ -325,6 +326,7 @@
                     &args.req_headers,
                     &args.body,
                     &args.method,
+                    args.dump_json,
                     dgram_sender,
                 ));
 
@@ -400,6 +402,7 @@
 struct ClientArgs {
     version: u32,
     dump_response_path: Option<String>,
+    dump_json: bool,
     urls: Vec<url::Url>,
     reqs_cardinal: u64,
     req_headers: Vec<String>,
@@ -421,6 +424,8 @@
             None
         };
 
+        let dump_json = args.get_bool("--dump-json");
+
         // URLs (can be multiple).
         let urls: Vec<url::Url> = args
             .get_vec("URL")
@@ -451,6 +456,7 @@
         ClientArgs {
             version,
             dump_response_path,
+            dump_json,
             urls,
             req_headers,
             reqs_cardinal,
diff --git a/tools/apps/src/lib.rs b/tools/apps/src/lib.rs
index ba4844a..d951006 100644
--- a/tools/apps/src/lib.rs
+++ b/tools/apps/src/lib.rs
@@ -41,6 +41,8 @@
 
 use quiche::h3::NameValue;
 
+const MAX_JSON_DUMP_PAYLOAD: usize = 10000;
+
 /// Returns a String containing a pretty printed version of the `buf` slice.
 pub fn hex_dump(buf: &[u8]) -> String {
     let vec: Vec<String> = buf.iter().map(|b| format!("{:02x}", b)).collect();
@@ -274,6 +276,62 @@
     }
 }
 
+fn dump_json(reqs: &[Http3Request]) {
+    println!("{{");
+    println!("  \"entries\": [");
+    let mut reqs = reqs.iter().peekable();
+
+    while let Some(req) = reqs.next() {
+        println!("  {{");
+        println!("    \"request\":{{");
+        println!("      \"headers\":[");
+
+        let mut req_hdrs = req.hdrs.iter().peekable();
+        while let Some(h) = req_hdrs.next() {
+            println!("        {{");
+            println!("          \"name\": \"{}\",", h.name());
+            println!("          \"value\": \"{}\"", h.value());
+
+            if req_hdrs.peek().is_some() {
+                println!("        }},");
+            } else {
+                println!("        }}");
+            }
+        }
+        println!("      ]}},");
+
+        println!("    \"response\":{{");
+        println!("      \"headers\":[");
+
+        let mut response_hdrs = req.response_hdrs.iter().peekable();
+        while let Some(h) = response_hdrs.next() {
+            println!("        {{");
+            println!("          \"name\": \"{}\",", h.name());
+            println!(
+                "          \"value\": \"{}\"",
+                h.value().replace("\"", "\\\"")
+            );
+
+            if response_hdrs.peek().is_some() {
+                println!("        }},");
+            } else {
+                println!("        }}");
+            }
+        }
+        println!("      ],");
+        println!("      \"body\": {:?}", req.response_body);
+        println!("    }}");
+
+        if reqs.peek().is_some() {
+            println!("}},");
+        } else {
+            println!("}}");
+        }
+    }
+    println!("]");
+    println!("}}");
+}
+
 pub trait HttpConn {
     fn send_requests(
         &mut self, conn: &mut quiche::Connection, target_path: &Option<String>,
@@ -465,6 +523,8 @@
     cardinal: u64,
     stream_id: Option<u64>,
     hdrs: Vec<quiche::h3::Header>,
+    response_hdrs: Vec<quiche::h3::Header>,
+    response_body: Vec<u8>,
     response_writer: Option<std::io::BufWriter<std::fs::File>>,
 }
 
@@ -789,14 +849,16 @@
     reqs_complete: usize,
     reqs: Vec<Http3Request>,
     body: Option<Vec<u8>>,
+    dump_json: bool,
     dgram_sender: Option<Http3DgramSender>,
 }
 
 impl Http3Conn {
+    #[allow(clippy::too_many_arguments)]
     pub fn with_urls(
         conn: &mut quiche::Connection, urls: &[url::Url], reqs_cardinal: u64,
         req_headers: &[String], body: &Option<Vec<u8>>, method: &str,
-        dgram_sender: Option<Http3DgramSender>,
+        dump_json: bool, dgram_sender: Option<Http3DgramSender>,
     ) -> Box<dyn HttpConn> {
         let mut reqs = Vec::new();
         for url in urls {
@@ -843,6 +905,8 @@
                     url: url.clone(),
                     cardinal: i,
                     hdrs,
+                    response_hdrs: Vec::new(),
+                    response_body: Vec::new(),
                     stream_id: None,
                     response_writer: None,
                 });
@@ -859,6 +923,7 @@
             reqs_complete: 0,
             reqs,
             body: body.as_ref().map(|b| b.to_vec()),
+            dump_json,
             dgram_sender,
         };
 
@@ -878,6 +943,7 @@
             reqs_complete: 0,
             reqs: Vec::new(),
             body: None,
+            dump_json: false,
             dgram_sender,
         };
 
@@ -1074,6 +1140,14 @@
                         "got response headers {:?} on stream id {}",
                         list, stream_id
                     );
+
+                    let req = self
+                        .reqs
+                        .iter_mut()
+                        .find(|r| r.stream_id == Some(stream_id))
+                        .unwrap();
+
+                    req.response_hdrs = list;
                 },
 
                 Ok((stream_id, quiche::h3::Event::Data)) => {
@@ -1090,16 +1164,25 @@
                             .find(|r| r.stream_id == Some(stream_id))
                             .unwrap();
 
+                        let len = std::cmp::min(
+                            read,
+                            MAX_JSON_DUMP_PAYLOAD - req.response_body.len(),
+                        );
+                        req.response_body.extend_from_slice(&buf[..len]);
+
                         match &mut req.response_writer {
                             Some(rw) => {
                                 rw.write_all(&buf[..read]).ok();
                             },
 
-                            None => {
-                                print!("{}", unsafe {
-                                    std::str::from_utf8_unchecked(&buf[..read])
-                                });
-                            },
+                            None =>
+                                if !self.dump_json {
+                                    print!("{}", unsafe {
+                                        std::str::from_utf8_unchecked(
+                                            &buf[..read],
+                                        )
+                                    });
+                                },
                         }
                     }
                 },
@@ -1121,6 +1204,10 @@
                             req_start.elapsed()
                         );
 
+                        if self.dump_json {
+                            dump_json(&self.reqs);
+                        }
+
                         match conn.close(true, 0x00, b"kthxbye") {
                             // Already closed.
                             Ok(_) | Err(quiche::Error::Done) => (),
@@ -1165,6 +1252,10 @@
                 self.reqs_complete,
                 self.reqs.len()
             );
+
+            if self.dump_json {
+                dump_json(&self.reqs);
+            }
         }
     }