Use pipeline data for http post|put|patch|delete commands. (#13254)

# Description
Provides the ability to use http commands as part of a pipeline.
Additionally, this pull requests extends the pipeline metadata to add a
content_type field. The content_type metadata field allows commands such
as `to json` to set the metadata in the pipeline allowing the http
commands to use it when making requests.

This pull request also introduces the ability to directly stream http
requests from streaming pipelines.

One other small change is that Content-Type will always be set if it is
passed in to the http commands, either indirectly or throw the content
type flag. Previously it was not preserved with requests that were not
of type json or form data.

# User-Facing Changes
* `http post`, `http put`, `http patch`, `http delete` can be used as
part of a pipeline
* `to text`, `to json`, `from json` all set the content_type metadata
field and the http commands will utilize them when making requests.
This commit is contained in:
Jack Wright 2024-07-01 12:34:19 -07:00 committed by GitHub
parent e5cf4863e9
commit 0d060aeae8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 466 additions and 120 deletions

View File

@ -368,6 +368,7 @@ fn theme_demo(span: Span) -> PipelineData {
.collect(); .collect();
Value::list(result, span).into_pipeline_data_with_metadata(PipelineMetadata { Value::list(result, span).into_pipeline_data_with_metadata(PipelineMetadata {
data_source: DataSource::HtmlThemes, data_source: DataSource::HtmlThemes,
content_type: None,
}) })
} }

View File

@ -50,6 +50,7 @@ is particularly large, this can cause high memory usage."#
// check where some input came from. // check where some input came from.
Some(PipelineMetadata { Some(PipelineMetadata {
data_source: DataSource::FilePath(_), data_source: DataSource::FilePath(_),
content_type: None,
}) => None, }) => None,
other => other, other => other,
}; };

View File

@ -80,16 +80,23 @@ impl Command for Metadata {
match x { match x {
PipelineMetadata { PipelineMetadata {
data_source: DataSource::Ls, data_source: DataSource::Ls,
..
} => record.push("source", Value::string("ls", head)), } => record.push("source", Value::string("ls", head)),
PipelineMetadata { PipelineMetadata {
data_source: DataSource::HtmlThemes, data_source: DataSource::HtmlThemes,
..
} => record.push("source", Value::string("into html --list", head)), } => record.push("source", Value::string("into html --list", head)),
PipelineMetadata { PipelineMetadata {
data_source: DataSource::FilePath(path), data_source: DataSource::FilePath(path),
..
} => record.push( } => record.push(
"source", "source",
Value::string(path.to_string_lossy().to_string(), head), Value::string(path.to_string_lossy().to_string(), head),
), ),
_ => {}
}
if let Some(ref content_type) = x.content_type {
record.push("content_type", Value::string(content_type, head));
} }
} }
@ -133,16 +140,23 @@ fn build_metadata_record(arg: &Value, metadata: Option<&PipelineMetadata>, head:
match x { match x {
PipelineMetadata { PipelineMetadata {
data_source: DataSource::Ls, data_source: DataSource::Ls,
..
} => record.push("source", Value::string("ls", head)), } => record.push("source", Value::string("ls", head)),
PipelineMetadata { PipelineMetadata {
data_source: DataSource::HtmlThemes, data_source: DataSource::HtmlThemes,
..
} => record.push("source", Value::string("into html --list", head)), } => record.push("source", Value::string("into html --list", head)),
PipelineMetadata { PipelineMetadata {
data_source: DataSource::FilePath(path), data_source: DataSource::FilePath(path),
..
} => record.push( } => record.push(
"source", "source",
Value::string(path.to_string_lossy().to_string(), head), Value::string(path.to_string_lossy().to_string(), head),
), ),
_ => {}
}
if let Some(ref content_type) = x.content_type {
record.push("content_type", Value::string(content_type, head));
} }
} }

View File

