use std::time::Instant;
use crate::config::normalize_romm_origin;
use crate::error::ApiError;
use super::response::{
api_error_from_response_truncated, read_error_response_text, version_from_heartbeat_json,
};
use super::RommClient;
pub fn api_root_url(base_url: &str) -> String {
normalize_romm_origin(base_url)
}
fn alternate_http_scheme_root(root: &str) -> Option<String> {
root.strip_prefix("http://")
.map(|rest| format!("https://{}", rest))
.or_else(|| {
root.strip_prefix("https://")
.map(|rest| format!("http://{}", rest))
})
}
pub fn resolve_openapi_root(api_base_url: &str) -> String {
if let Ok(s) = std::env::var("ROMM_OPENAPI_BASE_URL") {
let t = s.trim();
if !t.is_empty() {
return normalize_romm_origin(t);
}
}
normalize_romm_origin(api_base_url)
}
pub fn openapi_spec_urls(api_root: &str) -> Vec<String> {
let root = api_root.trim_end_matches('/').to_string();
let mut roots = vec![root.clone()];
if let Some(alt) = alternate_http_scheme_root(&root) {
if alt != root {
roots.push(alt);
}
}
let mut urls = Vec::new();
for r in roots {
let b = r.trim_end_matches('/');
urls.push(format!("{b}/openapi.json"));
urls.push(format!("{b}/api/openapi.json"));
}
urls
}
impl RommClient {
pub async fn rom_server_version_from_heartbeat(&self) -> Option<String> {
let v = self
.request_json_unauthenticated("GET", "/api/heartbeat", &[], None)
.await
.ok()?;
version_from_heartbeat_json(&v)
}
pub async fn fetch_openapi_json(&self) -> Result<String, ApiError> {
let root = resolve_openapi_root(&self.base_url);
let urls = openapi_spec_urls(&root);
let mut failures = Vec::new();
for url in &urls {
match self.fetch_openapi_json_once(url).await {
Ok(body) => return Ok(body),
Err(e) => failures.push(format!("{url}: {e}")),
}
}
Err(ApiError::UnexpectedResponse(format!(
"could not download OpenAPI ({} attempt(s)): {}",
failures.len(),
failures.join(" | ")
)))
}
async fn fetch_openapi_json_once(&self, url: &str) -> Result<String, ApiError> {
let headers = self.build_headers()?;
let t0 = Instant::now();
let resp = self.http.get(url).headers(headers).send().await?;
let status = resp.status();
if self.verbose {
tracing::info!(
"[romm-cli] GET {} -> {} ({}ms)",
crate::log_redact::redact_url_for_log(url),
status.as_u16(),
t0.elapsed().as_millis()
);
}
if !status.is_success() {
let body = read_error_response_text(resp).await;
return Err(api_error_from_response_truncated(status, &body, 500));
}
let body = resp.text().await.map_err(ApiError::from)?;
validate_openapi_json_body(&body)?;
Ok(body)
}
}
fn validate_openapi_json_body(body: &str) -> Result<(), ApiError> {
let trimmed = body.trim();
if trimmed.is_empty() {
return Err(ApiError::UnexpectedResponse(
"OpenAPI response body is empty".into(),
));
}
let value: serde_json::Value = serde_json::from_str(trimmed)
.map_err(|e| ApiError::UnexpectedResponse(format!("OpenAPI response is not JSON: {e}")))?;
if value.get("paths").and_then(|p| p.as_object()).is_none() {
return Err(ApiError::UnexpectedResponse(
"OpenAPI JSON missing 'paths' object".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::config::{AuthConfig, Config, ExtrasDefaults};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
fn client_for(base_url: &str) -> RommClient {
RommClient::new(
&Config {
base_url: base_url.to_string(),
download_dir: ".".to_string(),
use_https: false,
auth: Some(AuthConfig::Bearer {
token: "secret".to_string(),
}),
extras_defaults: ExtrasDefaults::default(),
save_sync: Default::default(),
roms_layout: Default::default(),
theme: crate::config::default_theme_id(),
tui_layout: Default::default(),
},
false,
)
.expect("client")
}
#[tokio::test]
async fn fetch_openapi_json_rejects_empty_200_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/openapi.json"))
.respond_with(ResponseTemplate::new(200).set_body_string(""))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/openapi.json"))
.respond_with(ResponseTemplate::new(200).set_body_string(""))
.mount(&server)
.await;
let err = client_for(&server.uri())
.fetch_openapi_json()
.await
.expect_err("empty OpenAPI body must not succeed");
let msg = err.to_string();
assert!(
msg.contains("OpenAPI") || msg.contains("empty") || msg.contains("JSON"),
"{msg}"
);
}
#[tokio::test]
async fn fetch_openapi_json_skips_empty_and_uses_next_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/openapi.json"))
.respond_with(ResponseTemplate::new(200).set_body_string(""))
.mount(&server)
.await;
let body = r#"{"openapi":"3.0.0","info":{"version":"1.0.0"},"paths":{}}"#;
Mock::given(method("GET"))
.and(path("/api/openapi.json"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(&server)
.await;
let got = client_for(&server.uri())
.fetch_openapi_json()
.await
.expect("second URL should supply valid OpenAPI JSON");
assert!(got.contains("\"paths\""));
}
}