diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index 0f7d6c7b49..8ec92b6e99 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -439,6 +439,7 @@ pub fn create_default_context() -> EngineState { Url, UrlBuildQuery, UrlEncode, + UrlJoin, UrlParse, Port, } diff --git a/crates/nu-command/src/network/url/join.rs b/crates/nu-command/src/network/url/join.rs new file mode 100644 index 0000000000..d2c3e2bde6 --- /dev/null +++ b/crates/nu-command/src/network/url/join.rs @@ -0,0 +1,338 @@ +use nu_protocol::engine::{Command, EngineState, Stack}; +use nu_protocol::{Category, Example, IntoPipelineData, ShellError, Signature, Span, Type, Value}; + +#[derive(Clone)] +pub struct SubCommand; + +impl Command for SubCommand { + fn name(&self) -> &str { + "url join" + } + + fn signature(&self) -> nu_protocol::Signature { + Signature::build("url join") + .input_output_types(vec![(Type::Record(vec![]), Type::String)]) + .category(Category::Network) + } + + fn usage(&self) -> &str { + "Converts a record to url" + } + + fn search_terms(&self) -> Vec<&str> { + vec![ + "scheme", "username", "password", "hostname", "port", "path", "query", "fragment", + ] + } + + fn examples(&self) -> Vec { + vec![ + Example { + description: "Outputs a url representing the contents of this record", + example: r#"{ + "scheme": "http", + "username": "", + "password": "", + "host": "www.pixiv.net", + "port": "", + "path": "/member_illust.php", + "query": "mode=medium&illust_id=99260204", + "fragment": "", + "params": + { + "mode": "medium", + "illust_id": "99260204" + } + } | url join"#, + result: Some(Value::test_string( + "http://www.pixiv.net/member_illust.php?mode=medium&illust_id=99260204", + )), + }, + Example { + description: "Outputs a url representing the contents of this record", + example: r#"{ + "scheme": "http", + "username": "user", + "password": "pwd", + "host": "www.pixiv.net", + "port": "1234", + "query": "test=a", + "fragment": "" + } | url join"#, + result: Some(Value::test_string( + "http://user:pwd@www.pixiv.net:1234?test=a", + )), + }, + Example { + description: "Outputs a url representing the contents of this record", + example: r#"{ + "scheme": "http", + "host": "www.pixiv.net", + "port": "1234", + "path": "user", + "fragment": "frag" + } | url join"#, + result: Some(Value::test_string("http://www.pixiv.net:1234/user#frag")), + }, + ] + } + + fn run( + &self, + _engine_state: &EngineState, + _stack: &mut Stack, + call: &nu_protocol::ast::Call, + input: nu_protocol::PipelineData, + ) -> Result { + let head = call.head; + + let output: Result = input + .into_iter() + .map(move |value| match value { + Value::Record { + ref cols, + ref vals, + span, + } => { + let url_components = cols + .iter() + .zip(vals.iter()) + .fold(Ok(UrlComponents::new()), |url, (k, v)| { + url?.add_component(k.clone(), v.clone(), span) + }); + + url_components?.to_url(span) + } + Value::Error { error } => Err(error), + other => Err(ShellError::UnsupportedInput( + "Expected a record from pipeline".to_string(), + "value originates from here".into(), + head, + other.expect_span(), + )), + }) + .collect(); + + Ok(Value::string(output?, head).into_pipeline_data()) + } +} + +#[derive(Default)] +struct UrlComponents { + scheme: Option, + username: Option, + password: Option, + host: Option, + port: Option, + path: Option, + query: Option, + fragment: Option, + query_span: Option, + params_span: Option, +} + +impl UrlComponents { + fn new() -> Self { + Default::default() + } + + pub fn add_component(self, key: String, value: Value, _span: Span) -> Result { + if key == "port" { + return match value { + Value::String { val, span } => { + if val.trim().is_empty() { + Ok(self) + } else { + match val.parse::() { + Ok(p) => Ok(Self { + port: Some(p), + ..self + }), + Err(_) => Err(ShellError::IncompatibleParametersSingle( + String::from("Port parameter should represent an unsigned integer"), + span, + )), + } + } + } + Value::Int { val, span: _ } => Ok(Self { + port: Some(val), + ..self + }), + Value::Error { error } => Err(error), + other => Err(ShellError::IncompatibleParametersSingle( + String::from( + "Port parameter should be an unsigned integer or a string representing it", + ), + other.expect_span(), + )), + }; + } + + if key == "params" { + return match value { + Value::Record { + ref cols, + ref vals, + span, + } => { + let mut qs = cols + .iter() + .zip(vals.iter()) + .map(|(k, v)| match v.as_string() { + Ok(val) => Ok(format!("{}={}", k, val)), + Err(err) => Err(err), + }) + .collect::, ShellError>>()? + .join("&"); + + qs = format!("?{}", qs); + + if let Some(q) = self.query { + if q != qs { + // if query is present it means that also query_span is setted. + return Err(ShellError::IncompatibleParameters { + left_message: format!("Mismatch, qs from params is: {}", qs), + left_span: value.expect_span(), + right_message: format!("instead query is: {}", q), + right_span: self.query_span.unwrap_or(Span::unknown()), + }); + } + } + + Ok(Self { + query: Some(qs), + params_span: Some(span), + ..self + }) + } + Value::Error { error } => Err(error), + other => Err(ShellError::IncompatibleParametersSingle( + String::from("Key params has to be a record"), + other.expect_span(), + )), + }; + } + + // a part from port and params all other keys are strings. + match value.as_string() { + Ok(s) => { + if s.trim().is_empty() { + Ok(self) + } else { + match key.as_str() { + "host" => Ok(Self { + host: Some(s), + ..self + }), + "scheme" => Ok(Self { + scheme: Some(s), + ..self + }), + "username" => Ok(Self { + username: Some(s), + ..self + }), + "password" => Ok(Self { + password: Some(s), + ..self + }), + "path" => Ok(Self { + path: Some(if s.starts_with('/') { + s + } else { + format!("/{}", s) + }), + ..self + }), + "query" => { + if let Some(q) = self.query { + if q != s { + // if query is present it means that also params_span is setted. + return Err(ShellError::IncompatibleParameters { + left_message: format!("Mismatch, query param is: {}", s), + left_span: value.expect_span(), + right_message: format!("instead qs from params is: {}", q), + right_span: self.params_span.unwrap_or(Span::unknown()), + }); + } + } + + Ok(Self { + query: Some(format!("?{}", s)), + query_span: Some(value.expect_span()), + ..self + }) + } + "fragment" => Ok(Self { + fragment: Some(if s.starts_with('#') { + s + } else { + format!("#{}", s) + }), + ..self + }), + _ => Ok(self), + } + } + } + _ => Ok(self), + } + } + + pub fn to_url(&self, span: Span) -> Result { + let mut user_and_pwd: String = String::from(""); + + if let Some(usr) = &self.username { + if let Some(pwd) = &self.password { + user_and_pwd = format!("{}:{}@", usr, pwd); + } + } + + let scheme_result = match &self.scheme { + Some(s) => Ok(s), + None => Err(UrlComponents::generate_shell_error_for_missing_parameter( + String::from("scheme"), + span, + )), + }; + + let host_result = match &self.host { + Some(h) => Ok(h), + None => Err(UrlComponents::generate_shell_error_for_missing_parameter( + String::from("host"), + span, + )), + }; + + Ok(format!( + "{}://{}{}{}{}{}{}", + scheme_result?, + user_and_pwd, + host_result?, + self.port + .map(|p| format!(":{}", p)) + .as_deref() + .unwrap_or_default(), + self.path.as_deref().unwrap_or_default(), + self.query.as_deref().unwrap_or_default(), + self.fragment.as_deref().unwrap_or_default() + )) + } + + fn generate_shell_error_for_missing_parameter(pname: String, span: Span) -> ShellError { + ShellError::MissingParameter(pname, span) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_examples() { + use crate::test_examples; + + test_examples(SubCommand {}) + } +} diff --git a/crates/nu-command/src/network/url/mod.rs b/crates/nu-command/src/network/url/mod.rs index 4602a7ffc4..1b6cc7fd0b 100644 --- a/crates/nu-command/src/network/url/mod.rs +++ b/crates/nu-command/src/network/url/mod.rs @@ -1,5 +1,6 @@ mod build_query; mod encode; +mod join; mod parse; mod url_; @@ -8,4 +9,5 @@ use url::{self}; pub use self::parse::SubCommand as UrlParse; pub use build_query::SubCommand as UrlBuildQuery; pub use encode::SubCommand as UrlEncode; +pub use join::SubCommand as UrlJoin; pub use url_::Url; diff --git a/crates/nu-command/tests/commands/url/join.rs b/crates/nu-command/tests/commands/url/join.rs new file mode 100644 index 0000000000..cd10916ffb --- /dev/null +++ b/crates/nu-command/tests/commands/url/join.rs @@ -0,0 +1,368 @@ +use nu_test_support::{nu, pipeline}; + +#[test] +fn url_join_simple() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "username": "", + "password": "", + "host": "localhost", + "port": "", + } | url join + "# + ) + ); + + assert_eq!(actual.out, "http://localhost"); +} + +#[test] +fn url_join_with_only_user() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "username": "usr", + "password": "", + "host": "localhost", + "port": "", + } | url join + "# + ) + ); + + assert_eq!(actual.out, "http://localhost"); +} + +#[test] +fn url_join_with_only_pwd() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "username": "", + "password": "pwd", + "host": "localhost", + "port": "", + } | url join + "# + ) + ); + + assert_eq!(actual.out, "http://localhost"); +} + +#[test] +fn url_join_with_user_and_pwd() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "username": "usr", + "password": "pwd", + "host": "localhost", + "port": "", + } | url join + "# + ) + ); + + assert_eq!(actual.out, "http://usr:pwd@localhost"); +} + +#[test] +fn url_join_with_query() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "username": "usr", + "password": "pwd", + "host": "localhost", + "query": "par_1=aaa&par_2=bbb" + "port": "", + } | url join + "# + ) + ); + + assert_eq!(actual.out, "http://usr:pwd@localhost?par_1=aaa&par_2=bbb"); +} + +#[test] +fn url_join_with_params() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "username": "usr", + "password": "pwd", + "host": "localhost", + "params": { + "par_1": "aaa", + "par_2": "bbb" + }, + "port": "1234", + } | url join + "# + ) + ); + + assert_eq!( + actual.out, + "http://usr:pwd@localhost:1234?par_1=aaa&par_2=bbb" + ); +} + +#[test] +fn url_join_with_same_query_and_params() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "username": "usr", + "password": "pwd", + "host": "localhost", + "query": "par_1=aaa&par_2=bbb", + "params": { + "par_1": "aaa", + "par_2": "bbb" + }, + "port": "1234", + } | url join + "# + ) + ); + + assert_eq!( + actual.out, + "http://usr:pwd@localhost:1234?par_1=aaa&par_2=bbb" + ); +} + +#[test] +fn url_join_with_different_query_and_params() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "username": "usr", + "password": "pwd", + "host": "localhost", + "query": "par_1=aaa&par_2=bbb", + "params": { + "par_1": "aaab", + "par_2": "bbb" + }, + "port": "1234", + } | url join + "# + ) + ); + + assert!(actual + .err + .contains("Mismatch, qs from params is: ?par_1=aaab&par_2=bbb")); + assert!(actual + .err + .contains("instead query is: ?par_1=aaa&par_2=bbb")); + + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "username": "usr", + "password": "pwd", + "host": "localhost", + "params": { + "par_1": "aaab", + "par_2": "bbb" + }, + "query": "par_1=aaa&par_2=bbb", + "port": "1234", + } | url join + "# + ) + ); + + assert!(actual + .err + .contains("Mismatch, query param is: par_1=aaa&par_2=bbb")); + assert!(actual + .err + .contains("instead qs from params is: ?par_1=aaab&par_2=bbb")); +} + +#[test] +fn url_join_with_invalid_params() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "username": "usr", + "password": "pwd", + "host": "localhost", + "params": "aaa", + "port": "1234", + } | url join + "# + ) + ); + + assert!(actual.err.contains("Key params has to be a record")); +} + +#[test] +fn url_join_with_port() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "host": "localhost", + "port": "1234", + } | url join + "# + ) + ); + + assert_eq!(actual.out, "http://localhost:1234"); + + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "host": "localhost", + "port": 1234, + } | url join + "# + ) + ); + + assert_eq!(actual.out, "http://localhost:1234"); +} + +#[test] +fn url_join_with_invalid_port() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "host": "localhost", + "port": "aaaa", + } | url join + "# + ) + ); + + assert!(actual + .err + .contains("Port parameter should represent an unsigned integer")); + + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "host": "localhost", + "port": [], + } | url join + "# + ) + ); + + assert!(actual + .err + .contains("Port parameter should be an unsigned integer or a string representing it")); +} + +#[test] +fn url_join_with_missing_scheme() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "host": "localhost" + } | url join + "# + ) + ); + + assert!(actual.err.contains("missing parameter: scheme")); +} + +#[test] +fn url_join_with_missing_host() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "https" + } | url join + "# + ) + ); + + assert!(actual.err.contains("missing parameter: host")); +} + +#[test] +fn url_join_with_fragment() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "username": "usr", + "password": "pwd", + "host": "localhost", + "fragment": "frag", + "port": "1234", + } | url join + "# + ) + ); + + assert_eq!(actual.out, "http://usr:pwd@localhost:1234#frag"); +} + +#[test] +fn url_join_with_fragment_and_params() { + let actual = nu!( + cwd: ".", pipeline( + r#" + { + "scheme": "http", + "username": "usr", + "password": "pwd", + "host": "localhost", + "params": { + "par_1": "aaa", + "par_2": "bbb" + }, + "port": "1234", + "fragment": "frag" + } | url join + "# + ) + ); + + assert_eq!( + actual.out, + "http://usr:pwd@localhost:1234?par_1=aaa&par_2=bbb#frag" + ); +} diff --git a/crates/nu-command/tests/commands/url/mod.rs b/crates/nu-command/tests/commands/url/mod.rs index 06f1a3c69d..6c825d4708 100644 --- a/crates/nu-command/tests/commands/url/mod.rs +++ b/crates/nu-command/tests/commands/url/mod.rs @@ -1 +1,2 @@ +mod join; mod parse;