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