Skip to content

Commit

Permalink
Refactor send_request in client.rs (#13701)
Browse files Browse the repository at this point in the history
Closes #13687
Closes #13686

# Description
Light refactoring of `send_request `in `client.rs`. In the end there are
more lines but now the logic is more concise and facilitates adding new
conditions in the future. Unit tests ran fine and I tested a few cases
manually.
Cool project btw, I'll be using nushell from now on.
  • Loading branch information
JTopanotti committed Sep 4, 2024
1 parent 63b94db commit 4792328
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 116 deletions.
273 changes: 157 additions & 116 deletions crates/nu-command/src/network/http/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ use std::{
use ureq::{Error, ErrorKind, Request, Response};
use url::Url;

const HTTP_DOCS: &str = "https://www.nushell.sh/cookbook/http.html";

#[derive(PartialEq, Eq)]
pub enum BodyType {
Json,
Expand Down Expand Up @@ -221,132 +223,171 @@ pub fn send_request(
_ => (BodyType::Unknown, request),
};

match body {
Value::Binary { val, .. } => send_cancellable_request(
&request_url,
Box::new(move || req.send_bytes(&val)),
span,
signals,
),
Value::String { .. } if body_type == BodyType::Json => {
let data = value_to_json_value(&body)?;
send_cancellable_request(
&request_url,
Box::new(|| req.send_json(data)),
span,
signals,
)
}
Value::String { val, .. } => send_cancellable_request(
&request_url,
Box::new(move || req.send_string(&val)),
span,
signals,
),
Value::Record { .. } if body_type == BodyType::Json => {
let data = value_to_json_value(&body)?;
send_cancellable_request(
&request_url,
Box::new(|| req.send_json(data)),
span,
signals,
)
match body_type {
BodyType::Json => send_json_request(&request_url, body, req, span, signals),
BodyType::Form => send_form_request(&request_url, body, req, span, signals),
BodyType::Multipart => {
send_multipart_request(&request_url, body, req, span, signals)
}
Value::Record { val, .. } if body_type == BodyType::Form => {
let mut data: Vec<(String, String)> = Vec::with_capacity(val.len());
BodyType::Unknown => send_default_request(&request_url, body, req, span, signals),
}
}
}
}

for (col, val) in val.into_owned() {
data.push((col, val.coerce_into_string()?))
}
fn send_json_request(
request_url: &str,
body: Value,
req: Request,
span: Span,
signals: &Signals,
) -> Result<Response, ShellErrorOrRequestError> {
let data = match body {
Value::Int { .. } | Value::List { .. } | Value::String { .. } | Value::Record { .. } => {
value_to_json_value(&body)?
}
_ => {
return Err(ShellErrorOrRequestError::ShellError(
ShellError::UnsupportedHttpBody {
msg: format!("Accepted types: [Int, List, String, Record]. Check: {HTTP_DOCS}"),
},
))
}
};
send_cancellable_request(request_url, Box::new(|| req.send_json(data)), span, signals)
}

let request_fn = move || {
// coerce `data` into a shape that send_form() is happy with
let data = data
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect::<Vec<(&str, &str)>>();
req.send_form(&data)
};
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();
fn send_form_request(
request_url: &str,
body: Value,
req: Request,
span: Span,
signals: &Signals,
) -> Result<Response, ShellErrorOrRequestError> {
let build_request_fn = |data: Vec<(String, String)>| {
// coerce `data` into a shape that send_form() is happy with
let data = data
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect::<Vec<(&str, &str)>>();
req.send_form(&data)
};

let (boundary, data) = (builder.boundary, builder.data);
let content_type = format!("multipart/form-data; boundary={}", boundary);
match body {
Value::List { vals, .. } => {
if vals.len() % 2 != 0 {
return Err(ShellErrorOrRequestError::ShellError(ShellError::UnsupportedHttpBody {
msg: "Body type 'List' for form requests requires paired values. E.g.: [value, 10]".into(),
}));
}

let request_fn =
move || req.set("Content-Type", &content_type).send_bytes(&data);
let data = vals
.chunks(2)
.map(|it| Ok((it[0].coerce_string()?, it[1].coerce_string()?)))
.collect::<Result<Vec<(String, String)>, ShellErrorOrRequestError>>()?;

send_cancellable_request(&request_url, Box::new(request_fn), span, signals)
}
Value::List { vals, .. } if body_type == BodyType::Form => {
if vals.len() % 2 != 0 {
return Err(ShellErrorOrRequestError::ShellError(ShellError::IOError {
msg: "unsupported body input".into(),
}));
}
let request_fn = Box::new(|| build_request_fn(data));
send_cancellable_request(request_url, request_fn, span, signals)
}
Value::Record { val, .. } => {
let mut data: Vec<(String, String)> = Vec::with_capacity(val.len());

let data = vals
.chunks(2)
.map(|it| Ok((it[0].coerce_string()?, it[1].coerce_string()?)))
.collect::<Result<Vec<(String, String)>, ShellErrorOrRequestError>>()?;

let request_fn = move || {
// coerce `data` into a shape that send_form() is happy with
let data = data
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect::<Vec<(&str, &str)>>();
req.send_form(&data)
};
send_cancellable_request(&request_url, Box::new(request_fn), span, signals)
}
Value::List { .. } if body_type == BodyType::Json => {
let data = value_to_json_value(&body)?;
send_cancellable_request(
&request_url,
Box::new(|| req.send_json(data)),
span,
signals,
)
for (col, val) in val.into_owned() {
data.push((col, val.coerce_into_string()?))
}

let request_fn = Box::new(|| build_request_fn(data));
send_cancellable_request(request_url, request_fn, span, signals)
}
_ => Err(ShellErrorOrRequestError::ShellError(
ShellError::UnsupportedHttpBody {
msg: format!("Accepted types: [List, Record]. Check: {HTTP_DOCS}"),
},
)),
}
}

fn send_multipart_request(
request_url: &str,
body: Value,
req: Request,
span: Span,
signals: &Signals,
) -> Result<Response, ShellErrorOrRequestError> {
let request_fn = match body {
Value::Record { val, .. } => {
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)?;
}
_ => Err(ShellErrorOrRequestError::ShellError(ShellError::IOError {
msg: "unsupported body input".into(),
})),
}
builder.finish();

let (boundary, data) = (builder.boundary, builder.data);
let content_type = format!("multipart/form-data; boundary={}", boundary);

move || req.set("Content-Type", &content_type).send_bytes(&data)
}
_ => {
return Err(ShellErrorOrRequestError::ShellError(
ShellError::UnsupportedHttpBody {
msg: format!("Accepted types: [Record]. Check: {HTTP_DOCS}"),
},
))
}
};
send_cancellable_request(request_url, Box::new(request_fn), span, signals)
}

