feat: added multiple options to http commands (#8571)

# Description

All `http` commands now have a `-f` flag which for now contains the
`headers`, `body` and `status` fields (we can later add stuff like
`is-redirect` or `cookies`).


![image](https://user-images.githubusercontent.com/3835355/227048504-6686445d-ad2e-4f5d-905d-e71b3a4b81a6.png)

*Try it yourself*
```
http get http://mockbin.org/bin/630069dc-2c09-483a-a484-672561b7de14
http get -f http://mockbin.org/bin/630069dc-2c09-483a-a484-672561b7de14
```

The `http` commands can also now use the `-e` flag, which stands for
`--allow-errors`. When the status code is `>= 400`, it will still allow
you to interpret it like a normal response.


![image](https://user-images.githubusercontent.com/3835355/227047790-b9f5a25f-2c0d-4741-881f-4189b23e4ef6.png)

*Try it yourself*
```
http get http://mockbin.org/bin/2ebd3d27-bdc2-4ee8-b042-0bc2c0d1ad2a # should fail like usual
http get -e http://mockbin.org/bin/2ebd3d27-bdc2-4ee8-b042-0bc2c0d1ad2a # will return the body
http get -e -f http://mockbin.org/bin/2ebd3d27-bdc2-4ee8-b042-0bc2c0d1ad2a # will let you see the full response
```

# User-Facing Changes

- Adds `-f` (`--full`) to all `http` commands
- Adds `-e` (--allow-errors) to all `http` commands
This commit is contained in:
Sygmei 2023-03-23 21:32:35 +01:00 committed by GitHub
parent 403bf1a734
commit ec5396a352
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 412 additions and 118 deletions

View file

@ -132,17 +132,27 @@ pub fn request_add_authorization_header(
request
}
#[allow(clippy::large_enum_variant)]
pub enum ShellErrorOrRequestError {
ShellError(ShellError),
RequestError(String, Error),
}
fn wrap_shell_error(err: ShellError) -> ShellErrorOrRequestError {
ShellErrorOrRequestError::ShellError(err)
}
pub fn send_request(
request: Request,
span: Span,
body: Option<Value>,
content_type: Option<String>,
) -> Result<Response, ShellError> {
) -> Result<Response, ShellErrorOrRequestError> {
let request_url = request.url().to_string();
let error_handler = |err: Error| -> ShellErrorOrRequestError {
ShellErrorOrRequestError::RequestError(request_url, err)
};
if body.is_none() {
return request
.call()
.map_err(|err| handle_response_error(span, &request_url, err));
return request.call().map_err(error_handler);
}
let body = body.expect("Should never be none.");
@ -152,23 +162,18 @@ pub fn send_request(
_ => BodyType::Unknown,
};
match body {
Value::Binary { val, .. } => request
.send_bytes(&val)
.map_err(|err| handle_response_error(span, &request_url, err)),
Value::String { val, .. } => request
.send_string(&val)
.map_err(|err| handle_response_error(span, &request_url, err)),
Value::Binary { val, .. } => request.send_bytes(&val).map_err(error_handler),
Value::String { val, .. } => request.send_string(&val).map_err(error_handler),
Value::Record { .. } if body_type == BodyType::Json => {
let data = value_to_json_value(&body)?;
request
.send_json(data)
.map_err(|err| handle_response_error(span, &request_url, err))
let data = value_to_json_value(&body);
request.send_json(data).map_err(error_handler)
}
Value::Record { cols, vals, .. } if body_type == BodyType::Form => {
let mut data: Vec<(String, String)> = Vec::with_capacity(cols.len());
for (col, val) in cols.iter().zip(vals.iter()) {
data.push((col.clone(), val.as_string()?))
let val_string = val.as_string().map_err(wrap_shell_error)?;
data.push((col.clone(), val_string))
}
let data = data
@ -176,30 +181,35 @@ pub fn send_request(
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect::<Vec<(&str, &str)>>();
request
.send_form(&data[..])
.map_err(|err| handle_response_error(span, &request_url, err))
request.send_form(&data[..]).map_err(error_handler)
}
Value::List { vals, .. } if body_type == BodyType::Form => {
if vals.len() % 2 != 0 {
return Err(ShellError::IOError("unsupported body input".into()));
return Err(ShellErrorOrRequestError::ShellError(ShellError::IOError(
"unsupported body input".into(),
)));
}
let data = vals
.chunks(2)
.map(|it| Ok((it[0].as_string()?, it[1].as_string()?)))
.collect::<Result<Vec<(String, String)>, ShellError>>()?;
.map(|it| {
Ok((
it[0].as_string().map_err(wrap_shell_error)?,
it[1].as_string().map_err(wrap_shell_error)?,
))
})
.collect::<Result<Vec<(String, String)>, ShellErrorOrRequestError>>()?;
let data = data
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect::<Vec<(&str, &str)>>();
request
.send_form(&data)
.map_err(|err| handle_response_error(span, &request_url, err))
request.send_form(&data).map_err(error_handler)
}
_ => Err(ShellError::IOError("unsupported body input".into())),
_ => Err(ShellErrorOrRequestError::ShellError(ShellError::IOError(
"unsupported body input".into(),
))),
}
}
@ -317,107 +327,203 @@ fn handle_response_error(span: Span, requested_url: &str, response_err: Error) -
}
}
pub struct RequestFlags {
pub allow_errors: bool,
pub raw: bool,
pub full: bool,
}
#[allow(clippy::needless_return)]
fn transform_response_using_content_type(
engine_state: &EngineState,
stack: &mut Stack,
span: Span,
requested_url: &str,
flags: &RequestFlags,
resp: Response,
content_type: &str,
) -> Result<PipelineData, ShellError> {
let content_type = mime::Mime::from_str(content_type).map_err(|_| {
ShellError::GenericError(
format!("MIME type unknown: {content_type}"),
"".to_string(),
None,
Some("given unknown MIME type".to_string()),
Vec::new(),
)
})?;
let ext = match (content_type.type_(), content_type.subtype()) {
(mime::TEXT, mime::PLAIN) => {
let path_extension = url::Url::parse(requested_url)
.map_err(|_| {
ShellError::GenericError(
format!("Cannot parse URL: {requested_url}"),
"".to_string(),
None,
Some("cannot parse".to_string()),
Vec::new(),
)
})?
.path_segments()
.and_then(|segments| segments.last())
.and_then(|name| if name.is_empty() { None } else { Some(name) })
.and_then(|name| {
PathBuf::from(name)
.extension()
.map(|name| name.to_string_lossy().to_string())
});
path_extension
}
_ => Some(content_type.subtype().to_string()),
};
let output = response_to_buffer(resp, engine_state, span);
if flags.raw {
return Ok(output);
} else if let Some(ext) = ext {
return match engine_state.find_decl(format!("from {ext}").as_bytes(), &[]) {
Some(converter_id) => engine_state.get_decl(converter_id).run(
engine_state,
stack,
&Call::new(span),
output,
),
None => Ok(output),
};
} else {
return Ok(output);
};
}
fn request_handle_response_content(
engine_state: &EngineState,
stack: &mut Stack,
span: Span,
requested_url: &str,
flags: RequestFlags,
resp: Response,
) -> Result<PipelineData, ShellError> {
let response_headers: Option<PipelineData> = if flags.full {
let headers_raw = request_handle_response_headers_raw(span, &resp)?;
Some(headers_raw)
} else {
None
};
let response_status = resp.status();
let content_type = resp.header("content-type").map(|s| s.to_owned());
let formatted_content = match content_type {
Some(content_type) => transform_response_using_content_type(
engine_state,
stack,
span,
requested_url,
&flags,
resp,
&content_type,
),
None => Ok(response_to_buffer(resp, engine_state, span)),
};
if flags.full {
let full_response = Value::Record {
cols: vec![
"headers".to_string(),
"body".to_string(),
"status".to_string(),
],
vals: vec![
match response_headers {
Some(headers) => headers.into_value(span),
None => Value::nothing(span),
},
formatted_content?.into_value(span),
Value::int(response_status as i64, span),
],
span,
}
.into_pipeline_data();
Ok(full_response)
} else {
Ok(formatted_content?)
}
}
pub fn request_handle_response(
engine_state: &EngineState,
stack: &mut Stack,
span: Span,
requested_url: &String,
raw: bool,
response: Result<Response, ShellError>,
requested_url: &str,
flags: RequestFlags,
response: Result<Response, ShellErrorOrRequestError>,
) -> Result<PipelineData, ShellError> {
match response {
Ok(resp) => match resp.header("content-type") {
Some(content_type) => {
let content_type = mime::Mime::from_str(content_type).map_err(|_| {
ShellError::GenericError(
format!("MIME type unknown: {content_type}"),
"".to_string(),
None,
Some("given unknown MIME type".to_string()),
Vec::new(),
)
})?;
let ext = match (content_type.type_(), content_type.subtype()) {
(mime::TEXT, mime::PLAIN) => {
let path_extension = url::Url::parse(requested_url)
.map_err(|_| {
ShellError::GenericError(
format!("Cannot parse URL: {requested_url}"),
"".to_string(),
None,
Some("cannot parse".to_string()),
Vec::new(),
)
})?
.path_segments()
.and_then(|segments| segments.last())
.and_then(|name| if name.is_empty() { None } else { Some(name) })
.and_then(|name| {
PathBuf::from(name)
.extension()
.map(|name| name.to_string_lossy().to_string())
});
path_extension
}
_ => Some(content_type.subtype().to_string()),
};
let output = response_to_buffer(resp, engine_state, span);
if raw {
return Ok(output);
}
if let Some(ext) = ext {
match engine_state.find_decl(format!("from {ext}").as_bytes(), &[]) {
Some(converter_id) => engine_state.get_decl(converter_id).run(
Ok(resp) => {
request_handle_response_content(engine_state, stack, span, requested_url, flags, resp)
}
Err(e) => match e {
ShellErrorOrRequestError::ShellError(e) => Err(e),
ShellErrorOrRequestError::RequestError(_, e) => {
if flags.allow_errors {
if let Error::Status(_, resp) = e {
Ok(request_handle_response_content(
engine_state,
stack,
&Call::new(span),
output,
),
None => Ok(output),
span,
requested_url,
flags,
resp,
)?)
} else {
Err(handle_response_error(span, requested_url, e))
}
} else {
Ok(output)
Err(handle_response_error(span, requested_url, e))
}
}
None => Ok(response_to_buffer(resp, engine_state, span)),
},
Err(e) => Err(e),
}
}
pub fn request_handle_response_headers_raw(
span: Span,
response: &Response,
) -> Result<PipelineData, ShellError> {
let cols = response.headers_names();
let mut vals = Vec::with_capacity(cols.len());
for key in &cols {
match response.header(key) {
// match value.to_str() {
Some(str_value) => vals.push(Value::String {
val: str_value.to_string(),
span,
}),
None => {
return Err(ShellError::GenericError(
"Failure when converting header value".to_string(),
"".to_string(),
None,
Some("Failure when converting header value".to_string()),
Vec::new(),
))
}
}
}
Ok(Value::Record { cols, vals, span }.into_pipeline_data())
}
pub fn request_handle_response_headers(
span: Span,
response: Result<Response, ShellError>,
response: Result<Response, ShellErrorOrRequestError>,
) -> Result<PipelineData, ShellError> {
match response {
Ok(resp) => {
let cols = resp.headers_names();
let mut vals = Vec::with_capacity(cols.len());
for key in &cols {
match resp.header(key) {
// match value.to_str() {
Some(str_value) => vals.push(Value::String {
val: str_value.to_string(),
span,
}),
None => {
return Err(ShellError::GenericError(
"Failure when converting header value".to_string(),
"".to_string(),
None,
Some("Failure when converting header value".to_string()),
Vec::new(),
))
}
}
Ok(resp) => request_handle_response_headers_raw(span, &resp),
Err(e) => match e {
ShellErrorOrRequestError::ShellError(e) => Err(e),
ShellErrorOrRequestError::RequestError(requested_url, e) => {
Err(handle_response_error(span, &requested_url, e))
}
Ok(Value::Record { cols, vals, span }.into_pipeline_data())
}
Err(e) => Err(e),
},
}
}

View file

@ -10,6 +10,8 @@ use crate::network::http::client::{
request_handle_response, request_set_timeout, send_request,
};
use super::client::RequestFlags;
#[derive(Clone)]
pub struct SubCommand;
@ -68,6 +70,16 @@ impl Command for SubCommand {
"allow insecure server connections when using SSL",
Some('k'),
)
.switch(
"full",
"returns the full response instead of only the body",
Some('f'),
)
.switch(
"allow-errors",
"do not fail if the server returns an error code",
Some('e'),
)
.filter()
.category(Category::Network)
}
@ -136,6 +148,8 @@ struct Arguments {
user: Option<String>,
password: Option<String>,
timeout: Option<Value>,
full: bool,
allow_errors: bool,
}
fn run_delete(
@ -154,6 +168,8 @@ fn run_delete(
user: call.get_flag(engine_state, stack, "user")?,
password: call.get_flag(engine_state, stack, "password")?,
timeout: call.get_flag(engine_state, stack, "max-time")?,
full: call.has_flag("full"),
allow_errors: call.has_flag("allow-errors"),
};
helper(engine_state, stack, call, args)
@ -177,14 +193,20 @@ fn helper(
request = request_add_authorization_header(args.user, args.password, request);
request = request_add_custom_headers(args.headers, request)?;
let response = send_request(request, span, args.data, args.content_type);
let response = send_request(request, args.data, args.content_type);
let request_flags = RequestFlags {
raw: args.raw,
full: args.full,
allow_errors: args.allow_errors,
};
request_handle_response(
engine_state,
stack,
span,
&requested_url,
args.raw,
request_flags,
response,
)
}

View file

@ -10,6 +10,8 @@ use crate::network::http::client::{
request_handle_response, request_set_timeout, send_request,
};
use super::client::RequestFlags;
#[derive(Clone)]
pub struct SubCommand;
@ -61,6 +63,16 @@ impl Command for SubCommand {
"allow insecure server connections when using SSL",
Some('k'),
)
.switch(
"full",
"returns the full response instead of only the body",
Some('f'),
)
.switch(
"allow-errors",
"do not fail if the server returns an error code",
Some('e'),
)
.filter()
.category(Category::Network)
}
@ -118,6 +130,8 @@ struct Arguments {
user: Option<String>,
password: Option<String>,
timeout: Option<Value>,
full: bool,
allow_errors: bool,
}
fn run_get(
@ -134,6 +148,8 @@ fn run_get(
user: call.get_flag(engine_state, stack, "user")?,
password: call.get_flag(engine_state, stack, "password")?,
timeout: call.get_flag(engine_state, stack, "max-time")?,
full: call.has_flag("full"),
allow_errors: call.has_flag("allow-errors"),
};
helper(engine_state, stack, call, args)
}
@ -156,13 +172,20 @@ fn helper(
request = request_add_authorization_header(args.user, args.password, request);
request = request_add_custom_headers(args.headers, request)?;
let response = send_request(request, span, None, None);
let response = send_request(request, None, None);
let request_flags = RequestFlags {
raw: args.raw,
full: args.full,
allow_errors: args.allow_errors,
};
request_handle_response(
engine_state,
stack,
span,
&requested_url,
args.raw,
request_flags,
response,
)
}

View file

@ -143,7 +143,7 @@ fn helper(call: &Call, args: Arguments) -> Result<PipelineData, ShellError> {
request = request_add_authorization_header(args.user, args.password, request);
request = request_add_custom_headers(args.headers, request)?;
let response = send_request(request, span, None, None);
let response = send_request(request, None, None);
request_handle_response_headers(span, response)
}

View file

@ -10,6 +10,8 @@ use crate::network::http::client::{
request_handle_response, request_set_timeout, send_request,
};
use super::client::RequestFlags;
#[derive(Clone)]
pub struct SubCommand;
@ -64,6 +66,16 @@ impl Command for SubCommand {
"allow insecure server connections when using SSL",
Some('k'),
)
.switch(
"full",
"returns the full response instead of only the body",
Some('f'),
)
.switch(
"allow-errors",
"do not fail if the server returns an error code",
Some('e'),
)
.filter()
.category(Category::Network)
}
@ -126,6 +138,8 @@ struct Arguments {
user: Option<String>,
password: Option<String>,
timeout: Option<Value>,
full: bool,
allow_errors: bool,
}
fn run_patch(
@ -144,6 +158,8 @@ fn run_patch(
user: call.get_flag(engine_state, stack, "user")?,
password: call.get_flag(engine_state, stack, "password")?,
timeout: call.get_flag(engine_state, stack, "max-time")?,
full: call.has_flag("full"),
allow_errors: call.has_flag("allow-errors"),
};
helper(engine_state, stack, call, args)
@ -167,13 +183,20 @@ fn helper(
request = request_add_authorization_header(args.user, args.password, request);
request = request_add_custom_headers(args.headers, request)?;
let response = send_request(request, span, Some(args.data), args.content_type);
let response = send_request(request, Some(args.data), args.content_type);
let request_flags = RequestFlags {
raw: args.raw,
full: args.full,
allow_errors: args.allow_errors,
};
request_handle_response(
engine_state,
stack,
span,
&requested_url,
args.raw,
request_flags,
response,
)
}

View file

@ -10,6 +10,8 @@ use crate::network::http::client::{
request_handle_response, request_set_timeout, send_request,
};
use super::client::RequestFlags;
#[derive(Clone)]
pub struct SubCommand;
@ -64,6 +66,16 @@ impl Command for SubCommand {
"allow insecure server connections when using SSL",
Some('k'),
)
.switch(
"full",
"returns the full response instead of only the body",
Some('f'),
)
.switch(
"allow-errors",
"do not fail if the server returns an error code",
Some('e'),
)
.filter()
.category(Category::Network)
}
@ -126,6 +138,8 @@ struct Arguments {
user: Option<String>,
password: Option<String>,
timeout: Option<Value>,
full: bool,
allow_errors: bool,
}
fn run_post(
@ -144,6 +158,8 @@ fn run_post(
user: call.get_flag(engine_state, stack, "user")?,
password: call.get_flag(engine_state, stack, "password")?,
timeout: call.get_flag(engine_state, stack, "max-time")?,
full: call.has_flag("full"),
allow_errors: call.has_flag("allow-errors"),
};
helper(engine_state, stack, call, args)
@ -167,13 +183,20 @@ fn helper(
request = request_add_authorization_header(args.user, args.password, request);
request = request_add_custom_headers(args.headers, request)?;
let response = send_request(request, span, Some(args.data), args.content_type);
let response = send_request(request, Some(args.data), args.content_type);
let request_flags = RequestFlags {
raw: args.raw,
full: args.full,
allow_errors: args.allow_errors,
};
request_handle_response(
engine_state,
stack,
span,
&requested_url,
args.raw,
request_flags,
response,
)
}

View file

@ -10,6 +10,8 @@ use crate::network::http::client::{
request_handle_response, request_set_timeout, send_request,
};
use super::client::RequestFlags;
#[derive(Clone)]
pub struct SubCommand;
@ -64,6 +66,16 @@ impl Command for SubCommand {
"allow insecure server connections when using SSL",
Some('k'),
)
.switch(
"full",
"returns the full response instead of only the body",
Some('f'),
)
.switch(
"allow-errors",
"do not fail if the server returns an error code",
Some('e'),
)
.filter()
.category(Category::Network)
}
@ -126,6 +138,8 @@ struct Arguments {
user: Option<String>,
password: Option<String>,
timeout: Option<Value>,
full: bool,
allow_errors: bool,
}
fn run_put(
@ -144,6 +158,8 @@ fn run_put(
user: call.get_flag(engine_state, stack, "user")?,
password: call.get_flag(engine_state, stack, "password")?,
timeout: call.get_flag(engine_state, stack, "max-time")?,
full: call.has_flag("full"),
allow_errors: call.has_flag("allow-errors"),
};
helper(engine_state, stack, call, args)
@ -167,13 +183,20 @@ fn helper(
request = request_add_authorization_header(args.user, args.password, request);
request = request_add_custom_headers(args.headers, request)?;
let response = send_request(request, span, Some(args.data), args.content_type);
let response = send_request(request, Some(args.data), args.content_type);
let request_flags = RequestFlags {
raw: args.raw,
full: args.full,
allow_errors: args.allow_errors,
};
request_handle_response(
engine_state,
stack,
span,
&requested_url,
args.raw,
request_flags,
response,
)
}

View file

@ -39,6 +39,80 @@ fn http_get_failed_due_to_server_error() {
assert!(actual.err.contains("Bad request (400)"))
}
#[test]
fn http_get_with_accept_errors() {
let mut server = Server::new();
let _mock = server
.mock("GET", "/")
.with_status(400)
.with_body("error body")
.create();
let actual = nu!(pipeline(
format!(
r#"
http get -e {url}
"#,
url = server.url()
)
.as_str()
));
assert!(actual.out.contains("error body"))
}
#[test]
fn http_get_with_accept_errors_and_full_raw_response() {
let mut server = Server::new();
let _mock = server
.mock("GET", "/")
.with_status(400)
.with_body("error body")
.create();
let actual = nu!(pipeline(
format!(
r#"
http get -e -f {url} | $"($in.status) => ($in.body)"
"#,
url = server.url()
)
.as_str()
));
assert!(actual.out.contains("400 => error body"))
}
#[test]
fn http_get_with_accept_errors_and_full_json_response() {
let mut server = Server::new();
let _mock = server
.mock("GET", "/")
.with_status(400)
.with_header("content-type", "application/json")
.with_body(
r#"
{"msg": "error body"}
"#,
)
.create();
let actual = nu!(pipeline(
format!(
r#"
http get -e -f {url} | $"($in.status) => ($in.body.msg)"
"#,
url = server.url()
)
.as_str()
));
assert!(actual.out.contains("400 => error body"))
}
// These tests require network access; they use badssl.com which is a Google-affiliated site for testing various SSL errors.
// Revisit this if these tests prove to be flaky or unstable.