Skip to main content

romm_cli/tui/
openapi_sync.rs

1//! Load the RomM OpenAPI spec for the API browser: prefer the live server, fall back to cache,
2//! then a bundled copy shipped in the binary so the TUI always starts without manual `openapi.json`.
3
4use anyhow::{anyhow, Result};
5use serde_json::Value;
6use std::path::Path;
7
8use crate::client::RommClient;
9use crate::tui::openapi::EndpointRegistry;
10
11/// OpenAPI document baked into the binary (same as `openapi.json` in the crate root at build time).
12const EMBEDDED_OPENAPI_JSON: &str =
13    include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/openapi.json"));
14
15fn openapi_from_cwd() -> Option<String> {
16    let dir = std::env::current_dir().ok()?;
17    let p = dir.join("openapi.json");
18    if p.is_file() {
19        std::fs::read_to_string(p).ok()
20    } else {
21        None
22    }
23}
24
25pub fn parse_openapi_info_version(json: &str) -> Option<String> {
26    let v: Value = serde_json::from_str(json).ok()?;
27    v.get("info")?.get("version")?.as_str().map(String::from)
28}
29
30fn heartbeat_rom_version(v: &Value) -> Option<String> {
31    v.get("SYSTEM")?.get("VERSION")?.as_str().map(String::from)
32}
33
34/// Resolve OpenAPI JSON: try the server first (updates disk cache when the spec changes), then
35/// `./openapi.json`, then the user cache file, then the embedded bundle.
36///
37/// Also calls `GET /api/heartbeat` for the RomM server version shown in Settings.
38pub async fn sync_openapi_registry(
39    client: &RommClient,
40    cache_path: &Path,
41) -> Result<(EndpointRegistry, Option<String>)> {
42    let fetch_result = client.fetch_openapi_json().await;
43
44    let openapi_body = match fetch_result {
45        Ok(body) => {
46            let remote_ver = parse_openapi_info_version(&body);
47            let local_ver = std::fs::read_to_string(cache_path)
48                .ok()
49                .as_deref()
50                .and_then(parse_openapi_info_version);
51
52            let needs_write =
53                !cache_path.is_file() || local_ver.as_deref() != remote_ver.as_deref();
54
55            if needs_write {
56                if let Some(parent) = cache_path.parent() {
57                    std::fs::create_dir_all(parent)
58                        .map_err(|e| anyhow!("create OpenAPI cache dir: {e}"))?;
59                }
60                std::fs::write(cache_path, &body)
61                    .map_err(|e| anyhow!("write OpenAPI cache {}: {e}", cache_path.display()))?;
62                tracing::info!(
63                    "OpenAPI cache {} (version {:?})",
64                    cache_path.display(),
65                    remote_ver
66                );
67            }
68            body
69        }
70        Err(e) => {
71            if let Some(body) = openapi_from_cwd() {
72                tracing::warn!(
73                    "Using ./openapi.json (could not fetch from server: {:#})",
74                    e
75                );
76                body
77            } else if let Ok(cached) = std::fs::read_to_string(cache_path) {
78                tracing::warn!(
79                    "Using cached OpenAPI at {} (server unreachable: {})",
80                    cache_path.display(),
81                    e
82                );
83                cached
84            } else {
85                tracing::warn!(
86                    "Using bundled OpenAPI spec (server unreachable: {:#}). \
87                     API browser paths match the build-time snapshot; connect to refresh from your server.",
88                    e
89                );
90                EMBEDDED_OPENAPI_JSON.to_string()
91            }
92        }
93    };
94
95    let registry = EndpointRegistry::from_openapi_json(&openapi_body)
96        .map_err(|e| anyhow!("invalid OpenAPI document: {e}"))?;
97
98    let server_version = client
99        .request_json("GET", "/api/heartbeat", &[], None)
100        .await
101        .ok()
102        .as_ref()
103        .and_then(heartbeat_rom_version);
104
105    Ok((registry, server_version))
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn parses_info_version() {
114        let j = r#"{"openapi":"3.0.0","info":{"version":"1.2.3"},"paths":{}}"#;
115        assert_eq!(parse_openapi_info_version(j), Some("1.2.3".to_string()));
116    }
117
118    #[test]
119    fn embedded_openapi_json_parses() {
120        super::EndpointRegistry::from_openapi_json(EMBEDDED_OPENAPI_JSON)
121            .expect("bundled openapi.json");
122    }
123}