fn send_default_request(
request_url: &str,
body: Value,
req: Request,
span: Span,
signals: &Signals,
) -> Result<Response, ShellErrorOrRequestError> {
match body {
Value::Binary { val, .. } => send_cancellable_request(
request_url,
Box::new(move || req.send_bytes(&val)),
span,
signals,
),
Value::String { val, .. } => send_cancellable_request(
request_url,
Box::new(move || req.send_string(&val)),
span,
signals,
),
_ => Err(ShellErrorOrRequestError::ShellError(
ShellError::UnsupportedHttpBody {
msg: format!("Accepted types: [Binary, String]. Check: {HTTP_DOCS}"),
},
)),
}
}

Expand Down
30 changes: 30 additions & 0 deletions crates/nu-command/tests/commands/network/http/post.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,36 @@ fn http_post_json_list_is_success() {
assert!(actual.out.is_empty())
}

#[test]
fn http_post_json_int_is_success() {
let mut server = Server::new();

let mock = server.mock("POST", "/").match_body(r#"50"#).create();

let actual = nu!(format!(
r#"http post -t 'application/json' {url} 50"#,
url = server.url()
));

mock.assert();
assert!(actual.out.is_empty())
}

#[test]
fn http_post_json_string_is_success() {
let mut server = Server::new();

let mock = server.mock("POST", "/").match_body(r#""test""#).create();

let actual = nu!(format!(
r#"http post -t 'application/json' {url} "test""#,
url = server.url()
));

mock.assert();
assert!(actual.out.is_empty())
}

#[test]
fn http_post_follows_redirect() {
let mut server = Server::new();
Expand Down
9 changes: 9 additions & 0 deletions crates/nu-protocol/src/errors/shell_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,15 @@ pub enum ShellError {
span: Span,
},

/// An unsupported body input was used for the respective application body type in 'http' command
///
/// ## Resolution
///
/// This error is fairly generic. Refer to the specific error message for further details.
#[error("Unsupported body for current content type")]
#[diagnostic(code(nu::shell::unsupported_body), help("{msg}"))]
UnsupportedHttpBody { msg: String },

/// An operation was attempted with an input unsupported for some reason.
///
/// ## Resolution
Expand Down

0 comments on commit 4792328

Please sign in to comment.