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