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#[derive(Deserialize)]
42pub struct OkResp;
43
44#[derive(Serialize)]
45struct CredentialsBody {
46 name: String,
47 password: String,
48}
49
50pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
51 request("GET", "/api/auth/me", None::<()>)
52}
53
54pub fn login(
55 name: String,
56 password: String,
57) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
58 request(
59 "POST",
60 "/api/auth/login",
61 Some(CredentialsBody { name, password }),
62 )
63}
64
65pub fn setup(
66 name: String,
67 password: String,
68) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
69 request(
70 "POST",
71 "/api/auth/setup",
72 Some(CredentialsBody { name, password }),
73 )
74}
75
76pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
77 request("POST", "/api/auth/logout", Some(()))
78}
79
80async fn request<T: DeserializeOwned>(
81 method: &str,
82 url: &str,
83 body: Option<impl Serialize>,
84) -> Result<T, ApiError> {
85 let window =
86 web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
87
88 let opts = web_sys::RequestInit::new();
89 opts.set_method(method);
90 opts.set_mode(web_sys::RequestMode::SameOrigin);
91 if let Some(body) = body {
92 let json = serde_json_to_string(&body)
93 .map_err(|e| ApiError::Net(e.to_string()))?;
94 opts.set_body_opt_str(Some(&json));
95 let headers = web_sys::Headers::new()
96 .expect("Headers constructor failed");
97 let _ = headers.set("Content-Type", "application/json");
98 opts.set_headers_headers(&headers);
99 }
100
101 let req =
102 web_sys::Request::new_with_str_and_init(url, &opts)
103 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
104 let promise = window.fetch_with_request(&req);
105 let resp_val = JsFuture::from(promise)
106 .await
107 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
108 let resp: web_sys::Response = resp_val
109 .dyn_into()
110 .map_err(|_| ApiError::Net("fetch did not return a Response".to_string()))?;
111
112 let status = resp.status();
113 if !(200..300).contains(&status) {
114 return Err(ApiError::Http(status, error_message(&resp).await));
115 }
116
117 let json_promise = resp
118 .json()
119 .map_err(|e| ApiError::Net(format!("{e:?}")))?;
120 let js: JsValue = JsFuture::from(json_promise)
121 .await
122 .map_err(|e| ApiError::Net(format!("response is not JSON: {e:?}")))?;
123
124 serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
125}
126
127/// Best-effort extraction of the server's `{"error": "..."}` message.
128async fn error_message(resp: &web_sys::Response) -> String {
129 let Ok(promise) = resp.text() else {
130 return "request failed".to_string();
131 };
132 let Ok(js) = JsFuture::from(promise).await else {
133 return "request failed".to_string();
134 };
135 let Some(text) = js.as_string() else {
136 return "request failed".to_string();
137 };
138 const MARKER: &str = "\"error\":\"";
139 if let Some(start) = text.find(MARKER) {
140 let rest = &text[start + MARKER.len()..];
141 if let Some(end) = rest.find('"') {
142 return rest[..end].replace("\\\"", "\"");
143 }
144 }
145 "request failed".to_string()
146}
147
148/// Read a form input's value by element id.
149pub fn input_value(id: &str) -> String {
150 web_sys::window()
151 .and_then(|w| w.document())
152 .and_then(|d| d.get_element_by_id(id).and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok()))
153 .map(|i| i.value())
154 .unwrap_or_default()
155}
156
157fn serde_json_to_string(v: &impl Serialize) -> Result<String, serde_wasm_bindgen::Error> {
158 // Reuse the wasm-bindgen JSON serializer; the body must be a plain string
159 // so we convert via JSON text.
160 let value = serde_wasm_bindgen::to_value(v)?;
161 let s = js_sys::JSON::stringify(&value).map_err(|e| {
162 serde_wasm_bindgen::Error::new(format!("JSON.stringify failed: {e:?}"))
163 })?;
164 s.as_string().ok_or_else(|| {
165 serde_wasm_bindgen::Error::new("stringify returned a non-string")
166 })
167}
168