diff --git a/Cargo.lock b/Cargo.lock index 399d0674..87ddc06b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,7 +46,7 @@ checksum = "e996dc7940838b7ef1096b882e29ec30a3149a3a443cdc8dba19ed382eca1fe2" dependencies = [ "bstr", "doc-comment", - "predicates 2.0.2", + "predicates", "predicates-core", "predicates-tree", "wait-timeout", @@ -308,12 +308,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "difference" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" - [[package]] name = "difflib" version = "0.4.0" @@ -418,9 +412,9 @@ dependencies = [ [[package]] name = "float-cmp" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1267f4ac4f343772758f7b1bdcbe767c218bbab93bb432acbf5162bbf85a6c4" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" dependencies = [ "num-traits", ] @@ -1079,28 +1073,18 @@ checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" [[package]] name = "predicates" -version = "1.0.8" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49cfaf7fdaa3bfacc6fa3e7054e65148878354a5cfddcf661df4c851f8021df" +checksum = "a5aab5be6e4732b473071984b3164dbbfb7a3674d30ea5ff44410b6bcd960c3c" dependencies = [ - "difference", + "difflib", "float-cmp", + "itertools", "normalize-line-endings", "predicates-core", "regex", ] -[[package]] -name = "predicates" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c143348f141cc87aab5b950021bac6145d0e5ae754b0591de23244cee42c9308" -dependencies = [ - "difflib", - "itertools", - "predicates-core", -] - [[package]] name = "predicates-core" version = "1.0.2" @@ -2165,7 +2149,7 @@ dependencies = [ "once_cell", "os_display", "pem", - "predicates 1.0.8", + "predicates", "rand", "regex", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index de017522..c23451b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,7 +68,7 @@ features = ["yaml-load", "dump-create", "regex-onig"] assert_cmd = "2.0" form_urlencoded = "1.0.1" indoc = "1.0" -predicates = "1.0.7" +predicates = "2.1.1" hyper = { version = "0.14", features = ["server"] } tokio = { version = "1", features = ["rt", "sync", "time"] } tempfile = "3.2.0" diff --git a/completions/_xh b/completions/_xh index 6020ebc0..8d77f459 100644 --- a/completions/_xh +++ b/completions/_xh @@ -58,8 +58,10 @@ none\:"Disable both coloring and formatting"))' \ '--headers[Print only the response headers. Shortcut for --print=h]' \ '-b[Print only the response body. Shortcut for --print=b]' \ '--body[Print only the response body. Shortcut for --print=b]' \ -'-v[Print the whole request as well as the response]' \ -'--verbose[Print the whole request as well as the response]' \ +'-m[Print only the response metadata. Shortcut for --print=m]' \ +'--meta[Print only the response metadata. Shortcut for --print=m]' \ +'*-v[Print the whole request as well as the response]' \ +'*--verbose[Print the whole request as well as the response]' \ '--all[Show any intermediary requests/responses while following redirects with --follow]' \ '-4[Resolve hostname to ipv4 addresses only]' \ '--ipv4[Resolve hostname to ipv4 addresses only]' \ @@ -97,6 +99,7 @@ none\:"Disable both coloring and formatting"))' \ '--no-print[]' \ '--no-headers[]' \ '--no-body[]' \ +'--no-meta[]' \ '--no-verbose[]' \ '--no-all[]' \ '--no-history-print[]' \ diff --git a/completions/_xh.ps1 b/completions/_xh.ps1 index 137b2e1e..3bb4995f 100644 --- a/completions/_xh.ps1 +++ b/completions/_xh.ps1 @@ -61,6 +61,8 @@ Register-ArgumentCompleter -Native -CommandName 'xh' -ScriptBlock { [CompletionResult]::new('--headers', 'headers', [CompletionResultType]::ParameterName, 'Print only the response headers. Shortcut for --print=h') [CompletionResult]::new('-b', 'b', [CompletionResultType]::ParameterName, 'Print only the response body. Shortcut for --print=b') [CompletionResult]::new('--body', 'body', [CompletionResultType]::ParameterName, 'Print only the response body. Shortcut for --print=b') + [CompletionResult]::new('-m', 'm', [CompletionResultType]::ParameterName, 'Print only the response metadata. Shortcut for --print=m') + [CompletionResult]::new('--meta', 'meta', [CompletionResultType]::ParameterName, 'Print only the response metadata. Shortcut for --print=m') [CompletionResult]::new('-v', 'v', [CompletionResultType]::ParameterName, 'Print the whole request as well as the response') [CompletionResult]::new('--verbose', 'verbose', [CompletionResultType]::ParameterName, 'Print the whole request as well as the response') [CompletionResult]::new('--all', 'all', [CompletionResultType]::ParameterName, 'Show any intermediary requests/responses while following redirects with --follow') @@ -100,6 +102,7 @@ Register-ArgumentCompleter -Native -CommandName 'xh' -ScriptBlock { [CompletionResult]::new('--no-print', 'no-print', [CompletionResultType]::ParameterName, 'no-print') [CompletionResult]::new('--no-headers', 'no-headers', [CompletionResultType]::ParameterName, 'no-headers') [CompletionResult]::new('--no-body', 'no-body', [CompletionResultType]::ParameterName, 'no-body') + [CompletionResult]::new('--no-meta', 'no-meta', [CompletionResultType]::ParameterName, 'no-meta') [CompletionResult]::new('--no-verbose', 'no-verbose', [CompletionResultType]::ParameterName, 'no-verbose') [CompletionResult]::new('--no-all', 'no-all', [CompletionResultType]::ParameterName, 'no-all') [CompletionResult]::new('--no-history-print', 'no-history-print', [CompletionResultType]::ParameterName, 'no-history-print') diff --git a/completions/xh.bash b/completions/xh.bash index 96e2c740..de652002 100644 --- a/completions/xh.bash +++ b/completions/xh.bash @@ -19,7 +19,7 @@ _xh() { case "${cmd}" in xh) - opts="-V -j -f -s -p -h -b -v -P -4 -6 -q -S -o -d -c -A -a -F -I --help --version --json --form --multipart --raw --pretty --style --response-charset --response-mime --print --headers --body --verbose --all --history-print --ipv4 --ipv6 --quiet --stream --output --download --continue --session --session-read-only --auth-type --auth --bearer --ignore-netrc --offline --check-status --follow --max-redirects --timeout --proxy --verify --cert --cert-key --ssl --native-tls --default-scheme --https --http-version --ignore-stdin --curl --curl-long --no-help --no-version --no-json --no-form --no-multipart --no-raw --no-pretty --no-style --no-response-charset --no-response-mime --no-print --no-headers --no-body --no-verbose --no-all --no-history-print --no-ipv4 --no-ipv6 --no-quiet --no-stream --no-output --no-download --no-continue --no-session --no-session-read-only --no-auth-type --no-auth --no-bearer --no-ignore-netrc --no-offline --no-check-status --no-follow --no-max-redirects --no-timeout --no-proxy --no-verify --no-cert --no-cert-key --no-ssl --no-native-tls --no-default-scheme --no-https --no-http-version --no-ignore-stdin --no-curl --no-curl-long <[METHOD] URL> ..." + opts="-V -j -f -s -p -h -b -m -v -P -4 -6 -q -S -o -d -c -A -a -F -I --help --version --json --form --multipart --raw --pretty --style --response-charset --response-mime --print --headers --body --meta --verbose --all --history-print --ipv4 --ipv6 --quiet --stream --output --download --continue --session --session-read-only --auth-type --auth --bearer --ignore-netrc --offline --check-status --follow --max-redirects --timeout --proxy --verify --cert --cert-key --ssl --native-tls --default-scheme --https --http-version --ignore-stdin --curl --curl-long --no-help --no-version --no-json --no-form --no-multipart --no-raw --no-pretty --no-style --no-response-charset --no-response-mime --no-print --no-headers --no-body --no-meta --no-verbose --no-all --no-history-print --no-ipv4 --no-ipv6 --no-quiet --no-stream --no-output --no-download --no-continue --no-session --no-session-read-only --no-auth-type --no-auth --no-bearer --no-ignore-netrc --no-offline --no-check-status --no-follow --no-max-redirects --no-timeout --no-proxy --no-verify --no-cert --no-cert-key --no-ssl --no-native-tls --no-default-scheme --no-https --no-http-version --no-ignore-stdin --no-curl --no-curl-long <[METHOD] URL> ..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 diff --git a/completions/xh.fish b/completions/xh.fish index 3cafecb9..78c07af2 100644 --- a/completions/xh.fish +++ b/completions/xh.fish @@ -27,6 +27,7 @@ complete -c xh -s f -l form -d 'Serialize data items from the command line as fo complete -c xh -l multipart -d 'Like --form, but force a multipart/form-data request even without files' complete -c xh -s h -l headers -d 'Print only the response headers. Shortcut for --print=h' complete -c xh -s b -l body -d 'Print only the response body. Shortcut for --print=b' +complete -c xh -s m -l meta -d 'Print only the response metadata. Shortcut for --print=m' complete -c xh -s v -l verbose -d 'Print the whole request as well as the response' complete -c xh -l all -d 'Show any intermediary requests/responses while following redirects with --follow' complete -c xh -s 4 -l ipv4 -d 'Resolve hostname to ipv4 addresses only' @@ -57,6 +58,7 @@ complete -c xh -l no-response-mime complete -c xh -l no-print complete -c xh -l no-headers complete -c xh -l no-body +complete -c xh -l no-meta complete -c xh -l no-verbose complete -c xh -l no-all complete -c xh -l no-history-print diff --git a/doc/xh.1 b/doc/xh.1 index 8fa2fd81..50678977 100644 --- a/doc/xh.1 +++ b/doc/xh.1 @@ -132,9 +132,13 @@ Override the response mime type for coloring and formatting for the terminal. Example: \-\-response\-mime=application/json. .TP 4 \fB\-p\fR, \fB\-\-print\fR=\fIFORMAT\fR -String specifying what the output should contain. +String specifying what the output should contain -Use "H" and "B" for request header and body respectively, and "h" and "b" for response header and body. + 'H' request headers + 'B' request body + 'h' response headers + 'b' response body + 'm' response metadata Example: \-\-print=Hb. .TP 4 @@ -144,11 +148,16 @@ Print only the response headers. Shortcut for \-\-print=h. \fB\-b\fR, \fB\-\-body\fR Print only the response body. Shortcut for \-\-print=b. .TP 4 +\fB\-m\fR, \fB\-\-meta\fR +Print only the response metadata. Shortcut for \-\-print=m. +.TP 4 \fB\-v\fR, \fB\-\-verbose\fR Print the whole request as well as the response. Additionally, this enables \-\-all for printing intermediary requests/responses while following redirects. +Using verbose twice i.e. \-vv will print the response metadata as well. + Equivalent to \-\-print=HhBb \-\-all. .TP 4 \fB\-\-all\fR diff --git a/src/cli.rs b/src/cli.rs index cf22cf54..b4a82da9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -99,13 +99,22 @@ Defaults to \"format\" if the NO_COLOR env is set and to \"none\" if stdout is n #[clap(long, value_name = "MIME_TYPE")] pub response_mime: Option, - /// String specifying what the output should contain. - /// - /// Use "H" and "B" for request header and body respectively, - /// and "h" and "b" for response header and body. - /// - /// Example: --print=Hb - #[clap(short = 'p', long, value_name = "FORMAT")] + /// String specifying what the output should contain + #[clap( + short = 'p', + long, + value_name = "FORMAT", + long_help = "\ +String specifying what the output should contain + + 'H' request headers + 'B' request body + 'h' response headers + 'b' response body + 'm' response metadata + +Example: --print=Hb" + )] pub print: Option, /// Print only the response headers. Shortcut for --print=h. @@ -116,14 +125,20 @@ Defaults to \"format\" if the NO_COLOR env is set and to \"none\" if stdout is n #[clap(short = 'b', long)] pub body: bool, + /// Print only the response metadata. Shortcut for --print=m. + #[clap(short = 'm', long)] + pub meta: bool, + /// Print the whole request as well as the response. /// /// Additionally, this enables --all for printing intermediary /// requests/responses while following redirects. /// + /// Using verbose twice i.e. -vv will print the response metadata as well. + /// /// Equivalent to --print=HhBb --all. - #[clap(short = 'v', long)] - pub verbose: bool, + #[clap(short = 'v', long, parse(from_occurrences))] + pub verbose: usize, /// Show any intermediary requests/responses while following redirects with --follow. #[clap(long)] @@ -520,7 +535,7 @@ impl Cli { /// Set flags that are implied by other flags and report conflicting flags. fn process_relations(&mut self, matches: &clap::ArgMatches) -> clap::Result<()> { - if self.verbose { + if self.verbose > 0 { self.all = true; } if self.curl_long { @@ -952,23 +967,26 @@ pub struct Print { pub request_body: bool, pub response_headers: bool, pub response_body: bool, + pub response_meta: bool, } impl Print { pub fn new( - verbose: bool, + verbose: usize, headers: bool, body: bool, + meta: bool, quiet: bool, offline: bool, buffer: &Buffer, ) -> Self { - if verbose { + if verbose > 0 { Print { request_headers: true, request_body: true, response_headers: true, response_body: true, + response_meta: verbose > 1, } } else if quiet { Print { @@ -976,6 +994,7 @@ impl Print { request_body: false, response_headers: false, response_body: false, + response_meta: false, } } else if offline { Print { @@ -983,6 +1002,7 @@ impl Print { request_body: true, response_headers: false, response_body: false, + response_meta: false, } } else if headers { Print { @@ -990,6 +1010,7 @@ impl Print { request_body: false, response_headers: true, response_body: false, + response_meta: false, } } else if body || !buffer.is_terminal() { Print { @@ -997,6 +1018,15 @@ impl Print { request_body: false, response_headers: false, response_body: true, + response_meta: false, + } + } else if meta { + Print { + request_headers: false, + request_body: false, + response_headers: false, + response_body: false, + response_meta: true, } } else { Print { @@ -1004,6 +1034,7 @@ impl Print { request_body: false, response_headers: true, response_body: true, + response_meta: false, } } } @@ -1016,6 +1047,7 @@ impl FromStr for Print { let mut request_body = false; let mut response_headers = false; let mut response_body = false; + let mut response_meta = false; for char in s.chars() { match char { @@ -1023,6 +1055,7 @@ impl FromStr for Print { 'B' => request_body = true, 'h' => response_headers = true, 'b' => response_body = true, + 'm' => response_meta = true, char => return Err(anyhow!("{:?} is not a valid value", char)), } } @@ -1032,6 +1065,7 @@ impl FromStr for Print { request_body, response_headers, response_body, + response_meta, }; Ok(p) } diff --git a/src/main.rs b/src/main.rs index d5f7280a..f5f89fc3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -483,6 +483,7 @@ fn run(args: Cli) -> Result { args.verbose, args.headers, args.body, + args.meta, args.quiet, args.offline, &buffer, @@ -518,6 +519,9 @@ fn run(args: Cli) -> Result { )?; printer.print_separator()?; } + if history_print.response_meta { + printer.print_response_meta(prev_response)?; + } if history_print.request_headers { printer.print_request_headers(next_request, &*cookie_jar)?; } @@ -563,8 +567,16 @@ fn run(args: Cli) -> Result { args.quiet, )?; } - } else if print.response_body { - printer.print_response_body(&mut response, response_charset, response_mime)?; + } else { + if print.response_body { + printer.print_response_body(&mut response, response_charset, response_mime)?; + if print.response_meta { + printer.print_separator()?; + } + } + if print.response_meta { + printer.print_response_meta(&response)?; + } } } diff --git a/src/middleware.rs b/src/middleware.rs index 1dadd627..d60c24a2 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -1,6 +1,29 @@ +use std::time::{Duration, Instant}; + use anyhow::Result; use reqwest::blocking::{Client, Request, Response}; +#[derive(Clone)] +pub struct ResponseMeta { + pub request_duration: Duration, + pub content_download_duration: Option, +} + +pub trait ResponseExt { + fn meta(&self) -> &ResponseMeta; + fn meta_mut(&mut self) -> &mut ResponseMeta; +} + +impl ResponseExt for Response { + fn meta(&self) -> &ResponseMeta { + self.extensions().get::().unwrap() + } + + fn meta_mut(&mut self) -> &mut ResponseMeta { + self.extensions_mut().get_mut::().unwrap() + } +} + type Printer<'a, 'b> = &'a mut (dyn FnMut(&mut Response, &mut Request) -> Result<()> + 'b); pub struct Context<'a, 'b> { @@ -24,7 +47,15 @@ impl<'a, 'b> Context<'a, 'b> { fn execute(&mut self, request: Request) -> Result { match self.middlewares { - [] => Ok(self.client.execute(request)?), + [] => { + let starting_time = Instant::now(); + let mut response = self.client.execute(request)?; + response.extensions_mut().insert(ResponseMeta { + request_duration: starting_time.elapsed(), + content_download_duration: None, + }); + Ok(response) + } [ref mut head, tail @ ..] => head.handle( #[allow(clippy::needless_option_as_deref)] Context::new(self.client, self.printer.as_deref_mut(), tail), diff --git a/src/printer.rs b/src/printer.rs index 74b01ffd..26f17f32 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; use std::io::{self, BufRead, BufReader, Read, Write}; +use std::time::Instant; use encoding_rs::Encoding; use encoding_rs_io::DecodeReaderBytesBuilder; @@ -17,6 +18,7 @@ use crate::{ buffer::Buffer, cli::{Pretty, Theme}, formatting::{get_json_formatter, Highlighter}, + middleware::ResponseExt, utils::{copy_largebuf, test_mode, BUFFER_SIZE}, }; @@ -429,6 +431,7 @@ impl Printer { encoding: Option<&'static Encoding>, mime: Option<&str>, ) -> anyhow::Result<()> { + let starting_time = Instant::now(); let url = response.url().clone(); let content_type = mime.map_or_else(|| get_content_type(response.headers()), ContentType::from); @@ -486,7 +489,6 @@ impl Printer { match decode_blob(&buf, encoding, &url) { None => { self.buffer.print(BINARY_SUPPRESSOR)?; - return Ok(()); } Some(text) => { self.print_body_text(content_type, &text)?; @@ -495,6 +497,20 @@ impl Printer { }; } self.buffer.flush()?; + drop(body); // silence the borrow checker + response.meta_mut().content_download_duration = Some(starting_time.elapsed()); + Ok(()) + } + + pub fn print_response_meta(&mut self, response: &Response) -> anyhow::Result<()> { + let meta = response.meta(); + let mut total_elapsed_time = meta.request_duration.as_secs_f64(); + if let Some(content_download_duration) = meta.content_download_duration { + total_elapsed_time += content_download_duration.as_secs_f64(); + } + self.buffer + .print(format!("Elapsed time: {:.5}s", total_elapsed_time))?; + self.buffer.print("\n\n")?; Ok(()) } } diff --git a/src/to_curl.rs b/src/to_curl.rs index 288e3c52..c293e53a 100644 --- a/src/to_curl.rs +++ b/src/to_curl.rs @@ -129,7 +129,7 @@ pub fn translate(args: Cli) -> Result { // - .curl and .curl_long: you are here // Output options - if args.verbose { + if args.verbose > 0 { // Far from an exact match, but it does print the request headers cmd.opt("-v", "--verbose"); } diff --git a/tests/cli.rs b/tests/cli.rs index 54438077..0a73b7ef 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -3126,6 +3126,67 @@ fn empty_response_with_content_encoding_and_content_length() { "#}); } +#[test] +fn response_meta() { + let server = server::http(|_req| async move { + hyper::Response::builder() + .header("date", "N/A") + .body("Hello!".into()) + .unwrap() + }); + + get_command() + .arg("--print=m") + .arg(server.base_url()) + .assert() + .stdout(contains("Elapsed time: ")); +} + +#[test] +fn redirect_with_respone_meta() { + let server = server::http(|req| async move { + match req.uri().path() { + "/first_page" => hyper::Response::builder() + .status(302) + .header("Date", "N/A") + .header("Location", "/second_page") + .body("redirecting...".into()) + .unwrap(), + "/second_page" => hyper::Response::builder() + .header("Date", "N/A") + .body("final destination".into()) + .unwrap(), + _ => panic!("unknown path"), + } + }); + + get_command() + .arg(server.url("/first_page")) + .arg("--follow") + .arg("-vv") + .assert() + .stdout(contains("Elapsed time: ").count(2)); + + get_command() + .arg(server.url("/first_page")) + .arg("--follow") + .arg("--meta") + .assert() + .stdout(contains("Elapsed time: ").count(1)); +} + +#[cfg(feature = "online-tests")] +#[test] +fn digest_auth_with_response_meta() { + get_command() + .arg("--auth-type=digest") + .arg("--auth=ahmed:12345") + .arg("-vv") + .arg("httpbin.org/digest-auth/5/ahmed/12345") + .assert() + .stdout(contains("Elapsed time: ").count(2)); +} + #[test] fn non_get_redirect_translation_warning() { get_command()