@ -46,6 +46,7 @@ impl Command for MetadataSet {
(Some(path), false) => { (Some(path), false) => {
let metadata = PipelineMetadata { let metadata = PipelineMetadata {
data_source: DataSource::FilePath(path.into()), data_source: DataSource::FilePath(path.into()),
content_type: None,
}; };
Ok(input.into_pipeline_data_with_metadata( Ok(input.into_pipeline_data_with_metadata(
head, head,
@ -56,6 +57,7 @@ impl Command for MetadataSet {
(None, true) => { (None, true) => {
let metadata = PipelineMetadata { let metadata = PipelineMetadata {
data_source: DataSource::Ls, data_source: DataSource::Ls,
content_type: None,
}; };
Ok(input.into_pipeline_data_with_metadata( Ok(input.into_pipeline_data_with_metadata(
head, head,

View File

@ -122,6 +122,7 @@ impl Command for Ls {
ctrl_c, ctrl_c,
PipelineMetadata { PipelineMetadata {
data_source: DataSource::Ls, data_source: DataSource::Ls,
content_type: None,
}, },
)), )),
Some(pattern) => { Some(pattern) => {
@ -145,6 +146,7 @@ impl Command for Ls {
ctrl_c, ctrl_c,
PipelineMetadata { PipelineMetadata {
data_source: DataSource::Ls, data_source: DataSource::Ls,
content_type: None,
}, },
)) ))
} }

View File

@ -147,6 +147,7 @@ impl Command for Open {
ByteStream::file(file, call_span, ctrlc.clone()), ByteStream::file(file, call_span, ctrlc.clone()),
Some(PipelineMetadata { Some(PipelineMetadata {
data_source: DataSource::FilePath(path.to_path_buf()), data_source: DataSource::FilePath(path.to_path_buf()),
content_type: None,
}), }),
); );

View File

@ -4,7 +4,7 @@ use std::{
}; };
use nu_engine::command_prelude::*; use nu_engine::command_prelude::*;
use nu_protocol::ListStream; use nu_protocol::{ListStream, PipelineMetadata};
#[derive(Clone)] #[derive(Clone)]
pub struct FromJson; pub struct FromJson;
@ -81,7 +81,7 @@ impl Command for FromJson {
PipelineData::Value(Value::String { val, .. }, metadata) => { PipelineData::Value(Value::String { val, .. }, metadata) => {
Ok(PipelineData::ListStream( Ok(PipelineData::ListStream(
read_json_lines(Cursor::new(val), span, strict, engine_state.ctrlc.clone()), read_json_lines(Cursor::new(val), span, strict, engine_state.ctrlc.clone()),
metadata, update_metadata(metadata),
)) ))
} }
PipelineData::ByteStream(stream, metadata) PipelineData::ByteStream(stream, metadata)
@ -90,7 +90,7 @@ impl Command for FromJson {
if let Some(reader) = stream.reader() { if let Some(reader) = stream.reader() {
Ok(PipelineData::ListStream( Ok(PipelineData::ListStream(
read_json_lines(reader, span, strict, None), read_json_lines(reader, span, strict, None),
metadata, update_metadata(metadata),
)) ))
} else { } else {
Ok(PipelineData::Empty) Ok(PipelineData::Empty)
@ -113,10 +113,10 @@ impl Command for FromJson {
if strict { if strict {
Ok(convert_string_to_value_strict(&string_input, span)? Ok(convert_string_to_value_strict(&string_input, span)?
.into_pipeline_data_with_metadata(metadata)) .into_pipeline_data_with_metadata(update_metadata(metadata)))
} else { } else {
Ok(convert_string_to_value(&string_input, span)? Ok(convert_string_to_value(&string_input, span)?
.into_pipeline_data_with_metadata(metadata)) .into_pipeline_data_with_metadata(update_metadata(metadata)))
} }
} }
} }
@ -263,6 +263,14 @@ fn convert_string_to_value_strict(string_input: &str, span: Span) -> Result<Valu
} }
} }
fn update_metadata(metadata: Option<PipelineMetadata>) -> Option<PipelineMetadata> {
metadata
.map(|md| md.with_content_type(Some("application/json".into())))
.or_else(|| {
Some(PipelineMetadata::default().with_content_type(Some("application/json".into())))
})
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;

View File

@ -1,5 +1,5 @@
use nu_engine::command_prelude::*; use nu_engine::command_prelude::*;
use nu_protocol::ast::PathMember; use nu_protocol::{ast::PathMember, PipelineMetadata};
#[derive(Clone)] #[derive(Clone)]
pub struct ToJson; pub struct ToJson;
@ -61,7 +61,12 @@ impl Command for ToJson {
match json_result { match json_result {
Ok(serde_json_string) => { Ok(serde_json_string) => {
Ok(Value::string(serde_json_string, span).into_pipeline_data()) let res = Value::string(serde_json_string, span);
let metadata = PipelineMetadata {
data_source: nu_protocol::DataSource::None,
content_type: Some("application/json".to_string()),
};
Ok(PipelineData::Value(res, Some(metadata)))
} }
_ => Ok(Value::error( _ => Ok(Value::error(
ShellError::CantConvert { ShellError::CantConvert {

View File

@ -1,6 +1,8 @@
use chrono_humanize::HumanTime; use chrono_humanize::HumanTime;
use nu_engine::command_prelude::*; use nu_engine::command_prelude::*;
use nu_protocol::{format_duration, format_filesize_from_conf, ByteStream, Config}; use nu_protocol::{
format_duration, format_filesize_from_conf, ByteStream, Config, PipelineMetadata,
};
const LINE_ENDING: &str = if cfg!(target_os = "windows") { const LINE_ENDING: &str = if cfg!(target_os = "windows") {
"\r\n" "\r\n"
@ -37,10 +39,14 @@ impl Command for ToText {
let input = input.try_expand_range()?; let input = input.try_expand_range()?;
match input { match input {
PipelineData::Empty => Ok(Value::string(String::new(), span).into_pipeline_data()), PipelineData::Empty => Ok(Value::string(String::new(), span)
.into_pipeline_data_with_metadata(update_metadata(None))),
PipelineData::Value(value, ..) => { PipelineData::Value(value, ..) => {
let str = local_into_string(value, LINE_ENDING, engine_state.get_config()); let str = local_into_string(value, LINE_ENDING, engine_state.get_config());
Ok(Value::string(str, span).into_pipeline_data()) Ok(
Value::string(str, span)
.into_pipeline_data_with_metadata(update_metadata(None)),
)
} }
PipelineData::ListStream(stream, meta) => { PipelineData::ListStream(stream, meta) => {
let span = stream.span(); let span = stream.span();
@ -57,10 +63,12 @@ impl Command for ToText {
engine_state.ctrlc.clone(), engine_state.ctrlc.clone(),
ByteStreamType::String, ByteStreamType::String,
), ),
meta, update_metadata(meta),
)) ))
} }
PipelineData::ByteStream(stream, meta) => Ok(PipelineData::ByteStream(stream, meta)), PipelineData::ByteStream(stream, meta) => {
Ok(PipelineData::ByteStream(stream, update_metadata(meta)))
}
} }
} }
@ -124,6 +132,14 @@ fn local_into_string(value: Value, separator: &str, config: &Config) -> String {
} }
} }
fn update_metadata(metadata: Option<PipelineMetadata>) -> Option<PipelineMetadata> {
metadata
.map(|md| md.with_content_type(Some("text/plain".to_string())))
.or_else(|| {
Some(PipelineMetadata::default().with_content_type(Some("text/plain".to_string())))
})
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;

View File

@ -180,88 +180,113 @@ impl From<ShellError> for ShellErrorOrRequestError {
} }
} }
#[derive(Debug)]
pub enum HttpBody {
Value(Value),
ByteStream(ByteStream),
None,
}
// remove once all commands have been migrated
pub fn send_request( pub fn send_request(
request: Request, request: Request,
body: Option<Value>, http_body: HttpBody,
content_type: Option<String>, content_type: Option<String>,
ctrl_c: Option<Arc<AtomicBool>>, ctrl_c: Option<Arc<AtomicBool>>,
) -> Result<Response, ShellErrorOrRequestError> { ) -> Result<Response, ShellErrorOrRequestError> {
let request_url = request.url().to_string(); let request_url = request.url().to_string();
if body.is_none() {
return send_cancellable_request(&request_url, Box::new(|| request.call()), ctrl_c);
}
let body = body.expect("Should never be none.");
let body_type = match content_type { match http_body {
Some(it) if it == "application/json" => BodyType::Json, HttpBody::None => {
Some(it) if it == "application/x-www-form-urlencoded" => BodyType::Form, send_cancellable_request(&request_url, Box::new(|| request.call()), ctrl_c)
_ => BodyType::Unknown,
};
match body {
Value::Binary { val, .. } => send_cancellable_request(
&request_url,
Box::new(move || request.send_bytes(&val)),
ctrl_c,
),
Value::String { .. } if body_type == BodyType::Json => {
let data = value_to_json_value(&body)?;
send_cancellable_request(&request_url, Box::new(|| request.send_json(data)), ctrl_c)
} }
Value::String { val, .. } => send_cancellable_request( HttpBody::ByteStream(byte_stream) => {
&request_url, let req = if let Some(content_type) = content_type {
Box::new(move || request.send_string(&val)), request.set("Content-Type", &content_type)
ctrl_c, } else {
), request
Value::Record { .. } if body_type == BodyType::Json => {
let data = value_to_json_value(&body)?;
send_cancellable_request(&request_url, Box::new(|| request.send_json(data)), ctrl_c)
}
Value::Record { val, .. } if body_type == BodyType::Form => {
let mut data: Vec<(String, String)> = Vec::with_capacity(val.len());
for (col, val) in val.into_owned() {
data.push((col, val.coerce_into_string()?))
}
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)>>();
request.send_form(&data)
}; };
send_cancellable_request(&request_url, Box::new(request_fn), ctrl_c)
send_cancellable_request_bytes(&request_url, req, byte_stream, ctrl_c)
} }
Value::List { vals, .. } if body_type == BodyType::Form => { HttpBody::Value(body) => {
if vals.len() % 2 != 0 { let (body_type, req) = match content_type {
return Err(ShellErrorOrRequestError::ShellError(ShellError::IOError { Some(it) if it == "application/json" => (BodyType::Json, request),
Some(it) if it == "application/x-www-form-urlencoded" => (BodyType::Form, request),
Some(it) => {
let r = request.clone().set("Content-Type", &it);
(BodyType::Unknown, r)
}
_ => (BodyType::Unknown, request),
};
match body {
Value::Binary { val, .. } => send_cancellable_request(
&request_url,
Box::new(move || req.send_bytes(&val)),
ctrl_c,
),
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)), ctrl_c)
}
Value::String { val, .. } => send_cancellable_request(
&request_url,
Box::new(move || req.send_string(&val)),
ctrl_c,
),
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)), ctrl_c)
}
Value::Record { val, .. } if body_type == BodyType::Form => {
let mut data: Vec<(String, String)> = Vec::with_capacity(val.len());
for (col, val) in val.into_owned() {
data.push((col, val.coerce_into_string()?))
}
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), ctrl_c)
}
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 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), ctrl_c)
}
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)), ctrl_c)
}
_ => Err(ShellErrorOrRequestError::ShellError(ShellError::IOError {
msg: "unsupported body input".into(), msg: "unsupported body input".into(),
})); })),
} }
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)>>();
request.send_form(&data)
};
send_cancellable_request(&request_url, Box::new(request_fn), ctrl_c)
} }
Value::List { .. } if body_type == BodyType::Json => {
let data = value_to_json_value(&body)?;
send_cancellable_request(&request_url, Box::new(|| request.send_json(data)), ctrl_c)
}
_ => Err(ShellErrorOrRequestError::ShellError(ShellError::IOError {
msg: "unsupported body input".into(),
})),
} }
} }
@ -305,6 +330,61 @@ fn send_cancellable_request(
} }
} }
// Helper method used to make blocking HTTP request calls cancellable with ctrl+c
// ureq functions can block for a long time (default 30s?) while attempting to make an HTTP connection
fn send_cancellable_request_bytes(
request_url: &str,
request: Request,
byte_stream: ByteStream,
ctrl_c: Option<Arc<AtomicBool>>,
) -> Result<Response, ShellErrorOrRequestError> {
let (tx, rx) = mpsc::channel::<Result<Response, ShellErrorOrRequestError>>();
let request_url_string = request_url.to_string();
// Make the blocking request on a background thread...
std::thread::Builder::new()
.name("HTTP requester".to_string())
.spawn(move || {
let ret = byte_stream
.reader()
.ok_or_else(|| {
ShellErrorOrRequestError::ShellError(ShellError::GenericError {
error: "Could not read byte stream".to_string(),
msg: "".into(),
span: None,
help: None,
inner: vec![],
})
})
.and_then(|reader| {
request.send(reader).map_err(|e| {
ShellErrorOrRequestError::RequestError(request_url_string, Box::new(e))
})
});
// may fail if the user has cancelled the operation
let _ = tx.send(ret);
})
.map_err(ShellError::from)?;
// ...and poll the channel for responses
loop {
if nu_utils::ctrl_c::was_pressed(&ctrl_c) {
// Return early and give up on the background thread. The connection will either time out or be disconnected
return Err(ShellErrorOrRequestError::ShellError(
ShellError::InterruptedByUser { span: None },
));
}
// 100ms wait time chosen arbitrarily
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(result) => return result,
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => panic!("http response channel disconnected"),
}
}
}
pub fn request_set_timeout( pub fn request_set_timeout(
timeout: Option<Value>, timeout: Option<Value>,
mut request: Request, mut request: Request,

View File

@ -1,7 +1,7 @@
use crate::network::http::client::{ use crate::network::http::client::{
check_response_redirection, http_client, http_parse_redirect_mode, http_parse_url, check_response_redirection, http_client, http_parse_redirect_mode, http_parse_url,
request_add_authorization_header, request_add_custom_headers, request_handle_response, request_add_authorization_header, request_add_custom_headers, request_handle_response,
request_set_timeout, send_request, RequestFlags, request_set_timeout, send_request, HttpBody, RequestFlags,
}; };
use nu_engine::command_prelude::*; use nu_engine::command_prelude::*;
@ -15,7 +15,7 @@ impl Command for SubCommand {
fn signature(&self) -> Signature { fn signature(&self) -> Signature {
Signature::build("http delete") Signature::build("http delete")
.input_output_types(vec![(Type::Nothing, Type::Any)]) .input_output_types(vec![(Type::Any, Type::Any)])
.allow_variants_without_examples(true) .allow_variants_without_examples(true)
.required( .required(
"URL", "URL",
@ -132,6 +132,11 @@ impl Command for SubCommand {
"http delete --content-type application/json --data { field: value } https://www.example.com", "http delete --content-type application/json --data { field: value } https://www.example.com",
result: None, result: None,
}, },
Example {
description: "Perform an HTTP delete with JSON content from a pipeline to example.com",
example: "open foo.json | http delete https://www.example.com",
result: None,
},
] ]
} }
} }
@ -139,7 +144,7 @@ impl Command for SubCommand {
struct Arguments { struct Arguments {
url: Value, url: Value,
headers: Option<Value>, headers: Option<Value>,
data: Option<Value>, data: HttpBody,
content_type: Option<String>, content_type: Option<String>,
raw: bool, raw: bool,
insecure: bool, insecure: bool,
@ -155,13 +160,27 @@ fn run_delete(
engine_state: &EngineState, engine_state: &EngineState,
stack: &mut Stack, stack: &mut Stack,
call: &Call, call: &Call,
_input: PipelineData, input: PipelineData,
) -> Result<PipelineData, ShellError> { ) -> Result<PipelineData, ShellError> {
let (data, maybe_metadata) = call
.get_flag::<Value>(engine_state, stack, "data")?
.map(|v| (HttpBody::Value(v), None))
.unwrap_or_else(|| match input {
PipelineData::Value(v, metadata) => (HttpBody::Value(v), metadata),
PipelineData::ByteStream(byte_stream, metadata) => {
(HttpBody::ByteStream(byte_stream), metadata)
}
_ => (HttpBody::None, None),
});
let content_type = call
.get_flag(engine_state, stack, "content-type")?
.or_else(|| maybe_metadata.and_then(|m| m.content_type));
let args = Arguments { let args = Arguments {
url: call.req(engine_state, stack, 0)?, url: call.req(engine_state, stack, 0)?,
headers: call.get_flag(engine_state, stack, "headers")?, headers: call.get_flag(engine_state, stack, "headers")?,
data: call.get_flag(engine_state, stack, "data")?, data,
content_type: call.get_flag(engine_state, stack, "content-type")?, content_type,
raw: call.has_flag(engine_state, stack, "raw")?, raw: call.has_flag(engine_state, stack, "raw")?,
insecure: call.has_flag(engine_state, stack, "insecure")?, insecure: call.has_flag(engine_state, stack, "insecure")?,
user: call.get_flag(engine_state, stack, "user")?, user: call.get_flag(engine_state, stack, "user")?,

View File

@ -5,6 +5,8 @@ use crate::network::http::client::{
}; };
use nu_engine::command_prelude::*; use nu_engine::command_prelude::*;
use super::client::HttpBody;
#[derive(Clone)] #[derive(Clone)]
pub struct SubCommand; pub struct SubCommand;
@ -180,7 +182,7 @@ fn helper(
request = request_add_authorization_header(args.user, args.password, request); request = request_add_authorization_header(args.user, args.password, request);
request = request_add_custom_headers(args.headers, request)?; request = request_add_custom_headers(args.headers, request)?;
let response = send_request(request.clone(), None, None, ctrl_c); let response = send_request(request.clone(), HttpBody::None, None, ctrl_c);
let request_flags = RequestFlags { let request_flags = RequestFlags {
raw: args.raw, raw: args.raw,

View File

@ -7,6 +7,8 @@ use nu_engine::command_prelude::*;
use std::sync::{atomic::AtomicBool, Arc}; use std::sync::{atomic::AtomicBool, Arc};
use super::client::HttpBody;
#[derive(Clone)] #[derive(Clone)]
pub struct SubCommand; pub struct SubCommand;
@ -156,7 +158,7 @@ fn helper(
request = request_add_authorization_header(args.user, args.password, request); request = request_add_authorization_header(args.user, args.password, request);
request = request_add_custom_headers(args.headers, request)?; request = request_add_custom_headers(args.headers, request)?;
let response = send_request(request, None, None, ctrlc); let response = send_request(request, HttpBody::None, None, ctrlc);
check_response_redirection(redirect_mode, span, &response)?; check_response_redirection(redirect_mode, span, &response)?;
request_handle_response_headers(span, response) request_handle_response_headers(span, response)
} }

View File

@ -4,6 +4,8 @@ use crate::network::http::client::{
}; };
use nu_engine::command_prelude::*; use nu_engine::command_prelude::*;
use super::client::HttpBody;
#[derive(Clone)] #[derive(Clone)]
pub struct SubCommand; pub struct SubCommand;
@ -159,7 +161,7 @@ fn helper(
request = request_add_authorization_header(args.user, args.password, request); request = request_add_authorization_header(args.user, args.password, request);
request = request_add_custom_headers(args.headers, request)?; request = request_add_custom_headers(args.headers, request)?;
let response = send_request(request.clone(), None, None, ctrl_c); let response = send_request(request.clone(), HttpBody::None, None, ctrl_c);
// http options' response always showed in header, so we set full to true. // http options' response always showed in header, so we set full to true.
// And `raw` is useless too because options method doesn't return body, here we set to true // And `raw` is useless too because options method doesn't return body, here we set to true

View File

@ -1,7 +1,7 @@
use crate::network::http::client::{ use crate::network::http::client::{
check_response_redirection, http_client, http_parse_redirect_mode, http_parse_url, check_response_redirection, http_client, http_parse_redirect_mode, http_parse_url,
request_add_authorization_header, request_add_custom_headers, request_handle_response, request_add_authorization_header, request_add_custom_headers, request_handle_response,
request_set_timeout, send_request, RequestFlags, request_set_timeout, send_request, HttpBody, RequestFlags,
}; };
use nu_engine::command_prelude::*; use nu_engine::command_prelude::*;
@ -15,10 +15,10 @@ impl Command for SubCommand {
fn signature(&self) -> Signature { fn signature(&self) -> Signature {
Signature::build("http patch") Signature::build("http patch")
.input_output_types(vec![(Type::Nothing, Type::Any)]) .input_output_types(vec![(Type::Any, Type::Any)])
.allow_variants_without_examples(true) .allow_variants_without_examples(true)
.required("URL", SyntaxShape::String, "The URL to post to.") .required("URL", SyntaxShape::String, "The URL to post to.")
.required("data", SyntaxShape::Any, "The contents of the post body.") .optional("data", SyntaxShape::Any, "The contents of the post body.")
.named( .named(
"user", "user",
SyntaxShape::Any, SyntaxShape::Any,
@ -124,6 +124,11 @@ impl Command for SubCommand {
example: "http patch --content-type application/json https://www.example.com { field: value }", example: "http patch --content-type application/json https://www.example.com { field: value }",
result: None, result: None,
}, },
Example {
description: "Patch JSON content from a pipeline to example.com",
example: "open foo.json | http patch https://www.example.com",
result: None,
},
] ]
} }
} }
@ -131,7 +136,7 @@ impl Command for SubCommand {
struct Arguments { struct Arguments {
url: Value, url: Value,
headers: Option<Value>, headers: Option<Value>,
data: Value, data: HttpBody,
content_type: Option<String>, content_type: Option<String>,
raw: bool, raw: bool,
insecure: bool, insecure: bool,
@ -147,13 +152,37 @@ fn run_patch(
engine_state: &EngineState, engine_state: &EngineState,
stack: &mut Stack, stack: &mut Stack,
call: &Call, call: &Call,
_input: PipelineData, input: PipelineData,
) -> Result<PipelineData, ShellError> { ) -> Result<PipelineData, ShellError> {
let (data, maybe_metadata) = call
.opt::<Value>(engine_state, stack, 1)?
.map(|v| (HttpBody::Value(v), None))
.unwrap_or_else(|| match input {
PipelineData::Value(v, metadata) => (HttpBody::Value(v), metadata),
PipelineData::ByteStream(byte_stream, metadata) => {
(HttpBody::ByteStream(byte_stream), metadata)
}
_ => (HttpBody::None, None),
});
let content_type = call
.get_flag(engine_state, stack, "content-type")?
.or_else(|| maybe_metadata.and_then(|m| m.content_type));
if let HttpBody::None = data {
return Err(ShellError::GenericError {
error: "Data must be provided either through pipeline or positional argument".into(),
msg: "".into(),
span: Some(call.head),
help: None,
inner: vec![],
});
}
let args = Arguments { let args = Arguments {
url: call.req(engine_state, stack, 0)?, url: call.req(engine_state, stack, 0)?,
headers: call.get_flag(engine_state, stack, "headers")?, headers: call.get_flag(engine_state, stack, "headers")?,
data: call.req(engine_state, stack, 1)?, data,
content_type: call.get_flag(engine_state, stack, "content-type")?, content_type,
raw: call.has_flag(engine_state, stack, "raw")?, raw: call.has_flag(engine_state, stack, "raw")?,
insecure: call.has_flag(engine_state, stack, "insecure")?, insecure: call.has_flag(engine_state, stack, "insecure")?,
user: call.get_flag(engine_state, stack, "user")?, user: call.get_flag(engine_state, stack, "user")?,
@ -187,7 +216,7 @@ fn helper(
request = request_add_authorization_header(args.user, args.password, request); request = request_add_authorization_header(args.user, args.password, request);
request = request_add_custom_headers(args.headers, request)?; request = request_add_custom_headers(args.headers, request)?;
let response = send_request(request.clone(), Some(args.data), args.content_type, ctrl_c); let response = send_request(request.clone(), args.data, args.content_type, ctrl_c);
let request_flags = RequestFlags { let request_flags = RequestFlags {
raw: args.raw, raw: args.raw,

View File

@ -1,7 +1,7 @@
use crate::network::http::client::{ use crate::network::http::client::{
check_response_redirection, http_client, http_parse_redirect_mode, http_parse_url, check_response_redirection, http_client, http_parse_redirect_mode, http_parse_url,
request_add_authorization_header, request_add_custom_headers, request_handle_response, request_add_authorization_header, request_add_custom_headers, request_handle_response,
request_set_timeout, send_request, RequestFlags, request_set_timeout, send_request, HttpBody, RequestFlags,
}; };
use nu_engine::command_prelude::*; use nu_engine::command_prelude::*;
@ -15,10 +15,10 @@ impl Command for SubCommand {
fn signature(&self) -> Signature { fn signature(&self) -> Signature {
Signature::build("http post") Signature::build("http post")
.input_output_types(vec![(Type::Nothing, Type::Any)]) .input_output_types(vec![(Type::Any, Type::Any)])
.allow_variants_without_examples(true) .allow_variants_without_examples(true)
.required("URL", SyntaxShape::String, "The URL to post to.") .required("URL", SyntaxShape::String, "The URL to post to.")
.required("data", SyntaxShape::Any, "The contents of the post body.") .optional("data", SyntaxShape::Any, "The contents of the post body. Required unless part of a pipeline.")
.named( .named(
"user", "user",
SyntaxShape::Any, SyntaxShape::Any,
@ -122,6 +122,11 @@ impl Command for SubCommand {
example: "http post --content-type application/json https://www.example.com { field: value }", example: "http post --content-type application/json https://www.example.com { field: value }",
result: None, result: None,
}, },
Example {
description: "Post JSON content from a pipeline to example.com",
example: "open foo.json | http post https://www.example.com",
result: None,
},
] ]
} }
} }
@ -129,7 +134,7 @@ impl Command for SubCommand {
struct Arguments { struct Arguments {
url: Value, url: Value,
headers: Option<Value>, headers: Option<Value>,
data: Value, data: HttpBody,
content_type: Option<String>, content_type: Option<String>,
raw: bool, raw: bool,
insecure: bool, insecure: bool,
@ -145,13 +150,37 @@ fn run_post(
engine_state: &EngineState, engine_state: &EngineState,
stack: &mut Stack, stack: &mut Stack,
call: &Call, call: &Call,
_input: PipelineData, input: PipelineData,
) -> Result<PipelineData, ShellError> { ) -> Result<PipelineData, ShellError> {
let (data, maybe_metadata) = call
.opt::<Value>(engine_state, stack, 1)?
.map(|v| (HttpBody::Value(v), None))
.unwrap_or_else(|| match input {
PipelineData::Value(v, metadata) => (HttpBody::Value(v), metadata),
PipelineData::ByteStream(byte_stream, metadata) => {
(HttpBody::ByteStream(byte_stream), metadata)
}
_ => (HttpBody::None, None),
});
let content_type = call
.get_flag(engine_state, stack, "content-type")?
.or_else(|| maybe_metadata.and_then(|m| m.content_type));
if let HttpBody::None = data {
return Err(ShellError::GenericError {
error: "Data must be provided either through pipeline or positional argument".into(),
msg: "".into(),
span: Some(call.head),
help: None,
inner: vec![],
});
}
let args = Arguments { let args = Arguments {
url: call.req(engine_state, stack, 0)?, url: call.req(engine_state, stack, 0)?,
headers: call.get_flag(engine_state, stack, "headers")?, headers: call.get_flag(engine_state, stack, "headers")?,
data: call.req(engine_state, stack, 1)?, data,
content_type: call.get_flag(engine_state, stack, "content-type")?, content_type,
raw: call.has_flag(engine_state, stack, "raw")?, raw: call.has_flag(engine_state, stack, "raw")?,
insecure: call.has_flag(engine_state, stack, "insecure")?, insecure: call.has_flag(engine_state, stack, "insecure")?,
user: call.get_flag(engine_state, stack, "user")?, user: call.get_flag(engine_state, stack, "user")?,
@ -185,7 +214,7 @@ fn helper(
request = request_add_authorization_header(args.user, args.password, request); request = request_add_authorization_header(args.user, args.password, request);
request = request_add_custom_headers(args.headers, request)?; request = request_add_custom_headers(args.headers, request)?;
let response = send_request(request.clone(), Some(args.data), args.content_type, ctrl_c); let response = send_request(request.clone(), args.data, args.content_type, ctrl_c);
let request_flags = RequestFlags { let request_flags = RequestFlags {
raw: args.raw, raw: args.raw,

View File

@ -1,7 +1,7 @@
use crate::network::http::client::{ use crate::network::http::client::{
check_response_redirection, http_client, http_parse_redirect_mode, http_parse_url, check_response_redirection, http_client, http_parse_redirect_mode, http_parse_url,
request_add_authorization_header, request_add_custom_headers, request_handle_response, request_add_authorization_header, request_add_custom_headers, request_handle_response,
request_set_timeout, send_request, RequestFlags, request_set_timeout, send_request, HttpBody, RequestFlags,
}; };
use nu_engine::command_prelude::*; use nu_engine::command_prelude::*;
@ -15,10 +15,10 @@ impl Command for SubCommand {
fn signature(&self) -> Signature { fn signature(&self) -> Signature {
Signature::build("http put") Signature::build("http put")
.input_output_types(vec![(Type::Nothing, Type::Any)]) .input_output_types(vec![(Type::Any, Type::Any)])
.allow_variants_without_examples(true) .allow_variants_without_examples(true)
.required("URL", SyntaxShape::String, "The URL to post to.") .required("URL", SyntaxShape::String, "The URL to post to.")
.required("data", SyntaxShape::Any, "The contents of the post body.") .optional("data", SyntaxShape::Any, "The contents of the post body. Required unless part of a pipeline.")
.named( .named(
"user", "user",
SyntaxShape::Any, SyntaxShape::Any,
@ -122,6 +122,11 @@ impl Command for SubCommand {
example: "http put --content-type application/json https://www.example.com { field: value }", example: "http put --content-type application/json https://www.example.com { field: value }",
result: None, result: None,
}, },
Example {
description: "Put JSON content from a pipeline to example.com",
example: "open foo.json | http put https://www.example.com",
result: None,
},
] ]
} }
} }
@ -129,7 +134,7 @@ impl Command for SubCommand {
struct Arguments { struct Arguments {
url: Value, url: Value,
headers: Option<Value>, headers: Option<Value>,
data: Value, data: HttpBody,
content_type: Option<String>, content_type: Option<String>,
raw: bool, raw: bool,
insecure: bool, insecure: bool,
@ -145,13 +150,38 @@ fn run_put(
engine_state: &EngineState, engine_state: &EngineState,
stack: &mut Stack, stack: &mut Stack,
call: &Call, call: &Call,
_input: PipelineData, input: PipelineData,
) -> Result<PipelineData, ShellError> { ) -> Result<PipelineData, ShellError> {
let (data, maybe_metadata) = call
.opt::<Value>(engine_state, stack, 1)?
.map(|v| (HttpBody::Value(v), None))
.unwrap_or_else(|| match input {
PipelineData::Value(v, metadata) => (HttpBody::Value(v), metadata),
PipelineData::ByteStream(byte_stream, metadata) => {
(HttpBody::ByteStream(byte_stream), metadata)
}
_ => (HttpBody::None, None),
});
if let HttpBody::None = data {
return Err(ShellError::GenericError {
error: "Data must be provided either through pipeline or positional argument".into(),
msg: "".into(),
span: Some(call.head),
help: None,
inner: vec![],
});
}
let content_type = call
.get_flag(engine_state, stack, "content-type")?
.or_else(|| maybe_metadata.and_then(|m| m.content_type));
let args = Arguments { let args = Arguments {
url: call.req(engine_state, stack, 0)?, url: call.req(engine_state, stack, 0)?,
headers: call.get_flag(engine_state, stack, "headers")?, headers: call.get_flag(engine_state, stack, "headers")?,
data: call.req(engine_state, stack, 1)?, data,
content_type: call.get_flag(engine_state, stack, "content-type")?, content_type,
raw: call.has_flag(engine_state, stack, "raw")?, raw: call.has_flag(engine_state, stack, "raw")?,
insecure: call.has_flag(engine_state, stack, "insecure")?, insecure: call.has_flag(engine_state, stack, "insecure")?,
user: call.get_flag(engine_state, stack, "user")?, user: call.get_flag(engine_state, stack, "user")?,
@ -185,7 +215,7 @@ fn helper(
request = request_add_authorization_header(args.user, args.password, request); request = request_add_authorization_header(args.user, args.password, request);
request = request_add_custom_headers(args.headers, request)?; request = request_add_custom_headers(args.headers, request)?;
let response = send_request(request.clone(), Some(args.data), args.content_type, ctrl_c); let response = send_request(request.clone(), args.data, args.content_type, ctrl_c);
let request_flags = RequestFlags { let request_flags = RequestFlags {
raw: args.raw, raw: args.raw,

View File

@ -605,6 +605,7 @@ fn handle_row_stream(
// First, `ls` sources: // First, `ls` sources:
Some(PipelineMetadata { Some(PipelineMetadata {
data_source: DataSource::Ls, data_source: DataSource::Ls,
..
}) => { }) => {
let config = get_config(input.engine_state, input.stack); let config = get_config(input.engine_state, input.stack);
let ls_colors_env_str = match input.stack.get_env_var(input.engine_state, "LS_COLORS") { let ls_colors_env_str = match input.stack.get_env_var(input.engine_state, "LS_COLORS") {
@ -636,6 +637,7 @@ fn handle_row_stream(
// Next, `to html -l` sources: // Next, `to html -l` sources:
Some(PipelineMetadata { Some(PipelineMetadata {
data_source: DataSource::HtmlThemes, data_source: DataSource::HtmlThemes,
..
}) => { }) => {
stream.map(|mut value| { stream.map(|mut value| {
if let Value::Record { val: record, .. } = &mut value { if let Value::Record { val: record, .. } = &mut value {

View File

@ -20,6 +20,25 @@ fn http_delete_is_success() {
assert!(actual.out.is_empty()) assert!(actual.out.is_empty())
} }
#[test]
fn http_delete_is_success_pipeline() {
let mut server = Server::new();
let _mock = server.mock("DELETE", "/").create();
let actual = nu!(pipeline(
format!(
r#"
"foo" | http delete {url}
"#,
url = server.url()
)
.as_str()
));
assert!(actual.out.is_empty())
}
#[test] #[test]
fn http_delete_failed_due_to_server_error() { fn http_delete_failed_due_to_server_error() {
let mut server = Server::new(); let mut server = Server::new();

View File

@ -20,6 +20,25 @@ fn http_patch_is_success() {
assert!(actual.out.is_empty()) assert!(actual.out.is_empty())
} }
#[test]
fn http_patch_is_success_pipeline() {
let mut server = Server::new();
let _mock = server.mock("PATCH", "/").match_body("foo").create();
let actual = nu!(pipeline(
format!(
r#"
"foo" | http patch {url}
"#,
url = server.url()
)
.as_str()
));
assert!(actual.out.is_empty())
}
#[test] #[test]
fn http_patch_failed_due_to_server_error() { fn http_patch_failed_due_to_server_error() {
let mut server = Server::new(); let mut server = Server::new();
@ -55,7 +74,9 @@ fn http_patch_failed_due_to_missing_body() {
.as_str() .as_str()
)); ));
assert!(actual.err.contains("Usage: http patch")) assert!(actual
.err
.contains("Data must be provided either through pipeline or positional argument"))
} }
#[test] #[test]

View File

@ -19,6 +19,24 @@ fn http_post_is_success() {
assert!(actual.out.is_empty()) assert!(actual.out.is_empty())
} }
#[test]
fn http_post_is_success_pipeline() {
let mut server = Server::new();
let _mock = server.mock("POST", "/").match_body("foo").create();
let actual = nu!(pipeline(
format!(
r#"
"foo" | http post {url}
"#,
url = server.url()
)
.as_str()
));
assert!(actual.out.is_empty())
}
#[test] #[test]
fn http_post_failed_due_to_server_error() { fn http_post_failed_due_to_server_error() {
@ -55,7 +73,9 @@ fn http_post_failed_due_to_missing_body() {
.as_str() .as_str()
)); ));
assert!(actual.err.contains("Usage: http post")) assert!(actual
.err
.contains("Data must be provided either through pipeline or positional argument"))
} }
#[test] #[test]

View File

@ -20,6 +20,25 @@ fn http_put_is_success() {
assert!(actual.out.is_empty()) assert!(actual.out.is_empty())
} }
#[test]
fn http_put_is_success_pipeline() {
let mut server = Server::new();
let _mock = server.mock("PUT", "/").match_body("foo").create();
let actual = nu!(pipeline(
format!(
r#"
"foo" | http put {url}
"#,
url = server.url()
)
.as_str()
));
assert!(actual.out.is_empty())
}
#[test] #[test]
fn http_put_failed_due_to_server_error() { fn http_put_failed_due_to_server_error() {
let mut server = Server::new(); let mut server = Server::new();
@ -55,7 +74,9 @@ fn http_put_failed_due_to_missing_body() {
.as_str() .as_str()
)); ));
assert!(actual.err.contains("Usage: http put")) assert!(actual
.err
.contains("Data must be provided either through pipeline or positional argument"))
} }
#[test] #[test]

View File

@ -18,6 +18,7 @@ use std::{path::Path, sync::Arc};
fn test_metadata() -> PipelineMetadata { fn test_metadata() -> PipelineMetadata {
PipelineMetadata { PipelineMetadata {
data_source: DataSource::FilePath("/test/path".into()), data_source: DataSource::FilePath("/test/path".into()),
content_type: None,
} }
} }
@ -258,7 +259,7 @@ fn read_pipeline_data_prepared_properly() -> Result<(), ShellError> {
}); });
match manager.read_pipeline_data(header, None)? { match manager.read_pipeline_data(header, None)? {
PipelineData::ListStream(_, meta) => match meta { PipelineData::ListStream(_, meta) => match meta {
Some(PipelineMetadata { data_source }) => match data_source { Some(PipelineMetadata { data_source, .. }) => match data_source {
DataSource::FilePath(path) => { DataSource::FilePath(path) => {
assert_eq!(Path::new("/test/path"), path); assert_eq!(Path::new("/test/path"), path);
Ok(()) Ok(())

View File

@ -1,18 +1,37 @@
use std::path::PathBuf; use std::path::PathBuf;
/// Metadata that is valid for the whole [`PipelineData`](crate::PipelineData) /// Metadata that is valid for the whole [`PipelineData`](crate::PipelineData)
#[derive(Debug, Clone)] #[derive(Debug, Default, Clone)]
pub struct PipelineMetadata { pub struct PipelineMetadata {
pub data_source: DataSource, pub data_source: DataSource,
pub content_type: Option<String>,
}
impl PipelineMetadata {
pub fn with_data_source(self, data_source: DataSource) -> Self {
Self {
data_source,
..self
}
}
pub fn with_content_type(self, content_type: Option<String>) -> Self {
Self {
content_type,
..self
}
}
} }
/// Describes where the particular [`PipelineMetadata`] originates. /// Describes where the particular [`PipelineMetadata`] originates.
/// ///
/// This can either be a particular family of commands (useful so downstream commands can adjust /// This can either be a particular family of commands (useful so downstream commands can adjust
/// the presentation e.g. `Ls`) or the opened file to protect against overwrite-attempts properly. /// the presentation e.g. `Ls`) or the opened file to protect against overwrite-attempts properly.
#[derive(Debug, Clone)] #[derive(Debug, Default, Clone)]
pub enum DataSource { pub enum DataSource {
Ls, Ls,
HtmlThemes, HtmlThemes,
FilePath(PathBuf), FilePath(PathBuf),
#[default]
None,
} }