mirror of
https://github.com/nushell/nushell.git
synced 2024-11-22 00:13:21 +01:00
Add multipart/form-data uploads (#13532)
Fixes nushell/nushell#11046 # Description This adds support for `multipart/form-data` (RFC 7578) uploads to nushell. Binary data is uploaded as files (`application/octet-stream`), everything else is uploaded as plain text. ```console $ http post https://echo.free.beeceptor.com --content-type multipart/form-data {cargo: (open -r Cargo.toml | into binary ), description: "It's some TOML"} | upsert ip "<redacted>" ╭───────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ method │ POST │ │ protocol │ https │ │ host │ echo.free.beeceptor.com │ │ path │ / │ │ ip │ <redacted> │ │ │ ╭─────────────────┬────────────────────────────────────────────────────────────────────╮ │ │ headers │ │ Host │ echo.free.beeceptor.com │ │ │ │ │ User-Agent │ nushell │ │ │ │ │ Content-Length │ 9453 │ │ │ │ │ Accept │ */* │ │ │ │ │ Accept-Encoding │ gzip │ │ │ │ │ Content-Type │ multipart/form-data; boundary=a15f6a14-5768-4a6a-b3a4-686a112d9e27 │ │ │ │ ╰─────────────────┴────────────────────────────────────────────────────────────────────╯ │ │ parsedQueryParams │ {record 0 fields} │ │ │ ╭─────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ parsedBody │ │ │ ╭─────────────┬────────────────╮ │ │ │ │ │ textFields │ │ description │ It's some TOML │ │ │ │ │ │ │ ╰─────────────┴────────────────╯ │ │ │ │ │ │ ╭───┬───────┬──────────┬──────────────────────────┬───────────────────────────┬───────────────────────────────────────────┬────────────────╮ │ │ │ │ │ files │ │ # │ name │ fileName │ Content-Type │ Content-Transfer-Encoding │ Content-Disposition │ Content-Length │ │ │ │ │ │ │ ├───┼───────┼──────────┼──────────────────────────┼───────────────────────────┼───────────────────────────────────────────┼────────────────┤ │ │ │ │ │ │ │ 0 │ cargo │ cargo │ application/octet-stream │ binary │ form-data; name="cargo"; filename="cargo" │ 9101 │ │ │ │ │ │ │ ╰───┴───────┴──────────┴──────────────────────────┴───────────────────────────┴───────────────────────────────────────────┴────────────────╯ │ │ │ │ ╰─────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ ╰───────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` # User-Facing Changes `http post --content-type multipart/form-data` now accepts a record which is uploaded as `multipart/form-data`. Binary data is uploaded as files (`application/octet-stream`), everything else is uploaded as plain text. Previously `http post --content-type multipart/form-data` rejected records, so there's no BC break. # Tests + Formatting Added. # After Submitting - [ ] update docs to showcase new functionality
This commit is contained in:
parent
926331dbfb
commit
2ced9e4d19
16
Cargo.lock
generated
16
Cargo.lock
generated
@ -2748,6 +2748,20 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multipart-rs"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22ea34e5c0fa65ba84707cfaf5bf43d500f1c5a4c6c36327bf5541c5bcd17e98"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multiversion"
|
name = "multiversion"
|
||||||
version = "0.7.4"
|
version = "0.7.4"
|
||||||
@ -2877,6 +2891,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"miette",
|
"miette",
|
||||||
"mimalloc",
|
"mimalloc",
|
||||||
|
"multipart-rs",
|
||||||
"nix",
|
"nix",
|
||||||
"nu-cli",
|
"nu-cli",
|
||||||
"nu-cmd-base",
|
"nu-cmd-base",
|
||||||
@ -3064,6 +3079,7 @@ dependencies = [
|
|||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"mockito",
|
"mockito",
|
||||||
|
"multipart-rs",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"nix",
|
"nix",
|
||||||
"notify-debouncer-full",
|
"notify-debouncer-full",
|
||||||
|
@ -113,6 +113,7 @@ miette = "7.2"
|
|||||||
mime = "0.3"
|
mime = "0.3"
|
||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
mockito = { version = "1.4", default-features = false }
|
mockito = { version = "1.4", default-features = false }
|
||||||
|
multipart-rs = "0.1.11"
|
||||||
native-tls = "0.2"
|
native-tls = "0.2"
|
||||||
nix = { version = "0.28", default-features = false }
|
nix = { version = "0.28", default-features = false }
|
||||||
notify-debouncer-full = { version = "0.3", default-features = false }
|
notify-debouncer-full = { version = "0.3", default-features = false }
|
||||||
@ -207,6 +208,7 @@ dirs = { workspace = true }
|
|||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
miette = { workspace = true, features = ["fancy-no-backtrace", "fancy"] }
|
miette = { workspace = true, features = ["fancy-no-backtrace", "fancy"] }
|
||||||
mimalloc = { version = "0.1.42", default-features = false, optional = true }
|
mimalloc = { version = "0.1.42", default-features = false, optional = true }
|
||||||
|
multipart-rs = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
simplelog = "0.12"
|
simplelog = "0.12"
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
|
@ -60,6 +60,7 @@ lscolors = { workspace = true, default-features = false, features = ["nu-ansi-te
|
|||||||
md5 = { workspace = true }
|
md5 = { workspace = true }
|
||||||
mime = { workspace = true }
|
mime = { workspace = true }
|
||||||
mime_guess = { workspace = true }
|
mime_guess = { workspace = true }
|
||||||
|
multipart-rs = { workspace = true }
|
||||||
native-tls = { workspace = true }
|
native-tls = { workspace = true }
|
||||||
notify-debouncer-full = { workspace = true, default-features = false }
|
notify-debouncer-full = { workspace = true, default-features = false }
|
||||||
num-format = { workspace = true }
|
num-format = { workspace = true }
|
||||||
@ -146,4 +147,4 @@ quickcheck = { workspace = true }
|
|||||||
quickcheck_macros = { workspace = true }
|
quickcheck_macros = { workspace = true }
|
||||||
rstest = { workspace = true, default-features = false }
|
rstest = { workspace = true, default-features = false }
|
||||||
pretty_assertions = { workspace = true }
|
pretty_assertions = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
@ -4,10 +4,12 @@ use base64::{
|
|||||||
engine::{general_purpose::PAD, GeneralPurpose},
|
engine::{general_purpose::PAD, GeneralPurpose},
|
||||||
Engine,
|
Engine,
|
||||||
};
|
};
|
||||||
|
use multipart_rs::MultipartWriter;
|
||||||
use nu_engine::command_prelude::*;
|
use nu_engine::command_prelude::*;
|
||||||
use nu_protocol::{ByteStream, Signals};
|
use nu_protocol::{ByteStream, Signals};
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
|
io::Cursor,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
str::FromStr,
|
str::FromStr,
|
||||||
sync::mpsc::{self, RecvTimeoutError},
|
sync::mpsc::{self, RecvTimeoutError},
|
||||||
@ -20,6 +22,7 @@ use url::Url;
|
|||||||
pub enum BodyType {
|
pub enum BodyType {
|
||||||
Json,
|
Json,
|
||||||
Form,
|
Form,
|
||||||
|
Multipart,
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,6 +213,7 @@ pub fn send_request(
|
|||||||
let (body_type, req) = match content_type {
|
let (body_type, req) = match content_type {
|
||||||
Some(it) if it == "application/json" => (BodyType::Json, request),
|
Some(it) if it == "application/json" => (BodyType::Json, request),
|
||||||
Some(it) if it == "application/x-www-form-urlencoded" => (BodyType::Form, request),
|
Some(it) if it == "application/x-www-form-urlencoded" => (BodyType::Form, request),
|
||||||
|
Some(it) if it == "multipart/form-data" => (BodyType::Multipart, request),
|
||||||
Some(it) => {
|
Some(it) => {
|
||||||
let r = request.clone().set("Content-Type", &it);
|
let r = request.clone().set("Content-Type", &it);
|
||||||
(BodyType::Unknown, r)
|
(BodyType::Unknown, r)
|
||||||
@ -265,6 +269,48 @@ pub fn send_request(
|
|||||||
};
|
};
|
||||||
send_cancellable_request(&request_url, Box::new(request_fn), span, signals)
|
send_cancellable_request(&request_url, Box::new(request_fn), span, signals)
|
||||||
}
|
}
|
||||||
|
// multipart form upload
|
||||||
|
Value::Record { val, .. } if body_type == BodyType::Multipart => {
|
||||||
|
let mut builder = MultipartWriter::new();
|
||||||
|
|
||||||
|
let err = |e| {
|
||||||
|
ShellErrorOrRequestError::ShellError(ShellError::IOError {
|
||||||
|
msg: format!("failed to build multipart data: {}", e),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
for (col, val) in val.into_owned() {
|
||||||
|
if let Value::Binary { val, .. } = val {
|
||||||
|
let headers = [
|
||||||
|
"Content-Type: application/octet-stream".to_string(),
|
||||||
|
"Content-Transfer-Encoding: binary".to_string(),
|
||||||
|
format!(
|
||||||
|
"Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"",
|
||||||
|
col, col
|
||||||
|
),
|
||||||
|
format!("Content-Length: {}", val.len()),
|
||||||
|
];
|
||||||
|
builder
|
||||||
|
.add(&mut Cursor::new(val), &headers.join("\n"))
|
||||||
|
.map_err(err)?;
|
||||||
|
} else {
|
||||||
|
let headers =
|
||||||
|
format!(r#"Content-Disposition: form-data; name="{}""#, col);
|
||||||
|
builder
|
||||||
|
.add(val.coerce_into_string()?.as_bytes(), &headers)
|
||||||
|
.map_err(err)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.finish();
|
||||||
|
|
||||||
|
let (boundary, data) = (builder.boundary, builder.data);
|
||||||
|
let content_type = format!("multipart/form-data; boundary={}", boundary);
|
||||||
|
|
||||||
|
let request_fn =
|
||||||
|
move || req.set("Content-Type", &content_type).send_bytes(&data);
|
||||||
|
|
||||||
|
send_cancellable_request(&request_url, Box::new(request_fn), span, signals)
|
||||||
|
}
|
||||||
Value::List { vals, .. } if body_type == BodyType::Form => {
|
Value::List { vals, .. } if body_type == BodyType::Form => {
|
||||||
if vals.len() % 2 != 0 {
|
if vals.len() % 2 != 0 {
|
||||||
return Err(ShellErrorOrRequestError::ShellError(ShellError::IOError {
|
return Err(ShellErrorOrRequestError::ShellError(ShellError::IOError {
|
||||||
|
@ -127,6 +127,11 @@ impl Command for SubCommand {
|
|||||||
example: "open foo.json | http post https://www.example.com",
|
example: "open foo.json | http post https://www.example.com",
|
||||||
result: None,
|
result: None,
|
||||||
},
|
},
|
||||||
|
Example {
|
||||||
|
description: "Upload a file to example.com",
|
||||||
|
example: "http post --content-type multipart/form-data https://www.example.com { audio: (open -r file.mp3) }",
|
||||||
|
result: None,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
use mockito::Server;
|
use mockito::{Matcher, Server, ServerOpts};
|
||||||
use nu_test_support::{nu, pipeline};
|
use nu_test_support::{nu, pipeline};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -197,3 +197,34 @@ fn http_post_redirect_mode_error() {
|
|||||||
"Redirect encountered when redirect handling mode was 'error' (301 Moved Permanently)"
|
"Redirect encountered when redirect handling mode was 'error' (301 Moved Permanently)"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn http_post_multipart_is_success() {
|
||||||
|
let mut server = Server::new_with_opts(ServerOpts {
|
||||||
|
assert_on_drop: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let _mock = server
|
||||||
|
.mock("POST", "/")
|
||||||
|
.match_header(
|
||||||
|
"content-type",
|
||||||
|
Matcher::Regex("multipart/form-data; boundary=.*".to_string()),
|
||||||
|
)
|
||||||
|
.match_body(Matcher::AllOf(vec![
|
||||||
|
Matcher::Regex(r#"(?m)^Content-Disposition: form-data; name="foo""#.to_string()),
|
||||||
|
Matcher::Regex(r#"(?m)^Content-Type: application/octet-stream"#.to_string()),
|
||||||
|
Matcher::Regex(r#"(?m)^Content-Length: 3"#.to_string()),
|
||||||
|
Matcher::Regex(r#"(?m)^bar"#.to_string()),
|
||||||
|
]))
|
||||||
|
.with_status(200)
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let actual = nu!(pipeline(
|
||||||
|
format!(
|
||||||
|
"http post --content-type multipart/form-data {url} {{foo: ('bar' | into binary) }}",
|
||||||
|
url = server.url()
|
||||||
|
)
|
||||||
|
.as_str()
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(actual.out.is_empty())
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user