Skip to main content

romm_api/client/
openapi.rs

1use 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
11/// Returns the browser-style origin for RomM (no `/api` suffix).
12pub 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
25/// Resolves the origin used to fetch `/openapi.json`.
26pub 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
36/// Returns a list of candidate URLs to try for the OpenAPI JSON document.
37pub 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    /// RomM application version from `GET /api/heartbeat` (`SYSTEM.VERSION`), if the endpoint succeeds.
57    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    /// GET the OpenAPI spec from the server.
66    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
109/// Reject empty/HTML/non-OpenAPI 200 bodies so callers can try the next URL or fall back.
110fn 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}