Skip to main content

romm_cli/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 anyhow::{anyhow, Result};
17use base64::{engine::general_purpose, Engine as _};
18use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
19use reqwest::Client as HttpClient;
20
21use crate::config::{AuthConfig, Config};
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> {
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> {
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)
89                            .map_err(|_| anyhow!("invalid basic auth header value"))?,
90                    );
91                }
92                AuthConfig::Bearer { token } => {
93                    let value = format!("Bearer {token}");
94                    headers.insert(
95                        AUTHORIZATION,
96                        HeaderValue::from_str(&value)
97                            .map_err(|_| anyhow!("invalid bearer auth header value"))?,
98                    );
99                }
100                AuthConfig::ApiKey { header, key } => {
101                    let name = reqwest::header::HeaderName::from_bytes(header.as_bytes()).map_err(
102                        |_| anyhow!("invalid API_KEY_HEADER, must be a valid HTTP header name"),
103                    )?;
104                    headers.insert(
105                        name,
106                        HeaderValue::from_str(key)
107                            .map_err(|_| anyhow!("invalid API_KEY header value"))?,
108                    );
109                }
110            }
111        }
112
113        Ok(headers)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::response::decode_json_response_body;
120    use serde_json::Value;
121
122    #[test]
123    fn decode_json_empty_and_whitespace_to_null() {
124        assert_eq!(decode_json_response_body(b""), Value::Null);
125        assert_eq!(decode_json_response_body(b"  \n\t "), Value::Null);
126    }
127
128    #[test]
129    fn decode_json_object_roundtrip() {
130        let v = decode_json_response_body(br#"{"a":1}"#);
131        assert_eq!(v["a"], 1);
132    }
133
134    #[test]
135    fn decode_non_json_wrapped() {
136        let v = decode_json_response_body(b"plain text");
137        assert_eq!(v["_non_json_body"], "plain text");
138    }
139
140    #[test]
141    fn api_root_url_strips_trailing_api() {
142        assert_eq!(
143            super::api_root_url("http://localhost:8080/api"),
144            "http://localhost:8080"
145        );
146        assert_eq!(
147            super::api_root_url("http://localhost:8080/api/"),
148            "http://localhost:8080"
149        );
150        assert_eq!(
151            super::api_root_url("http://localhost:8080"),
152            "http://localhost:8080"
153        );
154    }
155
156    #[test]
157    fn openapi_spec_urls_try_primary_scheme_then_alt() {
158        let urls = super::openapi_spec_urls("http://example.test");
159        assert_eq!(urls[0], "http://example.test/openapi.json");
160        assert_eq!(urls[1], "http://example.test/api/openapi.json");
161        assert!(
162            urls.iter()
163                .any(|u| u == "https://example.test/openapi.json"),
164            "{urls:?}"
165        );
166    }
167}