Skip to main content

romm_cli/client/
openapi.rs

1use anyhow::{anyhow, Result};
2use std::time::Instant;
3
4use crate::config::normalize_romm_origin;
5
6use super::response::{
7    read_error_response_text, romm_api_error_truncated, 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> {
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(anyhow!(
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> {
84        let headers = self.build_headers()?;
85
86        let t0 = Instant::now();
87        let resp = self
88            .http
89            .get(url)
90            .headers(headers)
91            .send()
92            .await
93            .map_err(|e| anyhow!("request failed: {e}"))?;
94
95        let status = resp.status();
96        if self.verbose {
97            tracing::info!(
98                "[romm-cli] GET {} -> {} ({}ms)",
99                url,
100                status.as_u16(),
101                t0.elapsed().as_millis()
102            );
103        }
104        if !status.is_success() {
105            let body = read_error_response_text(resp).await;
106            return Err(romm_api_error_truncated(status, &body, 500));
107        }
108
109        resp.text()
110            .await
111            .map_err(|e| anyhow!("read OpenAPI body: {e}"))
112    }
113}