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);
+ }
}
}