api.rs
⎇
Raw
1use serde::de::DeserializeOwned;
2use serde::{Deserialize, Serialize};
3use wasm_bindgen::JsCast;
4use wasm_bindgen::JsValue;
5use wasm_bindgen_futures::JsFuture;
6
7#[derive(Debug, thiserror::Error)]
8pub enum ApiError {
9 /// Non-2xx response. `skipped` carries the server's conflict file list
10 /// when present (upload conflicts).
11 #[error("{message}")]
12 Http {
13 #[allow(dead_code)]
14 status: u16,
15 message: String,
16 skipped: Option<Vec<String>>,
17 },
18 /// The file changed on disk since it was read (save conflict, HTTP 409).
19 #[error("the file was changed on disk")]
20 Conflict,
21 #[error("network error: {0}")]
22 Net(String),
23}
24
25impl ApiError {
26 pub fn skipped(&self) -> Option<&[String]> {
27 match self {
28 ApiError::Http { skipped: Some(s), .. } => Some(s),
29 _ => None,
30 }
31 }
32
33 /// The HTTP status code, when this was an HTTP (non-2xx) error.
34 pub fn status(&self) -> Option<u16> {
35 match self {
36 ApiError::Http { status, .. } => Some(*status),
37 _ => None,
38 }
39 }
40}
41
42#[derive(Deserialize, Clone)]
43pub struct Me {
44 pub first_boot: bool,
45 #[serde(default)]
46 pub user: Option<UserInfo>,
47 #[serde(default)]
48 pub roots: Vec<RootInfo>,
49 /// Whether the server allows users to create writable (read-write) shares.
50 #[serde(default)]
51 pub allow_writable_shares: bool,
52}
53
54#[derive(Deserialize, Clone)]
55pub struct UserInfo {
56 #[allow(dead_code)] // used from milestone 7 onwards
57 pub id: i64,
58 pub name: String,
59 pub is_admin: bool,
60}
61
62#[derive(Deserialize, Clone)]
63pub struct RootInfo {
64 pub id: i64,
65 pub name: String,
66 pub path: String,
67 pub mode: String,
68}
69
70#[derive(Deserialize, Clone, Debug, PartialEq)]
71pub struct Entry {
72 pub name: String,
73 pub is_dir: bool,
74 pub size: u64,
75 pub mtime: String,
76}
77
78#[derive(Deserialize)]
79pub struct FilesResp {
80 pub entries: Vec<Entry>,
81}
82
83#[derive(Deserialize)]
84pub struct UploadResp {
85 #[allow(dead_code)]
86 pub uploaded: usize,
87}
88
89/// Acknowledges a successful 2xx response whose body we don't care about.
90/// Accepts any JSON value (the server sends `{"ok": true}`).
91pub struct OkResp;
92
93impl<'de> Deserialize<'de> for OkResp {
94 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
95 serde::de::IgnoredAny::deserialize(d)?;
96 Ok(OkResp)
97 }
98}
99
100#[derive(Serialize)]
101struct CredentialsBody {
102 name: String,
103 password: String,
104}
105
106/// Server error body: `{"error": "...", "skipped": [...]?}`.
107#[derive(Deserialize, Default)]
108struct ErrBody {
109 #[serde(default)]
110 error: Option<String>,
111 #[serde(default)]
112 skipped: Option<Vec<String>>,
113}
114
115// ---------------------------------------------------------------------------
116// Auth
117// ---------------------------------------------------------------------------
118
119pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
120 request("GET", "/api/auth/me".to_string(), None::<()>)
121}
122
123pub fn login(
124 name: String,
125 password: String,
126) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
127 request(
128 "POST",
129 "/api/auth/login".to_string(),
130 Some(CredentialsBody { name, password }),
131 )
132}
133
134pub fn setup(
135 name: String,
136 password: String,
137) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
138 request(
139 "POST",
140 "/api/auth/setup".to_string(),
141 Some(CredentialsBody { name, password }),
142 )
143}
144
145pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
146 request("POST", "/api/auth/logout".to_string(), Some(()))
147}
148
149// ---------------------------------------------------------------------------
150// Files
151// ---------------------------------------------------------------------------
152
153/// The public share token while the app is showing a share page. Every file
154/// API call appends `?share=<token>` (or `&share=<token>`). Only one share is
155/// shown per page, so a plain static suffices (wasm is single-threaded).
156use std::sync::Mutex;
157static SHARE_TOKEN: Mutex<Option<String>> = Mutex::new(None);
158
159/// Set (or clear, with `None`) the share token used by file API calls.
160pub fn set_share_token(token: Option<&str>) {
161 *SHARE_TOKEN.lock().unwrap() = token.map(|s| s.to_string());
162}
163
164fn share_suffix() -> String {
165 SHARE_TOKEN
166 .lock()
167 .unwrap()
168 .as_deref()
169 .map(|t| format!("?share={t}"))
170 .unwrap_or_default()
171}
172
173/// Append `key=value` to a URL, using `&` when a query string already exists.
174fn append_query(url: &str, kv: &str) -> String {
175 if url.contains('?') {
176 format!("{url}&{kv}")
177 } else {
178 format!("{url}?{kv}")
179 }
180}
181
182fn files_url(root_id: i64, path: &str) -> String {
183 let base = if path.is_empty() {
184 format!("/api/files/{root_id}")
185 } else {
186 let encoded: Vec<String> = path
187 .split('/')
188 .map(|s| js_sys::encode_uri_component(s).into())
189 .collect();
190 format!("/api/files/{root_id}/{}", encoded.join("/"))
191 };
192 format!("{base}{}", share_suffix())
193}
194
195pub fn list_files(
196 root_id: i64,
197 path: &str,
198) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
199 request("GET", files_url(root_id, path), None::<()>)
200}
201
202/// Create a folder. `path` is relative to the root (may contain subfolders).
203pub fn mkdir(root_id: i64, path: &str) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
204 request_no_body("POST", files_url(root_id, path))
205}
206
207pub fn rename_item(
208 root_id: i64,
209 path: &str,
210 new_name: String,
211 overwrite: bool,
212) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
213 request(
214 "POST",
215 files_url(root_id, path),
216 Some(Mutation {
217 op: "rename".to_string(),
218 new_name: Some(new_name),
219 dst_root_id: None,
220 dst: None,
221 overwrite,
222 }),
223 )
224}
225
226pub fn move_item(
227 root_id: i64,
228 path: &str,
229 dst_root_id: i64,
230 dst: &str,
231 overwrite: bool,
232) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
233 mutation(root_id, path, "move", dst_root_id, dst, overwrite)
234}
235
236pub fn copy_item(
237 root_id: i64,
238 path: &str,
239 dst_root_id: i64,
240 dst: &str,
241 overwrite: bool,
242) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
243 mutation(root_id, path, "copy", dst_root_id, dst, overwrite)
244}
245
246fn mutation(
247 root_id: i64,
248 path: &str,
249 op: &str,
250 dst_root_id: i64,
251 dst: &str,
252 overwrite: bool,
253) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
254 request(
255 "POST",
256 files_url(root_id, path),
257 Some(Mutation {
258 op: op.to_string(),
259 new_name: None,
260 dst_root_id: Some(dst_root_id),
261 dst: Some(dst.to_string()),
262 overwrite,
263 }),
264 )
265}
266
267#[derive(Serialize)]
268struct Mutation {
269 op: String,
270 #[serde(skip_serializing_if = "Option::is_none")]
271 new_name: Option<String>,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 dst_root_id: Option<i64>,
274 #[serde(skip_serializing_if = "Option::is_none")]
275 dst: Option<String>,
276 overwrite: bool,
277}
278
279pub fn delete_item(
280 root_id: i64,
281 path: &str,
282) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
283 request_no_body("DELETE", files_url(root_id, path))
284}
285
286// ---------------------------------------------------------------------------
287// Download / preview / content (milestone 4)
288// ---------------------------------------------------------------------------
289
290/// `...?action=download` — a single file as-is, or a folder as `format`.
291pub fn download_url(root_id: i64, path: &str, format: Option<&str>) -> String {
292 let mut base = append_query(&files_url(root_id, path), "action=download");
293 if let Some(f) = format {
294 base = append_query(&base, &format!("format={f}"));
295 }
296 base
297}
298
299/// `...?action=preview` — a single file, inline (native media).
300pub fn preview_url(root_id: i64, path: &str) -> String {
301 append_query(&files_url(root_id, path), "action=preview")
302}
303
304/// `...?action=content` — raw file bytes for the text preview/editor.
305pub fn content_url(root_id: i64, path: &str) -> String {
306 append_query(&files_url(root_id, path), "action=content")
307}
308
309/// Fetch a file's raw text content (for the CodeMirror preview/editor).
310pub async fn fetch_content(root_id: i64, path: &str) -> Result<String, ApiError> {
311 let window =
312 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
313 let opts = web_sys::RequestInit::new();
314 opts.set_method("GET");
315 opts.set_mode(web_sys::RequestMode::SameOrigin);
316 let req = web_sys::Request::new_with_str_and_init(&content_url(root_id, path), &opts)
317 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
318 let promise = window.fetch_with_request(&req);
319 let resp_val = JsFuture::from(promise)
320 .await
321 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
322 let resp: web_sys::Response = resp_val
323 .dyn_into()
324 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
325 let status = resp.status();
326 if !(200..300).contains(&status) {
327 let body = parse_error_body(&resp).await;
328 return Err(ApiError::Http {
329 status,
330 message: body.error.unwrap_or_else(|| "could not read file".to_string()),
331 skipped: None,
332 });
333 }
334 let tp = resp
335 .text()
336 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
337 let js = JsFuture::from(tp)
338 .await
339 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
340 js.as_string().ok_or_else(|| ApiError::Net("content is not a string".to_string()))
341}
342
343/// Fetch a file's raw text content plus its mtime (unix seconds), for the
344/// editor. The mtime anchors the save-time conflict check.
345pub async fn fetch_content_meta(
346 root_id: i64,
347 path: &str,
348) -> Result<(String, Option<i64>), ApiError> {
349 let window =
350 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
351 let opts = web_sys::RequestInit::new();
352 opts.set_method("GET");
353 opts.set_mode(web_sys::RequestMode::SameOrigin);
354 let req = web_sys::Request::new_with_str_and_init(&content_url(root_id, path), &opts)
355 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
356 let promise = window.fetch_with_request(&req);
357 let resp_val = JsFuture::from(promise)
358 .await
359 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
360 let resp: web_sys::Response = resp_val
361 .dyn_into()
362 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
363 let status = resp.status();
364 if !(200..300).contains(&status) {
365 let body = parse_error_body(&resp).await;
366 return Err(ApiError::Http {
367 status,
368 message: body.error.unwrap_or_else(|| "could not read file".to_string()),
369 skipped: None,
370 });
371 }
372 let mtime: Option<i64> = resp
373 .headers()
374 .get("x-file-mtime")
375 .ok()
376 .flatten()
377 .and_then(|s| s.parse::<i64>().ok());
378 let tp = resp
379 .text()
380 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
381 let js = JsFuture::from(tp)
382 .await
383 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
384 let text = js
385 .as_string()
386 .ok_or_else(|| ApiError::Net("content is not a string".to_string()))?;
387 Ok((text, mtime))
388}
389
390/// Save a file's text content (the editor's write path).
391///
392/// When `force` is false, `expected_mtime` is sent and the server rejects the
393/// save with [`ApiError::Conflict`] if the file changed on disk since it was
394/// read. When `force` is true the check is skipped (overwrite). Returns the
395/// file's new mtime (unix seconds) to anchor the next check.
396pub async fn save_content(
397 root_id: i64,
398 path: &str,
399 text: &str,
400 expected_mtime: Option<i64>,
401 force: bool,
402) -> Result<i64, ApiError> {
403 let window =
404 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
405 let url = append_query(&files_url(root_id, path), "action=content");
406 let opts = web_sys::RequestInit::new();
407 opts.set_method("PUT");
408 opts.set_mode(web_sys::RequestMode::SameOrigin);
409 opts.set_body_opt_str(Some(text));
410 let headers = web_sys::Headers::new()
411 .map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?;
412 headers
413 .set("Content-Type", "text/plain; charset=utf-8")
414 .map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?;
415 if !force {
416 if let Some(m) = expected_mtime {
417 headers
418 .set("X-Expected-Mtime", &m.to_string())
419 .map_err(|e| ApiError::Net(format!("could not set header: {e:?}")))?;
420 }
421 }
422 opts.set_headers_headers(&headers);
423 let req = web_sys::Request::new_with_str_and_init(&url, &opts)
424 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
425 let promise = window.fetch_with_request(&req);
426 let resp_val = JsFuture::from(promise)
427 .await
428 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
429 let resp: web_sys::Response = resp_val
430 .dyn_into()
431 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
432 let status = resp.status();
433 if status == 409 {
434 return Err(ApiError::Conflict);
435 }
436 if !(200..300).contains(&status) {
437 let body = parse_error_body(&resp).await;
438 return Err(ApiError::Http {
439 status,
440 message: body.error.unwrap_or_else(|| "could not save file".to_string()),
441 skipped: None,
442 });
443 }
444 let js = resp
445 .json()
446 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
447 let js: JsValue = JsFuture::from(js)
448 .await
449 .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
450 #[derive(Deserialize)]
451 struct SaveResp {
452 #[serde(default)]
453 mtime: Option<i64>,
454 }
455 let save: SaveResp =
456 serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))?;
457 Ok(save.mtime.unwrap_or(0))
458}
459
460/// Trigger a browser download of a same-origin URL via a temporary anchor.
461/// No data is pulled into JS memory — the browser streams it.
462pub fn trigger_download(url: &str, filename: &str) {
463 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
464 return;
465 };
466 let Ok(el) = doc.create_element("a") else {
467 return;
468 };
469 let Ok(a) = el.dyn_into::<web_sys::HtmlAnchorElement>() else {
470 return;
471 };
472 a.set_href(url);
473 a.set_download(filename);
474 if let Some(body) = doc.body() {
475 let _ = body.append_child(&a);
476 }
477 a.click();
478 let _ = a.remove();
479}
480
481/// Upload files into a directory. Each part is `(relative_path, file)`;
482/// the relative path may contain subfolders (created on the server).
483///
484/// The multipart body is assembled by hand into a `Blob` (instead of using
485/// `FormData` directly as the fetch body): a FormData body makes Chrome send
486/// the request as a *streaming* body, which forces the HTTP/2-cleartext
487/// (h2c/ALPN) path and fails against an HTTP/1.1-only server with
488/// `ERR_ALPN_NEGOTIATION_FAILED`. A pre-assembled Blob has a known size, so
489/// it goes out as a regular length-prefixed HTTP/1.1 request.
490pub async fn upload(
491 root_id: i64,
492 dir: &str,
493 overwrite: bool,
494 parts: Vec<(String, web_sys::File)>,
495) -> Result<UploadResp, ApiError> {
496 let window =
497 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
498
499 let boundary = format!("----fbng{}", random_boundary_suffix());
500 let segments = js_sys::Array::new();
501 for (name, file) in &parts {
502 let basename = name.rsplit('/').next().unwrap_or(name);
503 segments.push(&JsValue::from_str(&format!(
504 "--{boundary}\r\n\
505 Content-Disposition: form-data; name=\"{name}\"; filename=\"{basename}\"\r\n\
506 Content-Type: application/octet-stream\r\n\r\n"
507 )));
508 let f: JsValue = file.clone().unchecked_into();
509 segments.push(&f);
510 segments.push(&JsValue::from_str("\r\n"));
511 }
512 segments.push(&JsValue::from_str(&format!("--{boundary}--\r\n")));
513 let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
514 .map_err(|e| ApiError::Net(format!("could not build upload body: {e:?}")))?;
515
516 let url = append_query(
517 &files_url(root_id, dir),
518 &format!("overwrite={}", if overwrite { "true" } else { "false" }),
519 );
520
521 let headers = web_sys::Headers::new()
522 .map_err(|e| ApiError::Net(format!("could not create headers: {e:?}")))?;
523 headers
524 .set(
525 "Content-Type",
526 &format!("multipart/form-data; boundary={boundary}"),
527 )
528 .map_err(|e| ApiError::Net(format!("could not set content-type: {e:?}")))?;
529
530 let opts = web_sys::RequestInit::new();
531 opts.set_method("POST");
532 opts.set_mode(web_sys::RequestMode::SameOrigin);
533 opts.set_headers_headers(&headers);
534 opts.set_body_opt_blob(Some(&body));
535
536 let promise = window.fetch_with_str_and_init(&url, &opts);
537 let resp_val = JsFuture::from(promise)
538 .await
539 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
540 let resp: web_sys::Response = resp_val
541 .dyn_into()
542 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
543
544 let status = resp.status();
545 if !(200..300).contains(&status) {
546 let body = parse_error_body(&resp).await;
547 return Err(ApiError::Http {
548 status,
549 message: body.error.unwrap_or_else(|| "upload failed".to_string()),
550 skipped: body.skipped,
551 });
552 }
553 let js = resp
554 .json()
555 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
556 let js: JsValue = JsFuture::from(js)
557 .await
558 .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
559 serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
560}
561
562// ---------------------------------------------------------------------------
563// Shares (milestone 6)
564// ---------------------------------------------------------------------------
565
566#[derive(Deserialize, Clone)]
567pub struct ShareInfo {
568 pub id: i64,
569 pub token: String,
570 pub name: String,
571 pub is_file: bool,
572 pub writable: bool,
573 pub target: String,
574 pub created_at: String,
575 #[serde(default)]
576 pub expires_at: Option<String>,
577 /// The synthetic root id to use in file API calls.
578 #[serde(default)]
579 pub root_id: Option<i64>,
580}
581
582#[derive(Serialize)]
583struct CreateShareBody {
584 root_id: i64,
585 path: String,
586 writable: bool,
587 #[serde(skip_serializing_if = "Option::is_none")]
588 expires_at: Option<String>,
589}
590
591pub fn list_shares() -> impl std::future::Future<Output = Result<Vec<ShareInfo>, ApiError>> {
592 request("GET", "/api/shares".to_string(), None::<()>)
593}
594
595pub fn create_share(
596 root_id: i64,
597 path: &str,
598 writable: bool,
599 expires_at: Option<&str>,
600) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
601 request(
602 "POST",
603 "/api/shares".to_string(),
604 Some(CreateShareBody {
605 root_id,
606 path: path.to_string(),
607 writable,
608 expires_at: expires_at.map(|s| s.to_string()),
609 }),
610 )
611}
612
613pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
614 request_no_body("DELETE", format!("/api/shares/{id}"))
615}
616
617/// Public: resolve a share (no session required).
618pub fn resolve_share(
619 token: &str,
620) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
621 request("GET", format!("/api/share/{token}"), None::<()>)
622}
623
624// ---------------------------------------------------------------------------
625// File picker (imperative, one at a time)
626// ---------------------------------------------------------------------------
627
628fn random_boundary_suffix() -> String {
629 let mut s = String::with_capacity(16);
630 for _ in 0..16 {
631 let n = (js_sys::Math::random() * 36.0) as u32;
632 s.push(char::from_digit(n, 36).unwrap_or('a'));
633 }
634 s
635}
636
637use wasm_bindgen::closure::Closure;
638
639/// Open the native file dialog and run `on_files` with the picked files once
640/// the user confirms. `directory` uses webkitdirectory (folder upload).
641fn webkit_relative_path(file: &web_sys::File) -> String {
642 let js: JsValue = file.into();
643 js_sys::Reflect::get(&js, &JsValue::from_str("webkitRelativePath"))
644 .ok()
645 .and_then(|v| v.as_string())
646 .filter(|s| !s.is_empty())
647 .unwrap_or_default()
648}
649
650pub fn pick_files(
651 multiple: bool,
652 directory: bool,
653 on_files: impl Fn(Vec<(String, web_sys::File)>) + 'static,
654) {
655 let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
656 return;
657 };
658 let Ok(el) = doc.create_element("input") else {
659 return;
660 };
661 let Ok(input) = el.dyn_into::<web_sys::HtmlInputElement>() else {
662 return;
663 };
664 input.set_type("file");
665 if multiple {
666 input.set_multiple(true);
667 }
668 if directory {
669 let _ = input.set_attribute("webkitdirectory", "");
670 }
671
672 // The listener keeps the JS callback alive while the input exists;
673 // detach from Rust (the input is removed after the dialog is used).
674 // A cancelled dialog leaks the hidden input until reload — acceptable.
675 let input2 = input.clone();
676 let closure = Closure::<dyn FnMut()>::new(move || {
677 let mut files: Vec<(String, web_sys::File)> = Vec::new();
678 if let Some(list) = input.files() {
679 for i in 0..list.length() {
680 if let Some(f) = list.get(i) {
681 let rel = webkit_relative_path(&f);
682 let name = if rel.is_empty() { f.name() } else { rel };
683 files.push((name, f));
684 }
685 }
686 }
687 let _ = input.remove();
688 if !files.is_empty() {
689 on_files(files);
690 }
691 });
692 let listener: &js_sys::Function = closure.as_js_value().unchecked_ref();
693 let _ = input2.add_event_listener_with_callback("change", listener);
694 closure.forget();
695 if let Some(body) = doc.body() {
696 let _ = body.append_child(&input2);
697 }
698 input2.click();
699}
700
701// ---------------------------------------------------------------------------
702// Low-level request helpers
703// ---------------------------------------------------------------------------
704
705async fn request<T: DeserializeOwned>(
706 method: &str,
707 url: String,
708 body: Option<impl Serialize>,
709) -> Result<T, ApiError> {
710 let window =
711 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
712
713 let opts = web_sys::RequestInit::new();
714 opts.set_method(method);
715 opts.set_mode(web_sys::RequestMode::SameOrigin);
716 if let Some(body) = body {
717 let json = serde_json_to_string(&body)
718 .map_err(|e| ApiError::Net(e.to_string()))?;
719 opts.set_body_opt_str(Some(&json));
720 let headers = web_sys::Headers::new()
721 .expect("Headers constructor failed");
722 let _ = headers.set("Content-Type", "application/json");
723 opts.set_headers_headers(&headers);
724 }
725
726 do_fetch(window, url, opts).await
727}
728
729/// Request without a body (mkdir / delete).
730async fn request_no_body<T: DeserializeOwned>(
731 method: &str,
732 url: String,
733) -> Result<T, ApiError> {
734 let window =
735 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
736 let opts = web_sys::RequestInit::new();
737 opts.set_method(method);
738 opts.set_mode(web_sys::RequestMode::SameOrigin);
739 do_fetch(window, url, opts).await
740}
741
742async fn do_fetch<T: DeserializeOwned>(
743 window: web_sys::Window,
744 url: String,
745 opts: web_sys::RequestInit,
746) -> Result<T, ApiError> {
747 let req = web_sys::Request::new_with_str_and_init(&url, &opts)
748 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
749
750 let promise = window.fetch_with_request(&req);
751 let resp_val = JsFuture::from(promise)
752 .await
753 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
754 let resp: web_sys::Response = resp_val
755 .dyn_into()
756 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
757
758 let status = resp.status();
759 if !(200..300).contains(&status) {
760 let body = parse_error_body(&resp).await;
761 return Err(ApiError::Http {
762 status,
763 message: body.error.unwrap_or_else(|| "request failed".to_string()),
764 skipped: body.skipped,
765 });
766 }
767
768 let json_promise = resp
769 .json()
770 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
771 let js: JsValue = JsFuture::from(json_promise)
772 .await
773 .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
774
775 serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
776}
777
778/// Parse the server's error JSON (message + optional conflict list).
779async fn parse_error_body(resp: &web_sys::Response) -> ErrBody {
780 let Ok(promise) = resp.text() else {
781 return ErrBody::default();
782 };
783 let Ok(js) = JsFuture::from(promise).await else {
784 return ErrBody::default();
785 };
786 let Some(text) = js.as_string() else {
787 return ErrBody::default();
788 };
789 if let Ok(v) = js_sys::JSON::parse(&text) {
790 if let Ok(body) = serde_wasm_bindgen::from_value::<ErrBody>(v) {
791 return body;
792 }
793 }
794 // Fallback: naive extraction of the "error" string.
795 const MARKER: &str = "\"error\":\"";
796 let error = text.find(MARKER).and_then(|start| {
797 let rest = &text[start + MARKER.len()..];
798 rest.find('"').map(|end| rest[..end].replace("\\\"", "\""))
799 });
800 ErrBody {
801 error,
802 skipped: None,
803 }
804}
805
806/// Read a form input's value by element id.
807pub fn input_value(id: &str) -> String {
808 web_sys::window()
809 .and_then(|w| w.document())
810 .and_then(|d| {
811 d.get_element_by_id(id)
812 .and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
813 })
814 .map(|i| i.value())
815 .unwrap_or_default()
816}
817
818fn serde_json_to_string(v: &impl Serialize) -> Result<String, serde_wasm_bindgen::Error> {
819 // Reuse the wasm-bindgen JSON serializer; the body must be a plain string
820 // so we convert via JSON text.
821 let value = serde_wasm_bindgen::to_value(v)?;
822 let s = js_sys::JSON::stringify(&value).map_err(|e| {
823 serde_wasm_bindgen::Error::new(format!("JSON.stringify failed: {e:?}"))
824 })?;
825 s.as_string().ok_or_else(|| {
826 serde_wasm_bindgen::Error::new("stringify returned a non-string")
827 })
828}
829