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