Skip to main content

resourcespace_client/
client.rs

1use secrecy::{ExposeSecret, SecretString};
2use serde::Serialize;
3use serde_json::json;
4use sha2::{Digest, Sha256};
5use std::time::Duration;
6use url::Url;
7
8use crate::APP_USER_AGENT;
9use crate::auth::{Auth, login};
10use crate::error::RsError;
11
12// Typestates
13mod private {
14    use secrecy::SecretString;
15
16    pub struct NoUrl;
17    pub struct WithUrl(pub(crate) url::Url);
18    pub struct NoAuth;
19    pub struct WithUserKey {
20        pub(crate) user: String,
21        pub(crate) key: SecretString,
22    }
23    pub struct WithSessionKey {
24        pub(crate) user: String,
25        pub(crate) password: SecretString,
26    }
27}
28
29#[derive(Serialize)]
30pub(crate) struct ApiRequest<'a, P: Serialize> {
31    pub(crate) user: &'a str,
32    #[serde(rename = "function")]
33    pub(crate) function: &'a str,
34    #[serde(flatten)]
35    pub(crate) params: P,
36}
37
38pub(crate) fn build_query<P: Serialize>(params: &P) -> Result<String, RsError> {
39    serde_qs::Config::new()
40        .use_form_encoding(true)
41        .serialize_string(params)
42        .map_err(|e| RsError::Other(format!("Failed to serialize request: {}", e)))
43}
44
45/// some endpoints return JSON with status codes, some plain text, some error with 200 status code, etc.
46/// for now just try to parse and hope for the best. Montala stated they are working on an OpenAPI spec
47/// for the api which should allow for much better handling in the future.
48/// So, for now, responses can be:
49/// - JSON arrays
50/// - JSON objects
51/// - Plain true/false strings
52/// - Raw integers (resource IDs)
53/// - "FAILED: ..." strings for certain errors, even with 200 status code
54/// - "Invalid signature" strings, even with 200 status code
55#[derive(Debug)]
56pub struct Client {
57    base_url: Url,
58    auth: Auth,
59    client: reqwest::Client,
60}
61
62impl Client {
63    #[must_use]
64    pub fn builder() -> ClientBuilder<private::NoUrl, private::NoAuth> {
65        ClientBuilder {
66            base_url: private::NoUrl,
67            auth: private::NoAuth,
68        }
69    }
70
71    pub(crate) async fn send_request<P>(
72        &self,
73        function: &str,
74        method: reqwest::Method,
75        params: P,
76    ) -> Result<serde_json::Value, RsError>
77    where
78        P: Serialize,
79    {
80        let (user, key, authmode) = match &self.auth {
81            Auth::UserKey { user, key } => (user, key.expose_secret(), "userkey"),
82            Auth::SessionKey { user, key } => (user, key.expose_secret(), "sessionkey"),
83        };
84
85        // Build query string
86        let req = ApiRequest {
87            user,
88            function,
89            params,
90        };
91        let query = build_query(&req)?;
92        let signature = sign(key, &query);
93
94        let response = match method {
95            reqwest::Method::GET => {
96                let full_url = format!(
97                    "{}api/?{}&sign={}&authmode={}",
98                    self.base_url, query, signature, authmode
99                );
100                self.client.get(&full_url).send().await
101            }
102            reqwest::Method::POST => {
103                let full_url = format!("{}api/", self.base_url);
104                self.client
105                    .post(&full_url)
106                    .form(&[
107                        ("user", user.clone()),
108                        ("query", query),
109                        ("sign", signature),
110                        ("authmode", authmode.to_string()),
111                    ])
112                    .send()
113                    .await
114            }
115            _ => return Err(RsError::Other("Unsupported HTTP method".into())),
116        }
117        .map_err(RsError::Http)?;
118
119        // 1. check HTTP status before touching the body
120        if !response.status().is_success() {
121            return Err(RsError::Api {
122                status: response.status().as_u16(),
123                message: response.text().await.unwrap_or_default(),
124            });
125        }
126
127        let text = response.text().await.map_err(RsError::Http)?;
128        let trimmed = text.trim();
129
130        // 2. RS returns plain "false" for failed operations
131        if trimmed.eq_ignore_ascii_case("false") {
132            return Err(RsError::OperationFailed);
133        }
134
135        // 3. RS returns "FAILED: ..." strings from upload functions
136        if let Some(msg) = trimmed.strip_prefix("FAILED:") {
137            return Err(RsError::Api {
138                status: 400,
139                message: msg.trim().to_string(),
140            });
141        }
142
143        // 4. Try to parse as JSON, fall back to wrapping as a JSON string
144        // This handles plain integers (create_resource), "true", and error strings
145        let json: serde_json::Value = serde_json::from_str(trimmed)
146            .unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string()));
147
148        Ok(json)
149    }
150
151    pub(crate) async fn send_multipart_request<P>(
152        &self,
153        function: &str,
154        params: P,
155        file: &std::path::Path,
156    ) -> Result<serde_json::Value, RsError>
157    where
158        P: Serialize,
159    {
160        let (user, key, authmode) = match &self.auth {
161            Auth::UserKey { user, key } => (user, key.expose_secret(), "userkey"),
162            Auth::SessionKey { user, key } => (user, key.expose_secret(), "sessionkey"),
163        };
164
165        // Build query string — same as regular POST, file is NOT included
166        let req = ApiRequest {
167            user,
168            function,
169            params,
170        };
171        let query = build_query(&req)?;
172        let signature = sign(key, &query);
173
174        let full_url = format!("{}api/", self.base_url);
175
176        let response = self
177            .client
178            .post(&full_url)
179            .multipart(
180                reqwest::multipart::Form::new()
181                    .text("user", user.clone())
182                    .text("query", query)
183                    .text("sign", signature)
184                    .text("authmode", authmode.to_string())
185                    .file("file", file)
186                    .await
187                    .map_err(|e| RsError::Other(format!("Failed to read file: {}", e)))?,
188            )
189            .send()
190            .await
191            .map_err(RsError::Http)?;
192
193        if !response.status().is_success() {
194            return Err(RsError::Api {
195                status: response.status().as_u16(),
196                message: response.text().await.unwrap_or_default(),
197            });
198        }
199
200        let text = response.text().await.map_err(RsError::Http)?;
201
202        Ok(json!(text))
203    }
204
205    // Sub-APIs
206    pub fn search(&self) -> crate::api::search::SearchApi<'_> {
207        crate::api::search::SearchApi::new(self)
208    }
209    pub fn system(&self) -> crate::api::system::SystemApi<'_> {
210        crate::api::system::SystemApi::new(self)
211    }
212    pub fn message(&self) -> crate::api::message::MessageApi<'_> {
213        crate::api::message::MessageApi::new(self)
214    }
215    pub fn metadata(&self) -> crate::api::metadata::MetadataApi<'_> {
216        crate::api::metadata::MetadataApi::new(self)
217    }
218    pub fn user(&self) -> crate::api::user::UserApi<'_> {
219        crate::api::user::UserApi::new(self)
220    }
221    pub fn collection(&self) -> crate::api::collection::CollectionApi<'_> {
222        crate::api::collection::CollectionApi::new(self)
223    }
224    pub fn resource(&self) -> crate::api::resource::ResourceApi<'_> {
225        crate::api::resource::ResourceApi::new(self)
226    }
227}
228
229pub struct ClientBuilder<U = private::NoUrl, A = private::NoAuth> {
230    base_url: U,
231    auth: A,
232}
233
234impl<A> ClientBuilder<private::NoUrl, A> {
235    pub fn base_url(
236        self,
237        url: impl Into<String>,
238    ) -> Result<ClientBuilder<private::WithUrl, A>, RsError> {
239        let url = url.into();
240        let parsed_url = Url::parse(&url).map_err(|e| RsError::Other(e.to_string()))?;
241
242        Ok(ClientBuilder {
243            base_url: private::WithUrl(parsed_url),
244            auth: self.auth,
245        })
246    }
247}
248
249impl<U> ClientBuilder<U, private::NoAuth> {
250    pub fn user_key(
251        self,
252        user: impl Into<String>,
253        key: impl Into<String>,
254    ) -> ClientBuilder<U, private::WithUserKey> {
255        ClientBuilder {
256            base_url: self.base_url,
257            auth: private::WithUserKey {
258                user: user.into(),
259                key: SecretString::from(key.into()),
260            },
261        }
262    }
263
264    pub fn session_key(
265        self,
266        user: impl Into<String>,
267        password: impl Into<String>,
268    ) -> ClientBuilder<U, private::WithSessionKey> {
269        ClientBuilder {
270            base_url: self.base_url,
271            auth: private::WithSessionKey {
272                user: user.into(),
273                password: SecretString::from(password.into()),
274            },
275        }
276    }
277}
278
279impl ClientBuilder<private::WithUrl, private::WithSessionKey> {
280    pub async fn build(self) -> Result<Client, RsError> {
281        let http = make_client()?;
282        let session_key = login(
283            &http,
284            &self.base_url.0,
285            &self.auth.user,
286            self.auth.password.expose_secret(),
287        )
288        .await?;
289        let auth = Auth::SessionKey {
290            user: self.auth.user,
291            key: SecretString::from(session_key),
292        };
293
294        Ok(Client {
295            base_url: self.base_url.0,
296            auth,
297            client: http,
298        })
299    }
300}
301
302impl ClientBuilder<private::WithUrl, private::WithUserKey> {
303    pub async fn build(self) -> Result<Client, RsError> {
304        let http = make_client()?;
305        let auth = Auth::UserKey {
306            user: self.auth.user,
307            key: self.auth.key,
308        };
309
310        Ok(Client {
311            base_url: self.base_url.0,
312            auth,
313            client: http,
314        })
315    }
316}
317
318fn sign(key: &str, query: &str) -> String {
319    let mut hasher = Sha256::new();
320    hasher.update(key.as_bytes());
321    hasher.update(query.as_bytes());
322    hex::encode(hasher.finalize())
323}
324
325fn make_client() -> Result<reqwest::Client, RsError> {
326    Ok(reqwest::Client::builder()
327        .timeout(Duration::from_secs(30))
328        .connect_timeout(Duration::from_secs(10))
329        .user_agent(APP_USER_AGENT)
330        .build()?)
331}