romm_api/client/
openapi.rs1use std::time::Instant;
2
3use crate::config::normalize_romm_origin;
4use crate::error::ApiError;
5
6use super::response::{
7 api_error_from_response_truncated, read_error_response_text, version_from_heartbeat_json,
8};
9use super::RommClient;
10
11pub fn api_root_url(base_url: &str) -> String {
13 normalize_romm_origin(base_url)
14}
15
16fn alternate_http_scheme_root(root: &str) -> Option<String> {
17 root.strip_prefix("http://")
18 .map(|rest| format!("https://{}", rest))
19 .or_else(|| {
20 root.strip_prefix("https://")
21 .map(|rest| format!("http://{}", rest))
22 })
23}
24
25pub fn resolve_openapi_root(api_base_url: &str) -> String {
27 if let Ok(s) = std::env::var("ROMM_OPENAPI_BASE_URL") {
28 let t = s.trim();
29 if !t.is_empty() {
30 return normalize_romm_origin(t);
31 }
32 }
33 normalize_romm_origin(api_base_url)
34}
35
36pub fn openapi_spec_urls(api_root: &str) -> Vec<String> {
38 let root = api_root.trim_end_matches('/').to_string();
39 let mut roots = vec![root.clone()];
40 if let Some(alt) = alternate_http_scheme_root(&root) {
41 if alt != root {
42 roots.push(alt);
43 }
44 }
45
46 let mut urls = Vec::new();
47 for r in roots {
48 let b = r.trim_end_matches('/');
49 urls.push(format!("{b}/openapi.json"));
50 urls.push(format!("{b}/api/openapi.json"));
51 }
52 urls
53}
54
55impl RommClient {
56 pub async fn rom_server_version_from_heartbeat(&self) -> Option<String> {
58 let v = self
59 .request_json_unauthenticated("GET", "/api/heartbeat", &[], None)
60 .await
61 .ok()?;
62 version_from_heartbeat_json(&v)
63 }
64
65 pub async fn fetch_openapi_json(&self) -> Result<String, ApiError> {
67 let root = resolve_openapi_root(&self.base_url);
68 let urls = openapi_spec_urls(&root);
69 let mut failures = Vec::new();
70 for url in &urls {
71 match self.fetch_openapi_json_once(url).await {
72 Ok(body) => return Ok(body),
73 Err(e) => failures.push(format!("{url}: {e}")),
74 }
75 }
76 Err(ApiError::UnexpectedResponse(format!(
77 "could not download OpenAPI ({} attempt(s)): {}",
78 failures.len(),
79 failures.join(" | ")
80 )))
81 }
82
83 async fn fetch_openapi_json_once(&self, url: &str) -> Result<String, ApiError> {
84 let headers = self.build_headers()?;
85
86 let t0 = Instant::now();
87 let resp = self.http.get(url).headers(headers).send().await?;
88
89 let status = resp.status();
90 if self.verbose {
91 tracing::info!(
92 "[romm-cli] GET {} -> {} ({}ms)",
93 crate::log_redact::redact_url_for_log(url),
94 status.as_u16(),
95 t0.elapsed().as_millis()
96 );
97 }
98 if !status.is_success() {
99 let body = read_error_response_text(resp).await;
100 return Err(api_error_from_response_truncated(status, &body, 500));
101 }
102
103 let body = resp.text().await.map_err(ApiError::from)?;
104 validate_openapi_json_body(&body)?;
105 Ok(body)
106 }
107}
108
109fn validate_openapi_json_body(body: &str) -> Result<(), ApiError> {
111 let trimmed = body.trim();
112 if trimmed.is_empty() {
113 return Err(ApiError::UnexpectedResponse(
114 "OpenAPI response body is empty".into(),
115 ));
116 }
117 let value: serde_json::Value = serde_json::from_str(trimmed)
118 .map_err(|e| ApiError::UnexpectedResponse(format!("OpenAPI response is not JSON: {e}")))?;
119 if value.get("paths").and_then(|p| p.as_object()).is_none() {
120 return Err(ApiError::UnexpectedResponse(
121 "OpenAPI JSON missing 'paths' object".into(),
122 ));
123 }
124 Ok(())
125}
126
127#[cfg(test)]
128mod tests {
129 use crate::config::{AuthConfig, Config, ExtrasDefaults};
130 use wiremock::matchers::{method, path};
131 use wiremock::{Mock, MockServer, ResponseTemplate};
132
133 use super::*;
134
135 fn client_for(base_url: &str) -> RommClient {
136 RommClient::new(
137 &Config {
138 base_url: base_url.to_string(),
139 download_dir: ".".to_string(),
140 use_https: false,
141 auth: Some(AuthConfig::Bearer {
142 token: "secret".to_string(),
143 }),
144 extras_defaults: ExtrasDefaults::default(),
145 save_sync: Default::default(),
146 roms_layout: Default::default(),
147 theme: crate::config::default_theme_id(),
148 tui_layout: Default::default(),
149 },
150 false,
151 )
152 .expect("client")
153 }
154
155 #[tokio::test]
156 async fn fetch_openapi_json_rejects_empty_200_body() {
157 let server = MockServer::start().await;
158 Mock::given(method("GET"))
159 .and(path("/openapi.json"))
160 .respond_with(ResponseTemplate::new(200).set_body_string(""))
161 .mount(&server)
162 .await;
163 Mock::given(method("GET"))
164 .and(path("/api/openapi.json"))
165 .respond_with(ResponseTemplate::new(200).set_body_string(""))
166 .mount(&server)
167 .await;
168
169 let err = client_for(&server.uri())
170 .fetch_openapi_json()
171 .await
172 .expect_err("empty OpenAPI body must not succeed");
173 let msg = err.to_string();
174 assert!(
175 msg.contains("OpenAPI") || msg.contains("empty") || msg.contains("JSON"),
176 "{msg}"
177 );
178 }
179
180 #[tokio::test]
181 async fn fetch_openapi_json_skips_empty_and_uses_next_url() {
182 let server = MockServer::start().await;
183 Mock::given(method("GET"))
184 .and(path("/openapi.json"))
185 .respond_with(ResponseTemplate::new(200).set_body_string(""))
186 .mount(&server)
187 .await;
188 let body = r#"{"openapi":"3.0.0","info":{"version":"1.0.0"},"paths":{}}"#;
189 Mock::given(method("GET"))
190 .and(path("/api/openapi.json"))
191 .respond_with(ResponseTemplate::new(200).set_body_string(body))
192 .mount(&server)
193 .await;
194
195 let got = client_for(&server.uri())
196 .fetch_openapi_json()
197 .await
198 .expect("second URL should supply valid OpenAPI JSON");
199 assert!(got.contains("\"paths\""));
200 }
201}