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