Skip to main content

roam_sdk/api/
client.rs

1use reqwest::Client;
2
3use crate::api::types::{PullRequest, PullResponse, QueryRequest, QueryResponse, WriteAction};
4use crate::error::{Result, RoamError};
5
6#[derive(Clone)]
7pub struct RoamClient {
8    client: Client,
9    base_url: String,
10    token: String,
11}
12
13impl RoamClient {
14    pub fn new(graph_name: &str, token: &str) -> Self {
15        Self {
16            client: Client::new(),
17            base_url: format!("https://api.roamresearch.com/api/graph/{}", graph_name),
18            token: token.to_string(),
19        }
20    }
21
22    pub fn new_with_base_url(base_url: &str, token: &str) -> Self {
23        Self {
24            client: Client::new(),
25            base_url: base_url.to_string(),
26            token: token.to_string(),
27        }
28    }
29
30    pub async fn pull(&self, eid: serde_json::Value, selector: &str) -> Result<PullResponse> {
31        let req = PullRequest {
32            eid,
33            selector: selector.to_string(),
34        };
35        let resp = self
36            .client
37            .post(format!("{}/pull", self.base_url))
38            .header("X-Authorization", format!("Bearer {}", self.token))
39            .json(&req)
40            .send()
41            .await?;
42
43        if !resp.status().is_success() {
44            let status = resp.status().as_u16();
45            let message = resp.text().await.unwrap_or_default();
46            return Err(RoamError::Api { status, message });
47        }
48
49        let body = resp.json::<PullResponse>().await?;
50        Ok(body)
51    }
52
53    pub async fn query(
54        &self,
55        query: String,
56        args: Vec<serde_json::Value>,
57    ) -> Result<QueryResponse> {
58        let req = QueryRequest { query, args };
59        let resp = self
60            .client
61            .post(format!("{}/q", self.base_url))
62            .header("X-Authorization", format!("Bearer {}", self.token))
63            .json(&req)
64            .send()
65            .await?;
66
67        if !resp.status().is_success() {
68            let status = resp.status().as_u16();
69            let message = resp.text().await.unwrap_or_default();
70            return Err(RoamError::Api { status, message });
71        }
72
73        let body = resp.json::<QueryResponse>().await?;
74        Ok(body)
75    }
76
77    pub async fn write(&self, action: WriteAction) -> Result<()> {
78        let resp = self
79            .client
80            .post(format!("{}/write", self.base_url))
81            .header("X-Authorization", format!("Bearer {}", self.token))
82            .json(&action)
83            .send()
84            .await?;
85
86        if !resp.status().is_success() {
87            let status = resp.status().as_u16();
88            let message = resp.text().await.unwrap_or_default();
89            return Err(RoamError::Api { status, message });
90        }
91
92        Ok(())
93    }
94
95    pub async fn write_batch(&self, actions: Vec<WriteAction>) -> Result<()> {
96        for action in actions {
97            self.write(action).await?;
98        }
99        Ok(())
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use serde_json::json;
107    use wiremock::matchers::{header, method, path};
108    use wiremock::{Mock, MockServer, ResponseTemplate};
109
110    async fn setup() -> (MockServer, RoamClient) {
111        let server = MockServer::start().await;
112        let client = RoamClient::new_with_base_url(&server.uri(), "test-token");
113        (server, client)
114    }
115
116    #[tokio::test]
117    async fn pull_sends_correct_request() {
118        let (server, client) = setup().await;
119
120        Mock::given(method("POST"))
121            .and(path("/pull"))
122            .and(header("X-Authorization", "Bearer test-token"))
123            .respond_with(
124                ResponseTemplate::new(200).set_body_json(
125                    json!({"result": {":block/uid": "abc", ":block/string": "hello"}}),
126                ),
127            )
128            .mount(&server)
129            .await;
130
131        let resp = client
132            .pull(json!(["block/uid", "abc"]), "[:block/string :block/uid]")
133            .await
134            .unwrap();
135
136        assert_eq!(resp.result[":block/uid"], "abc");
137    }
138
139    #[tokio::test]
140    async fn write_sends_correct_request() {
141        let (server, client) = setup().await;
142
143        Mock::given(method("POST"))
144            .and(path("/write"))
145            .and(header("X-Authorization", "Bearer test-token"))
146            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
147            .mount(&server)
148            .await;
149
150        let result = client
151            .write(WriteAction::UpdateBlock {
152                block: crate::api::types::BlockUpdate {
153                    uid: "abc".into(),
154                    string: "Updated".into(),
155                },
156            })
157            .await;
158
159        assert!(result.is_ok());
160    }
161
162    #[tokio::test]
163    async fn write_returns_error_on_500() {
164        let (server, client) = setup().await;
165
166        Mock::given(method("POST"))
167            .and(path("/write"))
168            .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
169            .mount(&server)
170            .await;
171
172        let err = client
173            .write(WriteAction::DeleteBlock {
174                block: crate::api::types::BlockRef { uid: "abc".into() },
175            })
176            .await;
177
178        assert!(err.is_err());
179        match err.unwrap_err() {
180            RoamError::Api { status, .. } => assert_eq!(status, 500),
181            other => panic!("Expected Api error, got: {:?}", other),
182        }
183    }
184
185    #[tokio::test]
186    async fn query_sends_correct_request() {
187        let (server, client) = setup().await;
188
189        Mock::given(method("POST"))
190            .and(path("/q"))
191            .and(header("X-Authorization", "Bearer test-token"))
192            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
193                "result": [[{":block/string": "ref text", ":block/uid": "abc"}]]
194            })))
195            .mount(&server)
196            .await;
197
198        let resp = client
199            .query("[:find ?b :where [?b :block/string]]".into(), vec![])
200            .await
201            .unwrap();
202
203        assert_eq!(resp.result.len(), 1);
204    }
205
206    #[tokio::test]
207    async fn query_returns_error_on_500() {
208        let (server, client) = setup().await;
209
210        Mock::given(method("POST"))
211            .and(path("/q"))
212            .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
213            .mount(&server)
214            .await;
215
216        let err = client
217            .query("[:find ?b :where [?b :block/string]]".into(), vec![])
218            .await;
219
220        assert!(err.is_err());
221        match err.unwrap_err() {
222            RoamError::Api { status, .. } => assert_eq!(status, 500),
223            other => panic!("Expected Api error, got: {:?}", other),
224        }
225    }
226
227    #[tokio::test]
228    async fn write_batch_sends_individual_requests() {
229        let (server, client) = setup().await;
230
231        Mock::given(method("POST"))
232            .and(path("/write"))
233            .and(header("X-Authorization", "Bearer test-token"))
234            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
235            .expect(2)
236            .mount(&server)
237            .await;
238
239        let result = client
240            .write_batch(vec![
241                WriteAction::CreatePage {
242                    page: crate::api::types::PageCreate {
243                        title: "Page 1".into(),
244                        uid: None,
245                    },
246                },
247                WriteAction::UpdateBlock {
248                    block: crate::api::types::BlockUpdate {
249                        uid: "b1".into(),
250                        string: "Updated".into(),
251                    },
252                },
253            ])
254            .await;
255
256        assert!(result.is_ok());
257    }
258
259    #[tokio::test]
260    async fn write_batch_stops_on_first_error() {
261        let (server, client) = setup().await;
262
263        // First request succeeds, second fails
264        Mock::given(method("POST"))
265            .and(path("/write"))
266            .respond_with(ResponseTemplate::new(500).set_body_string("Server Error"))
267            .mount(&server)
268            .await;
269
270        let result = client
271            .write_batch(vec![
272                WriteAction::UpdateBlock {
273                    block: crate::api::types::BlockUpdate {
274                        uid: "b1".into(),
275                        string: "text".into(),
276                    },
277                },
278                WriteAction::UpdateBlock {
279                    block: crate::api::types::BlockUpdate {
280                        uid: "b2".into(),
281                        string: "text".into(),
282                    },
283                },
284            ])
285            .await;
286
287        assert!(result.is_err());
288    }
289}