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