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 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 let s = value.coerce_into_string()?; 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 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 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}