1use std::collections::HashMap;
6use std::io::{BufRead, BufReader};
7use std::time::Duration;
8
9use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
10use reqwest::blocking::{Client, multipart};
11
12use crate::auth::Auth;
13use crate::config::Config;
14use crate::error::{Result, XurlError};
15use crate::output::OutputConfig;
16
17const URL_VALUE_ENCODE_SET: &AsciiSet = &CONTROLS
23 .add(b' ')
24 .add(b'"')
25 .add(b'#')
26 .add(b'%')
27 .add(b'&')
28 .add(b'+')
29 .add(b',')
30 .add(b'/')
31 .add(b':')
32 .add(b';')
33 .add(b'<')
34 .add(b'=')
35 .add(b'>')
36 .add(b'?')
37 .add(b'@')
38 .add(b'[')
39 .add(b'\\')
40 .add(b']')
41 .add(b'^')
42 .add(b'`')
43 .add(b'{')
44 .add(b'|')
45 .add(b'}');
46
47#[derive(Debug, Clone)]
55pub enum RequestTarget {
56 Template {
60 path: String,
63 path_params: HashMap<String, String>,
65 query: Vec<(String, String)>,
69 },
70 RawUrl(String),
75}
76
77impl Default for RequestTarget {
78 fn default() -> Self {
79 Self::Template {
80 path: String::new(),
81 path_params: HashMap::new(),
82 query: Vec::new(),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Default)]
93pub struct RequestOptions {
94 pub method: String,
96 pub target: RequestTarget,
98 pub headers: Vec<String>,
100 pub data: String,
103 pub auth_type: String,
106 pub username: String,
109 pub no_auth: bool,
111 pub verbose: bool,
114 pub trace: bool,
116 pub pagination_token: String,
122}
123
124pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
126
127#[derive(Debug, Clone)]
132pub struct CallOptions {
133 pub auth_type: String,
136 pub username: String,
139 pub no_auth: bool,
141 pub verbose: bool,
143 pub trace: bool,
145 pub timeout_secs: u64,
150 pub pagination_token: String,
155}
156
157impl Default for CallOptions {
158 fn default() -> Self {
159 Self {
160 auth_type: String::new(),
161 username: String::new(),
162 no_auth: false,
163 verbose: false,
164 trace: false,
165 timeout_secs: DEFAULT_TIMEOUT_SECS,
166 pagination_token: String::new(),
167 }
168 }
169}
170
171impl CallOptions {
172 #[must_use]
175 pub(crate) fn to_request_options(&self) -> RequestOptions {
176 RequestOptions {
177 auth_type: self.auth_type.clone(),
178 username: self.username.clone(),
179 no_auth: self.no_auth,
180 verbose: self.verbose,
181 trace: self.trace,
182 pagination_token: self.pagination_token.clone(),
183 ..Default::default()
184 }
185 }
186}
187
188#[derive(Debug, Clone)]
193pub struct MultipartOptions {
194 pub request: RequestOptions,
196 pub form_fields: std::collections::HashMap<String, String>,
198 pub file_field: String,
200 pub file_path: String,
202 pub file_name: String,
205 pub file_data: Vec<u8>,
207}
208
209pub struct ApiClient {
239 base_url: String,
240 client: Client,
241 auth: Auth,
242 timeout_secs: u64,
243 out: OutputConfig,
248}
249
250impl ApiClient {
251 pub fn new(config: &Config, auth: Auth) -> Self {
257 Self::with_timeout(config, auth, config.http_timeout_secs)
258 }
259
260 pub fn with_timeout(config: &Config, auth: Auth, timeout_secs: u64) -> Self {
267 let client = Client::builder()
268 .timeout(Duration::from_secs(timeout_secs))
269 .build()
270 .unwrap_or_else(|_| Client::new());
271
272 Self {
273 base_url: config.api_base_url.clone(),
274 client,
275 auth,
276 timeout_secs,
277 out: OutputConfig::default(),
278 }
279 }
280
281 #[must_use]
283 pub fn timeout_secs(&self) -> u64 {
284 self.timeout_secs
285 }
286
287 pub fn set_output(&mut self, out: OutputConfig) {
293 self.out = out;
294 }
295
296 #[allow(dead_code)] pub fn from_env() -> Result<Self> {
308 let cfg = Config::new();
309 if cfg.client_id.is_empty() {
310 return Err(XurlError::validation(
311 "CLIENT_ID not set — set the environment variable or use ApiClient::new() for manual configuration",
312 ));
313 }
314 let auth = Auth::new(&cfg);
315 Ok(Self::new(&cfg, auth))
316 }
317
318 pub fn build_url_public(&self, target: &RequestTarget) -> Result<String> {
328 self.build_url(target)
329 }
330
331 #[must_use]
337 pub fn auth_app_name(&self) -> &str {
338 self.auth.app_name()
339 }
340
341 fn build_url(&self, target: &RequestTarget) -> Result<String> {
343 build_url_for_target(&self.base_url, target)
344 }
345
346 pub fn send_request(&mut self, options: &RequestOptions) -> Result<serde_json::Value> {
353 let method = options.method.to_uppercase();
354 let method = if method.is_empty() { "GET" } else { &method };
355 let url = self.build_url(&options.target)?;
361
362 let req_method = reqwest::Method::from_bytes(method.as_bytes())
364 .map_err(|_| XurlError::InvalidMethod(method.to_string()))?;
365
366 let mut builder = self.client.request(req_method.clone(), &url);
367
368 if !options.data.is_empty() && (method == "POST" || method == "PUT" || method == "PATCH") {
370 if serde_json::from_str::<serde_json::Value>(&options.data).is_ok() {
372 builder = builder
373 .header("Content-Type", "application/json")
374 .body(options.data.clone());
375 } else {
376 builder = builder
377 .header("Content-Type", "application/x-www-form-urlencoded")
378 .body(options.data.clone());
379 }
380 }
381
382 for header in &options.headers {
384 if let Some((key, value)) = header.split_once(':') {
385 builder = builder.header(key.trim(), value.trim());
386 }
387 }
388
389 if !options.no_auth {
396 let auth_header = self.get_auth_header(options)?;
397 builder = builder.header("Authorization", auth_header);
398 }
399
400 builder = builder.header("User-Agent", format!("xurl/{}", env!("CARGO_PKG_VERSION")));
402
403 if options.trace {
404 builder = builder.header("X-B3-Flags", "1");
405 }
406
407 if options.verbose {
408 let mut err = std::io::stderr().lock();
409 if self.out.use_color {
410 self.out
411 .verbose(&mut err, &format!("\x1b[1;34m> {method}\x1b[0m {url}"));
412 } else {
413 self.out.verbose(&mut err, &format!("> {method} {url}"));
414 }
415 }
416
417 let resp = builder.send()?;
418
419 if options.verbose {
420 let mut err = std::io::stderr().lock();
421 log_response_headers(&self.out, &mut err, resp.status(), resp.headers());
422 }
423
424 let status = resp.status();
425 let body = resp.text().unwrap_or_default();
426
427 let json: serde_json::Value = if body.is_empty() {
428 serde_json::json!({})
429 } else if let Ok(v) = serde_json::from_str(&body) {
430 v
431 } else {
432 if status.as_u16() >= 400 {
433 return Err(XurlError::api(
434 status.as_u16(),
435 format!("HTTP error: {status}"),
436 ));
437 }
438 serde_json::json!({})
439 };
440
441 if status.as_u16() >= 400 {
442 return Err(XurlError::api(status.as_u16(), json.to_string()));
443 }
444
445 Ok(json)
446 }
447
448 pub fn send_multipart_request(
455 &mut self,
456 options: &MultipartOptions,
457 ) -> Result<serde_json::Value> {
458 let method = options.request.method.to_uppercase();
459 let method = if method.is_empty() { "POST" } else { &method };
460 let url = self.build_url(&options.request.target)?;
463
464 let req_method = reqwest::Method::from_bytes(method.as_bytes())
465 .map_err(|_| XurlError::InvalidMethod(method.to_string()))?;
466
467 let mut form = multipart::Form::new();
468
469 if !options.file_field.is_empty() && !options.file_path.is_empty() {
471 let part = multipart::Part::file(&options.file_path)
472 .map_err(|e| XurlError::Io(format!("error opening file: {e}")))?;
473 form = form.part(options.file_field.clone(), part);
474 } else if !options.file_field.is_empty() && !options.file_data.is_empty() {
475 let part = multipart::Part::bytes(options.file_data.clone())
476 .file_name(options.file_name.clone());
477 form = form.part(options.file_field.clone(), part);
478 }
479
480 for (key, value) in &options.form_fields {
482 form = form.text(key.clone(), value.clone());
483 }
484
485 let mut builder = self.client.request(req_method, &url).multipart(form);
486
487 for header in &options.request.headers {
489 if let Some((key, value)) = header.split_once(':') {
490 builder = builder.header(key.trim(), value.trim());
491 }
492 }
493
494 if !options.request.no_auth {
498 let auth_header = self.get_auth_header(&options.request)?;
499 builder = builder.header("Authorization", auth_header);
500 }
501
502 builder = builder.header("User-Agent", format!("xurl/{}", env!("CARGO_PKG_VERSION")));
503
504 if options.request.trace {
505 builder = builder.header("X-B3-Flags", "1");
506 }
507
508 if options.request.verbose {
509 let mut err = std::io::stderr().lock();
510 if self.out.use_color {
511 self.out
512 .verbose(&mut err, &format!("\x1b[1;34m> {method}\x1b[0m {url}"));
513 } else {
514 self.out.verbose(&mut err, &format!("> {method} {url}"));
515 }
516 }
517
518 let resp = builder.send()?;
519 let status = resp.status();
520 let body = resp.text().unwrap_or_default();
521
522 let json: serde_json::Value = if body.is_empty() {
523 serde_json::json!({})
524 } else {
525 serde_json::from_str(&body).unwrap_or(serde_json::json!({}))
526 };
527
528 if status.as_u16() >= 400 {
529 return Err(XurlError::api(status.as_u16(), json.to_string()));
530 }
531
532 Ok(json)
533 }
534
535 #[allow(dead_code)] pub fn stream_request(
550 &mut self,
551 options: &RequestOptions,
552 stdout: &mut dyn std::io::Write,
553 stderr: &mut dyn std::io::Write,
554 ) -> Result<()> {
555 let method = options.method.to_uppercase();
556 let method = if method.is_empty() { "GET" } else { &method };
557 let url = self.build_url(&options.target)?;
562
563 let req_method = reqwest::Method::from_bytes(method.as_bytes())
564 .map_err(|_| XurlError::InvalidMethod(method.to_string()))?;
565
566 let mut builder = Client::builder()
567 .timeout(None)
568 .build()
569 .unwrap_or_else(|_| Client::new())
570 .request(req_method, &url);
571
572 if !options.data.is_empty() {
573 if serde_json::from_str::<serde_json::Value>(&options.data).is_ok() {
574 builder = builder
575 .header("Content-Type", "application/json")
576 .body(options.data.clone());
577 } else {
578 builder = builder
579 .header("Content-Type", "application/x-www-form-urlencoded")
580 .body(options.data.clone());
581 }
582 }
583
584 for header in &options.headers {
585 if let Some((key, value)) = header.split_once(':') {
586 builder = builder.header(key.trim(), value.trim());
587 }
588 }
589
590 if !options.no_auth {
591 let auth_header = self.get_auth_header(options)?;
592 builder = builder.header("Authorization", auth_header);
593 }
594
595 builder = builder.header("User-Agent", format!("xurl/{}", env!("CARGO_PKG_VERSION")));
596
597 if options.trace {
598 builder = builder.header("X-B3-Flags", "1");
599 }
600
601 if options.verbose {
602 if self.out.use_color {
603 self.out
604 .verbose(stderr, &format!("\x1b[1;34m> {method}\x1b[0m {url}"));
605 } else {
606 self.out.verbose(stderr, &format!("> {method} {url}"));
607 }
608 }
609
610 self.out
611 .status(stderr, &format!("Connecting to streaming endpoint: {url}"));
612
613 let resp = builder.send()?;
614
615 if options.verbose {
616 log_response_headers(&self.out, stderr, resp.status(), resp.headers());
617 }
618
619 let resp_status = resp.status();
620 if resp_status.as_u16() >= 400 {
621 let body = resp.text().unwrap_or_default();
622 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
623 return Err(XurlError::api(resp_status.as_u16(), json.to_string()));
624 }
625 return Err(XurlError::api(resp_status.as_u16(), body));
626 }
627
628 self.out
629 .status(stderr, "--- Streaming response started ---");
630 self.out.status(stderr, "--- Press Ctrl+C to stop ---");
631
632 let reader = BufReader::with_capacity(1024 * 1024, resp);
633 for line in reader.lines() {
634 match line {
635 Ok(line) => {
636 if line.is_empty() {
637 continue;
638 }
639 self.out.print_stream_line(stdout, &line);
640 }
641 Err(e) => {
642 return Err(XurlError::Io(e.to_string()));
643 }
644 }
645 }
646
647 self.out.status(stderr, "--- End of stream ---");
648 Ok(())
649 }
650
651 pub fn get_auth_header_public(&mut self, options: &RequestOptions) -> Result<String> {
660 self.get_auth_header(options)
661 }
662
663 fn get_auth_header(&mut self, options: &RequestOptions) -> Result<String> {
674 let auth_type = &options.auth_type;
675 let method_raw = options.method.to_uppercase();
676 let method = if method_raw.is_empty() {
677 "GET"
678 } else {
679 method_raw.as_str()
680 };
681
682 let endpoint_schemes = match &options.target {
688 RequestTarget::Template { path, .. } => {
689 crate::api::auth_matrix::supported_auth(method, path).map(|s| (path.clone(), s))
690 }
691 RequestTarget::RawUrl(_) => None,
692 };
693
694 if !auth_type.is_empty() {
695 if let Some((path, schemes)) = &endpoint_schemes {
701 let supported_static = crate::api::auth_matrix::schemes_to_wire_list(schemes);
702 let requested_norm = auth_type.to_ascii_lowercase();
703 if !supported_static.contains(&requested_norm.as_str()) {
704 let supported: Vec<String> =
705 supported_static.iter().map(|s| (*s).to_string()).collect();
706 let rendered_url = render_template_template(&options.target).ok();
707 let raw_app = self.auth.app_name();
708 let app_name = if raw_app.is_empty() {
709 self.auth.token_store.default_app.clone()
710 } else {
711 raw_app.to_string()
712 };
713 return Err(XurlError::AuthMethodMismatch {
714 endpoint: path.clone(),
715 rendered_url,
716 method: method.to_string(),
717 requested: Some(requested_norm),
718 supported,
719 available_in_app: None,
720 app: Some(app_name),
721 other_apps_with_creds: None,
722 });
723 }
724 }
725 let url = self.build_url(&options.target)?;
726 return match auth_type.to_lowercase().as_str() {
727 "oauth1" => self.auth.get_oauth1_header(method, &url, None),
728 "oauth2" => self.auth.get_oauth2_header(&options.username),
729 "app" => self.auth.get_bearer_token_header(),
730 _ => Err(XurlError::auth(format!("invalid auth type: {auth_type}"))),
731 };
732 }
733
734 let raw_app = self.auth.app_name();
741 let app_name = if raw_app.is_empty() {
746 self.auth.token_store.default_app.clone()
747 } else {
748 raw_app.to_string()
749 };
750 let available_in_app = self.available_auth_in_app(&app_name);
751
752 let endpoint_supported_static: Option<Vec<&'static str>> = endpoint_schemes
757 .as_ref()
758 .map(|(_, schemes)| crate::api::auth_matrix::schemes_to_wire_list(schemes));
759 let candidate_order: Vec<crate::api::auth_matrix::WireScheme> =
760 crate::api::auth_matrix::WireScheme::ALL_BY_PREFERENCE
761 .into_iter()
762 .filter(|m| {
763 let wire = m.as_wire();
764 let in_app = available_in_app.contains(&wire);
765 let in_endpoint = endpoint_supported_static
766 .as_ref()
767 .is_none_or(|sup| sup.contains(&wire));
768 in_app && in_endpoint
769 })
770 .collect();
771
772 if candidate_order.is_empty() {
773 if let Some((path, _)) = &endpoint_schemes {
778 let rendered_url = render_template_template(&options.target).ok();
779 let endpoint_supported = endpoint_supported_static
780 .as_ref()
781 .map(|sup| sup.iter().map(|s| (*s).to_string()).collect::<Vec<_>>())
782 .unwrap_or_default();
783 if available_in_app.is_empty() {
784 let other_apps = self.other_apps_with_credentials(&app_name);
790 if other_apps.is_empty() {
791 return Err(XurlError::auth(
792 "NoAuthMethod: no authentication method available",
793 ));
794 }
795 return Err(XurlError::AuthMethodMismatch {
796 endpoint: path.clone(),
797 rendered_url,
798 method: method.to_string(),
799 requested: None,
800 supported: endpoint_supported,
801 available_in_app: Some(Vec::new()),
802 app: Some(app_name.clone()),
803 other_apps_with_creds: Some(other_apps),
804 });
805 }
806 return Err(XurlError::AuthMethodMismatch {
807 endpoint: path.clone(),
808 rendered_url,
809 method: method.to_string(),
810 requested: None,
811 supported: endpoint_supported,
812 available_in_app: Some(
813 available_in_app.iter().map(|s| (*s).to_string()).collect(),
814 ),
815 app: Some(app_name.clone()),
816 other_apps_with_creds: None,
817 });
818 }
819 return Err(XurlError::auth(
820 "NoAuthMethod: no authentication method available",
821 ));
822 }
823
824 use crate::api::auth_matrix::WireScheme;
830 match candidate_order[0] {
831 WireScheme::OAuth2 => self.auth.get_oauth2_header(&options.username),
832 WireScheme::OAuth1 => {
833 let url = self.build_url(&options.target)?;
834 self.auth.get_oauth1_header(method, &url, None)
835 }
836 WireScheme::App => self.auth.get_bearer_token_header(),
837 }
838 }
839
840 fn available_auth_in_app(&self, app_name: &str) -> Vec<&'static str> {
847 let mut out: Vec<&'static str> = Vec::with_capacity(3);
848 if self
849 .auth
850 .token_store
851 .get_first_oauth2_token_for_app(app_name)
852 .is_some()
853 {
854 out.push("oauth2");
855 }
856 if self
857 .auth
858 .token_store
859 .get_oauth1_tokens_for_app(app_name)
860 .is_some()
861 {
862 out.push("oauth1");
863 }
864 if self
865 .auth
866 .token_store
867 .get_bearer_token_for_app(app_name)
868 .is_some()
869 {
870 out.push("app");
871 }
872 out
873 }
874
875 fn other_apps_with_credentials(&self, active: &str) -> Vec<String> {
883 self.auth
884 .token_store
885 .apps_with_credentials()
886 .into_iter()
887 .filter(|name| name != active)
888 .collect()
889 }
890}
891
892fn render_template_template(target: &RequestTarget) -> Result<String> {
901 match target {
902 RequestTarget::Template {
903 path, path_params, ..
904 } => render_template_path(path, path_params),
905 RequestTarget::RawUrl(_) => Err(XurlError::Internal(
906 "RawUrl target has no template to render".to_string(),
907 )),
908 }
909}
910
911fn build_url_for_target(base_url: &str, target: &RequestTarget) -> Result<String> {
916 match target {
917 RequestTarget::Template {
918 path,
919 path_params,
920 query,
921 } => {
922 let rendered_path = render_template_path(path, path_params)?;
923
924 let mut url = base_url.to_string();
925 if !url.ends_with('/') {
926 url.push('/');
927 }
928 if let Some(stripped) = rendered_path.strip_prefix('/') {
929 url.push_str(stripped);
930 } else {
931 url.push_str(&rendered_path);
932 }
933
934 if !query.is_empty() {
935 url.push('?');
936 for (i, (key, value)) in query.iter().enumerate() {
937 if i > 0 {
938 url.push('&');
939 }
940 write_encoded(&mut url, key);
941 url.push('=');
942 write_encoded(&mut url, value);
943 }
944 }
945 Ok(url)
946 }
947 RequestTarget::RawUrl(raw) => {
948 validate_raw_url_scheme(raw)?;
949 Ok(raw.clone())
950 }
951 }
952}
953
954pub(crate) fn render_template_path(
962 template: &str,
963 path_params: &HashMap<String, String>,
964) -> Result<String> {
965 let mut out = String::with_capacity(template.len());
966 let bytes = template.as_bytes();
967 let mut i = 0;
968 while i < bytes.len() {
969 if bytes[i] == b'{' {
970 if let Some(end) = template[i + 1..].find('}') {
972 let name = &template[i + 1..i + 1 + end];
973 let value = path_params.get(name).ok_or_else(|| {
974 XurlError::Internal(format!(
975 "path template {template:?} references {{{name}}} but path_params has no such key"
976 ))
977 })?;
978 if value.contains('/')
979 || value.contains('?')
980 || value.contains('#')
981 || value.contains('%')
982 {
983 return Err(XurlError::InvalidPathParam {
984 name: name.to_string(),
985 value: value.clone(),
986 });
987 }
988 write_encoded(&mut out, value);
989 i += 1 + end + 1;
990 continue;
991 }
992 }
993 out.push(char::from(bytes[i]));
996 i += 1;
997 }
998 Ok(out)
999}
1000
1001fn validate_raw_url_scheme(url: &str) -> Result<()> {
1007 let lower = url.trim_start().to_ascii_lowercase();
1008 if lower.starts_with("https://") || lower.starts_with("http://") {
1009 return Ok(());
1010 }
1011 Err(XurlError::InvalidUrl(format!(
1012 "URL must start with http:// or https://: {url}"
1013 )))
1014}
1015
1016fn write_encoded(out: &mut String, value: &str) {
1018 for chunk in utf8_percent_encode(value, URL_VALUE_ENCODE_SET) {
1019 out.push_str(chunk);
1020 }
1021}
1022
1023fn log_response_headers(
1028 out: &OutputConfig,
1029 err: &mut dyn std::io::Write,
1030 status: reqwest::StatusCode,
1031 headers: &reqwest::header::HeaderMap,
1032) {
1033 if out.use_color {
1034 out.verbose(err, &format!("\x1b[1;31m< {status}\x1b[0m"));
1035 for (key, value) in headers {
1036 out.verbose(
1037 err,
1038 &format!("\x1b[1;32m< {key}\x1b[0m: {}", value.to_str().unwrap_or("")),
1039 );
1040 }
1041 } else {
1042 out.verbose(err, &format!("< {status}"));
1043 for (key, value) in headers {
1044 out.verbose(err, &format!("< {key}: {}", value.to_str().unwrap_or("")));
1045 }
1046 }
1047 out.verbose(err, "");
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052 use super::*;
1053
1054 #[test]
1055 fn call_options_to_request_options_maps_all_fields() {
1056 let opts = CallOptions {
1057 auth_type: "oauth2".to_string(),
1058 username: "testuser".to_string(),
1059 no_auth: true,
1060 verbose: true,
1061 trace: true,
1062 timeout_secs: 45,
1063 pagination_token: "abc123".to_string(),
1064 };
1065
1066 let req = opts.to_request_options();
1067
1068 assert_eq!(req.auth_type, "oauth2");
1069 assert_eq!(req.username, "testuser");
1070 assert!(req.no_auth);
1071 assert!(req.verbose);
1072 assert!(req.trace);
1073 assert_eq!(req.pagination_token, "abc123");
1074 assert!(req.method.is_empty());
1076 match &req.target {
1077 RequestTarget::Template {
1078 path,
1079 path_params,
1080 query,
1081 } => {
1082 assert!(path.is_empty());
1083 assert!(path_params.is_empty());
1084 assert!(query.is_empty());
1085 }
1086 RequestTarget::RawUrl(_) => panic!("default target must be Template"),
1087 }
1088 assert!(req.data.is_empty());
1089 assert!(req.headers.is_empty());
1090 }
1091
1092 #[test]
1093 fn call_options_default_has_safe_values() {
1094 let opts = CallOptions::default();
1095 let req = opts.to_request_options();
1096
1097 assert!(!req.no_auth, "no_auth should default to false");
1098 assert!(!req.verbose);
1099 assert!(!req.trace);
1100 assert!(req.auth_type.is_empty());
1101 assert!(req.username.is_empty());
1102 assert!(
1103 opts.pagination_token.is_empty(),
1104 "pagination_token should default to empty so non-paginated endpoints stay clean"
1105 );
1106 assert_eq!(
1107 opts.timeout_secs, DEFAULT_TIMEOUT_SECS,
1108 "timeout_secs should default to {DEFAULT_TIMEOUT_SECS}"
1109 );
1110 }
1111
1112 const TEST_BASE_URL: &str = "https://api.x.com";
1115
1116 fn tmpl(path: &str) -> RequestTarget {
1117 RequestTarget::Template {
1118 path: path.to_string(),
1119 path_params: HashMap::new(),
1120 query: Vec::new(),
1121 }
1122 }
1123
1124 #[test]
1125 fn build_url_template_empty_params_and_query() {
1126 let url = build_url_for_target(TEST_BASE_URL, &tmpl("/2/users/me")).unwrap();
1127 assert_eq!(url, "https://api.x.com/2/users/me");
1128 }
1129
1130 #[test]
1131 fn build_url_template_substitutes_path_param() {
1132 let mut params = HashMap::new();
1133 params.insert("id".to_string(), "12345".to_string());
1134 let target = RequestTarget::Template {
1135 path: "/2/users/{id}/likes".to_string(),
1136 path_params: params,
1137 query: Vec::new(),
1138 };
1139 let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
1140 assert_eq!(url, "https://api.x.com/2/users/12345/likes");
1141 }
1142
1143 #[test]
1144 fn build_url_template_query_preserves_insertion_order() {
1145 let target = RequestTarget::Template {
1146 path: "/2/tweets/search/recent".to_string(),
1147 path_params: HashMap::new(),
1148 query: vec![
1149 ("query".to_string(), "rustlang".to_string()),
1150 ("max_results".to_string(), "10".to_string()),
1151 ],
1152 };
1153 let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
1154 assert_eq!(
1155 url,
1156 "https://api.x.com/2/tweets/search/recent?query=rustlang&max_results=10"
1157 );
1158 }
1159
1160 #[test]
1161 fn build_url_template_percent_encodes_value_with_spaces() {
1162 let target = RequestTarget::Template {
1163 path: "/2/tweets/search/recent".to_string(),
1164 path_params: HashMap::new(),
1165 query: vec![("query".to_string(), "hello world".to_string())],
1166 };
1167 let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
1168 assert_eq!(
1169 url,
1170 "https://api.x.com/2/tweets/search/recent?query=hello%20world"
1171 );
1172 }
1173
1174 #[test]
1175 fn build_url_template_rejects_path_param_with_slash() {
1176 let mut params = HashMap::new();
1177 params.insert("id".to_string(), "abc/etc/passwd".to_string());
1178 let target = RequestTarget::Template {
1179 path: "/2/users/{id}/likes".to_string(),
1180 path_params: params,
1181 query: Vec::new(),
1182 };
1183 let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1184 match err {
1185 XurlError::InvalidPathParam { name, value } => {
1186 assert_eq!(name, "id");
1187 assert_eq!(value, "abc/etc/passwd");
1188 }
1189 other => panic!("expected InvalidPathParam, got {other:?}"),
1190 }
1191 }
1192
1193 #[test]
1194 fn build_url_template_rejects_path_param_with_hash() {
1195 let mut params = HashMap::new();
1196 params.insert("id".to_string(), "abc#fragment".to_string());
1197 let target = RequestTarget::Template {
1198 path: "/2/users/{id}/likes".to_string(),
1199 path_params: params,
1200 query: Vec::new(),
1201 };
1202 let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1203 match err {
1204 XurlError::InvalidPathParam { name, value } => {
1205 assert_eq!(name, "id");
1206 assert_eq!(value, "abc#fragment");
1207 }
1208 other => panic!("expected InvalidPathParam, got {other:?}"),
1209 }
1210 }
1211
1212 #[test]
1213 fn build_url_template_rejects_path_param_with_percent() {
1214 let mut params = HashMap::new();
1215 params.insert("id".to_string(), "already%20encoded".to_string());
1216 let target = RequestTarget::Template {
1217 path: "/2/users/{id}/likes".to_string(),
1218 path_params: params,
1219 query: Vec::new(),
1220 };
1221 let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1222 match err {
1223 XurlError::InvalidPathParam { name, value } => {
1224 assert_eq!(name, "id");
1225 assert_eq!(value, "already%20encoded");
1226 }
1227 other => panic!("expected InvalidPathParam, got {other:?}"),
1228 }
1229 }
1230
1231 #[test]
1232 fn build_url_template_rejects_path_param_with_question_mark() {
1233 let mut params = HashMap::new();
1234 params.insert("id".to_string(), "abc?injected".to_string());
1235 let target = RequestTarget::Template {
1236 path: "/2/users/{id}".to_string(),
1237 path_params: params,
1238 query: Vec::new(),
1239 };
1240 let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1241 assert!(matches!(err, XurlError::InvalidPathParam { .. }));
1242 }
1243
1244 #[test]
1245 fn build_url_template_missing_path_param_is_internal_error() {
1246 let target = RequestTarget::Template {
1247 path: "/2/users/{id}/likes".to_string(),
1248 path_params: HashMap::new(),
1249 query: Vec::new(),
1250 };
1251 let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1252 assert!(matches!(err, XurlError::Internal(_)), "got {err:?}");
1253 }
1254
1255 #[test]
1256 fn build_url_raw_url_https_returns_clone() {
1257 let target = RequestTarget::RawUrl("https://api.x.com/2/raw".to_string());
1258 let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
1259 assert_eq!(url, "https://api.x.com/2/raw");
1260 }
1261
1262 #[test]
1263 fn build_url_raw_url_http_returns_clone() {
1264 let target = RequestTarget::RawUrl("http://localhost:8080/dev".to_string());
1265 let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
1266 assert_eq!(url, "http://localhost:8080/dev");
1267 }
1268
1269 #[test]
1270 fn build_url_raw_url_file_scheme_rejected() {
1271 let target = RequestTarget::RawUrl("file:///etc/passwd".to_string());
1272 let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1273 assert!(matches!(err, XurlError::InvalidUrl(_)), "got {err:?}");
1274 }
1275
1276 #[test]
1277 fn build_url_raw_url_ftp_scheme_rejected() {
1278 let target = RequestTarget::RawUrl("ftp://attacker.com/payload".to_string());
1279 let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
1280 assert!(matches!(err, XurlError::InvalidUrl(_)), "got {err:?}");
1281 }
1282}