Skip to main content

nu_command/network/url/
join.rs

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