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