Skip to main content

romm_api/client/
mod.rs

1//! HTTP client wrapper around the ROMM API.
2//!
3//! `RommClient` owns a configured `reqwest::Client` plus base URL and
4//! authentication settings. Frontends (CLI, TUI, or a future GUI) depend
5//! on this type instead of talking to `reqwest` directly.
6
7mod download;
8mod openapi;
9mod request;
10mod response;
11mod rom_update;
12mod tasks;
13mod upload;
14
15pub use openapi::{api_root_url, openapi_spec_urls, resolve_openapi_root};
16
17use base64::{engine::general_purpose, Engine as _};
18use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
19use reqwest::Client as HttpClient;
20
21use crate::config::{AuthConfig, Config};
22use crate::error::ApiError;
23
24/// Optional query fields for save uploads used in sync flows.
25#[derive(Debug, Clone, Default)]
26pub struct SaveUploadOptions<'a> {
27    pub emulator: Option<&'a str>,
28    pub slot: Option<&'a str>,
29    pub device_id: Option<&'a str>,
30    pub session_id: Option<u64>,
31    pub overwrite: bool,
32}
33
34/// High-level HTTP client for the ROMM API.
35///
36/// This type hides the details of `reqwest` and authentication headers
37/// behind a small interface that all frontends can share.
38#[derive(Clone)]
39pub struct RommClient {
40    pub(crate) http: HttpClient,
41    pub(crate) base_url: String,
42    pub(crate) auth: Option<AuthConfig>,
43    pub(crate) verbose: bool,
44}
45
46/// Default `User-Agent` for every request. The stock `reqwest` UA is sometimes blocked at the HTTP
47/// layer (403, etc.) by reverse proxies; override with env `ROMM_USER_AGENT` if needed.
48pub(crate) fn http_user_agent() -> String {
49    match std::env::var("ROMM_USER_AGENT") {
50        Ok(s) if !s.trim().is_empty() => s,
51        _ => format!(
52            "Mozilla/5.0 (compatible; romm-cli/{}; +https://github.com/patricksmill/romm-cli)",
53            env!("CARGO_PKG_VERSION")
54        ),
55    }
56}
57
58impl RommClient {
59    /// Construct a new client from the high-level [`Config`].
60    pub fn new(config: &Config, verbose: bool) -> Result<Self, ApiError> {
61        let http = HttpClient::builder()
62            .user_agent(http_user_agent())
63            .build()?;
64        Ok(Self {
65            http,
66            base_url: config.base_url.clone(),
67            auth: config.auth.clone(),
68            verbose,
69        })
70    }
71
72    /// Returns true if verbose logging is enabled.
73    pub fn verbose(&self) -> bool {
74        self.verbose
75    }
76
77    pub fn base_url(&self) -> &str {
78        &self.base_url
79    }
80
81    /// Build the HTTP headers for the current authentication mode.
82    pub(crate) fn build_headers(&self) -> Result<HeaderMap, ApiError> {
83        let mut headers = HeaderMap::new();
84
85        if let Some(auth) = &self.auth {
86            match auth {
87                AuthConfig::Basic { username, password } => {
88                    let creds = format!("{username}:{password}");
89                    let encoded = general_purpose::STANDARD.encode(creds.as_bytes());
90                    let value = format!("Basic {encoded}");
91                    headers.insert(
92                        AUTHORIZATION,
93                        HeaderValue::from_str(&value).map_err(|_| {
94                            ApiError::InvalidHeader("invalid basic auth header value".into())
95                        })?,
96                    );
97                }
98                AuthConfig::Bearer { token } => {
99                    let value = format!("Bearer {token}");
100                    headers.insert(
101                        AUTHORIZATION,
102                        HeaderValue::from_str(&value).map_err(|_| {
103                            ApiError::InvalidHeader("invalid bearer auth header value".into())
104                        })?,
105                    );
106                }
107                AuthConfig::ApiKey { header, key } => {
108                    let name = reqwest::header::HeaderName::from_bytes(header.as_bytes()).map_err(
109                        |_| {
110                            ApiError::InvalidHeader(
111                                "invalid API_KEY_HEADER, must be a valid HTTP header name".into(),
112                            )
113                        },
114                    )?;
115                    headers.insert(
116                        name,
117                        HeaderValue::from_str(key).map_err(|_| {
118                            ApiError::InvalidHeader("invalid API_KEY header value".into())
119                        })?,
120                    );
121                }
122            }
123        }
124
125        Ok(headers)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::response::decode_json_response_body;
132    use serde_json::Value;
133
134    #[test]
135    fn decode_json_empty_and_whitespace_to_null() {
136        assert_eq!(decode_json_response_body(b""), Value::Null);
137        assert_eq!(decode_json_response_body(b"  \n\t "), Value::Null);
138    }
139
140    #[test]
141    fn decode_json_object_roundtrip() {
142        let v = decode_json_response_body(br#"{"a":1}"#);
143        assert_eq!(v["a"], 1);
144    }
145
146    #[test]
147    fn decode_non_json_wrapped() {
148        let v = decode_json_response_body(b"plain text");
149        assert_eq!(v["_non_json_body"], "plain text");
150    }
151
152    #[test]
153    fn api_root_url_strips_trailing_api() {
154        assert_eq!(
155            super::api_root_url("http://localhost:8080/api"),
156            "http://localhost:8080"
157        );
158        assert_eq!(
159            super::api_root_url("http://localhost:8080/api/"),
160            "http://localhost:8080"
161        );
162        assert_eq!(
163            super::api_root_url("http://localhost:8080"),
164            "http://localhost:8080"
165        );
166    }
167
168    #[test]
169    fn openapi_spec_urls_try_primary_scheme_then_alt() {
170        let urls = super::openapi_spec_urls("http://example.test");
171        assert_eq!(urls[0], "http://example.test/openapi.json");
172        assert_eq!(urls[1], "http://example.test/api/openapi.json");
173        assert!(
174            urls.iter()
175                .any(|u| u == "https://example.test/openapi.json"),
176            "{urls:?}"
177        );
178    }
179}