Feat/7725 url join (#7823)

# Description

Added command: `url join`.
Closes: #7725 

# User-Facing Changes

```
Converts a record to url

Search terms: scheme, username, password, hostname, port, path, query, fragment

Usage:
  > url join

Flags:
  -h, --help - Display the help message for this command

Signatures:
  <record> | url join -> <string>

Examples:
  Outputs a url representing the contents of this record
  > {
          "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

  Outputs a url representing the contents of this record
  > {
        "scheme": "http",
        "username": "user",
        "password": "pwd",
        "host": "www.pixiv.net",
        "port": "1234",
        "query": "test=a",
        "fragment": ""
      } | url join

  Outputs a url representing the contents of this record
  > {
        "scheme": "http",
        "host": "www.pixiv.net",
        "port": "1234",
        "path": "user",
        "fragment": "frag"
      } | url join
```                  

# Questions
- Which parameters should be required? Currenlty are: `scheme` and
`host`.
This commit is contained in:
Vincenzo Carlino 2023-01-22 19:49:40 +01:00 committed by GitHub
parent d8027656b5
commit 8d5165c449
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 710 additions and 0 deletions

View File

@ -439,6 +439,7 @@ pub fn create_default_context() -> EngineState {
Url,
UrlBuildQuery,
UrlEncode,
UrlJoin,
UrlParse,
Port,
}

View File

@ -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<Example> {
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<nu_protocol::PipelineData, nu_protocol::ShellError> {
let head = call.head;
let output: Result<String, ShellError> = 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<String>,
username: Option<String>,
password: Option<String>,
host: Option<String>,
port: Option<i64>,
path: Option<String>,
query: Option<String>,
fragment: Option<String>,
query_span: Option<Span>,
params_span: Option<Span>,
}
impl UrlComponents {
fn new() -> Self {
Default::default()
}
pub fn add_component(self, key: String, value: Value, _span: Span) -> Result<Self, ShellError> {
if key == "port" {
return match value {
Value::String { val, span } => {
if val.trim().is_empty() {
Ok(self)
} else {
match val.parse::<i64>() {
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::<Result<Vec<String>, 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<String, ShellError> {
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 {})
}
}

View File

@ -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;

View File

@ -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"
);
}

View File

@ -1 +1,2 @@
mod join;
mod parse;