add support for pretty-printing json

This commit is contained in:
anna 2023-01-30 19:58:27 +01:00
parent dfe4676d2e
commit ff5ec6158b
Signed by: fef
GPG key ID: EC22E476DC2D3D84
3 changed files with 116 additions and 2 deletions

7
Cargo.lock generated
View file

@ -322,6 +322,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"futures", "futures",
"json",
"libc", "libc",
"reqwest", "reqwest",
"tokio", "tokio",
@ -426,6 +427,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "json"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.4.0" version = "1.4.0"

View file

@ -6,6 +6,7 @@ edition = "2021"
[dependencies] [dependencies]
clap = { version = "4", features = [ "derive" ] } clap = { version = "4", features = [ "derive" ] }
futures = "0.3" futures = "0.3"
json = "0.12"
reqwest = { version = "0.11", features = [ "blocking", "stream" ] } reqwest = { version = "0.11", features = [ "blocking", "stream" ] }
tokio = { version = "1", features = [ "full" ] } tokio = { version = "1", features = [ "full" ] }

View file

@ -1,5 +1,7 @@
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use futures::stream::StreamExt; use futures::stream::StreamExt;
use json::JsonValue;
use reqwest::header::HeaderMap;
use reqwest::{ use reqwest::{
header::{HeaderName, HeaderValue, ACCEPT, CONTENT_TYPE, LOCATION}, header::{HeaderName, HeaderValue, ACCEPT, CONTENT_TYPE, LOCATION},
redirect, Body, RequestBuilder, Response, StatusCode, Url, Version, redirect, Body, RequestBuilder, Response, StatusCode, Url, Version,
@ -44,6 +46,10 @@ struct Params {
#[arg(short = 'I', long, conflicts_with = "include_headers")] #[arg(short = 'I', long, conflicts_with = "include_headers")]
headers: bool, headers: bool,
/// Never pretty-print supported content types
#[arg(short = 'r', long)]
raw: bool,
/// Shortcut for setting the `Content-Type` header /// Shortcut for setting the `Content-Type` header
#[arg(short = 't', long)] #[arg(short = 't', long)]
content_type: Option<String>, content_type: Option<String>,
@ -140,7 +146,8 @@ async fn do_request(params: Params) -> Option<String> {
} }
if !params.headers { if !params.headers {
let len = print_body(response).await; let pretty = !params.raw && isatty(1);
let len = print_body(response, pretty).await;
if len == 0 && params.verbose { if len == 0 && params.verbose {
eprintln!("Response body is empty"); eprintln!("Response body is empty");
} }
@ -150,20 +157,119 @@ async fn do_request(params: Params) -> Option<String> {
redirect_url redirect_url
} }
async fn print_body(response: Response) -> usize { async fn print_body(response: Response, pretty: bool) -> usize {
if pretty && content_type_is_json(response.headers()) {
return print_body_json(response).await;
}
let mut stdout = stdout(); let mut stdout = stdout();
let stream = response.bytes_stream(); let stream = response.bytes_stream();
tokio::pin!(stream); tokio::pin!(stream);
let mut total_bytes = 0; let mut total_bytes = 0;
let mut ends_with_newline = true;
while let Some(chunk) = stream.next().await { while let Some(chunk) = stream.next().await {
let chunk = ok_or_exit(chunk); let chunk = ok_or_exit(chunk);
ends_with_newline = chunk.as_ref()[chunk.len() - 1] == b'\n';
total_bytes += chunk.len(); total_bytes += chunk.len();
ok_or_exit(stdout.write(chunk.as_ref()).await); ok_or_exit(stdout.write(chunk.as_ref()).await);
} }
if pretty && !ends_with_newline {
ok_or_exit(stdout.write(b"\x1b[1;97;101m%\x1b[0m\n").await);
}
total_bytes total_bytes
} }
fn content_type_is_json(headers: &HeaderMap) -> bool {
if let Some(content_type) = headers.get(CONTENT_TYPE) {
let content_type = content_type.as_bytes();
if !content_type.starts_with(b"application/") {
return false;
}
let after_plus_pos = content_type
.iter()
.enumerate()
.skip(12)
.find_map(|(i, &c)| (c == b'+').then_some(i + 1));
let after_plus = if let Some(plus_pos) = after_plus_pos {
&content_type[plus_pos..]
} else {
&content_type[12..]
};
after_plus.starts_with(b"json")
} else {
false
}
}
async fn print_body_json(response: Response) -> usize {
let bytes = ok_or_exit(response.bytes().await);
let nbyte = bytes.len();
let s = ok_or_exit(String::from_utf8(bytes.to_vec()));
if let Ok(json) = json::parse(&s) {
let mut s = String::with_capacity(nbyte);
format_json(&json, &mut s, 0);
if !s.is_empty() {
s.push('\n');
}
ok_or_exit(stdout().write(s.as_bytes()).await);
} else {
ok_or_exit(stdout().write(s.as_bytes()).await);
}
nbyte
}
fn format_json(json: &JsonValue, s: &mut String, indent: usize) {
match json {
JsonValue::Null => *s += "\x1b[0;96mnull\x1b[0m",
JsonValue::Boolean(b) => s.push_str(&format!("\x1b[0;96m{b}\x1b[0m")),
JsonValue::Number(n) => s.push_str(&format!("\x1b[0;33m{n}\x1b[0m")),
JsonValue::String(val) => s.push_str(&format!("\x1b[0;32m\"{val}\"\x1b[0m")),
JsonValue::Short(val) => s.push_str(&format!("\x1b[0;32m\"{val}\"\x1b[0m")),
JsonValue::Array(a) => {
if a.is_empty() {
s.push_str("[]");
} else {
s.push('[');
for (i, e) in a.iter().enumerate() {
let indent = indent + 4;
if i > 0 {
s.push(',');
}
s.push('\n');
s.extend((0..indent).into_iter().map(|_| ' '));
format_json(e, s, indent);
}
s.push('\n');
s.extend((0..indent).into_iter().map(|_| ' '));
s.push(']');
}
}
JsonValue::Object(o) => {
if o.is_empty() {
s.push_str("{}");
} else {
s.push('{');
for (i, (k, v)) in o.iter().enumerate() {
let indent = indent + 4;
if i > 0 {
s.push(',');
}
s.push('\n');
s.extend((0..indent).into_iter().map(|_| ' '));
s.push_str(&format!("\x1b[0;36m\"{k}\"\x1b[0m: "));
format_json(v, s, indent);
}
s.push('\n');
s.extend((0..indent).into_iter().map(|_| ' '));
s.push('}');
}
}
}
}
fn http_version(response: &Response) -> &'static str { fn http_version(response: &Response) -> &'static str {
match response.version() { match response.version() {
Version::HTTP_09 => "0.9", Version::HTTP_09 => "0.9",