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