Repository: ducaale/xh (rust)

Issue:
Support HTTPie's `--raw` flag.
See https://httpie.io/docs#request-data-via---raw

Pull request description:
add support for HTTPie's --raw flag
Resolves #178 

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 16cc06e..bcbddde 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -49,9 +49,9 @@ checksum = "61604a8f862e1d5c3229fdd78f8b02c68dcf73a4c4b05fd636d12240aaa242c1"
 
 [[package]]
 name = "assert_cmd"
-version = "1.0.8"
+version = "2.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c98233c6673d8601ab23e77eb38f999c51100d46c5703b17288c57fddf3a1ffe"
+checksum = "e996dc7940838b7ef1096b882e29ec30a3149a3a443cdc8dba19ed382eca1fe2"
 dependencies = [
  "bstr",
  "doc-comment",
diff --git a/Cargo.toml b/Cargo.toml
index 1878f68..2256a75 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -52,7 +52,7 @@ default-features = false
 features = ["parsing", "html", "yaml-load", "dump-load", "dump-create", "regex-onig"]
 
 [dev-dependencies]
-assert_cmd = "1.0"
+assert_cmd = "2.0"
 assert_matches = "1.4.0"
 form_urlencoded = "1.0.1"
 indoc = "1.0"
diff --git a/README.md b/README.md
index c4aa2d1..84705c6 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,7 @@ OPTIONS:
     -j, --json                           (default) Serialize data items from the command line as a JSON object
     -f, --form                           Serialize data items from the command line as form fields
     -m, --multipart                      Like --form, but force a multipart/form-data request even without files
+        --raw <RAW>                      Pass raw request data without extra processing
         --pretty <STYLE>                 Controls output processing [possible values: all, colors, format, none]
     -s, --style <THEME>                  Output coloring style [possible values: auto, solarized, monokai]
         --response-charset <ENCODING>    Override the response encoding for terminal display purposes
diff --git a/src/cli.rs b/src/cli.rs
index 08c7b8c..c0d7a73 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -61,6 +61,10 @@ pub struct Cli {
     #[structopt(short = "m", long, overrides_with_all = &["json", "form"])]
     pub multipart: bool,
 
+    /// Pass raw request data without extra processing.
+    #[structopt(long, value_name = "RAW")]
+    pub raw: Option<String>,
+
     /// Controls output processing.
     #[structopt(long, possible_values = &Pretty::variants(), case_insensitive = true, value_name = "STYLE")]
     pub pretty: Option<Pretty>,
@@ -365,6 +369,7 @@ const NEGATION_FLAGS: &[&str] = &[
     "--no-print",
     "--no-proxy",
     "--no-quiet",
+    "--no-raw",
     "--no-response-charset",
     "--no-response-mime",
     "--no-session",
diff --git a/src/main.rs b/src/main.rs
index b67f39a..4cc1924 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -100,12 +100,29 @@ fn run(args: Cli) -> Result<i32> {
 
     let (mut headers, headers_to_unset) = args.request_items.headers()?;
 
-    let ignore_stdin = args.ignore_stdin || atty::is(Stream::Stdin) || test_pretend_term();
+    let use_stdin = !(args.ignore_stdin || atty::is(Stream::Stdin) || test_pretend_term());
     let body_type = args.request_items.body_type;
-    let mut body = args.request_items.body()?;
-    if !ignore_stdin {
-        if !body.is_empty() {
-            if body.is_multipart() {
+    let body_from_request_items = args.request_items.body()?;
+    let has_request_items = !body_from_request_items.is_empty();
+
+    let body = match (use_stdin, args.raw, has_request_items) {
+        (true, None, false) => {
+            let mut buffer = Vec::new();
+            stdin().read_to_end(&mut buffer)?;
+            Body::Raw(buffer)
+        }
+        (false, Some(raw), false) => Body::Raw(raw.into_bytes()),
+        (false, None, _) => body_from_request_items,
+
+        (true, Some(_), _) => {
+            return Err(anyhow!(
+                "Request body from stdin and --raw cannot be mixed. \
+                Pass --ignore-stdin to ignore standard input."
+            ))
+        }
+        (true, _, true) => {
+            if args.multipart {
+                // Multipart bodies are never "empty", so we can get here without request items
                 return Err(anyhow!("Cannot build a multipart request body from stdin"));
             } else {
                 return Err(anyhow!(
@@ -114,10 +131,17 @@ fn run(args: Cli) -> Result<i32> {
                 ));
             }
         }
-        let mut buffer = Vec::new();
-        stdin().read_to_end(&mut buffer)?;
-        body = Body::Raw(buffer);
-    }
+        (_, Some(_), true) => {
+            if args.multipart {
+                // Multipart bodies are never "empty", so we can get here without request items
+                return Err(anyhow!("Cannot build a multipart request body from --raw"));
+            } else {
+                return Err(anyhow!(
+                    "Request body (from --raw) and request data (key=value) cannot be mixed."
+                ));
+            }
+        }
+    };
 
     let method = args.method.unwrap_or_else(|| body.pick_method());
     let timeout = args.timeout.and_then(|t| t.as_duration());
diff --git a/src/request_items.rs b/src/request_items.rs
index 265f903..55fd9bb 100644
--- a/src/request_items.rs
+++ b/src/request_items.rs
@@ -228,7 +228,6 @@ impl Body {
         match self {
             Body::Json(map) => map.is_empty(),
             Body::Form(items) => items.is_empty(),
-            Body::Raw(data) => data.is_empty(),
             // A multipart form without items isn't empty, and we can't read
             // a body from stdin because it has to match the header, so we
             // should never consider this "empty"
@@ -237,6 +236,7 @@ impl Body {
             // but that behavior is useless so there's no need to match it
             Body::Multipart(..) => false,
             Body::File { .. } => false,
+            Body::Raw(..) => false,
         }
     }
 
@@ -247,10 +247,6 @@ impl Body {
             Method::POST
         }
     }
-
-    pub fn is_multipart(&self) -> bool {
-        matches!(self, Body::Multipart(..))
-    }
 }
 
 impl RequestItems {


Test patch:
diff --git a/tests/cli.rs b/tests/cli.rs
index 36f409d..2fb8e0a 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -4,16 +4,15 @@ mod server;
 use std::collections::{HashMap, HashSet};
 use std::fs::{create_dir_all, read_to_string, File, OpenOptions};
 use std::future::Future;
-use std::io::{Seek, SeekFrom, Write};
+use std::io::Write;
 use std::iter::FromIterator;
 use std::pin::Pin;
-use std::process::Command;
 use std::time::Duration;
 
-use assert_cmd::prelude::*;
+use assert_cmd::cmd::Command;
 use indoc::indoc;
 use predicates::str::contains;
-use tempfile::{tempdir, tempfile};
+use tempfile::{tempdir, NamedTempFile};
 
 pub trait RequestExt {
     fn query_params(&self) -> HashMap<String, String>;
@@ -502,12 +501,9 @@ fn streaming_binary_detection() {
 
 #[test]
 fn request_binary_detection() {
-    let mut binary_file = tempfile().unwrap();
-    binary_file.write_all(b"foo\0bar").unwrap();
-    binary_file.seek(SeekFrom::Start(0)).unwrap();
     redirecting_command()
         .args(&["--print=B", "--offline", ":"])
-        .stdin(binary_file)
+        .write_stdin(b"foo\0bar".as_ref())
         .assert()
         .stdout(indoc! {r#"
             +-----------------------------------------+
@@ -806,7 +802,7 @@ fn netrc_env_user_password_auth() {
         hyper::Response::default()
     });
 
-    let mut netrc = tempfile::NamedTempFile::new().unwrap();
+    let mut netrc = NamedTempFile::new().unwrap();
     writeln!(
         netrc,
         "machine {}\nlogin user\npassword pass",
@@ -1337,12 +1333,78 @@ fn noninferred_json_output() {
         "#});
 }
 
+#[test]
+fn empty_body_defaults_to_get() {
+    let server = server::http(|req| async move {
+        assert_eq!(req.method(), "GET");
+        assert_eq!(req.body_as_string().await, "");
+        hyper::Response::default()
+    });
+
+    get_command().arg(server.base_url()).assert().success();
+}
+
+#[test]
+fn non_empty_body_defaults_to_post() {
+    let server = server::http(|req| async move {
+        assert_eq!(req.method(), "POST");
+        assert_eq!(req.body_as_string().await, "{\"x\":4}");
+        hyper::Response::default()
+    });
+
+    get_command()
+        .args(&[&server.base_url(), "x:=4"])
+        .assert()
+        .success();
+}
+
+#[test]
+fn empty_raw_body_defaults_to_post() {
+    let server = server::http(|req| async move {
+        assert_eq!(req.method(), "POST");
+        assert_eq!(req.body_as_string().await, "");
+        hyper::Response::default()
+    });
+
+    redirecting_command()
+        .arg(server.base_url())
+        .write_stdin("")
+        .assert()
+        .success();
+}
+
+#[test]
+fn body_from_stdin() {
+    let server = server::http(|req| async move {
+        assert_eq!(req.body_as_string().await, "body from stdin");
+        hyper::Response::default()
+    });
+
+    redirecting_command()
+        .arg(server.base_url())
+        .write_stdin("body from stdin")
+        .assert()
+        .success();
+}
+
+#[test]
+fn body_from_raw() {
+    let server = server::http(|req| async move {
+        assert_eq!(req.body_as_string().await, "body from raw");
+        hyper::Response::default()
+    });
+
+    get_command()
+        .args(&["--raw=body from raw", &server.base_url()])
+        .assert()
+        .success();
+}
+
 #[test]
 fn mixed_stdin_request_items() {
-    let input_file = tempfile().unwrap();
     redirecting_command()
         .args(&["--offline", ":", "x=3"])
-        .stdin(input_file)
+        .write_stdin("")
         .assert()
         .failure()
         .stderr(contains(
@@ -1350,27 +1412,57 @@ fn mixed_stdin_request_items() {
         ));
 }
 
+#[test]
+fn mixed_stdin_raw() {
+    redirecting_command()
+        .args(&["--offline", "--raw=hello", ":"])
+        .write_stdin("")
+        .assert()
+        .failure()
+        .stderr(contains(
+            "Request body from stdin and --raw cannot be mixed",
+        ));
+}
+
+#[test]
+fn mixed_raw_request_items() {
+    get_command()
+        .args(&["--offline", "--raw=hello", ":", "x=3"])
+        .assert()
+        .failure()
+        .stderr(contains(
+            "Request body (from --raw) and request data (key=value) cannot be mixed",
+        ));
+}
+
 #[test]
 fn multipart_stdin() {
-    let input_file = tempfile().unwrap();
     redirecting_command()
         .args(&["--offline", "--multipart", ":"])
-        .stdin(input_file)
+        .write_stdin("")
         .assert()
         .failure()
         .stderr(contains("Cannot build a multipart request body from stdin"));
 }
 
+#[test]
+fn multipart_raw() {
+    get_command()
+        .args(&["--offline", "--raw=hello", "--multipart", ":"])
+        .assert()
+        .failure()
+        .stderr(contains("Cannot build a multipart request body from --raw"));
+}
+
 #[test]
 fn default_json_for_raw_body() {
     let server = server::http(|req| async move {
         assert_eq!(req.headers()["content-type"], "application/json");
         hyper::Response::default()
     });
-    let input_file = tempfile().unwrap();
     redirecting_command()
         .arg(server.base_url())
-        .stdin(input_file)
+        .write_stdin("")
         .assert()
         .success();
 }
@@ -1579,7 +1671,7 @@ fn data_field_from_file() {
         hyper::Response::default()
     });
 
-    let mut text_file = tempfile::NamedTempFile::new().unwrap();
+    let mut text_file = NamedTempFile::new().unwrap();
     write!(text_file, "[1,2,3]").unwrap();
 
     get_command()
@@ -1596,7 +1688,7 @@ fn data_field_from_file_in_form_mode() {
         hyper::Response::default()
     });
 
-    let mut text_file = tempfile::NamedTempFile::new().unwrap();
+    let mut text_file = NamedTempFile::new().unwrap();
     write!(text_file, "hello world").unwrap();
 
     get_command()
@@ -1614,7 +1706,7 @@ fn json_field_from_file() {
         hyper::Response::default()
     });
 
-    let mut json_file = tempfile::NamedTempFile::new().unwrap();
+    let mut json_file = NamedTempFile::new().unwrap();
     writeln!(json_file, "[1,2,3]").unwrap();
 
     get_command()
@@ -1773,7 +1865,7 @@ fn anonymous_read_only_session() {
             .unwrap()
     });
 
-    let session_file = tempfile::NamedTempFile::new().unwrap();
+    let session_file = NamedTempFile::new().unwrap();
     let old_session_content = serde_json::json!({
         "__meta__": { "about": "xh session file", "xh": "0.0.0" },
         "auth": { "type": null, "raw_auth": null },
@@ -1903,7 +1995,7 @@ fn expired_cookies_are_removed_from_session() {
         + 1000;
     let past_timestamp = 1114425967; // 2005-04-25
 
-    let session_file = tempfile::NamedTempFile::new().unwrap();
+    let session_file = NamedTempFile::new().unwrap();
 
     std::fs::write(
         &session_file,
@@ -1980,7 +2072,7 @@ fn cookies_override_each_other_in_the_correct_order() {
             .unwrap()
     });
 
-    let session_file = tempfile::NamedTempFile::new().unwrap();
+    let session_file = NamedTempFile::new().unwrap();
 
     std::fs::write(
         &session_file,
@@ -2033,7 +2125,7 @@ fn basic_auth_from_session_is_used() {
         hyper::Response::default()
     });
 
-    let session_file = tempfile::NamedTempFile::new().unwrap();
+    let session_file = NamedTempFile::new().unwrap();
 
     std::fs::write(
         &session_file,
@@ -2065,7 +2157,7 @@ fn bearer_auth_from_session_is_used() {
         hyper::Response::default()
     });
 
-    let session_file = tempfile::NamedTempFile::new().unwrap();
+    let session_file = NamedTempFile::new().unwrap();
 
     std::fs::write(
         &session_file,
@@ -2102,7 +2194,7 @@ fn auth_netrc_is_not_persisted_in_session() {
     path_to_session.push(file_name);
     assert_eq!(path_to_session.exists(), false);
 
-    let mut netrc = tempfile::NamedTempFile::new().unwrap();
+    let mut netrc = NamedTempFile::new().unwrap();
     writeln!(
         netrc,
         "machine {}\nlogin user\npassword pass",
@@ -2396,7 +2488,7 @@ fn request_body_is_buffered_for_307_redirect() {
         }
     });
 
-    let mut file = tempfile::NamedTempFile::new().unwrap();
+    let mut file = NamedTempFile::new().unwrap();
     writeln!(file, "hello world").unwrap();
 
     get_command()