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