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 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}