diff --git a/README.md b/README.md index 47b99431..b4f0ff23 100644 --- a/README.md +++ b/README.md @@ -84,11 +84,12 @@ Run `xh help` for more detailed information. `xh` uses [HTTPie's request-item syntax](https://httpie.io/docs#request-items) to set headers, request body, query string, etc. -* `=`/`:=` for setting the request body's JSON fields (`=` for strings and `:=` for other JSON types). +* `=`/`:=` for setting the request body's JSON or form fields (`=` for strings and `:=` for other JSON types). * `==` for adding query strings. * `@` for including files in multipart requests e.g `picture@hello.jpg` or `picture@hello.jpg;type=image/jpeg`. * `:` for adding or removing headers e.g `connection:keep-alive` or `connection:`. * `;` for including headers with empty values e.g `header-without-value;`. +* `=@`/`:=@` for setting the request body's JSON or form fields from a file (`=` for strings and `:=` for other JSON types). ## xh and xhs diff --git a/doc/man-template.roff b/doc/man-template.roff index e88b54c9..48d0ecdb 100644 --- a/doc/man-template.roff +++ b/doc/man-template.roff @@ -21,8 +21,12 @@ Optional key-value pairs to be included in the request. .IP \fBkey=value\fR to add a JSON field (\fB\-\-json\fR) or form field (\fB\-\-form\fR) .IP +\fBkey=@value\fR to add a JSON field (\fB\-\-json\fR) or form field (\fB\-\-form\fR) from a file. +.IP \fBkey:=value\fR to add a complex JSON value (e.g. `numbers:=[1,2,3]`) .IP +\fBkey:=@value\fR to add a complex JSON value (e.g. `numbers:=@data.json`) from a file. +.IP \fBkey@filename\fR to upload a file from filename (with \fB\-\-form\fR) .IP \fBheader:value\fR to add a header diff --git a/src/buffer.rs b/src/buffer.rs index ed24226d..3a5c437c 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -33,7 +33,7 @@ impl Buffer { None if test_default_color() => ColorChoice::AlwaysAnsi, None => ColorChoice::Auto, Some(pretty) if pretty.color() => ColorChoice::Always, - _ => ColorChoice::Never, + Some(..) => ColorChoice::Never, }; Ok(if download { Buffer::Stderr(StandardStream::stderr(color_choice)) diff --git a/src/request_items.rs b/src/request_items.rs index 3d6d8dfb..377ac089 100644 --- a/src/request_items.rs +++ b/src/request_items.rs @@ -1,4 +1,4 @@ -use std::{fs::File, io, path::Path, str::FromStr}; +use std::{fs, fs::File, io, path::Path, str::FromStr}; use anyhow::{anyhow, Result}; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; @@ -17,7 +17,9 @@ pub enum RequestItem { HttpHeaderToUnset(String), UrlParam(String, String), DataField(String, String), + DataFieldFromFile(String, String), JsonField(String, serde_json::Value), + JsonFieldFromFile(String, String), FormFile(String, String, Option), } @@ -25,7 +27,7 @@ impl FromStr for RequestItem { type Err = clap::Error; fn from_str(request_item: &str) -> clap::Result { const SPECIAL_CHARS: &str = "=@:;\\"; - const SEPS: &[&str] = &["==", ":=", "=", "@", ":"]; + const SEPS: &[&str] = &["=@", ":=@", "==", ":=", "=", "@", ":"]; fn unescape(text: &str) -> String { let mut out = String::new(); @@ -107,6 +109,8 @@ impl FromStr for RequestItem { } ":" if value.is_empty() => Ok(RequestItem::HttpHeaderToUnset(key)), ":" => Ok(RequestItem::HttpHeader(key, value)), + "=@" => Ok(RequestItem::DataFieldFromFile(key, value)), + ":=@" => Ok(RequestItem::JsonFieldFromFile(key, value)), _ => unreachable!(), } } else if let Some(header) = request_item.strip_suffix(';') { @@ -145,7 +149,7 @@ impl Body { // This is a slight divergence from HTTPie, which will simply // discard stdin if it receives --multipart without request items, // but that behavior is useless so there's no need to match it - Body::Multipart(_) => false, + Body::Multipart(..) => false, } } @@ -187,7 +191,12 @@ impl RequestItems { let key = HeaderName::from_bytes(&key.as_bytes())?; headers_to_unset.push(key); } - _ => {} + RequestItem::UrlParam(..) => {} + RequestItem::DataField(..) => {} + RequestItem::DataFieldFromFile(..) => {} + RequestItem::JsonField(..) => {} + RequestItem::JsonFieldFromFile(..) => {} + RequestItem::FormFile(..) => {} } } Ok((headers, headers_to_unset)) @@ -210,15 +219,23 @@ impl RequestItems { RequestItem::JsonField(key, value) => { body.insert(key, value); } + RequestItem::JsonFieldFromFile(key, value) => { + body.insert(key, serde_json::from_str(&fs::read_to_string(value)?)?); + } RequestItem::DataField(key, value) => { body.insert(key, serde_json::Value::String(value)); } - RequestItem::FormFile(_, _, _) => { + RequestItem::DataFieldFromFile(key, value) => { + body.insert(key, serde_json::Value::String(fs::read_to_string(value)?)); + } + RequestItem::FormFile(..) => { return Err(anyhow!( "Sending Files is not supported when the request body is in JSON format" )); } - _ => {} + RequestItem::HttpHeader(..) => {} + RequestItem::HttpHeaderToUnset(..) => {} + RequestItem::UrlParam(..) => {} } } Ok(Body::Json(body)) @@ -228,12 +245,17 @@ impl RequestItems { let mut text_fields = Vec::<(String, String)>::new(); for item in self.0 { match item { - RequestItem::JsonField(_, _) => { + RequestItem::JsonField(..) | RequestItem::JsonFieldFromFile(..) => { return Err(anyhow!("JSON values are not supported in Form fields")); } RequestItem::DataField(key, value) => text_fields.push((key, value)), + RequestItem::DataFieldFromFile(key, value) => { + text_fields.push((key, fs::read_to_string(value)?)); + } RequestItem::FormFile(..) => unreachable!(), - _ => {} + RequestItem::HttpHeader(..) => {} + RequestItem::HttpHeaderToUnset(..) => {} + RequestItem::UrlParam(..) => {} } } Ok(Body::Form(text_fields)) @@ -243,12 +265,15 @@ impl RequestItems { let mut form = multipart::Form::new(); for item in self.0 { match item { - RequestItem::JsonField(_, _) => { + RequestItem::JsonField(..) | RequestItem::JsonFieldFromFile(..) => { return Err(anyhow!("JSON values are not supported in multipart fields")); } RequestItem::DataField(key, value) => { form = form.text(key, value); } + RequestItem::DataFieldFromFile(key, value) => { + form = form.text(key, fs::read_to_string(value)?); + } RequestItem::FormFile(key, value, file_type) => { let mut part = file_to_part(&value)?; if let Some(file_type) = file_type { @@ -256,7 +281,9 @@ impl RequestItems { } form = form.part(key, part); } - _ => {} + RequestItem::HttpHeader(..) => {} + RequestItem::HttpHeaderToUnset(..) => {} + RequestItem::UrlParam(..) => {} } } Ok(Body::Multipart(form)) @@ -286,7 +313,9 @@ impl RequestItems { | RequestItem::HttpHeaderToUnset(..) | RequestItem::UrlParam(..) => continue, RequestItem::DataField(..) + | RequestItem::DataFieldFromFile(..) | RequestItem::JsonField(..) + | RequestItem::JsonFieldFromFile(..) | RequestItem::FormFile(..) => return Method::POST, } } @@ -326,6 +355,11 @@ mod tests { // Data field assert_eq!(parse("foo=bar"), DataField("foo".into(), "bar".into())); + // Data field from file + assert_eq!( + parse("foo=@data.json"), + DataFieldFromFile("foo".into(), "data.json".into()) + ); // URL param assert_eq!(parse("foo==bar"), UrlParam("foo".into(), "bar".into())); // Escaped right before separator @@ -334,6 +368,11 @@ mod tests { assert_eq!(parse("foo:bar"), HttpHeader("foo".into(), "bar".into())); // JSON field assert_eq!(parse("foo:=[1,2]"), JsonField("foo".into(), json!([1, 2]))); + // JSON field from file + assert_eq!( + parse("foo:=@data.json"), + JsonFieldFromFile("foo".into(), "data.json".into()) + ); // Bad JSON field "foo:=bar".parse::().unwrap_err(); // Can't escape normal chars diff --git a/src/to_curl.rs b/src/to_curl.rs index 731fe48d..803363b3 100644 --- a/src/to_curl.rs +++ b/src/to_curl.rs @@ -253,13 +253,17 @@ pub fn translate(args: Cli) -> Result { // form after construction and we don't want to actually read the files for item in request_items.0 { match item { - RequestItem::JsonField(..) => { + RequestItem::JsonField(..) | RequestItem::JsonFieldFromFile(..) => { return Err(anyhow!("JSON values are not supported in multipart fields")); } RequestItem::DataField(key, value) => { cmd.flag("-F", "--form"); cmd.push(format!("{}={}", key, value)); } + RequestItem::DataFieldFromFile(key, value) => { + cmd.flag("-F", "--form"); + cmd.push(format!("{}=<{}", key, value)); + } RequestItem::FormFile(key, value, file_type) => { cmd.flag("-F", "--form"); if let Some(file_type) = file_type { @@ -268,7 +272,9 @@ pub fn translate(args: Cli) -> Result { cmd.push(format!("{}=@{}", key, value)); } } - _ => {} + RequestItem::HttpHeader(..) => {} + RequestItem::HttpHeaderToUnset(..) => {} + RequestItem::UrlParam(..) => {} } } } else { diff --git a/tests/cli.rs b/tests/cli.rs index 9f800922..44d9e17d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1006,3 +1006,55 @@ fn request_json_keys_order_is_preserved() { .assert(); mock.assert(); } + +#[test] +fn data_field_from_file() { + let server = MockServer::start(); + let mock = server.mock(|when, _| { + when.body(r#"{"ids":"[1,2,3]"}"#); + }); + + let mut text_file = tempfile::NamedTempFile::new().unwrap(); + write!(text_file, "[1,2,3]").unwrap(); + + get_command() + .arg(server.base_url()) + .arg(format!("ids=@{}", text_file.path().to_string_lossy())) + .assert(); + mock.assert(); +} + +#[test] +fn data_field_from_file_in_form_mode() { + let server = MockServer::start(); + let mock = server.mock(|when, _| { + when.body(r#"message=hello+world"#); + }); + + let mut text_file = tempfile::NamedTempFile::new().unwrap(); + write!(text_file, "hello world").unwrap(); + + get_command() + .arg(server.base_url()) + .arg("--form") + .arg(format!("message=@{}", text_file.path().to_string_lossy())) + .assert(); + mock.assert(); +} + +#[test] +fn json_field_from_file() { + let server = MockServer::start(); + let mock = server.mock(|when, _| { + when.body(r#"{"ids":[1,2,3]}"#); + }); + + let mut json_file = tempfile::NamedTempFile::new().unwrap(); + writeln!(json_file, "[1,2,3]").unwrap(); + + get_command() + .arg(server.base_url()) + .arg(format!("ids:=@{}", json_file.path().to_string_lossy())) + .assert(); + mock.assert(); +}