sui_graphql/client/
mod.rs1pub(crate) mod chain;
4pub(crate) mod checkpoints;
5pub(crate) mod coins;
6pub(crate) mod dynamic_fields;
7pub(crate) mod execution;
8pub(crate) mod objects;
9pub(crate) mod transactions;
10
11use reqwest::Url;
12use reqwest::header::HeaderMap;
13use reqwest::header::HeaderName;
14use reqwest::header::HeaderValue;
15use serde::Deserialize;
16use serde::Serialize;
17use serde::de::DeserializeOwned;
18
19use crate::error::Error;
20use crate::error::GraphQLError;
21use crate::response::Response;
22
23const CLIENT_SDK_TYPE_HEADER: HeaderName = HeaderName::from_static("client-sdk-type");
26
27#[derive(Clone, Debug)]
29pub struct Client {
30 endpoint: Url,
31 http: reqwest::Client,
32 headers: HeaderMap,
33}
34
35impl Client {
36 pub const MAINNET: &str = "https://graphql.mainnet.sui.io/graphql";
38
39 pub const TESTNET: &str = "https://graphql.testnet.sui.io/graphql";
41
42 pub const DEVNET: &str = "https://graphql.devnet.sui.io/graphql";
44
45 pub fn new(endpoint: &str) -> Result<Self, Error> {
55 let endpoint = Url::parse(endpoint)?;
56 Ok(Self {
57 endpoint,
58 http: reqwest::Client::new(),
59 headers: HeaderMap::new(),
60 })
61 }
62
63 pub fn with_headers(mut self, headers: HeaderMap) -> Self {
81 self.headers = headers;
82 self
83 }
84
85 pub fn extend_headers(&mut self, headers: HeaderMap) -> &mut Self {
96 let mut current_key: Option<reqwest::header::HeaderName> = None;
97 for (key, value) in headers {
98 match key {
99 Some(k) => {
100 self.headers.insert(k.clone(), value);
101 current_key = Some(k);
102 }
103 None => {
104 if let Some(k) = ¤t_key {
105 self.headers.append(k, value);
106 }
107 }
108 }
109 }
110 self
111 }
112
113 pub fn bearer_auth<T>(&mut self, token: T) -> &mut Self
119 where
120 T: std::fmt::Display,
121 {
122 let value = format!("Bearer {token}");
123 let mut header = reqwest::header::HeaderValue::from_str(&value)
124 .expect("token is always a valid HeaderValue");
125 header.set_sensitive(true);
126 self.headers.insert(reqwest::header::AUTHORIZATION, header);
127 self
128 }
129
130 pub fn basic_auth<U, P>(&mut self, username: U, password: Option<P>) -> &mut Self
136 where
137 U: std::fmt::Display,
138 P: std::fmt::Display,
139 {
140 use base64ct::Base64;
141 use base64ct::Encoding;
142 let pair = match password {
143 Some(p) => format!("{username}:{p}"),
144 None => format!("{username}:"),
145 };
146 let value = format!("Basic {}", Base64::encode_string(pair.as_bytes()));
147 let mut header = reqwest::header::HeaderValue::from_str(&value)
148 .expect("base64 is always a valid HeaderValue");
149 header.set_sensitive(true);
150 self.headers.insert(reqwest::header::AUTHORIZATION, header);
151 self
152 }
153
154 pub async fn query<T: DeserializeOwned>(
193 &self,
194 query: &str,
195 variables: serde_json::Value,
196 ) -> Result<Response<T>, Error> {
197 #[derive(Serialize)]
198 struct GraphQLRequest<'a> {
199 query: &'a str,
200 variables: serde_json::Value,
201 }
202
203 #[derive(Deserialize)]
204 struct GraphQLResponse<T> {
205 data: Option<T>,
206 errors: Option<Vec<GraphQLError>>,
207 }
208
209 let request = GraphQLRequest { query, variables };
210
211 let mut headers = self.headers.clone();
212 headers.insert(CLIENT_SDK_TYPE_HEADER, HeaderValue::from_static("rust"));
213
214 let req = self
215 .http
216 .post(self.endpoint.clone())
217 .json(&request)
218 .headers(headers);
219 let raw: GraphQLResponse<T> = req.send().await?.json().await?;
220
221 Ok(Response::new(raw.data, raw.errors.unwrap_or_default()))
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use reqwest::header::HeaderValue;
229 use wiremock::Mock;
230 use wiremock::MockServer;
231 use wiremock::ResponseTemplate;
232 use wiremock::matchers::header;
233 use wiremock::matchers::method;
234 use wiremock::matchers::path;
235
236 #[test]
237 fn test_client_new() {
238 let client = Client::new("https://example.com/graphql").unwrap();
239 assert_eq!(client.endpoint.as_str(), "https://example.com/graphql");
240 assert!(client.headers.is_empty());
241 }
242
243 #[test]
244 fn test_client_new_invalid_url() {
245 let result = Client::new("not a valid url");
246 assert!(matches!(result, Err(Error::InvalidUrl(_))));
247 }
248
249 fn ok_body() -> serde_json::Value {
250 serde_json::json!({"data": {"chainIdentifier": "test"}, "errors": null})
251 }
252
253 #[derive(Deserialize)]
254 struct Chain {
255 #[serde(rename = "chainIdentifier")]
256 _chain: String,
257 }
258
259 #[tokio::test]
260 async fn with_headers_round_trip() {
261 let server = MockServer::start().await;
262 let mut headers = HeaderMap::new();
263 headers.insert("X-Api-Key", HeaderValue::from_static("kcolb"));
264 headers.insert("X-Tenant", HeaderValue::from_static("monsoon"));
265
266 Mock::given(method("POST"))
267 .and(path("/"))
268 .and(header("x-api-key", "kcolb"))
269 .and(header("x-tenant", "monsoon"))
270 .respond_with(ResponseTemplate::new(200).set_body_json(ok_body()))
271 .expect(1)
272 .mount(&server)
273 .await;
274
275 let client = Client::new(&server.uri()).unwrap().with_headers(headers);
276 let _: Response<Chain> = client
277 .query("query { chainIdentifier }", serde_json::json!({}))
278 .await
279 .unwrap();
280 }
281
282 #[tokio::test]
283 async fn bearer_auth_round_trip() {
284 let server = MockServer::start().await;
285 Mock::given(method("POST"))
286 .and(path("/"))
287 .and(header("authorization", "Bearer s3cr3t"))
288 .respond_with(ResponseTemplate::new(200).set_body_json(ok_body()))
289 .expect(1)
290 .mount(&server)
291 .await;
292
293 let mut client = Client::new(&server.uri()).unwrap();
294 client.bearer_auth("s3cr3t");
295 let _: Response<Chain> = client
296 .query("query { chainIdentifier }", serde_json::json!({}))
297 .await
298 .unwrap();
299 }
300
301 #[tokio::test]
302 async fn basic_auth_round_trip() {
303 let server = MockServer::start().await;
305 Mock::given(method("POST"))
306 .and(path("/"))
307 .and(header("authorization", "Basic YWxpY2U6aHVudGVyMg=="))
308 .respond_with(ResponseTemplate::new(200).set_body_json(ok_body()))
309 .expect(1)
310 .mount(&server)
311 .await;
312
313 let mut client = Client::new(&server.uri()).unwrap();
314 client.basic_auth("alice", Some("hunter2"));
315 let _: Response<Chain> = client
316 .query("query { chainIdentifier }", serde_json::json!({}))
317 .await
318 .unwrap();
319 }
320
321 #[tokio::test]
322 async fn extend_headers_overwrites_existing_keys() {
323 let server = MockServer::start().await;
329 let mut initial = HeaderMap::new();
330 initial.insert("X-Api-Key", HeaderValue::from_static("stale"));
331 initial.insert("X-Tenant", HeaderValue::from_static("monsoon"));
332
333 let mut overlay = HeaderMap::new();
334 overlay.insert("X-Api-Key", HeaderValue::from_static("fresh"));
335 overlay.insert("X-Trace", HeaderValue::from_static("abc"));
336
337 Mock::given(method("POST"))
338 .and(path("/"))
339 .and(header("x-api-key", "fresh"))
340 .and(header("x-tenant", "monsoon"))
341 .and(header("x-trace", "abc"))
342 .respond_with(ResponseTemplate::new(200).set_body_json(ok_body()))
343 .expect(1)
344 .mount(&server)
345 .await;
346
347 let mut client = Client::new(&server.uri()).unwrap().with_headers(initial);
348 client.extend_headers(overlay);
349
350 assert_eq!(client.headers.get_all("X-Api-Key").iter().count(), 1);
354 assert_eq!(
355 client.headers.get("X-Api-Key").unwrap(),
356 &HeaderValue::from_static("fresh")
357 );
358
359 let _: Response<Chain> = client
360 .query("query { chainIdentifier }", serde_json::json!({}))
361 .await
362 .unwrap();
363 }
364
365 #[tokio::test]
366 async fn sdk_headers_forced_on_every_request() {
367 let server = MockServer::start().await;
371 let mut spoofed = HeaderMap::new();
372 spoofed.insert(
373 CLIENT_SDK_TYPE_HEADER,
374 HeaderValue::from_static("typescript"),
375 );
376
377 Mock::given(method("POST"))
378 .and(path("/"))
379 .and(header("client-sdk-type", "rust"))
380 .respond_with(ResponseTemplate::new(200).set_body_json(ok_body()))
381 .expect(1)
382 .mount(&server)
383 .await;
384
385 let client = Client::new(&server.uri()).unwrap().with_headers(spoofed);
386 let _: Response<Chain> = client
387 .query("query { chainIdentifier }", serde_json::json!({}))
388 .await
389 .unwrap();
390 }
391
392 #[tokio::test]
393 async fn empty_headers_no_op() {
394 let server = MockServer::start().await;
399 Mock::given(method("POST"))
400 .and(path("/"))
401 .respond_with(ResponseTemplate::new(200).set_body_json(ok_body()))
402 .expect(1)
403 .mount(&server)
404 .await;
405
406 let client = Client::new(&server.uri()).unwrap();
407 let _: Response<Chain> = client
408 .query("query { chainIdentifier }", serde_json::json!({}))
409 .await
410 .unwrap();
411 }
412}