use std::io::{BufRead, BufReader};
use std::time::Duration;
use reqwest::blocking::{Client, multipart};
use crate::auth::Auth;
use crate::config::Config;
use crate::error::{Result, XurlError};
#[derive(Debug, Clone, Default)]
pub struct RequestOptions {
pub method: String,
pub endpoint: String,
pub headers: Vec<String>,
pub data: String,
pub auth_type: String,
pub username: String,
pub no_auth: bool,
pub verbose: bool,
pub trace: bool,
}
#[derive(Debug, Clone, Default)]
pub struct CallOptions {
pub auth_type: String,
pub username: String,
pub no_auth: bool,
pub verbose: bool,
pub trace: bool,
}
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,
..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,
}
impl ApiClient {
pub fn new(config: &Config, auth: Auth) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new());
Self {
base_url: config.api_base_url.clone(),
client,
auth,
}
}
#[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))
}
#[must_use]
pub fn build_url_public(&self, endpoint: &str) -> String {
self.build_url(endpoint)
}
fn build_url(&self, endpoint: &str) -> String {
if endpoint.to_lowercase().starts_with("http") {
return endpoint.to_string();
}
let mut url = self.base_url.clone();
if !url.ends_with('/') {
url.push('/');
}
if let Some(stripped) = endpoint.strip_prefix('/') {
url.push_str(stripped);
} else {
url.push_str(endpoint);
}
url
}
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.endpoint);
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 {
if let Ok(auth_header) =
self.get_auth_header(method, &url, &options.auth_type, &options.username)
{
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 {
eprintln!("\x1b[1;34m> {method}\x1b[0m {url}");
}
let resp = builder.send()?;
if options.verbose {
eprintln!("\x1b[1;31m< {}\x1b[0m", resp.status());
for (key, value) in resp.headers() {
eprintln!(
"\x1b[1;32m< {}\x1b[0m: {}",
key,
value.to_str().unwrap_or("")
);
}
eprintln!();
}
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.endpoint);
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 {
if let Ok(auth_header) = self.get_auth_header(
method,
&url,
&options.request.auth_type,
&options.request.username,
) {
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 {
eprintln!("\x1b[1;34m> {method}\x1b[0m {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) -> Result<()> {
let method = options.method.to_uppercase();
let method = if method.is_empty() { "GET" } else { &method };
let url = self.build_url(&options.endpoint);
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 {
if let Ok(auth_header) =
self.get_auth_header(method, &url, &options.auth_type, &options.username)
{
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 {
eprintln!("\x1b[1;34m> {method}\x1b[0m {url}");
}
eprintln!("Connecting to streaming endpoint: {}", options.endpoint);
let resp = builder.send()?;
if options.verbose {
eprintln!("\x1b[1;31m< {}\x1b[0m", resp.status());
for (key, value) in resp.headers() {
eprintln!(
"\x1b[1;32m< {}\x1b[0m: {}",
key,
value.to_str().unwrap_or("")
);
}
eprintln!();
}
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));
}
eprintln!("--- Streaming response started ---");
eprintln!("--- 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;
}
println!("{line}");
}
Err(e) => {
return Err(XurlError::Io(e.to_string()));
}
}
}
eprintln!("--- End of stream ---");
Ok(())
}
pub fn get_auth_header_public(
&mut self,
method: &str,
url: &str,
auth_type: &str,
username: &str,
) -> Result<String> {
self.get_auth_header(method, url, auth_type, username)
}
fn get_auth_header(
&mut self,
method: &str,
url: &str,
auth_type: &str,
username: &str,
) -> Result<String> {
if !auth_type.is_empty() {
return match auth_type.to_lowercase().as_str() {
"oauth1" => self.auth.get_oauth1_header(method, url, None),
"oauth2" => self.auth.get_oauth2_header(username),
"app" => self.auth.get_bearer_token_header(),
_ => Err(XurlError::auth(format!("invalid auth type: {auth_type}"))),
};
}
if self.auth.token_store.get_first_oauth2_token().is_some() {
return self.auth.get_oauth2_header(username);
}
if self.auth.token_store.get_oauth1_tokens().is_some() {
return self.auth.get_oauth1_header(method, url, None);
}
if self.auth.token_store.has_bearer_token() {
return self.auth.get_bearer_token_header();
}
Err(XurlError::auth(
"NoAuthMethod: no authentication method available",
))
}
}
#[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,
};
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!(req.method.is_empty());
assert!(req.endpoint.is_empty());
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());
}
}