use crate::cli::commands::{ApiRequestCmd, ApiTargetOptions};
use crate::config::{resolve_xbp_api_token, ApiConfig};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE, LOCATION};
use reqwest::{Client, Method, Url};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct ApiRequestExecution {
pub path: String,
pub method: Method,
pub body: Option<String>,
pub body_file: Option<PathBuf>,
pub target: ApiTargetOptions,
}
pub async fn run_api_request(cmd: &ApiRequestCmd) -> Result<(), String> {
execute_api_request(ApiRequestExecution {
path: cmd.path.clone(),
method: resolve_method(
cmd.method.as_deref(),
cmd.body.is_some() || cmd.body_file.is_some(),
)?,
body: cmd.body.clone(),
body_file: cmd.body_file.clone(),
target: cmd.target.clone(),
})
.await
}
pub async fn execute_api_request(spec: ApiRequestExecution) -> Result<(), String> {
let url = resolve_request_url(&spec.path, &spec.target)?;
let body = load_request_body(spec.body.as_deref(), spec.body_file.as_deref())?;
let headers = parse_headers(&spec.target.header)?;
let client = Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let mut request = client.request(spec.method.clone(), url.clone());
if !spec.target.no_auth {
if let Some(token) = resolve_xbp_api_token() {
request = request.bearer_auth(token);
}
}
if let Some(body) = body {
let has_content_type = headers.contains_key(CONTENT_TYPE);
request = request.body(body);
if !has_content_type {
request = request.header(CONTENT_TYPE, "application/json");
}
}
request = request.headers(headers);
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
let status = response.status();
let response_headers = response.headers().clone();
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read response body: {}", e))?;
println!(
"{} {}",
status.as_u16(),
status.canonical_reason().unwrap_or("")
);
if spec.target.include_headers {
for (name, value) in &response_headers {
let rendered = value.to_str().unwrap_or("<binary>");
println!("{}: {}", name.as_str(), rendered);
}
if !bytes.is_empty() {
println!();
}
} else if let Some(location) = response_headers.get(LOCATION) {
if let Ok(location) = location.to_str() {
println!("location: {}", location);
if !bytes.is_empty() {
println!();
}
}
}
print_response_body(&bytes, &response_headers, spec.target.raw)?;
if !status.is_success() {
return Err(format!(
"XBP API request failed with status {} {}",
status.as_u16(),
status.canonical_reason().unwrap_or("")
));
}
Ok(())
}
pub fn resolve_method(method: Option<&str>, has_body: bool) -> Result<Method, String> {
let inferred = if has_body { "POST" } else { "GET" };
let raw = method.unwrap_or(inferred).trim().to_ascii_uppercase();
Method::from_str(&raw).map_err(|_| format!("Unsupported HTTP method: {}", raw))
}
pub fn resolve_request_url(path: &str, target: &ApiTargetOptions) -> Result<Url, String> {
resolve_request_url_with_config(path, target, &ApiConfig::load())
}
fn resolve_request_url_with_config(
path: &str,
target: &ApiTargetOptions,
api_config: &ApiConfig,
) -> Result<Url, String> {
if let Ok(url) = Url::parse(path) {
return Ok(url);
}
let base = if let Some(base_url) = target.base_url.as_deref() {
normalize_base_url(base_url)
} else {
if target.web {
api_config.web_base_url()
} else {
api_config.base_url().to_string()
}
};
let normalized_path = if path.starts_with('/') {
path.to_string()
} else {
format!("/{}", path)
};
Url::parse(&format!("{}{}", base, normalized_path))
.map_err(|e| format!("Failed to build request URL from `{}`: {}", path, e))
}
fn normalize_base_url(raw: &str) -> String {
raw.trim().trim_end_matches('/').to_string()
}
pub fn load_request_body(
body: Option<&str>,
body_file: Option<&Path>,
) -> Result<Option<String>, String> {
match (body, body_file) {
(Some(_), Some(_)) => Err("Use either --body or --body-file, not both.".to_string()),
(Some(body), None) => Ok(Some(body.to_string())),
(None, Some(path)) => Ok(Some(read_body_file(path)?)),
(None, None) => Ok(None),
}
}
fn read_body_file(path: &Path) -> Result<String, String> {
fs::read_to_string(path)
.map_err(|e| format!("Failed to read request body file {}: {}", path.display(), e))
}
pub fn parse_headers(values: &[String]) -> Result<HeaderMap, String> {
let mut headers = HeaderMap::new();
for value in values {
let (name, header_value) = value
.split_once(':')
.ok_or_else(|| format!("Invalid header `{}`. Use `Name: Value` format.", value))?;
let name = HeaderName::from_str(name.trim())
.map_err(|e| format!("Invalid header name `{}`: {}", name.trim(), e))?;
let header_value = HeaderValue::from_str(header_value.trim())
.map_err(|e| format!("Invalid header value for `{}`: {}", name, e))?;
headers.append(name, header_value);
}
Ok(headers)
}
fn print_response_body(bytes: &[u8], headers: &HeaderMap, raw: bool) -> Result<(), String> {
if bytes.is_empty() {
return Ok(());
}
let text = String::from_utf8(bytes.to_vec()).map_err(|_| {
"Response body is not valid UTF-8; binary output is not supported.".to_string()
})?;
if !raw && is_json_response(headers, &text) {
if let Ok(value) = serde_json::from_str::<Value>(&text) {
println!(
"{}",
serde_json::to_string_pretty(&value)
.map_err(|e| format!("Failed to format JSON response: {}", e))?
);
return Ok(());
}
}
println!("{}", text);
Ok(())
}
fn is_json_response(headers: &HeaderMap, body: &str) -> bool {
headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.contains("application/json") || value.contains("+json"))
.unwrap_or_else(|| {
let trimmed = body.trim_start();
trimmed.starts_with('{') || trimmed.starts_with('[')
})
}
#[cfg(test)]
mod tests {
use super::{
is_json_response, load_request_body, parse_headers, resolve_method, resolve_request_url,
resolve_request_url_with_config,
};
use crate::cli::commands::ApiTargetOptions;
use crate::config::ApiConfig;
use reqwest::header::{HeaderMap, CONTENT_TYPE};
use std::env;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn sample_target() -> ApiTargetOptions {
ApiTargetOptions {
base_url: None,
web: false,
no_auth: false,
header: vec![],
include_headers: false,
raw: false,
}
}
#[test]
fn request_method_defaults_to_get_without_body() {
let method = resolve_method(None, false).expect("resolve method");
assert_eq!(method.as_str(), "GET");
}
#[test]
fn request_method_defaults_to_post_with_body() {
let method = resolve_method(None, true).expect("resolve method");
assert_eq!(method.as_str(), "POST");
}
#[test]
fn request_url_uses_control_plane_base_by_default() {
let api = ApiConfig::from_base_url("https://api.example.com/");
let url = resolve_request_url_with_config("/health", &sample_target(), &api)
.expect("resolve url");
assert_eq!(url.as_str(), "https://api.example.com/health");
}
#[test]
fn request_url_can_target_web_surface() {
let api = ApiConfig::from_base_url("https://api.xbp.app/");
let mut target = sample_target();
target.web = true;
let url =
resolve_request_url_with_config("/api/registry", &target, &api).expect("resolve url");
assert_eq!(url.as_str(), "https://xbp.app/api/registry");
}
#[test]
fn request_url_can_use_base_override() {
let mut target = sample_target();
target.base_url = Some("http://127.0.0.1:8080/".to_string());
let url = resolve_request_url("/routes", &target).expect("resolve url");
assert_eq!(url.as_str(), "http://127.0.0.1:8080/routes");
}
#[test]
fn headers_require_name_value_shape() {
let error = parse_headers(&["broken".to_string()]).expect_err("expected error");
assert!(error.contains("Name: Value"));
}
#[test]
fn load_request_body_reads_file() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let path = env::temp_dir().join(format!("xbp-api-request-{}.json", nanos));
fs::write(&path, "{\"hello\":\"world\"}").expect("write file");
let body = load_request_body(None, Some(path.as_path())).expect("load body");
assert_eq!(body.as_deref(), Some("{\"hello\":\"world\"}"));
let _ = fs::remove_file(path);
}
#[test]
fn json_detection_accepts_content_type_or_shape() {
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE,
"application/json".parse().expect("content type"),
);
assert!(is_json_response(&headers, "not json"));
let headers = HeaderMap::new();
assert!(is_json_response(&headers, "{\"ok\":true}"));
assert!(!is_json_response(&headers, "plain text"));
}
}