Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support compress request body #403

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions completions/_xh
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ none\:"Disable both coloring and formatting"))' \
'--http-version=[HTTP version to use]:VERSION:(1.0 1.1 2 2-prior-knowledge)' \
'*--resolve=[Override DNS resolution for specific domain to a custom IP]:HOST:ADDRESS:_default' \
'--interface=[Bind to a network interface or local IP address]:NAME:_default' \
'--generate=[Generate shell completions or man pages]:KIND:(complete-bash complete-elvish complete-fish complete-nushell complete-powershell complete-zsh man)' \
'()--generate=[Generate shell completions or man pages]:KIND:(complete-bash complete-elvish complete-fish complete-nushell complete-powershell complete-zsh man)' \
'-j[(default) Serialize data items from the command line as a JSON object]' \
'--json[(default) Serialize data items from the command line as a JSON object]' \
'-f[Serialize data items from the command line as form fields]' \
Expand All @@ -69,6 +69,8 @@ none\:"Disable both coloring and formatting"))' \
'*--quiet[Do not print to stdout or stderr]' \
'-S[Always stream the response body]' \
'--stream[Always stream the response body]' \
'*-x[Content compressed (encoded) with Deflate algorithm. The Content-Encoding header is set to deflate]' \
'*--compress[Content compressed (encoded) with Deflate algorithm. The Content-Encoding header is set to deflate]' \
'-d[Download the body to a file instead of printing it]' \
'--download[Download the body to a file instead of printing it]' \
'-c[Resume an interrupted download. Requires --download and --output]' \
Expand Down Expand Up @@ -108,6 +110,7 @@ none\:"Disable both coloring and formatting"))' \
'--no-history-print[]' \
'--no-quiet[]' \
'--no-stream[]' \
'--no-compress[]' \
'--no-output[]' \
'--no-download[]' \
'--no-continue[]' \
Expand Down Expand Up @@ -142,7 +145,7 @@ none\:"Disable both coloring and formatting"))' \
'--no-help[]' \
'-V[Print version]' \
'--version[Print version]' \
'::raw_method_or_url -- The request URL, preceded by an optional HTTP method:_default' \
':raw_method_or_url -- The request URL, preceded by an optional HTTP method:_default' \
'*::raw_rest_args -- Optional key-value pairs to be included in the request.:_default' \
&& ret=0
}
Expand Down
3 changes: 3 additions & 0 deletions completions/_xh.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ Register-ArgumentCompleter -Native -CommandName 'xh' -ScriptBlock {
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Do not print to stdout or stderr')
[CompletionResult]::new('-S', '-S ', [CompletionResultType]::ParameterName, 'Always stream the response body')
[CompletionResult]::new('--stream', '--stream', [CompletionResultType]::ParameterName, 'Always stream the response body')
[CompletionResult]::new('-x', '-x', [CompletionResultType]::ParameterName, 'Content compressed (encoded) with Deflate algorithm. The Content-Encoding header is set to deflate')
[CompletionResult]::new('--compress', '--compress', [CompletionResultType]::ParameterName, 'Content compressed (encoded) with Deflate algorithm. The Content-Encoding header is set to deflate')
[CompletionResult]::new('-d', '-d', [CompletionResultType]::ParameterName, 'Download the body to a file instead of printing it')
[CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'Download the body to a file instead of printing it')
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Resume an interrupted download. Requires --download and --output')
Expand Down Expand Up @@ -111,6 +113,7 @@ Register-ArgumentCompleter -Native -CommandName 'xh' -ScriptBlock {
[CompletionResult]::new('--no-history-print', '--no-history-print', [CompletionResultType]::ParameterName, 'no-history-print')
[CompletionResult]::new('--no-quiet', '--no-quiet', [CompletionResultType]::ParameterName, 'no-quiet')
[CompletionResult]::new('--no-stream', '--no-stream', [CompletionResultType]::ParameterName, 'no-stream')
[CompletionResult]::new('--no-compress', '--no-compress', [CompletionResultType]::ParameterName, 'no-compress')
[CompletionResult]::new('--no-output', '--no-output', [CompletionResultType]::ParameterName, 'no-output')
[CompletionResult]::new('--no-download', '--no-download', [CompletionResultType]::ParameterName, 'no-download')
[CompletionResult]::new('--no-continue', '--no-continue', [CompletionResultType]::ParameterName, 'no-continue')
Expand Down
2 changes: 1 addition & 1 deletion completions/xh.bash
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ _xh() {

case "${cmd}" in
xh)
opts="-j -f -s -p -h -b -m -v -P -q -S -o -d -c -A -a -F -4 -6 -I -V --json --form --multipart --raw --pretty --format-options --style --response-charset --response-mime --print --headers --body --meta --verbose --debug --all --history-print --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 --resolve --interface --ipv4 --ipv6 --ignore-stdin --curl --curl-long --generate --help --no-json --no-form --no-multipart --no-raw --no-pretty --no-format-options --no-style --no-response-charset --no-response-mime --no-print --no-headers --no-body --no-meta --no-verbose --no-debug --no-all --no-history-print --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-resolve --no-interface --no-ipv4 --no-ipv6 --no-ignore-stdin --no-curl --no-curl-long --no-generate --no-help --version [[METHOD] URL] [REQUEST_ITEM]..."
opts="-j -f -s -p -h -b -m -v -P -q -S -x -o -d -c -A -a -F -4 -6 -I -V --json --form --multipart --raw --pretty --format-options --style --response-charset --response-mime --print --headers --body --meta --verbose --debug --all --history-print --quiet --stream --compress --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 --resolve --interface --ipv4 --ipv6 --ignore-stdin --curl --curl-long --generate --help --no-json --no-form --no-multipart --no-raw --no-pretty --no-format-options --no-style --no-response-charset --no-response-mime --no-print --no-headers --no-body --no-meta --no-verbose --no-debug --no-all --no-history-print --no-quiet --no-stream --no-compress --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-resolve --no-interface --no-ipv4 --no-ipv6 --no-ignore-stdin --no-curl --no-curl-long --no-generate --no-help --version <[METHOD] URL> [REQUEST_ITEM]..."
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
Expand Down
3 changes: 3 additions & 0 deletions completions/xh.elv
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ set edit:completion:arg-completer[xh] = {|@words|
cand --quiet 'Do not print to stdout or stderr'
cand -S 'Always stream the response body'
cand --stream 'Always stream the response body'
cand -x 'Content compressed (encoded) with Deflate algorithm. The Content-Encoding header is set to deflate'
cand --compress 'Content compressed (encoded) with Deflate algorithm. The Content-Encoding header is set to deflate'
cand -d 'Download the body to a file instead of printing it'
cand --download 'Download the body to a file instead of printing it'
cand -c 'Resume an interrupted download. Requires --download and --output'
Expand Down Expand Up @@ -108,6 +110,7 @@ set edit:completion:arg-completer[xh] = {|@words|
cand --no-history-print 'no-history-print'
cand --no-quiet 'no-quiet'
cand --no-stream 'no-stream'
cand --no-compress 'no-compress'
cand --no-output 'no-output'
cand --no-download 'no-download'
cand --no-continue 'no-continue'
Expand Down
2 changes: 2 additions & 0 deletions completions/xh.fish
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ complete -c xh -l debug -d 'Print full error stack traces and debug log messages
complete -c xh -l all -d 'Show any intermediary requests/responses while following redirects with --follow'
complete -c xh -s q -l quiet -d 'Do not print to stdout or stderr'
complete -c xh -s S -l stream -d 'Always stream the response body'
complete -c xh -s x -l compress -d 'Content compressed (encoded) with Deflate algorithm. The Content-Encoding header is set to deflate'
complete -c xh -s d -l download -d 'Download the body to a file instead of printing it'
complete -c xh -s c -l continue -d 'Resume an interrupted download. Requires --download and --output'
complete -c xh -l ignore-netrc -d 'Do not use credentials from .netrc'
Expand Down Expand Up @@ -68,6 +69,7 @@ complete -c xh -l no-all
complete -c xh -l no-history-print
complete -c xh -l no-quiet
complete -c xh -l no-stream
complete -c xh -l no-compress
complete -c xh -l no-output
complete -c xh -l no-download
complete -c xh -l no-continue
Expand Down
4 changes: 3 additions & 1 deletion completions/xh.nu
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ module completions {
--history-print(-P): string # The same as --print but applies only to intermediary requests/responses
--quiet(-q) # Do not print to stdout or stderr
--stream(-S) # Always stream the response body
--compress(-x) # Content compressed (encoded) with Deflate algorithm. The Content-Encoding header is set to deflate
--output(-o): string # Save output to FILE instead of stdout
--download(-d) # Download the body to a file instead of printing it
--continue(-c) # Resume an interrupted download. Requires --download and --output
Expand Down Expand Up @@ -77,7 +78,7 @@ module completions {
--curl-long # Use the long versions of curl's flags
--generate: string@"nu-complete xh generate" # Generate shell completions or man pages
--help # Print help
raw_method_or_url?: string # The request URL, preceded by an optional HTTP method
raw_method_or_url: string # The request URL, preceded by an optional HTTP method
...raw_rest_args: string # Optional key-value pairs to be included in the request.
--no-json
--no-form
Expand All @@ -98,6 +99,7 @@ module completions {
--no-history-print
--no-quiet
--no-stream
--no-compress
--no-output
--no-download
--no-continue
Expand Down
17 changes: 14 additions & 3 deletions doc/xh.1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.TH XH 1 2025-01-04 0.23.1 "User Commands"
.TH XH 1 2025-01-22 0.23.1 "User Commands"

.SH NAME
xh \- Friendly and fast tool for sending HTTP requests
Expand Down Expand Up @@ -188,6 +188,9 @@ Using quiet twice i.e. \-qq will suppress warnings as well.
\fB\-S\fR, \fB\-\-stream\fR
Always stream the response body.
.TP 4
\fB\-x\fR, \fB\-\-compress\fR
Content compressed (encoded) with Deflate algorithm. The Content\-Encoding header is set to deflate.
.TP 4
\fB\-o\fR, \fB\-\-output\fR=\fIFILE\fR
Save output to FILE instead of stdout.
.TP 4
Expand Down Expand Up @@ -321,9 +324,17 @@ For translating the other way, try https://curl2httpie.online/.
Use the long versions of curl's flags.
.TP 4
\fB\-\-generate\fR=\fIKIND\fR
Generate shell completions or man pages.
Generate shell completions or man pages. Possible values are:

complete\-bash
complete\-elvish
complete\-fish
complete\-nushell
complete\-powershell
complete\-zsh
man

[possible values: complete\-bash, complete\-elvish, complete\-fish, complete\-nushell, complete\-powershell, complete\-zsh, man]
Example: xh \-\-generate=complete\-bash > xh.bash.
.TP 4
\fB\-\-help\fR
Print help.
Expand Down
5 changes: 5 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ Example: --print=Hb"
#[clap(short = 'S', long = "stream", name = "stream")]
pub stream_raw: bool,

/// Content compressed (encoded) with Deflate algorithm.
/// The Content-Encoding header is set to deflate.
#[clap(short = 'x', long = "compress", name = "compress", action = ArgAction::Count)]
pub compress: u8,

#[clap(skip)]
pub stream: Option<bool>,

Expand Down
2 changes: 1 addition & 1 deletion src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ fn get_file_name(response: &Response, orig_url: &reqwest::Url) -> String {
.or_else(|| from_url(orig_url))
.unwrap_or_else(|| "index".to_string());

let filename = filename.split(std::path::is_separator).last().unwrap();
let filename = filename.split(std::path::is_separator).next_back().unwrap();

let mut filename = filename.trim().trim_start_matches('.').to_string();

Expand Down
27 changes: 25 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ mod utils;

use std::env;
use std::fs::File;
use std::io::{self, IsTerminal, Read};
use std::io::{self, IsTerminal, Read, Write as _};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::path::PathBuf;
use std::process;
Expand All @@ -28,8 +28,10 @@ use std::sync::Arc;

use anyhow::{anyhow, Context, Result};
use cookie_store::{CookieStore, RawCookie};
use flate2::write::ZlibEncoder;
use hyper::header::CONTENT_ENCODING;
use redirect::RedirectFollower;
use reqwest::blocking::Client;
use reqwest::blocking::{Body as ReqwestBody, Client};
use reqwest::header::{
HeaderValue, ACCEPT, ACCEPT_ENCODING, CONNECTION, CONTENT_TYPE, COOKIE, RANGE, USER_AGENT,
};
Expand Down Expand Up @@ -508,6 +510,27 @@ fn run(args: Cli) -> Result<i32> {

let mut request = request_builder.headers(headers).build()?;

if args.compress >= 1 && request.headers().get(CONTENT_ENCODING).is_none() {
let mut compressed = false;
if let Some(body) = request.body_mut() {
if let Some(body_bytes) = body.as_bytes() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this work for streaming requests as well (i.e. xh -x : @file.txt)? In that case it might be best to just compress unconditionally even with a single -x so that we don't have to buffer it.

(HTTPie does buffer the whole file in this case.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's possible; the latest commit supports this. 😄

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, that gets us the same behavior as HTTPie. But we might want to diverge from HTTPie by streaming the body in this case instead of buffering it, so that it's possible to upload files that don't fit in RAM. That should be possible with ReqwestBody::new.

(I also suggested this in httpie/cli#1613 so we could wait to see what they think.)

Copy link
Contributor Author

@zuisong zuisong Jan 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can directly compress the stream without buffering it , which would make it different from httpie's implementation.
Should we do it this way in this PR?

[update] The into_reader() method of reqwest#Body is pub(crate). We cannot access it. Currently, it is not possible to achieve our goal.

let mut encoder = ZlibEncoder::new(Vec::new(), Default::default());
encoder.write_all(body_bytes)?;
let output = encoder.finish()?;
if output.len() < body_bytes.len() || args.compress >= 2 {
let _ = std::mem::replace(body, ReqwestBody::from(output));
compressed = true;
}
}
}
if compressed {
request
.headers_mut()
.entry(CONTENT_ENCODING)
.or_insert(HeaderValue::from_static("deflate"));
}
}

for header in &headers_to_unset {
request.headers_mut().remove(header);
}
Expand Down
2 changes: 2 additions & 0 deletions src/to_curl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ pub fn translate(args: Cli) -> Result<Command> {
// No equivalent
(args.style.is_some(), "-s/--style"),
// No equivalent
(args.compress > 0, "-x/--compress"),
// No equivalent
(args.response_charset.is_some(), "--response-charset"),
// No equivalent
(args.response_mime.is_some(), "--response-mime"),
Expand Down
142 changes: 141 additions & 1 deletion tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ mod server;
use std::collections::{HashMap, HashSet};
use std::fs::{self, File, OpenOptions};
use std::future::Future;
use std::io::Write;
use std::io::{Read as _, Write};
use std::iter::FromIterator;
use std::net::IpAddr;
use std::pin::Pin;
Expand Down Expand Up @@ -3483,6 +3483,146 @@ fn zstd() {
"#});
}

#[test]
fn compress_request_body() {
fn zlib_decode(bytes: Vec<u8>) -> std::io::Result<String> {
let mut z = flate2::read::ZlibDecoder::new(&bytes[..]);
let mut s = String::new();
z.read_to_string(&mut s)?;
Ok(s)
}

let server = server::http(|req| async move {
match req.uri().path() {
"/deflate" => {
assert_eq!(
req.headers().get(hyper::header::CONTENT_ENCODING),
Some(HeaderValue::from_static("deflate")).as_ref()
);

let compressed_body = req.body().await;
let body = zlib_decode(compressed_body).unwrap();
hyper::Response::builder()
.header("date", "N/A")
.header("Content-Type", "text/plain")
.body(body.into())
.unwrap()
}
"/normal" => {
let body = req.body_as_string().await;
hyper::Response::builder()
.header("date", "N/A")
.header("Content-Type", "text/plain")
.body(body.into())
.unwrap()
}
_ => panic!("unknown path"),
}
});

get_command()
.arg(format!("{}/deflate", server.base_url()))
.args([
&format!("key={}", "1".repeat(1000)),
"-x",
"-j",
"--pretty=none",
])
.assert()
.stdout(indoc::formatdoc! {r#"
HTTP/1.1 200 OK
Date: N/A
Content-Type: text/plain
Content-Length: 1010

{{"key":"{c}"}}
"#, c = "1".repeat(1000),});

get_command()
.arg(format!("{}/deflate", server.base_url()))
.args([
&format!("key={}", "1".repeat(1000)),
"-x",
"-x",
"-f",
"--pretty=none",
])
.assert()
.stdout(indoc::formatdoc! {r#"
HTTP/1.1 200 OK
Date: N/A
Content-Type: text/plain
Content-Length: 1004

key={c}
"#, c = "1".repeat(1000),});

get_command()
.arg(format!("{}/deflate", server.base_url()))
.args([
&format!("key={}", "1".repeat(1000)),
"-x",
"-x",
"-f",
"--pretty=none",
])
.assert()
.stdout(indoc::formatdoc! {r#"
HTTP/1.1 200 OK
Date: N/A
Content-Type: text/plain
Content-Length: 1004

key={c}
"#, c = "1".repeat(1000),});

get_command()
.arg(format!("{}/normal", server.base_url()))
.args([&format!("key={}", "1"), "-x", "-f", "--pretty=none"])
.assert()
.stdout(indoc::formatdoc! {r#"
HTTP/1.1 200 OK
Date: N/A
Content-Type: text/plain
Content-Length: 5

key={c}
"#, c = "1"});

// force compress
get_command()
.arg(format!("{}/deflate", server.base_url()))
.args([&format!("key={}", "1"), "-xx", "-f", "--pretty=none"])
.assert()
.stdout(indoc::formatdoc! {r#"
HTTP/1.1 200 OK
Date: N/A
Content-Type: text/plain
Content-Length: 5

key={c}
"#, c = "1"});
// dont compress_request_body_if_content_encoding_have_value
get_command()
.arg(format!("{}/normal", server.base_url()))
.args([
&format!("key={}", "1".repeat(1000)),
"content-encoding:gzip",
"-x",
"-f",
"--pretty=none",
])
.assert()
.stdout(indoc::formatdoc! {r#"
HTTP/1.1 200 OK
Date: N/A
Content-Type: text/plain
Content-Length: 1004

key={c}
"#, c = "1".repeat(1000),});
}

#[test]
fn empty_response_with_content_encoding() {
let server = server::http(|_req| async move {
Expand Down
Loading