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)]
57pub struct RommClient {
58 http: HttpClient,
59 base_url: String,
60 auth: Option<AuthConfig>,
61 verbose: bool,
62}
63
64pub 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
78pub 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
93pub 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 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 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 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 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 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 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 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 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 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 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 let (mut received, total, mut file) = if status == reqwest::StatusCode::PARTIAL_CONTENT {
498 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 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 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 let chunk_size: u64 = 2 * 1024 * 1024;
556 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 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 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 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 pub async fn run_all_tasks(&self) -> Result<Value> {
726 self.request_json("POST", "/api/tasks/run", &[], None).await
727 }
728
729 pub async fn list_tasks(&self) -> Result<Value> {
731 self.request_json("GET", "/api/tasks", &[], None).await
732 }
733
734 pub async fn get_tasks_queue_status(&self) -> Result<Value> {
736 self.request_json("GET", "/api/tasks/status", &[], None)
737 .await
738 }
739
740 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 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 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 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 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 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 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}