Skip to main content

romm_cli/
client.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
7use anyhow::{anyhow, Result};
8use base64::{engine::general_purpose, Engine as _};
9use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
10use reqwest::multipart;
11use reqwest::{Client as HttpClient, Method};
12use serde_json::Value;
13use std::path::Path;
14use std::time::Instant;
15use tokio::io::AsyncWriteExt as _;
16
17use crate::config::{normalize_romm_origin, AuthConfig, Config};
18use crate::core::interrupt::cancelled_error;
19use crate::endpoints::Endpoint;
20
21/// Default `User-Agent` for every request. The stock `reqwest` UA is sometimes blocked at the HTTP
22/// layer (403, etc.) by reverse proxies; override with env `ROMM_USER_AGENT` if needed.
23fn http_user_agent() -> String {
24    match std::env::var("ROMM_USER_AGENT") {
25        Ok(s) if !s.trim().is_empty() => s,
26        _ => format!(
27            "Mozilla/5.0 (compatible; romm-cli/{}; +https://github.com/patricksmill/romm-cli)",
28            env!("CARGO_PKG_VERSION")
29        ),
30    }
31}
32
33/// Map a successful HTTP response body to JSON [`Value`].
34///
35/// Empty or whitespace-only bodies become [`Value::Null`] (e.g. HTTP 204).
36/// Non-JSON UTF-8 bodies are wrapped as `{"_non_json_body": "..."}`.
37fn decode_json_response_body(bytes: &[u8]) -> Value {
38    if bytes.is_empty() || bytes.iter().all(|b| b.is_ascii_whitespace()) {
39        return Value::Null;
40    }
41    serde_json::from_slice(bytes).unwrap_or_else(|_| {
42        serde_json::json!({
43            "_non_json_body": String::from_utf8_lossy(bytes).to_string()
44        })
45    })
46}
47
48fn version_from_heartbeat_json(v: &Value) -> Option<String> {
49    v.get("SYSTEM")?.get("VERSION")?.as_str().map(String::from)
50}
51
52/// High-level HTTP client for the ROMM API.
53///
54/// This type hides the details of `reqwest` and authentication headers
55/// behind a small, easy-to-mock interface that all frontends can share.
56#[derive(Clone)]
57pub struct RommClient {
58    http: HttpClient,
59    base_url: String,
60    auth: Option<AuthConfig>,
61    verbose: bool,
62}
63
64/// Same as [`crate::config::normalize_romm_origin`]: browser-style origin for RomM (no `/api` suffix).
65pub fn api_root_url(base_url: &str) -> String {
66    normalize_romm_origin(base_url)
67}
68
69fn alternate_http_scheme_root(root: &str) -> Option<String> {
70    root.strip_prefix("http://")
71        .map(|rest| format!("https://{}", rest))
72        .or_else(|| {
73            root.strip_prefix("https://")
74                .map(|rest| format!("http://{}", rest))
75        })
76}
77
78/// Origin used to fetch `/openapi.json` (same as the RomM website). Normally equals
79/// [`normalize_romm_origin`] applied to `API_BASE_URL`.
80///
81/// Set `ROMM_OPENAPI_BASE_URL` only when that origin differs (wrong host in `API_BASE_URL`, split
82/// DNS, etc.).
83pub fn resolve_openapi_root(api_base_url: &str) -> String {
84    if let Ok(s) = std::env::var("ROMM_OPENAPI_BASE_URL") {
85        let t = s.trim();
86        if !t.is_empty() {
87            return normalize_romm_origin(t);
88        }
89    }
90    normalize_romm_origin(api_base_url)
91}
92
93/// URLs to try for the OpenAPI JSON document (scheme fallback and alternate paths).
94///
95/// `api_root` is an origin such as `https://example.com` (see [`resolve_openapi_root`]).
96pub fn openapi_spec_urls(api_root: &str) -> Vec<String> {
97    let root = api_root.trim_end_matches('/').to_string();
98    let mut roots = vec![root.clone()];
99    if let Some(alt) = alternate_http_scheme_root(&root) {
100        if alt != root {
101            roots.push(alt);
102        }
103    }
104
105    let mut urls = Vec::new();
106    for r in roots {
107        let b = r.trim_end_matches('/');
108        urls.push(format!("{b}/openapi.json"));
109        urls.push(format!("{b}/api/openapi.json"));
110    }
111    urls
112}
113
114impl RommClient {
115    /// Construct a new client from the high-level [`Config`].
116    ///
117    /// `verbose` enables stderr request logging (method, path, query key names, status, timing).
118    /// This is typically done once in `main` and the resulting `RommClient` is shared
119    /// (by reference or cloning) with the chosen frontend.
120    pub fn new(config: &Config, verbose: bool) -> Result<Self> {
121        let http = HttpClient::builder()
122            .user_agent(http_user_agent())
123            .build()?;
124        Ok(Self {
125            http,
126            base_url: config.base_url.clone(),
127            auth: config.auth.clone(),
128            verbose,
129        })
130    }
131
132    pub fn verbose(&self) -> bool {
133        self.verbose
134    }
135
136    /// Build the HTTP headers for the current authentication mode.
137    ///
138    /// This helper centralises all auth logic so that the rest of the
139    /// code never needs to worry about `Basic` vs `Bearer` vs API key.
140    fn build_headers(&self) -> Result<HeaderMap> {
141        let mut headers = HeaderMap::new();
142
143        if let Some(auth) = &self.auth {
144            match auth {
145                AuthConfig::Basic { username, password } => {
146                    let creds = format!("{username}:{password}");
147                    let encoded = general_purpose::STANDARD.encode(creds.as_bytes());
148                    let value = format!("Basic {encoded}");
149                    headers.insert(
150                        AUTHORIZATION,
151                        HeaderValue::from_str(&value)
152                            .map_err(|_| anyhow!("invalid basic auth header value"))?,
153                    );
154                }
155                AuthConfig::Bearer { token } => {
156                    let value = format!("Bearer {token}");
157                    headers.insert(
158                        AUTHORIZATION,
159                        HeaderValue::from_str(&value)
160                            .map_err(|_| anyhow!("invalid bearer auth header value"))?,
161                    );
162                }
163                AuthConfig::ApiKey { header, key } => {
164                    let name = reqwest::header::HeaderName::from_bytes(header.as_bytes()).map_err(
165                        |_| anyhow!("invalid API_KEY_HEADER, must be a valid HTTP header name"),
166                    )?;
167                    headers.insert(
168                        name,
169                        HeaderValue::from_str(key)
170                            .map_err(|_| anyhow!("invalid API_KEY header value"))?,
171                    );
172                }
173            }
174        }
175
176        Ok(headers)
177    }
178
179    /// Call a typed endpoint using the low-level `request_json` primitive.
180    pub async fn call<E>(&self, ep: &E) -> anyhow::Result<E::Output>
181    where
182        E: Endpoint,
183        E::Output: serde::de::DeserializeOwned,
184    {
185        let method = ep.method();
186        let path = ep.path();
187        let query = ep.query();
188        let body = ep.body();
189
190        let value = self.request_json(method, &path, &query, body).await?;
191        let output = serde_json::from_value(value)
192            .map_err(|e| anyhow!("failed to decode response for {} {}: {}", method, path, e))?;
193
194        Ok(output)
195    }
196
197    /// Low-level helper that issues an HTTP request and returns raw JSON.
198    ///
199    /// Higher-level helpers (such as typed `Endpoint` implementations)
200    /// should prefer [`RommClient::call`] instead of using this directly.
201    pub async fn request_json(
202        &self,
203        method: &str,
204        path: &str,
205        query: &[(String, String)],
206        body: Option<Value>,
207    ) -> Result<Value> {
208        let url = format!(
209            "{}/{}",
210            self.base_url.trim_end_matches('/'),
211            path.trim_start_matches('/')
212        );
213        let headers = self.build_headers()?;
214
215        let http_method = Method::from_bytes(method.as_bytes())
216            .map_err(|_| anyhow!("invalid HTTP method: {method}"))?;
217
218        // Ensure query params serialize as key=value pairs (reqwest/serde_urlencoded
219        // expect sequences of (key, value); using &[(&str, &str)] guarantees correct encoding).
220        let query_refs: Vec<(&str, &str)> = query
221            .iter()
222            .map(|(k, v)| (k.as_str(), v.as_str()))
223            .collect();
224
225        let mut req = self
226            .http
227            .request(http_method, &url)
228            .headers(headers)
229            .query(&query_refs);
230
231        if let Some(body) = body {
232            req = req.json(&body);
233        }
234
235        let t0 = Instant::now();
236        let resp = req
237            .send()
238            .await
239            .map_err(|e| anyhow!("request error: {e}"))?;
240
241        let status = resp.status();
242        if self.verbose {
243            let keys: Vec<&str> = query.iter().map(|(k, _)| k.as_str()).collect();
244            tracing::info!(
245                "[romm-cli] {} {} query_keys={:?} -> {} ({}ms)",
246                method,
247                path,
248                keys,
249                status.as_u16(),
250                t0.elapsed().as_millis()
251            );
252        }
253        if !status.is_success() {
254            let body = resp.text().await.unwrap_or_default();
255            return Err(anyhow!(
256                "ROMM API error: {} {} - {}",
257                status.as_u16(),
258                status.canonical_reason().unwrap_or(""),
259                body
260            ));
261        }
262
263        let bytes = resp
264            .bytes()
265            .await
266            .map_err(|e| anyhow!("read response body: {e}"))?;
267
268        Ok(decode_json_response_body(&bytes))
269    }
270
271    pub async fn request_json_unauthenticated(
272        &self,
273        method: &str,
274        path: &str,
275        query: &[(String, String)],
276        body: Option<Value>,
277    ) -> Result<Value> {
278        let url = format!(
279            "{}/{}",
280            self.base_url.trim_end_matches('/'),
281            path.trim_start_matches('/')
282        );
283        let headers = HeaderMap::new();
284
285        let http_method = Method::from_bytes(method.as_bytes())
286            .map_err(|_| anyhow!("invalid HTTP method: {method}"))?;
287
288        // Ensure query params serialize as key=value pairs (reqwest/serde_urlencoded
289        // expect sequences of (key, value); using &[(&str, &str)] guarantees correct encoding).
290        let query_refs: Vec<(&str, &str)> = query
291            .iter()
292            .map(|(k, v)| (k.as_str(), v.as_str()))
293            .collect();
294
295        let mut req = self
296            .http
297            .request(http_method, &url)
298            .headers(headers)
299            .query(&query_refs);
300
301        if let Some(body) = body {
302            req = req.json(&body);
303        }
304
305        let t0 = Instant::now();
306        let resp = req
307            .send()
308            .await
309            .map_err(|e| anyhow!("request error: {e}"))?;
310
311        let status = resp.status();
312        if self.verbose {
313            let keys: Vec<&str> = query.iter().map(|(k, _)| k.as_str()).collect();
314            tracing::info!(
315                "[romm-cli] {} {} query_keys={:?} -> {} ({}ms)",
316                method,
317                path,
318                keys,
319                status.as_u16(),
320                t0.elapsed().as_millis()
321            );
322        }
323        if !status.is_success() {
324            let body = resp.text().await.unwrap_or_default();
325            return Err(anyhow!(
326                "ROMM API error: {} {} - {}",
327                status.as_u16(),
328                status.canonical_reason().unwrap_or(""),
329                body
330            ));
331        }
332
333        let bytes = resp
334            .bytes()
335            .await
336            .map_err(|e| anyhow!("read response body: {e}"))?;
337
338        Ok(decode_json_response_body(&bytes))
339    }
340
341    /// RomM application version from `GET /api/heartbeat` (`SYSTEM.VERSION`), if the endpoint succeeds.
342    pub async fn rom_server_version_from_heartbeat(&self) -> Option<String> {
343        let v = self
344            .request_json_unauthenticated("GET", "/api/heartbeat", &[], None)
345            .await
346            .ok()?;
347        version_from_heartbeat_json(&v)
348    }
349
350    /// GET the OpenAPI spec from the server. Tries [`openapi_spec_urls`] in order (HTTP/HTTPS and
351    /// `/openapi.json` vs `/api/openapi.json`). Uses [`resolve_openapi_root`] for the origin.
352    pub async fn fetch_openapi_json(&self) -> Result<String> {
353        let root = resolve_openapi_root(&self.base_url);
354        let urls = openapi_spec_urls(&root);
355        let mut failures = Vec::new();
356        for url in &urls {
357            match self.fetch_openapi_json_once(url).await {
358                Ok(body) => return Ok(body),
359                Err(e) => failures.push(format!("{url}: {e:#}")),
360            }
361        }
362        Err(anyhow!(
363            "could not download OpenAPI ({} attempt(s)): {}",
364            failures.len(),
365            failures.join(" | ")
366        ))
367    }
368
369    async fn fetch_openapi_json_once(&self, url: &str) -> Result<String> {
370        let headers = self.build_headers()?;
371
372        let t0 = Instant::now();
373        let resp = self
374            .http
375            .get(url)
376            .headers(headers)
377            .send()
378            .await
379            .map_err(|e| anyhow!("request failed: {e}"))?;
380
381        let status = resp.status();
382        if self.verbose {
383            tracing::info!(
384                "[romm-cli] GET {} -> {} ({}ms)",
385                url,
386                status.as_u16(),
387                t0.elapsed().as_millis()
388            );
389        }
390        if !status.is_success() {
391            let body = resp.text().await.unwrap_or_default();
392            return Err(anyhow!(
393                "HTTP {} {} - {}",
394                status.as_u16(),
395                status.canonical_reason().unwrap_or(""),
396                body.chars().take(500).collect::<String>()
397            ));
398        }
399
400        resp.text()
401            .await
402            .map_err(|e| anyhow!("read OpenAPI body: {e}"))
403    }
404
405    /// Download ROM(s) as a zip file to `save_path`, calling `on_progress(received, total)`.
406    /// Uses GET /api/roms/download?rom_ids={id}&filename=... per RomM OpenAPI.
407    ///
408    /// If `save_path` already exists on disk (e.g. from a previous interrupted
409    /// download), the client sends an HTTP `Range` header to resume from the
410    /// existing byte offset. The server may reply with `206 Partial Content`
411    /// (resume works) or `200 OK` (server doesn't support ranges — restart
412    /// from scratch).
413    pub async fn download_rom<F>(
414        &self,
415        rom_id: u64,
416        save_path: &Path,
417        mut on_progress: F,
418    ) -> Result<()>
419    where
420        F: FnMut(u64, u64) + Send,
421    {
422        self.download_rom_with_cancel(rom_id, save_path, |_, _| false, &mut on_progress)
423            .await
424    }
425
426    pub async fn download_rom_with_cancel<F, C>(
427        &self,
428        rom_id: u64,
429        save_path: &Path,
430        mut is_cancelled: C,
431        on_progress: &mut F,
432    ) -> Result<()>
433    where
434        F: FnMut(u64, u64) + Send,
435        C: FnMut(u64, u64) -> bool + Send,
436    {
437        let path = "/api/roms/download";
438        let url = format!(
439            "{}/{}",
440            self.base_url.trim_end_matches('/'),
441            path.trim_start_matches('/')
442        );
443        let mut headers = self.build_headers()?;
444
445        let filename = save_path
446            .file_name()
447            .and_then(|n| n.to_str())
448            .unwrap_or("download.zip");
449
450        // Check for an existing partial file to resume from.
451        let existing_len = tokio::fs::metadata(save_path)
452            .await
453            .map(|m| m.len())
454            .unwrap_or(0);
455
456        if existing_len > 0 {
457            let range = format!("bytes={existing_len}-");
458            if let Ok(v) = reqwest::header::HeaderValue::from_str(&range) {
459                headers.insert(reqwest::header::RANGE, v);
460            }
461        }
462
463        let t0 = Instant::now();
464        let mut resp = self
465            .http
466            .get(&url)
467            .headers(headers)
468            .query(&[
469                ("rom_ids", rom_id.to_string()),
470                ("filename", filename.to_string()),
471            ])
472            .send()
473            .await
474            .map_err(|e| anyhow!("download request error: {e}"))?;
475
476        let status = resp.status();
477        if self.verbose {
478            tracing::info!(
479                "[romm-cli] GET /api/roms/download rom_id={} filename={:?} -> {} ({}ms)",
480                rom_id,
481                filename,
482                status.as_u16(),
483                t0.elapsed().as_millis()
484            );
485        }
486        if !status.is_success() {
487            let body = resp.text().await.unwrap_or_default();
488            return Err(anyhow!(
489                "ROMM API error: {} {} - {}",
490                status.as_u16(),
491                status.canonical_reason().unwrap_or(""),
492                body
493            ));
494        }
495
496        // Determine whether the server honoured our Range header.
497        let (mut received, total, mut file) = if status == reqwest::StatusCode::PARTIAL_CONTENT {
498            // 206 — resume: content_length is the *remaining* bytes.
499            let remaining = resp.content_length().unwrap_or(0);
500            let total = existing_len + remaining;
501            let file = tokio::fs::OpenOptions::new()
502                .append(true)
503                .open(save_path)
504                .await
505                .map_err(|e| anyhow!("open file for append {:?}: {e}", save_path))?;
506            (existing_len, total, file)
507        } else {
508            // 200 — server doesn't support ranges; start from scratch.
509            let total = resp.content_length().unwrap_or(0);
510            let file = tokio::fs::File::create(save_path)
511                .await
512                .map_err(|e| anyhow!("create file {:?}: {e}", save_path))?;
513            (0u64, total, file)
514        };
515
516        if is_cancelled(received, total) {
517            return Err(cancelled_error());
518        }
519
520        while let Some(chunk) = resp.chunk().await.map_err(|e| anyhow!("read chunk: {e}"))? {
521            if is_cancelled(received, total) {
522                return Err(cancelled_error());
523            }
524            file.write_all(&chunk)
525                .await
526                .map_err(|e| anyhow!("write chunk {:?}: {e}", save_path))?;
527            received += chunk.len() as u64;
528            on_progress(received, total);
529        }
530
531        Ok(())
532    }
533
534    /// Upload a ROM file using the RomM chunked upload API.
535    pub async fn upload_rom<F>(
536        &self,
537        platform_id: u64,
538        file_path: &Path,
539        mut on_progress: F,
540    ) -> Result<()>
541    where
542        F: FnMut(u64, u64) + Send,
543    {
544        let filename = file_path
545            .file_name()
546            .and_then(|n| n.to_str())
547            .ok_or_else(|| anyhow!("Invalid filename for upload"))?;
548
549        let metadata = tokio::fs::metadata(file_path)
550            .await
551            .map_err(|e| anyhow!("Failed to read file metadata {:?}: {}", file_path, e))?;
552        let total_size = metadata.len();
553
554        // 2MB chunk size
555        let chunk_size: u64 = 2 * 1024 * 1024;
556        // Use integer division ceiling
557        let total_chunks = if total_size == 0 {
558            1
559        } else {
560            total_size.div_ceil(chunk_size)
561        };
562
563        let mut start_headers = self.build_headers()?;
564        start_headers.insert(
565            reqwest::header::HeaderName::from_static("x-upload-platform"),
566            reqwest::header::HeaderValue::from_str(&platform_id.to_string())?,
567        );
568        start_headers.insert(
569            reqwest::header::HeaderName::from_static("x-upload-filename"),
570            reqwest::header::HeaderValue::from_str(filename)?,
571        );
572        start_headers.insert(
573            reqwest::header::HeaderName::from_static("x-upload-total-size"),
574            reqwest::header::HeaderValue::from_str(&total_size.to_string())?,
575        );
576        start_headers.insert(
577            reqwest::header::HeaderName::from_static("x-upload-total-chunks"),
578            reqwest::header::HeaderValue::from_str(&total_chunks.to_string())?,
579        );
580
581        let start_url = format!(
582            "{}/api/roms/upload/start",
583            self.base_url.trim_end_matches('/')
584        );
585
586        let t0 = Instant::now();
587        let resp = self
588            .http
589            .post(&start_url)
590            .headers(start_headers)
591            .send()
592            .await
593            .map_err(|e| anyhow!("upload start request error: {}", e))?;
594
595        let status = resp.status();
596        if self.verbose {
597            tracing::info!(
598                "[romm-cli] POST /api/roms/upload/start -> {} ({}ms)",
599                status.as_u16(),
600                t0.elapsed().as_millis()
601            );
602        }
603
604        if !status.is_success() {
605            let body = resp.text().await.unwrap_or_default();
606            return Err(anyhow!(
607                "ROMM API error: {} {} - {}",
608                status.as_u16(),
609                status.canonical_reason().unwrap_or(""),
610                body
611            ));
612        }
613
614        let start_resp: Value = resp
615            .json()
616            .await
617            .map_err(|e| anyhow!("failed to parse start upload response: {}", e))?;
618        let upload_id = start_resp
619            .get("upload_id")
620            .and_then(|v| v.as_str())
621            .ok_or_else(|| anyhow!("Missing upload_id in start response: {}", start_resp))?
622            .to_string();
623
624        use tokio::io::AsyncReadExt;
625        let mut file = tokio::fs::File::open(file_path).await?;
626        let mut uploaded_bytes = 0;
627        let mut buffer = vec![0u8; chunk_size as usize];
628
629        for chunk_index in 0..total_chunks {
630            let mut chunk_bytes = 0;
631            let mut chunk_data = Vec::new();
632
633            while chunk_bytes < chunk_size as usize {
634                let n = file.read(&mut buffer[..]).await?;
635                if n == 0 {
636                    break;
637                }
638                chunk_data.extend_from_slice(&buffer[..n]);
639                chunk_bytes += n;
640            }
641
642            let mut chunk_headers = self.build_headers()?;
643            chunk_headers.insert(
644                reqwest::header::HeaderName::from_static("x-chunk-index"),
645                reqwest::header::HeaderValue::from_str(&chunk_index.to_string())?,
646            );
647
648            let chunk_url = format!(
649                "{}/api/roms/upload/{}",
650                self.base_url.trim_end_matches('/'),
651                upload_id
652            );
653
654            let _t_chunk = Instant::now();
655            let chunk_resp = self
656                .http
657                .put(&chunk_url)
658                .headers(chunk_headers)
659                .body(chunk_data.clone())
660                .send()
661                .await
662                .map_err(|e| anyhow!("chunk upload request error: {}", e))?;
663
664            if !chunk_resp.status().is_success() {
665                let body = chunk_resp.text().await.unwrap_or_default();
666                // Attempt to cancel
667                let cancel_url = format!(
668                    "{}/api/roms/upload/{}/cancel",
669                    self.base_url.trim_end_matches('/'),
670                    upload_id
671                );
672                let _ = self
673                    .http
674                    .post(&cancel_url)
675                    .headers(self.build_headers()?)
676                    .send()
677                    .await;
678
679                return Err(anyhow!("Failed to upload chunk {}: {}", chunk_index, body));
680            }
681
682            uploaded_bytes += chunk_data.len() as u64;
683            on_progress(uploaded_bytes, total_size);
684        }
685
686        let complete_url = format!(
687            "{}/api/roms/upload/{}/complete",
688            self.base_url.trim_end_matches('/'),
689            upload_id
690        );
691        let complete_resp = self
692            .http
693            .post(&complete_url)
694            .headers(self.build_headers()?)
695            .send()
696            .await
697            .map_err(|e| anyhow!("upload complete request error: {}", e))?;
698
699        if !complete_resp.status().is_success() {
700            let body = complete_resp.text().await.unwrap_or_default();
701            return Err(anyhow!("Failed to complete upload: {}", body));
702        }
703
704        Ok(())
705    }
706
707    /// Trigger a server-side task by name (e.g. `"scan_library"`).
708    ///
709    /// Sends `POST /api/tasks/run/{task_name}` with an optional JSON body
710    /// (`task_kwargs`). Returns the raw `TaskExecutionResponse` JSON.
711    pub async fn run_task(&self, task_name: &str, kwargs: Option<Value>) -> Result<Value> {
712        let path = format!("/api/tasks/run/{}", task_name);
713        self.request_json("POST", &path, &[], kwargs).await
714    }
715
716    /// Poll the status of a running task.
717    ///
718    /// Sends `GET /api/tasks/{task_id}`. Returns the raw status JSON.
719    pub async fn get_task_status(&self, task_id: &str) -> Result<Value> {
720        let path = format!("/api/tasks/{}", task_id);
721        self.request_json("GET", &path, &[], None).await
722    }
723
724    /// `POST /api/tasks/run` — enqueue all runnable tasks.
725    pub async fn run_all_tasks(&self) -> Result<Value> {
726        self.request_json("POST", "/api/tasks/run", &[], None).await
727    }
728
729    /// `GET /api/tasks` — list tasks.
730    pub async fn list_tasks(&self) -> Result<Value> {
731        self.request_json("GET", "/api/tasks", &[], None).await
732    }
733
734    /// `GET /api/tasks/status` — active / queued / completed tasks.
735    pub async fn get_tasks_queue_status(&self) -> Result<Value> {
736        self.request_json("GET", "/api/tasks/status", &[], None)
737            .await
738    }
739
740    /// `POST /api/saves` with multipart field `saveFile` (RomM FastAPI).
741    pub async fn upload_save_file(
742        &self,
743        rom_id: u64,
744        emulator: Option<&str>,
745        file_path: &Path,
746    ) -> Result<Value> {
747        let url = format!("{}/api/saves", self.base_url.trim_end_matches('/'));
748        let bytes = tokio::fs::read(file_path)
749            .await
750            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
751        let fname = file_path
752            .file_name()
753            .and_then(|n| n.to_str())
754            .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
755        let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
756        let form = multipart::Form::new().part("saveFile", part);
757        let mut query: Vec<(String, String)> = vec![("rom_id".into(), rom_id.to_string())];
758        if let Some(em) = emulator {
759            if !em.is_empty() {
760                query.push(("emulator".into(), em.to_string()));
761            }
762        }
763        let query_refs: Vec<(&str, &str)> = query
764            .iter()
765            .map(|(k, v)| (k.as_str(), v.as_str()))
766            .collect();
767        let headers = self.build_headers()?;
768        let t0 = Instant::now();
769        let resp = self
770            .http
771            .post(&url)
772            .headers(headers)
773            .query(&query_refs)
774            .multipart(form)
775            .send()
776            .await
777            .map_err(|e| anyhow!("save upload request: {e}"))?;
778        let status = resp.status();
779        if self.verbose {
780            tracing::info!(
781                "[romm-cli] POST /api/saves rom_id={rom_id} -> {} ({}ms)",
782                status.as_u16(),
783                t0.elapsed().as_millis()
784            );
785        }
786        if !status.is_success() {
787            let body = resp.text().await.unwrap_or_default();
788            return Err(anyhow!(
789                "ROMM API error: {} {} - {}",
790                status.as_u16(),
791                status.canonical_reason().unwrap_or(""),
792                body
793            ));
794        }
795        let bytes = resp
796            .bytes()
797            .await
798            .map_err(|e| anyhow!("read save upload body: {e}"))?;
799        Ok(decode_json_response_body(&bytes))
800    }
801
802    /// `POST /api/states` with multipart field `stateFile`.
803    pub async fn upload_state_file(
804        &self,
805        rom_id: u64,
806        emulator: Option<&str>,
807        file_path: &Path,
808    ) -> Result<Value> {
809        let url = format!("{}/api/states", self.base_url.trim_end_matches('/'));
810        let bytes = tokio::fs::read(file_path)
811            .await
812            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
813        let fname = file_path
814            .file_name()
815            .and_then(|n| n.to_str())
816            .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
817        let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
818        let form = multipart::Form::new().part("stateFile", part);
819        let mut query: Vec<(String, String)> = vec![("rom_id".into(), rom_id.to_string())];
820        if let Some(em) = emulator {
821            if !em.is_empty() {
822                query.push(("emulator".into(), em.to_string()));
823            }
824        }
825        let query_refs: Vec<(&str, &str)> = query
826            .iter()
827            .map(|(k, v)| (k.as_str(), v.as_str()))
828            .collect();
829        let headers = self.build_headers()?;
830        let resp = self
831            .http
832            .post(&url)
833            .headers(headers)
834            .query(&query_refs)
835            .multipart(form)
836            .send()
837            .await
838            .map_err(|e| anyhow!("state upload request: {e}"))?;
839        let status = resp.status();
840        if !status.is_success() {
841            let body = resp.text().await.unwrap_or_default();
842            return Err(anyhow!(
843                "ROMM API error: {} {} - {}",
844                status.as_u16(),
845                status.canonical_reason().unwrap_or(""),
846                body
847            ));
848        }
849        let bytes = resp
850            .bytes()
851            .await
852            .map_err(|e| anyhow!("read state upload body: {e}"))?;
853        Ok(decode_json_response_body(&bytes))
854    }
855
856    /// `POST /api/screenshots` with multipart field `screenshotFile`.
857    pub async fn upload_screenshot_file(&self, rom_id: u64, file_path: &Path) -> Result<Value> {
858        let url = format!("{}/api/screenshots", self.base_url.trim_end_matches('/'));
859        let bytes = tokio::fs::read(file_path)
860            .await
861            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
862        let fname = file_path
863            .file_name()
864            .and_then(|n| n.to_str())
865            .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
866        let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
867        let form = multipart::Form::new().part("screenshotFile", part);
868        let headers = self.build_headers()?;
869        let resp = self
870            .http
871            .post(&url)
872            .headers(headers)
873            .query(&[("rom_id", rom_id.to_string().as_str())])
874            .multipart(form)
875            .send()
876            .await
877            .map_err(|e| anyhow!("screenshot upload: {e}"))?;
878        let status = resp.status();
879        if !status.is_success() {
880            let body = resp.text().await.unwrap_or_default();
881            return Err(anyhow!(
882                "ROMM API error: {} {} - {}",
883                status.as_u16(),
884                status.canonical_reason().unwrap_or(""),
885                body
886            ));
887        }
888        let bytes = resp
889            .bytes()
890            .await
891            .map_err(|e| anyhow!("read screenshot body: {e}"))?;
892        Ok(decode_json_response_body(&bytes))
893    }
894
895    /// `POST /api/firmware?platform_id=` with multipart `files` (single file supported).
896    pub async fn upload_firmware_file(&self, platform_id: u64, file_path: &Path) -> Result<Value> {
897        let url = format!("{}/api/firmware", self.base_url.trim_end_matches('/'));
898        let bytes = tokio::fs::read(file_path)
899            .await
900            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
901        let fname = file_path
902            .file_name()
903            .and_then(|n| n.to_str())
904            .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
905        let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
906        let form = multipart::Form::new().part("files", part);
907        let headers = self.build_headers()?;
908        let resp = self
909            .http
910            .post(&url)
911            .headers(headers)
912            .query(&[("platform_id", platform_id.to_string())])
913            .multipart(form)
914            .send()
915            .await
916            .map_err(|e| anyhow!("firmware upload: {e}"))?;
917        let status = resp.status();
918        if !status.is_success() {
919            let body = resp.text().await.unwrap_or_default();
920            return Err(anyhow!(
921                "ROMM API error: {} {} - {}",
922                status.as_u16(),
923                status.canonical_reason().unwrap_or(""),
924                body
925            ));
926        }
927        let bytes = resp
928            .bytes()
929            .await
930            .map_err(|e| anyhow!("read firmware body: {e}"))?;
931        Ok(decode_json_response_body(&bytes))
932    }
933
934    /// Authenticated GET returning raw bytes (e.g. save/state/firmware file or gamelist export).
935    pub async fn get_bytes(&self, path: &str, query: &[(String, String)]) -> Result<Vec<u8>> {
936        let url = format!(
937            "{}/{}",
938            self.base_url.trim_end_matches('/'),
939            path.trim_start_matches('/')
940        );
941        let headers = self.build_headers()?;
942        let query_refs: Vec<(&str, &str)> = query
943            .iter()
944            .map(|(k, v)| (k.as_str(), v.as_str()))
945            .collect();
946        let resp = self
947            .http
948            .get(&url)
949            .headers(headers)
950            .query(&query_refs)
951            .send()
952            .await
953            .map_err(|e| anyhow!("GET {path}: {e}"))?;
954        let status = resp.status();
955        if !status.is_success() {
956            let body = resp.text().await.unwrap_or_default();
957            return Err(anyhow!(
958                "ROMM API error: {} {} - {}",
959                status.as_u16(),
960                status.canonical_reason().unwrap_or(""),
961                body
962            ));
963        }
964        Ok(resp.bytes().await?.to_vec())
965    }
966
967    /// POST returning raw bytes (e.g. gamelist XML).
968    pub async fn post_bytes(
969        &self,
970        path: &str,
971        query: &[(String, String)],
972        json_body: Option<Value>,
973    ) -> Result<Vec<u8>> {
974        let url = format!(
975            "{}/{}",
976            self.base_url.trim_end_matches('/'),
977            path.trim_start_matches('/')
978        );
979        let headers = self.build_headers()?;
980        let query_refs: Vec<(&str, &str)> = query
981            .iter()
982            .map(|(k, v)| (k.as_str(), v.as_str()))
983            .collect();
984        let mut req = self.http.post(&url).headers(headers).query(&query_refs);
985        if let Some(b) = json_body {
986            req = req.json(&b);
987        }
988        let resp = req.send().await.map_err(|e| anyhow!("POST {path}: {e}"))?;
989        let status = resp.status();
990        if !status.is_success() {
991            let body = resp.text().await.unwrap_or_default();
992            return Err(anyhow!(
993                "ROMM API error: {} {} - {}",
994                status.as_u16(),
995                status.canonical_reason().unwrap_or(""),
996                body
997            ));
998        }
999        Ok(resp.bytes().await?.to_vec())
1000    }
1001
1002    /// `POST /api/roms/{id}/manuals` — raw file body with `x-upload-filename` header.
1003    pub async fn upload_rom_manual(&self, rom_id: u64, file_path: &Path) -> Result<Value> {
1004        let fname = file_path
1005            .file_name()
1006            .and_then(|n| n.to_str())
1007            .ok_or_else(|| anyhow!("manual path must have a unicode filename"))?
1008            .to_string();
1009        let url = format!(
1010            "{}/api/roms/{}/manuals",
1011            self.base_url.trim_end_matches('/'),
1012            rom_id
1013        );
1014        let bytes = tokio::fs::read(file_path)
1015            .await
1016            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
1017        let mut headers = self.build_headers()?;
1018        headers.insert(
1019            reqwest::header::HeaderName::from_static("x-upload-filename"),
1020            HeaderValue::from_str(&fname).map_err(|_| anyhow!("invalid x-upload-filename"))?,
1021        );
1022        let resp = self
1023            .http
1024            .post(&url)
1025            .headers(headers)
1026            .body(bytes)
1027            .send()
1028            .await
1029            .map_err(|e| anyhow!("manual upload: {e}"))?;
1030        let status = resp.status();
1031        if !status.is_success() {
1032            let body = resp.text().await.unwrap_or_default();
1033            return Err(anyhow!(
1034                "ROMM API error: {} {} - {}",
1035                status.as_u16(),
1036                status.canonical_reason().unwrap_or(""),
1037                body
1038            ));
1039        }
1040        let out = resp.bytes().await?;
1041        Ok(decode_json_response_body(&out))
1042    }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use super::*;
1048
1049    #[test]
1050    fn decode_json_empty_and_whitespace_to_null() {
1051        assert_eq!(decode_json_response_body(b""), Value::Null);
1052        assert_eq!(decode_json_response_body(b"  \n\t "), Value::Null);
1053    }
1054
1055    #[test]
1056    fn decode_json_object_roundtrip() {
1057        let v = decode_json_response_body(br#"{"a":1}"#);
1058        assert_eq!(v["a"], 1);
1059    }
1060
1061    #[test]
1062    fn decode_non_json_wrapped() {
1063        let v = decode_json_response_body(b"plain text");
1064        assert_eq!(v["_non_json_body"], "plain text");
1065    }
1066
1067    #[test]
1068    fn api_root_url_strips_trailing_api() {
1069        assert_eq!(
1070            super::api_root_url("http://localhost:8080/api"),
1071            "http://localhost:8080"
1072        );
1073        assert_eq!(
1074            super::api_root_url("http://localhost:8080/api/"),
1075            "http://localhost:8080"
1076        );
1077        assert_eq!(
1078            super::api_root_url("http://localhost:8080"),
1079            "http://localhost:8080"
1080        );
1081    }
1082
1083    #[test]
1084    fn openapi_spec_urls_try_primary_scheme_then_alt() {
1085        let urls = super::openapi_spec_urls("http://example.test");
1086        assert_eq!(urls[0], "http://example.test/openapi.json");
1087        assert_eq!(urls[1], "http://example.test/api/openapi.json");
1088        assert!(
1089            urls.iter()
1090                .any(|u| u == "https://example.test/openapi.json"),
1091            "{urls:?}"
1092        );
1093    }
1094}