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 #[error("{1}")]
10 Http(u16, String),
11 #[error("network error: {0}")]
12 Net(String),
13}
14
15#[derive(Deserialize, Clone)]
16pub struct Me {
17 pub first_boot: bool,
18 #[serde(default)]
19 pub user: Option<UserInfo>,
20 #[serde(default)]
21 pub roots: Vec<RootInfo>,
22}
23
24#[derive(Deserialize, Clone)]
25pub struct UserInfo {
26 #[allow(dead_code)] // used from milestone 2 onwards
27 pub id: i64,
28 pub name: String,
29 pub is_admin: bool,
30}
31
32#[derive(Deserialize, Clone)]
33pub struct RootInfo {
34 #[allow(dead_code)] // used from milestone 2 onwards
35 pub id: i64,
36 pub name: String,
37 pub path: String,
38 pub mode: String,
39}
40
41/// Acknowledges a successful 2xx response whose body we don't care about.
42/// Accepts any JSON value (the server sends `{"ok": true}`).
43pub struct OkResp;
44
45impl<'de> Deserialize<'de> for OkResp {
46 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
47 serde::de::IgnoredAny::deserialize(d)?;
48 Ok(OkResp)
49 }
50}
51
52#[derive(Serialize)]
53struct CredentialsBody {
54 name: String,
55 password: String,
56}
57
58pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
59 request("GET", "/api/auth/me", None::<()>)
60}
61
62pub fn login(
63 name: String,
64 password: String,
65) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
66 request(
67 "POST",
68 "/api/auth/login",
69 Some(CredentialsBody { name, password }),
70 )
71}
72
73pub fn setup(
74 name: String,
75 password: String,
76) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
77 request(
78 "POST",
79 "/api/auth/setup",
80 Some(CredentialsBody { name, password }),
81 )
82}
83
84pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
85 request("POST", "/api/auth/logout", Some(()))
86}
87
88async fn request<T: DeserializeOwned>(
89 method: &str,
90 url: &str,
91 body: Option<impl Serialize>,
92) -> Result<T, ApiError> {
93 let window =
94 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
95
96 let opts = web_sys::RequestInit::new();
97 opts.set_method(method);
98 opts.set_mode(web_sys::RequestMode::SameOrigin);
99 if let Some(body) = body {
100 let json = serde_json_to_string(&body)
101 .map_err(|e| ApiError::Net(e.to_string()))?;
102 opts.set_body_opt_str(Some(&json));
103 let headers = web_sys::Headers::new()
104 .expect("Headers constructor failed");
105 let _ = headers.set("Content-Type", "application/json");
106 opts.set_headers_headers(&headers);
107 }
108
109 let req =
110 web_sys::Request::new_with_str_and_init(url, &opts)
111 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
112 let promise = window.fetch_with_request(&req);
113 let resp_val = JsFuture::from(promise)
114 .await
115 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
116 let resp: web_sys::Response = resp_val
117 .dyn_into()
118 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
119
120 let status = resp.status();
121 if !(200..300).contains(&status) {
122 return Err(ApiError::Http(status, error_message(&resp).await));
123 }
124
125 let json_promise = resp
126 .json()
127 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
128 let js: JsValue = JsFuture::from(json_promise)
129 .await
130 .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
131
132 serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
133}
134
135/// Best-effort extraction of the server's `{"error": "..."}` message.
136async fn error_message(resp: &web_sys::Response) -> String {
137 let Ok(promise) = resp.text() else {
138 return "request failed".to_string();
139 };
140 let Ok(js) = JsFuture::from(promise).await else {
141 return "request failed".to_string();
142 };
143 let Some(text) = js.as_string() else {
144 return "request failed".to_string();
145 };
146 const MARKER: &str = "\"error\":\"";
147 if let Some(start) = text.find(MARKER) {
148 let rest = &text[start + MARKER.len()..];
149 if let Some(end) = rest.find('"') {
150 return rest[..end].replace("\\\"", "\"");
151 }
152 }
153 "request failed".to_string()
154}
155
156/// Read a form input's value by element id.
157pub fn input_value(id: &str) -> String {
158 web_sys::window()
159 .and_then(|w| w.document())
160 .and_then(|d| d.get_element_by_id(id).and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok()))
161 .map(|i| i.value())
162 .unwrap_or_default()
163}
164
165fn serde_json_to_string(v: &impl Serialize) -> Result<String, serde_wasm_bindgen::Error> {
166 // Reuse the wasm-bindgen JSON serializer; the body must be a plain string
167 // so we convert via JSON text.
168 let value = serde_wasm_bindgen::to_value(v)?;
169 let s = js_sys::JSON::stringify(&value).map_err(|e| {
170 serde_wasm_bindgen::Error::new(format!("JSON.stringify failed: {e:?}"))
171 })?;
172 s.as_string().ok_or_else(|| {
173 serde_wasm_bindgen::Error::new("stringify returned a non-string")
174 })
175}
176