1use 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, Url};
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#[derive(Debug, Clone, Default)]
23pub struct SaveUploadOptions<'a> {
24 pub emulator: Option<&'a str>,
25 pub slot: Option<&'a str>,
26 pub device_id: Option<&'a str>,
27 pub session_id: Option<u64>,
28 pub overwrite: bool,
29}
30
31fn http_user_agent() -> String {
34 match std::env::var("ROMM_USER_AGENT") {
35 Ok(s) if !s.trim().is_empty() => s,
36 _ => format!(
37 "Mozilla/5.0 (compatible; romm-cli/{}; +https://github.com/patricksmill/romm-cli)",
38 env!("CARGO_PKG_VERSION")
39 ),
40 }
41}
42
43fn decode_json_response_body(bytes: &[u8]) -> Value {
48 if bytes.is_empty() || bytes.iter().all(|b| b.is_ascii_whitespace()) {
49 return Value::Null;
50 }
51 serde_json::from_slice(bytes).unwrap_or_else(|_| {
52 serde_json::json!({
53 "_non_json_body": String::from_utf8_lossy(bytes).to_string()
54 })
55 })
56}
57
58fn version_from_heartbeat_json(v: &Value) -> Option<String> {
59 v.get("SYSTEM")?.get("VERSION")?.as_str().map(String::from)
60}
61
62#[derive(Clone)]
86pub struct RommClient {
87 http: HttpClient,
89 base_url: String,
91 auth: Option<AuthConfig>,
93 verbose: bool,
95}
96
97pub fn api_root_url(base_url: &str) -> String {
101 normalize_romm_origin(base_url)
102}
103
104fn alternate_http_scheme_root(root: &str) -> Option<String> {
105 root.strip_prefix("http://")
106 .map(|rest| format!("https://{}", rest))
107 .or_else(|| {
108 root.strip_prefix("https://")
109 .map(|rest| format!("http://{}", rest))
110 })
111}
112
113pub fn resolve_openapi_root(api_base_url: &str) -> String {
118 if let Ok(s) = std::env::var("ROMM_OPENAPI_BASE_URL") {
119 let t = s.trim();
120 if !t.is_empty() {
121 return normalize_romm_origin(t);
122 }
123 }
124 normalize_romm_origin(api_base_url)
125}
126
127pub fn openapi_spec_urls(api_root: &str) -> Vec<String> {
132 let root = api_root.trim_end_matches('/').to_string();
133 let mut roots = vec![root.clone()];
134 if let Some(alt) = alternate_http_scheme_root(&root) {
135 if alt != root {
136 roots.push(alt);
137 }
138 }
139
140 let mut urls = Vec::new();
141 for r in roots {
142 let b = r.trim_end_matches('/');
143 urls.push(format!("{b}/openapi.json"));
144 urls.push(format!("{b}/api/openapi.json"));
145 }
146 urls
147}
148
149impl RommClient {
150 pub fn new(config: &Config, verbose: bool) -> Result<Self> {
156 let http = HttpClient::builder()
157 .user_agent(http_user_agent())
158 .build()?;
159 Ok(Self {
160 http,
161 base_url: config.base_url.clone(),
162 auth: config.auth.clone(),
163 verbose,
164 })
165 }
166
167 pub fn verbose(&self) -> bool {
169 self.verbose
170 }
171
172 fn build_headers(&self) -> Result<HeaderMap> {
177 let mut headers = HeaderMap::new();
178
179 if let Some(auth) = &self.auth {
180 match auth {
181 AuthConfig::Basic { username, password } => {
182 let creds = format!("{username}:{password}");
183 let encoded = general_purpose::STANDARD.encode(creds.as_bytes());
184 let value = format!("Basic {encoded}");
185 headers.insert(
186 AUTHORIZATION,
187 HeaderValue::from_str(&value)
188 .map_err(|_| anyhow!("invalid basic auth header value"))?,
189 );
190 }
191 AuthConfig::Bearer { token } => {
192 let value = format!("Bearer {token}");
193 headers.insert(
194 AUTHORIZATION,
195 HeaderValue::from_str(&value)
196 .map_err(|_| anyhow!("invalid bearer auth header value"))?,
197 );
198 }
199 AuthConfig::ApiKey { header, key } => {
200 let name = reqwest::header::HeaderName::from_bytes(header.as_bytes()).map_err(
201 |_| anyhow!("invalid API_KEY_HEADER, must be a valid HTTP header name"),
202 )?;
203 headers.insert(
204 name,
205 HeaderValue::from_str(key)
206 .map_err(|_| anyhow!("invalid API_KEY header value"))?,
207 );
208 }
209 }
210 }
211
212 Ok(headers)
213 }
214
215 pub async fn call<E>(&self, ep: &E) -> anyhow::Result<E::Output>
220 where
221 E: Endpoint,
222 E::Output: serde::de::DeserializeOwned,
223 {
224 let method = ep.method();
225 let path = ep.path();
226 let query = ep.query();
227 let body = ep.body();
228
229 let value = self.request_json(method, &path, &query, body).await?;
230 let output = serde_json::from_value(value)
231 .map_err(|e| anyhow!("failed to decode response for {} {}: {}", method, path, e))?;
232
233 Ok(output)
234 }
235
236 pub async fn request_json(
240 &self,
241 method: &str,
242 path: &str,
243 query: &[(String, String)],
244 body: Option<Value>,
245 ) -> Result<Value> {
246 let url = format!(
247 "{}/{}",
248 self.base_url.trim_end_matches('/'),
249 path.trim_start_matches('/')
250 );
251 let headers = self.build_headers()?;
252
253 let http_method = Method::from_bytes(method.as_bytes())
254 .map_err(|_| anyhow!("invalid HTTP method: {method}"))?;
255
256 let query_refs: Vec<(&str, &str)> = query
259 .iter()
260 .map(|(k, v)| (k.as_str(), v.as_str()))
261 .collect();
262
263 let mut req = self
264 .http
265 .request(http_method, &url)
266 .headers(headers)
267 .query(&query_refs);
268
269 if let Some(body) = body {
270 req = req.json(&body);
271 }
272
273 let t0 = Instant::now();
274 let resp = req
275 .send()
276 .await
277 .map_err(|e| anyhow!("request error: {e}"))?;
278
279 let status = resp.status();
280 if self.verbose {
281 let keys: Vec<&str> = query.iter().map(|(k, _)| k.as_str()).collect();
282 tracing::info!(
283 "[romm-cli] {} {} query_keys={:?} -> {} ({}ms)",
284 method,
285 path,
286 keys,
287 status.as_u16(),
288 t0.elapsed().as_millis()
289 );
290 }
291 if !status.is_success() {
292 let body = resp.text().await.unwrap_or_default();
293 return Err(anyhow!(
294 "ROMM API error: {} {} - {}",
295 status.as_u16(),
296 status.canonical_reason().unwrap_or(""),
297 body
298 ));
299 }
300
301 let bytes = resp
302 .bytes()
303 .await
304 .map_err(|e| anyhow!("read response body: {e}"))?;
305
306 Ok(decode_json_response_body(&bytes))
307 }
308
309 pub async fn request_json_unauthenticated(
310 &self,
311 method: &str,
312 path: &str,
313 query: &[(String, String)],
314 body: Option<Value>,
315 ) -> Result<Value> {
316 let url = format!(
317 "{}/{}",
318 self.base_url.trim_end_matches('/'),
319 path.trim_start_matches('/')
320 );
321 let headers = HeaderMap::new();
322
323 let http_method = Method::from_bytes(method.as_bytes())
324 .map_err(|_| anyhow!("invalid HTTP method: {method}"))?;
325
326 let query_refs: Vec<(&str, &str)> = query
329 .iter()
330 .map(|(k, v)| (k.as_str(), v.as_str()))
331 .collect();
332
333 let mut req = self
334 .http
335 .request(http_method, &url)
336 .headers(headers)
337 .query(&query_refs);
338
339 if let Some(body) = body {
340 req = req.json(&body);
341 }
342
343 let t0 = Instant::now();
344 let resp = req
345 .send()
346 .await
347 .map_err(|e| anyhow!("request error: {e}"))?;
348
349 let status = resp.status();
350 if self.verbose {
351 let keys: Vec<&str> = query.iter().map(|(k, _)| k.as_str()).collect();
352 tracing::info!(
353 "[romm-cli] {} {} query_keys={:?} -> {} ({}ms)",
354 method,
355 path,
356 keys,
357 status.as_u16(),
358 t0.elapsed().as_millis()
359 );
360 }
361 if !status.is_success() {
362 let body = resp.text().await.unwrap_or_default();
363 return Err(anyhow!(
364 "ROMM API error: {} {} - {}",
365 status.as_u16(),
366 status.canonical_reason().unwrap_or(""),
367 body
368 ));
369 }
370
371 let bytes = resp
372 .bytes()
373 .await
374 .map_err(|e| anyhow!("read response body: {e}"))?;
375
376 Ok(decode_json_response_body(&bytes))
377 }
378
379 pub async fn rom_server_version_from_heartbeat(&self) -> Option<String> {
381 let v = self
382 .request_json_unauthenticated("GET", "/api/heartbeat", &[], None)
383 .await
384 .ok()?;
385 version_from_heartbeat_json(&v)
386 }
387
388 pub async fn fetch_openapi_json(&self) -> Result<String> {
391 let root = resolve_openapi_root(&self.base_url);
392 let urls = openapi_spec_urls(&root);
393 let mut failures = Vec::new();
394 for url in &urls {
395 match self.fetch_openapi_json_once(url).await {
396 Ok(body) => return Ok(body),
397 Err(e) => failures.push(format!("{url}: {e:#}")),
398 }
399 }
400 Err(anyhow!(
401 "could not download OpenAPI ({} attempt(s)): {}",
402 failures.len(),
403 failures.join(" | ")
404 ))
405 }
406
407 async fn fetch_openapi_json_once(&self, url: &str) -> Result<String> {
408 let headers = self.build_headers()?;
409
410 let t0 = Instant::now();
411 let resp = self
412 .http
413 .get(url)
414 .headers(headers)
415 .send()
416 .await
417 .map_err(|e| anyhow!("request failed: {e}"))?;
418
419 let status = resp.status();
420 if self.verbose {
421 tracing::info!(
422 "[romm-cli] GET {} -> {} ({}ms)",
423 url,
424 status.as_u16(),
425 t0.elapsed().as_millis()
426 );
427 }
428 if !status.is_success() {
429 let body = resp.text().await.unwrap_or_default();
430 return Err(anyhow!(
431 "HTTP {} {} - {}",
432 status.as_u16(),
433 status.canonical_reason().unwrap_or(""),
434 body.chars().take(500).collect::<String>()
435 ));
436 }
437
438 resp.text()
439 .await
440 .map_err(|e| anyhow!("read OpenAPI body: {e}"))
441 }
442
443 pub async fn download_rom<F>(
452 &self,
453 rom_id: u64,
454 save_path: &Path,
455 mut on_progress: F,
456 ) -> Result<()>
457 where
458 F: FnMut(u64, u64) + Send,
459 {
460 self.download_rom_with_cancel(rom_id, save_path, |_, _| false, &mut on_progress)
461 .await
462 }
463
464 pub async fn download_rom_with_cancel<F, C>(
465 &self,
466 rom_id: u64,
467 save_path: &Path,
468 is_cancelled: C,
469 on_progress: &mut F,
470 ) -> Result<()>
471 where
472 F: FnMut(u64, u64) + Send,
473 C: FnMut(u64, u64) -> bool + Send,
474 {
475 let filename = filename_hint(save_path);
476 let query = vec![
477 ("rom_ids".to_string(), rom_id.to_string()),
478 ("filename".to_string(), filename),
479 ];
480 self.download_url_with_query_with_cancel(
481 "/api/roms/download",
482 &query,
483 save_path,
484 is_cancelled,
485 on_progress,
486 )
487 .await
488 }
489
490 pub async fn download_url_with_cancel<F, C>(
492 &self,
493 url: &str,
494 save_path: &Path,
495 is_cancelled: C,
496 on_progress: &mut F,
497 ) -> Result<()>
498 where
499 F: FnMut(u64, u64) + Send,
500 C: FnMut(u64, u64) -> bool + Send,
501 {
502 self.download_url_with_query_with_cancel(url, &[], save_path, is_cancelled, on_progress)
503 .await
504 }
505
506 pub async fn download_url_with_query_with_cancel<F, C>(
508 &self,
509 url: &str,
510 query: &[(String, String)],
511 save_path: &Path,
512 mut is_cancelled: C,
513 on_progress: &mut F,
514 ) -> Result<()>
515 where
516 F: FnMut(u64, u64) + Send,
517 C: FnMut(u64, u64) -> bool + Send,
518 {
519 let url = self.resolve_download_url(url)?;
520 let filename = filename_hint(save_path);
521 let mut headers = self.build_headers()?;
522
523 let existing_len = tokio::fs::metadata(save_path)
525 .await
526 .map(|m| m.len())
527 .unwrap_or(0);
528
529 if existing_len > 0 {
530 let range = format!("bytes={existing_len}-");
531 if let Ok(v) = reqwest::header::HeaderValue::from_str(&range) {
532 headers.insert(reqwest::header::RANGE, v);
533 }
534 }
535
536 if let Some(parent) = save_path.parent() {
537 tokio::fs::create_dir_all(parent)
538 .await
539 .map_err(|e| anyhow!("create download parent dir {:?}: {e}", parent))?;
540 }
541
542 let t0 = Instant::now();
543 let mut resp = self
544 .http
545 .get(&url)
546 .headers(headers)
547 .query(query)
548 .send()
549 .await
550 .map_err(|e| anyhow!("download request error: {e}"))?;
551
552 let status = resp.status();
553 if self.verbose {
554 tracing::info!(
555 "[romm-cli] GET {} filename={:?} -> {} ({}ms)",
556 url,
557 filename,
558 status.as_u16(),
559 t0.elapsed().as_millis()
560 );
561 }
562 if !status.is_success() {
563 let body = resp.text().await.unwrap_or_default();
564 return Err(anyhow!(
565 "ROMM API error: {} {} - {}",
566 status.as_u16(),
567 status.canonical_reason().unwrap_or(""),
568 body
569 ));
570 }
571
572 let (mut received, total, mut file) = if status == reqwest::StatusCode::PARTIAL_CONTENT {
574 let remaining = resp.content_length().unwrap_or(0);
576 let total = existing_len + remaining;
577 let file = tokio::fs::OpenOptions::new()
578 .append(true)
579 .open(save_path)
580 .await
581 .map_err(|e| anyhow!("open file for append {:?}: {e}", save_path))?;
582 (existing_len, total, file)
583 } else {
584 let total = resp.content_length().unwrap_or(0);
586 let file = tokio::fs::File::create(save_path)
587 .await
588 .map_err(|e| anyhow!("create file {:?}: {e}", save_path))?;
589 (0u64, total, file)
590 };
591
592 if is_cancelled(received, total) {
593 return Err(cancelled_error());
594 }
595
596 while let Some(chunk) = resp.chunk().await.map_err(|e| anyhow!("read chunk: {e}"))? {
597 if is_cancelled(received, total) {
598 return Err(cancelled_error());
599 }
600 file.write_all(&chunk)
601 .await
602 .map_err(|e| anyhow!("write chunk {:?}: {e}", save_path))?;
603 received += chunk.len() as u64;
604 on_progress(received, total);
605 }
606
607 Ok(())
608 }
609
610 fn resolve_download_url(&self, url: &str) -> Result<String> {
611 let trimmed = url.trim();
612 if trimmed.is_empty() {
613 return Err(anyhow!("download URL cannot be empty"));
614 }
615 if let Ok(parsed) = Url::parse(trimmed) {
616 return Ok(parsed.to_string());
617 }
618
619 let base = Url::parse(&normalize_romm_origin(&self.base_url))
620 .map_err(|e| anyhow!("invalid RomM base URL: {e}"))?;
621 let joined = base
622 .join(trimmed)
623 .map_err(|e| anyhow!("could not resolve download URL {trimmed:?}: {e}"))?;
624 Ok(joined.to_string())
625 }
626
627 pub async fn upload_rom<F>(
632 &self,
633 platform_id: u64,
634 file_path: &Path,
635 mut on_progress: F,
636 ) -> Result<()>
637 where
638 F: FnMut(u64, u64) + Send,
639 {
640 let filename = file_path
641 .file_name()
642 .and_then(|n| n.to_str())
643 .ok_or_else(|| anyhow!("Invalid filename for upload"))?;
644
645 let metadata = tokio::fs::metadata(file_path)
646 .await
647 .map_err(|e| anyhow!("Failed to read file metadata {:?}: {}", file_path, e))?;
648 let total_size = metadata.len();
649
650 let chunk_size: u64 = 2 * 1024 * 1024;
652 let total_chunks = if total_size == 0 {
654 1
655 } else {
656 total_size.div_ceil(chunk_size)
657 };
658
659 let mut start_headers = self.build_headers()?;
660 start_headers.insert(
661 reqwest::header::HeaderName::from_static("x-upload-platform"),
662 reqwest::header::HeaderValue::from_str(&platform_id.to_string())?,
663 );
664 start_headers.insert(
665 reqwest::header::HeaderName::from_static("x-upload-filename"),
666 reqwest::header::HeaderValue::from_str(filename)?,
667 );
668 start_headers.insert(
669 reqwest::header::HeaderName::from_static("x-upload-total-size"),
670 reqwest::header::HeaderValue::from_str(&total_size.to_string())?,
671 );
672 start_headers.insert(
673 reqwest::header::HeaderName::from_static("x-upload-total-chunks"),
674 reqwest::header::HeaderValue::from_str(&total_chunks.to_string())?,
675 );
676
677 let start_url = format!(
678 "{}/api/roms/upload/start",
679 self.base_url.trim_end_matches('/')
680 );
681
682 let t0 = Instant::now();
683 let resp = self
684 .http
685 .post(&start_url)
686 .headers(start_headers)
687 .send()
688 .await
689 .map_err(|e| anyhow!("upload start request error: {}", e))?;
690
691 let status = resp.status();
692 if self.verbose {
693 tracing::info!(
694 "[romm-cli] POST /api/roms/upload/start -> {} ({}ms)",
695 status.as_u16(),
696 t0.elapsed().as_millis()
697 );
698 }
699
700 if !status.is_success() {
701 let body = resp.text().await.unwrap_or_default();
702 return Err(anyhow!(
703 "ROMM API error: {} {} - {}",
704 status.as_u16(),
705 status.canonical_reason().unwrap_or(""),
706 body
707 ));
708 }
709
710 let start_resp: Value = resp
711 .json()
712 .await
713 .map_err(|e| anyhow!("failed to parse start upload response: {}", e))?;
714 let upload_id = start_resp
715 .get("upload_id")
716 .and_then(|v| v.as_str())
717 .ok_or_else(|| anyhow!("Missing upload_id in start response: {}", start_resp))?
718 .to_string();
719
720 use tokio::io::AsyncReadExt;
721 let mut file = tokio::fs::File::open(file_path).await?;
722 let mut uploaded_bytes = 0;
723 let mut buffer = vec![0u8; chunk_size as usize];
724
725 for chunk_index in 0..total_chunks {
726 let mut chunk_bytes = 0;
727 let mut chunk_data = Vec::new();
728
729 while chunk_bytes < chunk_size as usize {
730 let n = file.read(&mut buffer[..]).await?;
731 if n == 0 {
732 break;
733 }
734 chunk_data.extend_from_slice(&buffer[..n]);
735 chunk_bytes += n;
736 }
737
738 let mut chunk_headers = self.build_headers()?;
739 chunk_headers.insert(
740 reqwest::header::HeaderName::from_static("x-chunk-index"),
741 reqwest::header::HeaderValue::from_str(&chunk_index.to_string())?,
742 );
743
744 let chunk_url = format!(
745 "{}/api/roms/upload/{}",
746 self.base_url.trim_end_matches('/'),
747 upload_id
748 );
749
750 let _t_chunk = Instant::now();
751 let chunk_resp = self
752 .http
753 .put(&chunk_url)
754 .headers(chunk_headers)
755 .body(chunk_data.clone())
756 .send()
757 .await
758 .map_err(|e| anyhow!("chunk upload request error: {}", e))?;
759
760 if !chunk_resp.status().is_success() {
761 let body = chunk_resp.text().await.unwrap_or_default();
762 let cancel_url = format!(
764 "{}/api/roms/upload/{}/cancel",
765 self.base_url.trim_end_matches('/'),
766 upload_id
767 );
768 let _ = self
769 .http
770 .post(&cancel_url)
771 .headers(self.build_headers()?)
772 .send()
773 .await;
774
775 return Err(anyhow!("Failed to upload chunk {}: {}", chunk_index, body));
776 }
777
778 uploaded_bytes += chunk_data.len() as u64;
779 on_progress(uploaded_bytes, total_size);
780 }
781
782 let complete_url = format!(
783 "{}/api/roms/upload/{}/complete",
784 self.base_url.trim_end_matches('/'),
785 upload_id
786 );
787 let complete_resp = self
788 .http
789 .post(&complete_url)
790 .headers(self.build_headers()?)
791 .send()
792 .await
793 .map_err(|e| anyhow!("upload complete request error: {}", e))?;
794
795 if !complete_resp.status().is_success() {
796 let body = complete_resp.text().await.unwrap_or_default();
797 return Err(anyhow!("Failed to complete upload: {}", body));
798 }
799
800 Ok(())
801 }
802
803 pub async fn run_task(&self, task_name: &str, kwargs: Option<Value>) -> Result<Value> {
810 let path = format!("/api/tasks/run/{}", task_name);
811 self.request_json("POST", &path, &[], kwargs).await
812 }
813
814 pub async fn get_task_status(&self, task_id: &str) -> Result<Value> {
816 let path = format!("/api/tasks/{}", task_id);
817 self.request_json("GET", &path, &[], None).await
818 }
819
820 pub async fn run_all_tasks(&self) -> Result<Value> {
822 self.request_json("POST", "/api/tasks/run", &[], None).await
823 }
824
825 pub async fn list_tasks(&self) -> Result<Value> {
827 self.request_json("GET", "/api/tasks", &[], None).await
828 }
829
830 pub async fn get_tasks_queue_status(&self) -> Result<Value> {
832 self.request_json("GET", "/api/tasks/status", &[], None)
833 .await
834 }
835
836 pub async fn upload_save_file(
844 &self,
845 rom_id: u64,
846 emulator: Option<&str>,
847 file_path: &Path,
848 ) -> Result<Value> {
849 let options = SaveUploadOptions {
850 emulator,
851 ..Default::default()
852 };
853 self.upload_save_file_with_options(rom_id, file_path, &options)
854 .await
855 }
856
857 pub async fn upload_save_file_with_options(
859 &self,
860 rom_id: u64,
861 file_path: &Path,
862 options: &SaveUploadOptions<'_>,
863 ) -> Result<Value> {
864 let url = format!("{}/api/saves", self.base_url.trim_end_matches('/'));
865 let bytes = tokio::fs::read(file_path)
866 .await
867 .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
868 let fname = file_path
869 .file_name()
870 .and_then(|n| n.to_str())
871 .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
872 let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
873 let form = multipart::Form::new().part("saveFile", part);
874 let mut query: Vec<(String, String)> = vec![("rom_id".into(), rom_id.to_string())];
875 if let Some(em) = options.emulator {
876 if !em.is_empty() {
877 query.push(("emulator".into(), em.to_string()));
878 }
879 }
880 if let Some(slot) = options.slot {
881 if !slot.is_empty() {
882 query.push(("slot".into(), slot.to_string()));
883 }
884 }
885 if let Some(device_id) = options.device_id {
886 if !device_id.is_empty() {
887 query.push(("device_id".into(), device_id.to_string()));
888 }
889 }
890 if let Some(session_id) = options.session_id {
891 query.push(("session_id".into(), session_id.to_string()));
892 }
893 if options.overwrite {
894 query.push(("overwrite".into(), "true".into()));
895 }
896 let query_refs: Vec<(&str, &str)> = query
897 .iter()
898 .map(|(k, v)| (k.as_str(), v.as_str()))
899 .collect();
900 let headers = self.build_headers()?;
901 let t0 = Instant::now();
902 let resp = self
903 .http
904 .post(&url)
905 .headers(headers)
906 .query(&query_refs)
907 .multipart(form)
908 .send()
909 .await
910 .map_err(|e| anyhow!("save upload request: {e}"))?;
911 let status = resp.status();
912 if self.verbose {
913 tracing::info!(
914 "[romm-cli] POST /api/saves rom_id={rom_id} -> {} ({}ms)",
915 status.as_u16(),
916 t0.elapsed().as_millis()
917 );
918 }
919 if !status.is_success() {
920 let body = resp.text().await.unwrap_or_default();
921 return Err(anyhow!(
922 "ROMM API error: {} {} - {}",
923 status.as_u16(),
924 status.canonical_reason().unwrap_or(""),
925 body
926 ));
927 }
928 let bytes = resp
929 .bytes()
930 .await
931 .map_err(|e| anyhow!("read save upload body: {e}"))?;
932 Ok(decode_json_response_body(&bytes))
933 }
934
935 pub async fn download_save_content(
937 &self,
938 save_id: u64,
939 device_id: Option<&str>,
940 session_id: Option<u64>,
941 ) -> Result<Vec<u8>> {
942 let path = format!("/api/saves/{save_id}/content");
943 let mut query = Vec::new();
944 if let Some(device_id) = device_id {
945 if !device_id.is_empty() {
946 query.push(("device_id".to_string(), device_id.to_string()));
947 }
948 }
949 if let Some(session_id) = session_id {
950 query.push(("session_id".to_string(), session_id.to_string()));
951 }
952 self.get_bytes(&path, &query).await
953 }
954
955 pub async fn upload_state_file(
957 &self,
958 rom_id: u64,
959 emulator: Option<&str>,
960 file_path: &Path,
961 ) -> Result<Value> {
962 let url = format!("{}/api/states", self.base_url.trim_end_matches('/'));
963 let bytes = tokio::fs::read(file_path)
964 .await
965 .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
966 let fname = file_path
967 .file_name()
968 .and_then(|n| n.to_str())
969 .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
970 let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
971 let form = multipart::Form::new().part("stateFile", part);
972 let mut query: Vec<(String, String)> = vec![("rom_id".into(), rom_id.to_string())];
973 if let Some(em) = emulator {
974 if !em.is_empty() {
975 query.push(("emulator".into(), em.to_string()));
976 }
977 }
978 let query_refs: Vec<(&str, &str)> = query
979 .iter()
980 .map(|(k, v)| (k.as_str(), v.as_str()))
981 .collect();
982 let headers = self.build_headers()?;
983 let resp = self
984 .http
985 .post(&url)
986 .headers(headers)
987 .query(&query_refs)
988 .multipart(form)
989 .send()
990 .await
991 .map_err(|e| anyhow!("state upload request: {e}"))?;
992 let status = resp.status();
993 if !status.is_success() {
994 let body = resp.text().await.unwrap_or_default();
995 return Err(anyhow!(
996 "ROMM API error: {} {} - {}",
997 status.as_u16(),
998 status.canonical_reason().unwrap_or(""),
999 body
1000 ));
1001 }
1002 let bytes = resp
1003 .bytes()
1004 .await
1005 .map_err(|e| anyhow!("read state upload body: {e}"))?;
1006 Ok(decode_json_response_body(&bytes))
1007 }
1008
1009 pub async fn upload_screenshot_file(&self, rom_id: u64, file_path: &Path) -> Result<Value> {
1011 let url = format!("{}/api/screenshots", self.base_url.trim_end_matches('/'));
1012 let bytes = tokio::fs::read(file_path)
1013 .await
1014 .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
1015 let fname = file_path
1016 .file_name()
1017 .and_then(|n| n.to_str())
1018 .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
1019 let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
1020 let form = multipart::Form::new().part("screenshotFile", part);
1021 let headers = self.build_headers()?;
1022 let resp = self
1023 .http
1024 .post(&url)
1025 .headers(headers)
1026 .query(&[("rom_id", rom_id.to_string().as_str())])
1027 .multipart(form)
1028 .send()
1029 .await
1030 .map_err(|e| anyhow!("screenshot upload: {e}"))?;
1031 let status = resp.status();
1032 if !status.is_success() {
1033 let body = resp.text().await.unwrap_or_default();
1034 return Err(anyhow!(
1035 "ROMM API error: {} {} - {}",
1036 status.as_u16(),
1037 status.canonical_reason().unwrap_or(""),
1038 body
1039 ));
1040 }
1041 let bytes = resp
1042 .bytes()
1043 .await
1044 .map_err(|e| anyhow!("read screenshot body: {e}"))?;
1045 Ok(decode_json_response_body(&bytes))
1046 }
1047
1048 pub async fn upload_firmware_file(&self, platform_id: u64, file_path: &Path) -> Result<Value> {
1050 let url = format!("{}/api/firmware", self.base_url.trim_end_matches('/'));
1051 let bytes = tokio::fs::read(file_path)
1052 .await
1053 .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
1054 let fname = file_path
1055 .file_name()
1056 .and_then(|n| n.to_str())
1057 .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
1058 let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
1059 let form = multipart::Form::new().part("files", part);
1060 let headers = self.build_headers()?;
1061 let resp = self
1062 .http
1063 .post(&url)
1064 .headers(headers)
1065 .query(&[("platform_id", platform_id.to_string())])
1066 .multipart(form)
1067 .send()
1068 .await
1069 .map_err(|e| anyhow!("firmware upload: {e}"))?;
1070 let status = resp.status();
1071 if !status.is_success() {
1072 let body = resp.text().await.unwrap_or_default();
1073 return Err(anyhow!(
1074 "ROMM API error: {} {} - {}",
1075 status.as_u16(),
1076 status.canonical_reason().unwrap_or(""),
1077 body
1078 ));
1079 }
1080 let bytes = resp
1081 .bytes()
1082 .await
1083 .map_err(|e| anyhow!("read firmware body: {e}"))?;
1084 Ok(decode_json_response_body(&bytes))
1085 }
1086
1087 pub async fn get_bytes(&self, path: &str, query: &[(String, String)]) -> Result<Vec<u8>> {
1089 let url = format!(
1090 "{}/{}",
1091 self.base_url.trim_end_matches('/'),
1092 path.trim_start_matches('/')
1093 );
1094 let headers = self.build_headers()?;
1095 let query_refs: Vec<(&str, &str)> = query
1096 .iter()
1097 .map(|(k, v)| (k.as_str(), v.as_str()))
1098 .collect();
1099 let resp = self
1100 .http
1101 .get(&url)
1102 .headers(headers)
1103 .query(&query_refs)
1104 .send()
1105 .await
1106 .map_err(|e| anyhow!("GET {path}: {e}"))?;
1107 let status = resp.status();
1108 if !status.is_success() {
1109 let body = resp.text().await.unwrap_or_default();
1110 return Err(anyhow!(
1111 "ROMM API error: {} {} - {}",
1112 status.as_u16(),
1113 status.canonical_reason().unwrap_or(""),
1114 body
1115 ));
1116 }
1117 Ok(resp.bytes().await?.to_vec())
1118 }
1119
1120 pub async fn post_bytes(
1122 &self,
1123 path: &str,
1124 query: &[(String, String)],
1125 json_body: Option<Value>,
1126 ) -> Result<Vec<u8>> {
1127 let url = format!(
1128 "{}/{}",
1129 self.base_url.trim_end_matches('/'),
1130 path.trim_start_matches('/')
1131 );
1132 let headers = self.build_headers()?;
1133 let query_refs: Vec<(&str, &str)> = query
1134 .iter()
1135 .map(|(k, v)| (k.as_str(), v.as_str()))
1136 .collect();
1137 let mut req = self.http.post(&url).headers(headers).query(&query_refs);
1138 if let Some(b) = json_body {
1139 req = req.json(&b);
1140 }
1141 let resp = req.send().await.map_err(|e| anyhow!("POST {path}: {e}"))?;
1142 let status = resp.status();
1143 if !status.is_success() {
1144 let body = resp.text().await.unwrap_or_default();
1145 return Err(anyhow!(
1146 "ROMM API error: {} {} - {}",
1147 status.as_u16(),
1148 status.canonical_reason().unwrap_or(""),
1149 body
1150 ));
1151 }
1152 Ok(resp.bytes().await?.to_vec())
1153 }
1154
1155 pub async fn upload_rom_manual(&self, rom_id: u64, file_path: &Path) -> Result<Value> {
1157 let fname = file_path
1158 .file_name()
1159 .and_then(|n| n.to_str())
1160 .ok_or_else(|| anyhow!("manual path must have a unicode filename"))?
1161 .to_string();
1162 let url = format!(
1163 "{}/api/roms/{}/manuals",
1164 self.base_url.trim_end_matches('/'),
1165 rom_id
1166 );
1167 let bytes = tokio::fs::read(file_path)
1168 .await
1169 .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
1170 let mut headers = self.build_headers()?;
1171 headers.insert(
1172 reqwest::header::HeaderName::from_static("x-upload-filename"),
1173 HeaderValue::from_str(&fname).map_err(|_| anyhow!("invalid x-upload-filename"))?,
1174 );
1175 let resp = self
1176 .http
1177 .post(&url)
1178 .headers(headers)
1179 .body(bytes)
1180 .send()
1181 .await
1182 .map_err(|e| anyhow!("manual upload: {e}"))?;
1183 let status = resp.status();
1184 if !status.is_success() {
1185 let body = resp.text().await.unwrap_or_default();
1186 return Err(anyhow!(
1187 "ROMM API error: {} {} - {}",
1188 status.as_u16(),
1189 status.canonical_reason().unwrap_or(""),
1190 body
1191 ));
1192 }
1193 let out = resp.bytes().await?;
1194 Ok(decode_json_response_body(&out))
1195 }
1196}
1197
1198fn filename_hint(save_path: &Path) -> String {
1199 save_path
1200 .file_name()
1201 .and_then(|n| n.to_str())
1202 .unwrap_or("download.bin")
1203 .to_string()
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209
1210 #[test]
1211 fn decode_json_empty_and_whitespace_to_null() {
1212 assert_eq!(decode_json_response_body(b""), Value::Null);
1213 assert_eq!(decode_json_response_body(b" \n\t "), Value::Null);
1214 }
1215
1216 #[test]
1217 fn decode_json_object_roundtrip() {
1218 let v = decode_json_response_body(br#"{"a":1}"#);
1219 assert_eq!(v["a"], 1);
1220 }
1221
1222 #[test]
1223 fn decode_non_json_wrapped() {
1224 let v = decode_json_response_body(b"plain text");
1225 assert_eq!(v["_non_json_body"], "plain text");
1226 }
1227
1228 #[test]
1229 fn api_root_url_strips_trailing_api() {
1230 assert_eq!(
1231 super::api_root_url("http://localhost:8080/api"),
1232 "http://localhost:8080"
1233 );
1234 assert_eq!(
1235 super::api_root_url("http://localhost:8080/api/"),
1236 "http://localhost:8080"
1237 );
1238 assert_eq!(
1239 super::api_root_url("http://localhost:8080"),
1240 "http://localhost:8080"
1241 );
1242 }
1243
1244 #[test]
1245 fn openapi_spec_urls_try_primary_scheme_then_alt() {
1246 let urls = super::openapi_spec_urls("http://example.test");
1247 assert_eq!(urls[0], "http://example.test/openapi.json");
1248 assert_eq!(urls[1], "http://example.test/api/openapi.json");
1249 assert!(
1250 urls.iter()
1251 .any(|u| u == "https://example.test/openapi.json"),
1252 "{urls:?}"
1253 );
1254 }
1255}