diff --git a/crates/nu-command/src/network/http/client.rs b/crates/nu-command/src/network/http/client.rs index c09cdb3200..028ae5980d 100644 --- a/crates/nu-command/src/network/http/client.rs +++ b/crates/nu-command/src/network/http/client.rs @@ -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, content_type: Option, -) -> Result { +) -> Result { 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::>(); - 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::, ShellError>>()?; + .map(|it| { + Ok(( + it[0].as_string().map_err(wrap_shell_error)?, + it[1].as_string().map_err(wrap_shell_error)?, + )) + }) + .collect::, ShellErrorOrRequestError>>()?; let data = data .iter() .map(|(a, b)| (a.as_str(), b.as_str())) .collect::>(); - 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 { + 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 { + let response_headers: Option = 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, + requested_url: &str, + flags: RequestFlags, + response: Result, ) -> Result { 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 { + 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: Result, ) -> Result { 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), + }, } } diff --git a/crates/nu-command/src/network/http/delete.rs b/crates/nu-command/src/network/http/delete.rs index 530de68cc0..9698be4523 100644 --- a/crates/nu-command/src/network/http/delete.rs +++ b/crates/nu-command/src/network/http/delete.rs @@ -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, password: Option, timeout: Option, + 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, ) } diff --git a/crates/nu-command/src/network/http/get.rs b/crates/nu-command/src/network/http/get.rs index 48a96a59b4..de458d396f 100644 --- a/crates/nu-command/src/network/http/get.rs +++ b/crates/nu-command/src/network/http/get.rs @@ -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, password: Option, timeout: Option, + 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, ) } diff --git a/crates/nu-command/src/network/http/head.rs b/crates/nu-command/src/network/http/head.rs index d78017dad9..20b3aa19f5 100644 --- a/crates/nu-command/src/network/http/head.rs +++ b/crates/nu-command/src/network/http/head.rs @@ -143,7 +143,7 @@ fn helper(call: &Call, args: Arguments) -> Result { 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) } diff --git a/crates/nu-command/src/network/http/patch.rs b/crates/nu-command/src/network/http/patch.rs index 155cffa468..fbad74b1fe 100644 --- a/crates/nu-command/src/network/http/patch.rs +++ b/crates/nu-command/src/network/http/patch.rs @@ -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, password: Option, timeout: Option, + 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, ) } diff --git a/crates/nu-command/src/network/http/post.rs b/crates/nu-command/src/network/http/post.rs index 80db01b404..ad06caa48b 100644 --- a/crates/nu-command/src/network/http/post.rs +++ b/crates/nu-command/src/network/http/post.rs @@ -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, password: Option, timeout: Option, + 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, ) } diff --git a/crates/nu-command/src/network/http/put.rs b/crates/nu-command/src/network/http/put.rs index 702d2e9a1c..796df1aec2 100644 --- a/crates/nu-command/src/network/http/put.rs +++ b/crates/nu-command/src/network/http/put.rs @@ -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, password: Option, timeout: Option, + 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, ) } diff --git a/crates/nu-command/tests/commands/network/http/get.rs b/crates/nu-command/tests/commands/network/http/get.rs index 6585e7865d..47ce63a31d 100644 --- a/crates/nu-command/tests/commands/network/http/get.rs +++ b/crates/nu-command/tests/commands/network/http/get.rs @@ -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.