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