use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::time::Duration;
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use reqwest::blocking::{Client, multipart};
use crate::auth::Auth;
use crate::config::Config;
use crate::error::{Result, XurlError};
use crate::output::OutputConfig;
const URL_VALUE_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'&')
.add(b'+')
.add(b',')
.add(b'/')
.add(b':')
.add(b';')
.add(b'<')
.add(b'=')
.add(b'>')
.add(b'?')
.add(b'@')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
#[derive(Debug, Clone)]
pub enum RequestTarget {
Template {
path: String,
path_params: HashMap<String, String>,
query: Vec<(String, String)>,
},
RawUrl(String),
}
impl Default for RequestTarget {
fn default() -> Self {
Self::Template {
path: String::new(),
path_params: HashMap::new(),
query: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RequestOptions {
pub method: String,
pub target: RequestTarget,
pub headers: Vec<String>,
pub data: String,
pub auth_type: String,
pub username: String,
pub no_auth: bool,
pub verbose: bool,
pub trace: bool,
pub pagination_token: String,
}
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Clone)]
pub struct CallOptions {
pub auth_type: String,
pub username: String,
pub no_auth: bool,
pub verbose: bool,
pub trace: bool,
pub timeout_secs: u64,
pub pagination_token: String,
}
impl Default for CallOptions {
fn default() -> Self {
Self {
auth_type: String::new(),
username: String::new(),
no_auth: false,
verbose: false,
trace: false,
timeout_secs: DEFAULT_TIMEOUT_SECS,
pagination_token: String::new(),
}
}
}
impl CallOptions {
#[must_use]
pub(crate) fn to_request_options(&self) -> RequestOptions {
RequestOptions {
auth_type: self.auth_type.clone(),
username: self.username.clone(),
no_auth: self.no_auth,
verbose: self.verbose,
trace: self.trace,
pagination_token: self.pagination_token.clone(),
..Default::default()
}
}
}
#[derive(Debug, Clone)]
pub struct MultipartOptions {
pub request: RequestOptions,
pub form_fields: std::collections::HashMap<String, String>,
pub file_field: String,
pub file_path: String,
pub file_name: String,
pub file_data: Vec<u8>,
}
pub struct ApiClient {
base_url: String,
client: Client,
auth: Auth,
timeout_secs: u64,
out: OutputConfig,
}
impl ApiClient {
pub fn new(config: &Config, auth: Auth) -> Self {
Self::with_timeout(config, auth, config.http_timeout_secs)
}
pub fn with_timeout(config: &Config, auth: Auth, timeout_secs: u64) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.unwrap_or_else(|_| Client::new());
Self {
base_url: config.api_base_url.clone(),
client,
auth,
timeout_secs,
out: OutputConfig::default(),
}
}
#[must_use]
pub fn timeout_secs(&self) -> u64 {
self.timeout_secs
}
pub fn set_output(&mut self, out: OutputConfig) {
self.out = out;
}
#[allow(dead_code)] pub fn from_env() -> Result<Self> {
let cfg = Config::new();
if cfg.client_id.is_empty() {
return Err(XurlError::validation(
"CLIENT_ID not set — set the environment variable or use ApiClient::new() for manual configuration",
));
}
let auth = Auth::new(&cfg);
Ok(Self::new(&cfg, auth))
}
pub fn build_url_public(&self, target: &RequestTarget) -> Result<String> {
self.build_url(target)
}
#[must_use]
pub fn auth_app_name(&self) -> &str {
self.auth.app_name()
}
fn build_url(&self, target: &RequestTarget) -> Result<String> {
build_url_for_target(&self.base_url, target)
}
pub fn send_request(&mut self, options: &RequestOptions) -> Result<serde_json::Value> {
let method = options.method.to_uppercase();
let method = if method.is_empty() { "GET" } else { &method };
let url = self.build_url(&options.target)?;
let req_method = reqwest::Method::from_bytes(method.as_bytes())
.map_err(|_| XurlError::InvalidMethod(method.to_string()))?;
let mut builder = self.client.request(req_method.clone(), &url);
if !options.data.is_empty() && (method == "POST" || method == "PUT" || method == "PATCH") {
if serde_json::from_str::<serde_json::Value>(&options.data).is_ok() {
builder = builder
.header("Content-Type", "application/json")
.body(options.data.clone());
} else {
builder = builder
.header("Content-Type", "application/x-www-form-urlencoded")
.body(options.data.clone());
}
}
for header in &options.headers {
if let Some((key, value)) = header.split_once(':') {
builder = builder.header(key.trim(), value.trim());
}
}
if !options.no_auth {
let auth_header = self.get_auth_header(options)?;
builder = builder.header("Authorization", auth_header);
}
builder = builder.header("User-Agent", format!("xurl/{}", env!("CARGO_PKG_VERSION")));
if options.trace {
builder = builder.header("X-B3-Flags", "1");
}
if options.verbose {
let mut err = std::io::stderr().lock();
if self.out.use_color {
self.out
.verbose(&mut err, &format!("\x1b[1;34m> {method}\x1b[0m {url}"));
} else {
self.out.verbose(&mut err, &format!("> {method} {url}"));
}
}
let resp = builder.send()?;
if options.verbose {
let mut err = std::io::stderr().lock();
log_response_headers(&self.out, &mut err, resp.status(), resp.headers());
}
let status = resp.status();
let body = resp.text().unwrap_or_default();
let json: serde_json::Value = if body.is_empty() {
serde_json::json!({})
} else if let Ok(v) = serde_json::from_str(&body) {
v
} else {
if status.as_u16() >= 400 {
return Err(XurlError::api(
status.as_u16(),
format!("HTTP error: {status}"),
));
}
serde_json::json!({})
};
if status.as_u16() >= 400 {
return Err(XurlError::api(status.as_u16(), json.to_string()));
}
Ok(json)
}
pub fn send_multipart_request(
&mut self,
options: &MultipartOptions,
) -> Result<serde_json::Value> {
let method = options.request.method.to_uppercase();
let method = if method.is_empty() { "POST" } else { &method };
let url = self.build_url(&options.request.target)?;
let req_method = reqwest::Method::from_bytes(method.as_bytes())
.map_err(|_| XurlError::InvalidMethod(method.to_string()))?;
let mut form = multipart::Form::new();
if !options.file_field.is_empty() && !options.file_path.is_empty() {
let part = multipart::Part::file(&options.file_path)
.map_err(|e| XurlError::Io(format!("error opening file: {e}")))?;
form = form.part(options.file_field.clone(), part);
} else if !options.file_field.is_empty() && !options.file_data.is_empty() {
let part = multipart::Part::bytes(options.file_data.clone())
.file_name(options.file_name.clone());
form = form.part(options.file_field.clone(), part);
}
for (key, value) in &options.form_fields {
form = form.text(key.clone(), value.clone());
}
let mut builder = self.client.request(req_method, &url).multipart(form);
for header in &options.request.headers {
if let Some((key, value)) = header.split_once(':') {
builder = builder.header(key.trim(), value.trim());
}
}
if !options.request.no_auth {
let auth_header = self.get_auth_header(&options.request)?;
builder = builder.header("Authorization", auth_header);
}
builder = builder.header("User-Agent", format!("xurl/{}", env!("CARGO_PKG_VERSION")));
if options.request.trace {
builder = builder.header("X-B3-Flags", "1");
}
if options.request.verbose {
let mut err = std::io::stderr().lock();
if self.out.use_color {
self.out
.verbose(&mut err, &format!("\x1b[1;34m> {method}\x1b[0m {url}"));
} else {
self.out.verbose(&mut err, &format!("> {method} {url}"));
}
}
let resp = builder.send()?;
let status = resp.status();
let body = resp.text().unwrap_or_default();
let json: serde_json::Value = if body.is_empty() {
serde_json::json!({})
} else {
serde_json::from_str(&body).unwrap_or(serde_json::json!({}))
};
if status.as_u16() >= 400 {
return Err(XurlError::api(status.as_u16(), json.to_string()));
}
Ok(json)
}
#[allow(dead_code)] pub fn stream_request(
&mut self,
options: &RequestOptions,
stdout: &mut dyn std::io::Write,
stderr: &mut dyn std::io::Write,
) -> Result<()> {
let method = options.method.to_uppercase();
let method = if method.is_empty() { "GET" } else { &method };
let url = self.build_url(&options.target)?;
let req_method = reqwest::Method::from_bytes(method.as_bytes())
.map_err(|_| XurlError::InvalidMethod(method.to_string()))?;
let mut builder = Client::builder()
.timeout(None)
.build()
.unwrap_or_else(|_| Client::new())
.request(req_method, &url);
if !options.data.is_empty() {
if serde_json::from_str::<serde_json::Value>(&options.data).is_ok() {
builder = builder
.header("Content-Type", "application/json")
.body(options.data.clone());
} else {
builder = builder
.header("Content-Type", "application/x-www-form-urlencoded")
.body(options.data.clone());
}
}
for header in &options.headers {
if let Some((key, value)) = header.split_once(':') {
builder = builder.header(key.trim(), value.trim());
}
}
if !options.no_auth {
let auth_header = self.get_auth_header(options)?;
builder = builder.header("Authorization", auth_header);
}
builder = builder.header("User-Agent", format!("xurl/{}", env!("CARGO_PKG_VERSION")));
if options.trace {
builder = builder.header("X-B3-Flags", "1");
}
if options.verbose {
if self.out.use_color {
self.out
.verbose(stderr, &format!("\x1b[1;34m> {method}\x1b[0m {url}"));
} else {
self.out.verbose(stderr, &format!("> {method} {url}"));
}
}
self.out
.status(stderr, &format!("Connecting to streaming endpoint: {url}"));
let resp = builder.send()?;
if options.verbose {
log_response_headers(&self.out, stderr, resp.status(), resp.headers());
}
let resp_status = resp.status();
if resp_status.as_u16() >= 400 {
let body = resp.text().unwrap_or_default();
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
return Err(XurlError::api(resp_status.as_u16(), json.to_string()));
}
return Err(XurlError::api(resp_status.as_u16(), body));
}
self.out
.status(stderr, "--- Streaming response started ---");
self.out.status(stderr, "--- Press Ctrl+C to stop ---");
let reader = BufReader::with_capacity(1024 * 1024, resp);
for line in reader.lines() {
match line {
Ok(line) => {
if line.is_empty() {
continue;
}
self.out.print_stream_line(stdout, &line);
}
Err(e) => {
return Err(XurlError::Io(e.to_string()));
}
}
}
self.out.status(stderr, "--- End of stream ---");
Ok(())
}
pub fn get_auth_header_public(&mut self, options: &RequestOptions) -> Result<String> {
self.get_auth_header(options)
}
fn get_auth_header(&mut self, options: &RequestOptions) -> Result<String> {
let auth_type = &options.auth_type;
let method_raw = options.method.to_uppercase();
let method = if method_raw.is_empty() {
"GET"
} else {
method_raw.as_str()
};
let endpoint_schemes = match &options.target {
RequestTarget::Template { path, .. } => {
crate::api::auth_matrix::supported_auth(method, path).map(|s| (path.clone(), s))
}
RequestTarget::RawUrl(_) => None,
};
if !auth_type.is_empty() {
if let Some((path, schemes)) = &endpoint_schemes {
let supported_static = crate::api::auth_matrix::schemes_to_wire_list(schemes);
let requested_norm = auth_type.to_ascii_lowercase();
if !supported_static.contains(&requested_norm.as_str()) {
let supported: Vec<String> =
supported_static.iter().map(|s| (*s).to_string()).collect();
let rendered_url = render_template_template(&options.target).ok();
let raw_app = self.auth.app_name();
let app_name = if raw_app.is_empty() {
self.auth.token_store.default_app.clone()
} else {
raw_app.to_string()
};
return Err(XurlError::AuthMethodMismatch {
endpoint: path.clone(),
rendered_url,
method: method.to_string(),
requested: Some(requested_norm),
supported,
available_in_app: None,
app: Some(app_name),
other_apps_with_creds: None,
});
}
}
let url = self.build_url(&options.target)?;
return match auth_type.to_lowercase().as_str() {
"oauth1" => self.auth.get_oauth1_header(method, &url, None),
"oauth2" => self.auth.get_oauth2_header(&options.username),
"app" => self.auth.get_bearer_token_header(),
_ => Err(XurlError::auth(format!("invalid auth type: {auth_type}"))),
};
}
let raw_app = self.auth.app_name();
let app_name = if raw_app.is_empty() {
self.auth.token_store.default_app.clone()
} else {
raw_app.to_string()
};
let available_in_app = self.available_auth_in_app(&app_name);
let endpoint_supported_static: Option<Vec<&'static str>> = endpoint_schemes
.as_ref()
.map(|(_, schemes)| crate::api::auth_matrix::schemes_to_wire_list(schemes));
let candidate_order: Vec<crate::api::auth_matrix::WireScheme> =
crate::api::auth_matrix::WireScheme::ALL_BY_PREFERENCE
.into_iter()
.filter(|m| {
let wire = m.as_wire();
let in_app = available_in_app.contains(&wire);
let in_endpoint = endpoint_supported_static
.as_ref()
.is_none_or(|sup| sup.contains(&wire));
in_app && in_endpoint
})
.collect();
if candidate_order.is_empty() {
if let Some((path, _)) = &endpoint_schemes {
let rendered_url = render_template_template(&options.target).ok();
let endpoint_supported = endpoint_supported_static
.as_ref()
.map(|sup| sup.iter().map(|s| (*s).to_string()).collect::<Vec<_>>())
.unwrap_or_default();
if available_in_app.is_empty() {
let other_apps = self.other_apps_with_credentials(&app_name);
if other_apps.is_empty() {
return Err(XurlError::auth(
"NoAuthMethod: no authentication method available",
));
}
return Err(XurlError::AuthMethodMismatch {
endpoint: path.clone(),
rendered_url,
method: method.to_string(),
requested: None,
supported: endpoint_supported,
available_in_app: Some(Vec::new()),
app: Some(app_name.clone()),
other_apps_with_creds: Some(other_apps),
});
}
return Err(XurlError::AuthMethodMismatch {
endpoint: path.clone(),
rendered_url,
method: method.to_string(),
requested: None,
supported: endpoint_supported,
available_in_app: Some(
available_in_app.iter().map(|s| (*s).to_string()).collect(),
),
app: Some(app_name.clone()),
other_apps_with_creds: None,
});
}
return Err(XurlError::auth(
"NoAuthMethod: no authentication method available",
));
}
use crate::api::auth_matrix::WireScheme;
match candidate_order[0] {
WireScheme::OAuth2 => self.auth.get_oauth2_header(&options.username),
WireScheme::OAuth1 => {
let url = self.build_url(&options.target)?;
self.auth.get_oauth1_header(method, &url, None)
}
WireScheme::App => self.auth.get_bearer_token_header(),
}
}
fn available_auth_in_app(&self, app_name: &str) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::with_capacity(3);
if self
.auth
.token_store
.get_first_oauth2_token_for_app(app_name)
.is_some()
{
out.push("oauth2");
}
if self
.auth
.token_store
.get_oauth1_tokens_for_app(app_name)
.is_some()
{
out.push("oauth1");
}
if self
.auth
.token_store
.get_bearer_token_for_app(app_name)
.is_some()
{
out.push("app");
}
out
}
fn other_apps_with_credentials(&self, active: &str) -> Vec<String> {
self.auth
.token_store
.apps_with_credentials()
.into_iter()
.filter(|name| name != active)
.collect()
}
}
fn render_template_template(target: &RequestTarget) -> Result<String> {
match target {
RequestTarget::Template {
path, path_params, ..
} => render_template_path(path, path_params),
RequestTarget::RawUrl(_) => Err(XurlError::Internal(
"RawUrl target has no template to render".to_string(),
)),
}
}
fn build_url_for_target(base_url: &str, target: &RequestTarget) -> Result<String> {
match target {
RequestTarget::Template {
path,
path_params,
query,
} => {
let rendered_path = render_template_path(path, path_params)?;
let mut url = base_url.to_string();
if !url.ends_with('/') {
url.push('/');
}
if let Some(stripped) = rendered_path.strip_prefix('/') {
url.push_str(stripped);
} else {
url.push_str(&rendered_path);
}
if !query.is_empty() {
url.push('?');
for (i, (key, value)) in query.iter().enumerate() {
if i > 0 {
url.push('&');
}
write_encoded(&mut url, key);
url.push('=');
write_encoded(&mut url, value);
}
}
Ok(url)
}
RequestTarget::RawUrl(raw) => {
validate_raw_url_scheme(raw)?;
Ok(raw.clone())
}
}
}
pub(crate) fn render_template_path(
template: &str,
path_params: &HashMap<String, String>,
) -> Result<String> {
let mut out = String::with_capacity(template.len());
let bytes = template.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{' {
if let Some(end) = template[i + 1..].find('}') {
let name = &template[i + 1..i + 1 + end];
let value = path_params.get(name).ok_or_else(|| {
XurlError::Internal(format!(
"path template {template:?} references {{{name}}} but path_params has no such key"
))
})?;
if value.contains('/')
|| value.contains('?')
|| value.contains('#')
|| value.contains('%')
{
return Err(XurlError::InvalidPathParam {
name: name.to_string(),
value: value.clone(),
});
}
write_encoded(&mut out, value);
i += 1 + end + 1;
continue;
}
}
out.push(char::from(bytes[i]));
i += 1;
}
Ok(out)
}
fn validate_raw_url_scheme(url: &str) -> Result<()> {
let lower = url.trim_start().to_ascii_lowercase();
if lower.starts_with("https://") || lower.starts_with("http://") {
return Ok(());
}
Err(XurlError::InvalidUrl(format!(
"URL must start with http:// or https://: {url}"
)))
}
fn write_encoded(out: &mut String, value: &str) {
for chunk in utf8_percent_encode(value, URL_VALUE_ENCODE_SET) {
out.push_str(chunk);
}
}
fn log_response_headers(
out: &OutputConfig,
err: &mut dyn std::io::Write,
status: reqwest::StatusCode,
headers: &reqwest::header::HeaderMap,
) {
if out.use_color {
out.verbose(err, &format!("\x1b[1;31m< {status}\x1b[0m"));
for (key, value) in headers {
out.verbose(
err,
&format!("\x1b[1;32m< {key}\x1b[0m: {}", value.to_str().unwrap_or("")),
);
}
} else {
out.verbose(err, &format!("< {status}"));
for (key, value) in headers {
out.verbose(err, &format!("< {key}: {}", value.to_str().unwrap_or("")));
}
}
out.verbose(err, "");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn call_options_to_request_options_maps_all_fields() {
let opts = CallOptions {
auth_type: "oauth2".to_string(),
username: "testuser".to_string(),
no_auth: true,
verbose: true,
trace: true,
timeout_secs: 45,
pagination_token: "abc123".to_string(),
};
let req = opts.to_request_options();
assert_eq!(req.auth_type, "oauth2");
assert_eq!(req.username, "testuser");
assert!(req.no_auth);
assert!(req.verbose);
assert!(req.trace);
assert_eq!(req.pagination_token, "abc123");
assert!(req.method.is_empty());
match &req.target {
RequestTarget::Template {
path,
path_params,
query,
} => {
assert!(path.is_empty());
assert!(path_params.is_empty());
assert!(query.is_empty());
}
RequestTarget::RawUrl(_) => panic!("default target must be Template"),
}
assert!(req.data.is_empty());
assert!(req.headers.is_empty());
}
#[test]
fn call_options_default_has_safe_values() {
let opts = CallOptions::default();
let req = opts.to_request_options();
assert!(!req.no_auth, "no_auth should default to false");
assert!(!req.verbose);
assert!(!req.trace);
assert!(req.auth_type.is_empty());
assert!(req.username.is_empty());
assert!(
opts.pagination_token.is_empty(),
"pagination_token should default to empty so non-paginated endpoints stay clean"
);
assert_eq!(
opts.timeout_secs, DEFAULT_TIMEOUT_SECS,
"timeout_secs should default to {DEFAULT_TIMEOUT_SECS}"
);
}
const TEST_BASE_URL: &str = "https://api.x.com";
fn tmpl(path: &str) -> RequestTarget {
RequestTarget::Template {
path: path.to_string(),
path_params: HashMap::new(),
query: Vec::new(),
}
}
#[test]
fn build_url_template_empty_params_and_query() {
let url = build_url_for_target(TEST_BASE_URL, &tmpl("/2/users/me")).unwrap();
assert_eq!(url, "https://api.x.com/2/users/me");
}
#[test]
fn build_url_template_substitutes_path_param() {
let mut params = HashMap::new();
params.insert("id".to_string(), "12345".to_string());
let target = RequestTarget::Template {
path: "/2/users/{id}/likes".to_string(),
path_params: params,
query: Vec::new(),
};
let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
assert_eq!(url, "https://api.x.com/2/users/12345/likes");
}
#[test]
fn build_url_template_query_preserves_insertion_order() {
let target = RequestTarget::Template {
path: "/2/tweets/search/recent".to_string(),
path_params: HashMap::new(),
query: vec![
("query".to_string(), "rustlang".to_string()),
("max_results".to_string(), "10".to_string()),
],
};
let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
assert_eq!(
url,
"https://api.x.com/2/tweets/search/recent?query=rustlang&max_results=10"
);
}
#[test]
fn build_url_template_percent_encodes_value_with_spaces() {
let target = RequestTarget::Template {
path: "/2/tweets/search/recent".to_string(),
path_params: HashMap::new(),
query: vec![("query".to_string(), "hello world".to_string())],
};
let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
assert_eq!(
url,
"https://api.x.com/2/tweets/search/recent?query=hello%20world"
);
}
#[test]
fn build_url_template_rejects_path_param_with_slash() {
let mut params = HashMap::new();
params.insert("id".to_string(), "abc/etc/passwd".to_string());
let target = RequestTarget::Template {
path: "/2/users/{id}/likes".to_string(),
path_params: params,
query: Vec::new(),
};
let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
match err {
XurlError::InvalidPathParam { name, value } => {
assert_eq!(name, "id");
assert_eq!(value, "abc/etc/passwd");
}
other => panic!("expected InvalidPathParam, got {other:?}"),
}
}
#[test]
fn build_url_template_rejects_path_param_with_hash() {
let mut params = HashMap::new();
params.insert("id".to_string(), "abc#fragment".to_string());
let target = RequestTarget::Template {
path: "/2/users/{id}/likes".to_string(),
path_params: params,
query: Vec::new(),
};
let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
match err {
XurlError::InvalidPathParam { name, value } => {
assert_eq!(name, "id");
assert_eq!(value, "abc#fragment");
}
other => panic!("expected InvalidPathParam, got {other:?}"),
}
}
#[test]
fn build_url_template_rejects_path_param_with_percent() {
let mut params = HashMap::new();
params.insert("id".to_string(), "already%20encoded".to_string());
let target = RequestTarget::Template {
path: "/2/users/{id}/likes".to_string(),
path_params: params,
query: Vec::new(),
};
let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
match err {
XurlError::InvalidPathParam { name, value } => {
assert_eq!(name, "id");
assert_eq!(value, "already%20encoded");
}
other => panic!("expected InvalidPathParam, got {other:?}"),
}
}
#[test]
fn build_url_template_rejects_path_param_with_question_mark() {
let mut params = HashMap::new();
params.insert("id".to_string(), "abc?injected".to_string());
let target = RequestTarget::Template {
path: "/2/users/{id}".to_string(),
path_params: params,
query: Vec::new(),
};
let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
assert!(matches!(err, XurlError::InvalidPathParam { .. }));
}
#[test]
fn build_url_template_missing_path_param_is_internal_error() {
let target = RequestTarget::Template {
path: "/2/users/{id}/likes".to_string(),
path_params: HashMap::new(),
query: Vec::new(),
};
let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
assert!(matches!(err, XurlError::Internal(_)), "got {err:?}");
}
#[test]
fn build_url_raw_url_https_returns_clone() {
let target = RequestTarget::RawUrl("https://api.x.com/2/raw".to_string());
let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
assert_eq!(url, "https://api.x.com/2/raw");
}
#[test]
fn build_url_raw_url_http_returns_clone() {
let target = RequestTarget::RawUrl("http://localhost:8080/dev".to_string());
let url = build_url_for_target(TEST_BASE_URL, &target).unwrap();
assert_eq!(url, "http://localhost:8080/dev");
}
#[test]
fn build_url_raw_url_file_scheme_rejected() {
let target = RequestTarget::RawUrl("file:///etc/passwd".to_string());
let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
assert!(matches!(err, XurlError::InvalidUrl(_)), "got {err:?}");
}
#[test]
fn build_url_raw_url_ftp_scheme_rejected() {
let target = RequestTarget::RawUrl("ftp://attacker.com/payload".to_string());
let err = build_url_for_target(TEST_BASE_URL, &target).unwrap_err();
assert!(matches!(err, XurlError::InvalidUrl(_)), "got {err:?}");
}
}