Skip to main content

xbp_cli/commands/
api_request.rs

1use crate::cli::commands::{ApiRequestCmd, ApiTargetOptions};
2use crate::config::{resolve_xbp_api_token, ApiConfig};
3use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE, LOCATION};
4use reqwest::{Client, Method, Url};
5use serde_json::Value;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::str::FromStr;
9use std::time::Duration;
10
11#[derive(Debug, Clone)]
12pub struct ApiRequestExecution {
13    pub path: String,
14    pub method: Method,
15    pub body: Option<String>,
16    pub body_file: Option<PathBuf>,
17    pub target: ApiTargetOptions,
18}
19
20pub async fn run_api_request(cmd: &ApiRequestCmd) -> Result<(), String> {
21    execute_api_request(ApiRequestExecution {
22        path: cmd.path.clone(),
23        method: resolve_method(
24            cmd.method.as_deref(),
25            cmd.body.is_some() || cmd.body_file.is_some(),
26        )?,
27        body: cmd.body.clone(),
28        body_file: cmd.body_file.clone(),
29        target: cmd.target.clone(),
30    })
31    .await
32}
33
34pub async fn execute_api_request(spec: ApiRequestExecution) -> Result<(), String> {
35    let url = resolve_request_url(&spec.path, &spec.target)?;
36    let body = load_request_body(spec.body.as_deref(), spec.body_file.as_deref())?;
37    let headers = parse_headers(&spec.target.header)?;
38
39    let client = Client::builder()
40        .timeout(Duration::from_secs(60))
41        .build()
42        .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
43
44    let mut request = client.request(spec.method.clone(), url.clone());
45    if !spec.target.no_auth {
46        if let Some(token) = resolve_xbp_api_token() {
47            request = request.bearer_auth(token);
48        }
49    }
50
51    if let Some(body) = body {
52        let has_content_type = headers.contains_key(CONTENT_TYPE);
53        request = request.body(body);
54        if !has_content_type {
55            request = request.header(CONTENT_TYPE, "application/json");
56        }
57    }
58
59    request = request.headers(headers);
60
61    let response = request
62        .send()
63        .await
64        .map_err(|e| format!("Request failed: {}", e))?;
65
66    let status = response.status();
67    let response_headers = response.headers().clone();
68    let bytes = response
69        .bytes()
70        .await
71        .map_err(|e| format!("Failed to read response body: {}", e))?;
72
73    println!(
74        "{} {}",
75        status.as_u16(),
76        status.canonical_reason().unwrap_or("")
77    );
78    if spec.target.include_headers {
79        for (name, value) in &response_headers {
80            let rendered = value.to_str().unwrap_or("<binary>");
81            println!("{}: {}", name.as_str(), rendered);
82        }
83        if !bytes.is_empty() {
84            println!();
85        }
86    } else if let Some(location) = response_headers.get(LOCATION) {
87        if let Ok(location) = location.to_str() {
88            println!("location: {}", location);
89            if !bytes.is_empty() {
90                println!();
91            }
92        }
93    }
94
95    print_response_body(&bytes, &response_headers, spec.target.raw)?;
96
97    if !status.is_success() {
98        return Err(format!(
99            "XBP API request failed with status {} {}",
100            status.as_u16(),
101            status.canonical_reason().unwrap_or("")
102        ));
103    }
104
105    Ok(())
106}
107
108pub fn resolve_method(method: Option<&str>, has_body: bool) -> Result<Method, String> {
109    let inferred = if has_body { "POST" } else { "GET" };
110    let raw = method.unwrap_or(inferred).trim().to_ascii_uppercase();
111    Method::from_str(&raw).map_err(|_| format!("Unsupported HTTP method: {}", raw))
112}
113
114pub fn resolve_request_url(path: &str, target: &ApiTargetOptions) -> Result<Url, String> {
115    resolve_request_url_with_config(path, target, &ApiConfig::load())
116}
117
118fn resolve_request_url_with_config(
119    path: &str,
120    target: &ApiTargetOptions,
121    api_config: &ApiConfig,
122) -> Result<Url, String> {
123    if let Ok(url) = Url::parse(path) {
124        return Ok(url);
125    }
126
127    let base = if let Some(base_url) = target.base_url.as_deref() {
128        normalize_base_url(base_url)
129    } else {
130        if target.web {
131            api_config.web_base_url()
132        } else {
133            api_config.base_url().to_string()
134        }
135    };
136
137    let normalized_path = if path.starts_with('/') {
138        path.to_string()
139    } else {
140        format!("/{}", path)
141    };
142
143    Url::parse(&format!("{}{}", base, normalized_path))
144        .map_err(|e| format!("Failed to build request URL from `{}`: {}", path, e))
145}
146
147fn normalize_base_url(raw: &str) -> String {
148    raw.trim().trim_end_matches('/').to_string()
149}
150
151pub fn load_request_body(
152    body: Option<&str>,
153    body_file: Option<&Path>,
154) -> Result<Option<String>, String> {
155    match (body, body_file) {
156        (Some(_), Some(_)) => Err("Use either --body or --body-file, not both.".to_string()),
157        (Some(body), None) => Ok(Some(body.to_string())),
158        (None, Some(path)) => Ok(Some(read_body_file(path)?)),
159        (None, None) => Ok(None),
160    }
161}
162
163fn read_body_file(path: &Path) -> Result<String, String> {
164    fs::read_to_string(path)
165        .map_err(|e| format!("Failed to read request body file {}: {}", path.display(), e))
166}
167
168pub fn parse_headers(values: &[String]) -> Result<HeaderMap, String> {
169    let mut headers = HeaderMap::new();
170    for value in values {
171        let (name, header_value) = value
172            .split_once(':')
173            .ok_or_else(|| format!("Invalid header `{}`. Use `Name: Value` format.", value))?;
174        let name = HeaderName::from_str(name.trim())
175            .map_err(|e| format!("Invalid header name `{}`: {}", name.trim(), e))?;
176        let header_value = HeaderValue::from_str(header_value.trim())
177            .map_err(|e| format!("Invalid header value for `{}`: {}", name, e))?;
178        headers.append(name, header_value);
179    }
180    Ok(headers)
181}
182
183fn print_response_body(bytes: &[u8], headers: &HeaderMap, raw: bool) -> Result<(), String> {
184    if bytes.is_empty() {
185        return Ok(());
186    }
187
188    let text = String::from_utf8(bytes.to_vec()).map_err(|_| {
189        "Response body is not valid UTF-8; binary output is not supported.".to_string()
190    })?;
191
192    if !raw && is_json_response(headers, &text) {
193        if let Ok(value) = serde_json::from_str::<Value>(&text) {
194            println!(
195                "{}",
196                serde_json::to_string_pretty(&value)
197                    .map_err(|e| format!("Failed to format JSON response: {}", e))?
198            );
199            return Ok(());
200        }
201    }
202
203    println!("{}", text);
204    Ok(())
205}
206
207fn is_json_response(headers: &HeaderMap, body: &str) -> bool {
208    headers
209        .get(CONTENT_TYPE)
210        .and_then(|value| value.to_str().ok())
211        .map(|value| value.contains("application/json") || value.contains("+json"))
212        .unwrap_or_else(|| {
213            let trimmed = body.trim_start();
214            trimmed.starts_with('{') || trimmed.starts_with('[')
215        })
216}
217
218#[cfg(test)]
219mod tests {
220    use super::{
221        is_json_response, load_request_body, parse_headers, resolve_method, resolve_request_url,
222        resolve_request_url_with_config,
223    };
224    use crate::cli::commands::ApiTargetOptions;
225    use crate::config::ApiConfig;
226    use reqwest::header::{HeaderMap, CONTENT_TYPE};
227    use std::env;
228    use std::fs;
229    use std::time::{SystemTime, UNIX_EPOCH};
230
231    fn sample_target() -> ApiTargetOptions {
232        ApiTargetOptions {
233            base_url: None,
234            web: false,
235            no_auth: false,
236            header: vec![],
237            include_headers: false,
238            raw: false,
239        }
240    }
241
242    #[test]
243    fn request_method_defaults_to_get_without_body() {
244        let method = resolve_method(None, false).expect("resolve method");
245        assert_eq!(method.as_str(), "GET");
246    }
247
248    #[test]
249    fn request_method_defaults_to_post_with_body() {
250        let method = resolve_method(None, true).expect("resolve method");
251        assert_eq!(method.as_str(), "POST");
252    }
253
254    #[test]
255    fn request_url_uses_control_plane_base_by_default() {
256        let api = ApiConfig::from_base_url("https://api.example.com/");
257        let url = resolve_request_url_with_config("/health", &sample_target(), &api)
258            .expect("resolve url");
259        assert_eq!(url.as_str(), "https://api.example.com/health");
260    }
261
262    #[test]
263    fn request_url_can_target_web_surface() {
264        let api = ApiConfig::from_base_url("https://api.xbp.app/");
265        let mut target = sample_target();
266        target.web = true;
267        let url =
268            resolve_request_url_with_config("/api/registry", &target, &api).expect("resolve url");
269        assert_eq!(url.as_str(), "https://xbp.app/api/registry");
270    }
271
272    #[test]
273    fn request_url_can_use_base_override() {
274        let mut target = sample_target();
275        target.base_url = Some("http://127.0.0.1:8080/".to_string());
276        let url = resolve_request_url("/routes", &target).expect("resolve url");
277        assert_eq!(url.as_str(), "http://127.0.0.1:8080/routes");
278    }
279
280    #[test]
281    fn headers_require_name_value_shape() {
282        let error = parse_headers(&["broken".to_string()]).expect_err("expected error");
283        assert!(error.contains("Name: Value"));
284    }
285
286    #[test]
287    fn load_request_body_reads_file() {
288        let nanos = SystemTime::now()
289            .duration_since(UNIX_EPOCH)
290            .expect("time")
291            .as_nanos();
292        let path = env::temp_dir().join(format!("xbp-api-request-{}.json", nanos));
293        fs::write(&path, "{\"hello\":\"world\"}").expect("write file");
294
295        let body = load_request_body(None, Some(path.as_path())).expect("load body");
296        assert_eq!(body.as_deref(), Some("{\"hello\":\"world\"}"));
297
298        let _ = fs::remove_file(path);
299    }
300
301    #[test]
302    fn json_detection_accepts_content_type_or_shape() {
303        let mut headers = HeaderMap::new();
304        headers.insert(
305            CONTENT_TYPE,
306            "application/json".parse().expect("content type"),
307        );
308        assert!(is_json_response(&headers, "not json"));
309
310        let headers = HeaderMap::new();
311        assert!(is_json_response(&headers, "{\"ok\":true}"));
312        assert!(!is_json_response(&headers, "plain text"));
313    }
314}