nu_command/network/url/
join.rs

1use nu_engine::command_prelude::*;
2
3use super::query::{record_to_query_string, table_to_query_string};
4
5#[derive(Clone)]
6pub struct UrlJoin;
7
8impl Command for UrlJoin {
9    fn name(&self) -> &str {
10        "url join"
11    }
12
13    fn signature(&self) -> nu_protocol::Signature {
14        Signature::build("url join")
15            .input_output_types(vec![(Type::record(), Type::String)])
16            .category(Category::Network)
17    }
18
19    fn description(&self) -> &str {
20        "Converts a record to url."
21    }
22
23    fn search_terms(&self) -> Vec<&str> {
24        vec![
25            "scheme", "username", "password", "hostname", "port", "path", "query", "fragment",
26        ]
27    }
28
29    fn examples(&self) -> Vec<Example<'_>> {
30        vec![
31            Example {
32                description: "Outputs a url representing the contents of this record, `params` and `query` fields must be equivalent",
33                example: r#"{
34        "scheme": "http",
35        "username": "",
36        "password": "",
37        "host": "www.pixiv.net",
38        "port": "",
39        "path": "/member_illust.php",
40        "query": "mode=medium&illust_id=99260204",
41        "fragment": "",
42        "params":
43        {
44            "mode": "medium",
45            "illust_id": "99260204"
46        }
47    } | url join"#,
48                result: Some(Value::test_string(
49                    "http://www.pixiv.net/member_illust.php?mode=medium&illust_id=99260204",
50                )),
51            },
52            Example {
53                description: "Outputs a url representing the contents of this record, \"exploding\" the list in `params` into multiple parameters",
54                example: r#"{
55        "scheme": "http",
56        "username": "user",
57        "password": "pwd",
58        "host": "www.pixiv.net",
59        "port": "1234",
60        "params": {a: ["one", "two"], b: "three"},
61        "fragment": ""
62    } | url join"#,
63                result: Some(Value::test_string(
64                    "http://user:pwd@www.pixiv.net:1234?a=one&a=two&b=three",
65                )),
66            },
67            Example {
68                description: "Outputs a url representing the contents of this record",
69                example: r#"{
70        "scheme": "http",
71        "username": "user",
72        "password": "pwd",
73        "host": "www.pixiv.net",
74        "port": "1234",
75        "query": "test=a",
76        "fragment": ""
77    } | url join"#,
78                result: Some(Value::test_string(
79                    "http://user:pwd@www.pixiv.net:1234?test=a",
80                )),
81            },
82            Example {
83                description: "Outputs a url representing the contents of this record",
84                example: r#"{
85        "scheme": "http",
86        "host": "www.pixiv.net",
87        "port": "1234",
88        "path": "user",
89        "fragment": "frag"
90    } | url join"#,
91                result: Some(Value::test_string("http://www.pixiv.net:1234/user#frag")),
92            },
93        ]
94    }
95
96    fn run(
97        &self,
98        engine_state: &EngineState,
99        _stack: &mut Stack,
100        call: &Call,
101        input: PipelineData,
102    ) -> Result<PipelineData, ShellError> {
103        let head = call.head;
104
105        let output: Result<String, ShellError> = input
106            .into_iter()
107            .map(move |value| {
108                let span = value.span();
109                match value {
110                    Value::Record { val, .. } => {
111                        let url_components = val
112                            .into_owned()
113                            .into_iter()
114                            .try_fold(UrlComponents::new(), |url, (k, v)| {
115                                url.add_component(k, v, head, engine_state)
116                            });
117
118                        url_components?.to_url(span)
119                    }
120                    Value::Error { error, .. } => Err(*error),
121                    other => Err(ShellError::UnsupportedInput {
122                        msg: "Expected a record from pipeline".to_string(),
123                        input: "value originates from here".into(),
124                        msg_span: head,
125                        input_span: other.span(),
126                    }),
127                }
128            })
129            .collect();
130
131        Ok(Value::string(output?, head).into_pipeline_data())
132    }
133}
134
135#[derive(Default)]
136struct UrlComponents {
137    scheme: Option<String>,
138    username: Option<String>,
139    password: Option<String>,
140    host: Option<String>,
141    port: Option<i64>,
142    path: Option<String>,
143    query: Option<String>,
144    fragment: Option<String>,
145    query_span: Option<Span>,
146    params_span: Option<Span>,
147}
148
149impl UrlComponents {
150    fn new() -> Self {
151        Default::default()
152    }
153
154    pub fn add_component(
155        self,
156        key: String,
157        value: Value,
158        head: Span,
159        engine_state: &EngineState,
160    ) -> Result<Self, ShellError> {
161        let value_span = value.span();
162        if key == "port" {
163            return match value {
164                Value::String { val, .. } => {
165                    if val.trim().is_empty() {
166                        Ok(self)
167                    } else {
168                        match val.parse::<i64>() {
169                            Ok(p) => Ok(Self {
170                                port: Some(p),
171                                ..self
172                            }),
173                            Err(_) => Err(ShellError::IncompatibleParametersSingle {
174                                msg: String::from(
175                                    "Port parameter should represent an unsigned int",
176                                ),
177                                span: value_span,
178                            }),
179                        }
180                    }
181                }
182                Value::Int { val, .. } => Ok(Self {
183                    port: Some(val),
184                    ..self
185                }),
186                Value::Error { error, .. } => Err(*error),
187                other => Err(ShellError::IncompatibleParametersSingle {
188                    msg: String::from(
189                        "Port parameter should be an unsigned int or a string representing it",
190                    ),
191                    span: other.span(),
192                }),
193            };
194        }
195
196        if key == "params" {
197            let mut qs = match value {
198                Value::Record { ref val, .. } => record_to_query_string(val, value_span, head)?,
199                Value::List { ref vals, .. } => table_to_query_string(vals, value_span, head)?,
200                Value::Error { error, .. } => return Err(*error),
201                other => {
202                    return Err(ShellError::IncompatibleParametersSingle {
203                        msg: String::from("Key params has to be a record or a table"),
204                        span: other.span(),
205                    });
206                }
207            };
208
209            qs = if !qs.trim().is_empty() {
210                format!("?{qs}")
211            } else {
212                qs
213            };
214
215            if let Some(q) = self.query
216                && q != qs
217            {
218                // if query is present it means that also query_span is set.
219                return Err(ShellError::IncompatibleParameters {
220                    left_message: format!("Mismatch, query string from params is: {qs}"),
221                    left_span: value_span,
222                    right_message: format!("instead query is: {q}"),
223                    right_span: self.query_span.unwrap_or(Span::unknown()),
224                });
225            }
226
227            return Ok(Self {
228                query: Some(qs),
229                params_span: Some(value_span),
230                ..self
231            });
232        }
233
234        // apart from port and params all other keys are strings.
235        let s = value.coerce_into_string()?; // If value fails String conversion, just output this ShellError
236        if !Self::check_empty_string_ok(&key, &s, value_span)? {
237            return Ok(self);
238        }
239        match key.as_str() {
240            "host" => Ok(Self {
241                host: Some(s),
242                ..self
243            }),
244            "scheme" => Ok(Self {
245                scheme: Some(s),
246                ..self
247            }),
248            "username" => Ok(Self {
249                username: Some(s),
250                ..self
251            }),
252            "password" => Ok(Self {
253                password: Some(s),
254                ..self
255            }),
256            "path" => Ok(Self {
257                path: Some(if s.starts_with('/') {
258                    s
259                } else {
260                    format!("/{s}")
261                }),
262                ..self
263            }),
264            "query" => {
265                if let Some(q) = self.query
266                    && q != s
267                {
268                    // if query is present it means that also params_span is set.
269                    return Err(ShellError::IncompatibleParameters {
270                        left_message: format!("Mismatch, query param is: {s}"),
271                        left_span: value_span,
272                        right_message: format!("instead query string from params is: {q}"),
273                        right_span: self.params_span.unwrap_or(Span::unknown()),
274                    });
275                }
276
277                Ok(Self {
278                    query: Some(format!("?{s}")),
279                    query_span: Some(value_span),
280                    ..self
281                })
282            }
283            "fragment" => Ok(Self {
284                fragment: Some(if s.starts_with('#') {
285                    s
286                } else {
287                    format!("#{s}")
288                }),
289                ..self
290            }),
291            _ => {
292                nu_protocol::report_shell_error(
293                    engine_state,
294                    &ShellError::GenericError {
295                        error: format!("'{key}' is not a valid URL field"),
296                        msg: format!("remove '{key}' col from input record"),
297                        span: Some(value_span),
298                        help: None,
299                        inner: vec![],
300                    },
301                );
302                Ok(self)
303            }
304        }
305    }
306
307    // Check if value is empty. If so, check if that is fine, i.e., not a required input
308    fn check_empty_string_ok(key: &str, s: &str, value_span: Span) -> Result<bool, ShellError> {
309        if !s.trim().is_empty() {
310            return Ok(true);
311        }
312        match key {
313            "host" => Err(ShellError::InvalidValue {
314                valid: "a non-empty string".into(),
315                actual: format!("'{s}'"),
316                span: value_span,
317            }),
318            "scheme" => Err(ShellError::InvalidValue {
319                valid: "a non-empty string".into(),
320                actual: format!("'{s}'"),
321                span: value_span,
322            }),
323            _ => Ok(false),
324        }
325    }
326
327    pub fn to_url(&self, span: Span) -> Result<String, ShellError> {
328        let user_and_pwd = match (&self.username, &self.password) {
329            (Some(usr), Some(pwd)) => format!("{usr}:{pwd}@"),
330            (Some(usr), None) => format!("{usr}@"),
331            _ => String::from(""),
332        };
333
334        let scheme_result = match &self.scheme {
335            Some(s) => Ok(s),
336            None => Err(UrlComponents::generate_shell_error_for_missing_parameter(
337                String::from("scheme"),
338                span,
339            )),
340        };
341
342        let host_result = match &self.host {
343            Some(h) => Ok(h),
344            None => Err(UrlComponents::generate_shell_error_for_missing_parameter(
345                String::from("host"),
346                span,
347            )),
348        };
349
350        Ok(format!(
351            "{}://{}{}{}{}{}{}",
352            scheme_result?,
353            user_and_pwd,
354            host_result?,
355            self.port
356                .map(|p| format!(":{p}"))
357                .as_deref()
358                .unwrap_or_default(),
359            self.path.as_deref().unwrap_or_default(),
360            self.query.as_deref().unwrap_or_default(),
361            self.fragment.as_deref().unwrap_or_default()
362        ))
363    }
364
365    fn generate_shell_error_for_missing_parameter(pname: String, span: Span) -> ShellError {
366        ShellError::MissingParameter {
367            param_name: pname,
368            span,
369        }
370    }
371}
372
373#[cfg(test)]
374mod test {
375    use super::*;
376
377    #[test]
378    fn test_examples() {
379        use crate::test_examples;
380
381        test_examples(UrlJoin {})
382    }
383}