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, stack, 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        stack: &Stack,
160        engine_state: &EngineState,
161    ) -> Result<Self, ShellError> {
162        let value_span = value.span();
163        if key == "port" {
164            return match value {
165                Value::String { val, .. } => {
166                    if val.trim().is_empty() {
167                        Ok(self)
168                    } else {
169                        match val.parse::<i64>() {
170                            Ok(p) => Ok(Self {
171                                port: Some(p),
172                                ..self
173                            }),
174                            Err(_) => Err(ShellError::IncompatibleParametersSingle {
175                                msg: String::from(
176                                    "Port parameter should represent an unsigned int",
177                                ),
178                                span: value_span,
179                            }),
180                        }
181                    }
182                }
183                Value::Int { val, .. } => Ok(Self {
184                    port: Some(val),
185                    ..self
186                }),
187                Value::Error { error, .. } => Err(*error),
188                other => Err(ShellError::IncompatibleParametersSingle {
189                    msg: String::from(
190                        "Port parameter should be an unsigned int or a string representing it",
191                    ),
192                    span: other.span(),
193                }),
194            };
195        }
196
197        if key == "params" {
198            let mut qs = match value {
199                Value::Record { ref val, .. } => record_to_query_string(val, value_span, head)?,
200                Value::List { ref vals, .. } => table_to_query_string(vals, value_span, head)?,
201                Value::Error { error, .. } => return Err(*error),
202                other => {
203                    return Err(ShellError::IncompatibleParametersSingle {
204                        msg: String::from("Key params has to be a record or a table"),
205                        span: other.span(),
206                    });
207                }
208            };
209
210            qs = if !qs.trim().is_empty() {
211                format!("?{qs}")
212            } else {
213                qs
214            };
215
216            if let Some(q) = self.query
217                && q != qs
218            {
219                // if query is present it means that also query_span is set.
220                return Err(ShellError::IncompatibleParameters {
221                    left_message: format!("Mismatch, query string from params is: {qs}"),
222                    left_span: value_span,
223                    right_message: format!("instead query is: {q}"),
224                    right_span: self.query_span.unwrap_or(Span::unknown()),
225                });
226            }
227
228            return Ok(Self {
229                query: Some(qs),
230                params_span: Some(value_span),
231                ..self
232            });
233        }
234
235        // apart from port and params all other keys are strings.
236        let s = value.coerce_into_string()?; // If value fails String conversion, just output this ShellError
237        if !Self::check_empty_string_ok(&key, &s, value_span)? {
238            return Ok(self);
239        }
240        match key.as_str() {
241            "host" => Ok(Self {
242                host: Some(s),
243                ..self
244            }),
245            "scheme" => Ok(Self {
246                scheme: Some(s),
247                ..self
248            }),
249            "username" => Ok(Self {
250                username: Some(s),
251                ..self
252            }),
253            "password" => Ok(Self {
254                password: Some(s),
255                ..self
256            }),
257            "path" => Ok(Self {
258                path: Some(if s.starts_with('/') {
259                    s
260                } else {
261                    format!("/{s}")
262                }),
263                ..self
264            }),
265            "query" => {
266                if let Some(q) = self.query
267                    && q != s
268                {
269                    // if query is present it means that also params_span is set.
270                    return Err(ShellError::IncompatibleParameters {
271                        left_message: format!("Mismatch, query param is: {s}"),
272                        left_span: value_span,
273                        right_message: format!("instead query string from params is: {q}"),
274                        right_span: self.params_span.unwrap_or(Span::unknown()),
275                    });
276                }
277
278                Ok(Self {
279                    query: Some(format!("?{s}")),
280                    query_span: Some(value_span),
281                    ..self
282                })
283            }
284            "fragment" => Ok(Self {
285                fragment: Some(if s.starts_with('#') {
286                    s
287                } else {
288                    format!("#{s}")
289                }),
290                ..self
291            }),
292            _ => {
293                nu_protocol::report_shell_error(
294                    Some(stack),
295                    engine_state,
296                    &ShellError::GenericError {
297                        error: format!("'{key}' is not a valid URL field"),
298                        msg: format!("remove '{key}' col from input record"),
299                        span: Some(value_span),
300                        help: None,
301                        inner: vec![],
302                    },
303                );
304                Ok(self)
305            }
306        }
307    }
308
309    // Check if value is empty. If so, check if that is fine, i.e., not a required input
310    fn check_empty_string_ok(key: &str, s: &str, value_span: Span) -> Result<bool, ShellError> {
311        if !s.trim().is_empty() {
312            return Ok(true);
313        }
314        match key {
315            "host" => Err(ShellError::InvalidValue {
316                valid: "a non-empty string".into(),
317                actual: format!("'{s}'"),
318                span: value_span,
319            }),
320            "scheme" => Err(ShellError::InvalidValue {
321                valid: "a non-empty string".into(),
322                actual: format!("'{s}'"),
323                span: value_span,
324            }),
325            _ => Ok(false),
326        }
327    }
328
329    pub fn to_url(&self, span: Span) -> Result<String, ShellError> {
330        let user_and_pwd = match (&self.username, &self.password) {
331            (Some(usr), Some(pwd)) => format!("{usr}:{pwd}@"),
332            (Some(usr), None) => format!("{usr}@"),
333            _ => String::from(""),
334        };
335
336        let scheme_result = match &self.scheme {
337            Some(s) => Ok(s),
338            None => Err(UrlComponents::generate_shell_error_for_missing_parameter(
339                String::from("scheme"),
340                span,
341            )),
342        };
343
344        let host_result = match &self.host {
345            Some(h) => Ok(h),
346            None => Err(UrlComponents::generate_shell_error_for_missing_parameter(
347                String::from("host"),
348                span,
349            )),
350        };
351
352        Ok(format!(
353            "{}://{}{}{}{}{}{}",
354            scheme_result?,
355            user_and_pwd,
356            host_result?,
357            self.port
358                .map(|p| format!(":{p}"))
359                .as_deref()
360                .unwrap_or_default(),
361            self.path.as_deref().unwrap_or_default(),
362            self.query.as_deref().unwrap_or_default(),
363            self.fragment.as_deref().unwrap_or_default()
364        ))
365    }
366
367    fn generate_shell_error_for_missing_parameter(pname: String, span: Span) -> ShellError {
368        ShellError::MissingParameter {
369            param_name: pname,
370            span,
371        }
372    }
373}
374
375#[cfg(test)]
376mod test {
377    use super::*;
378
379    #[test]
380    fn test_examples() {
381        use crate::test_examples;
382
383        test_examples(UrlJoin {})
384    }
385}