Skip to main content

sui_graphql/client/
mod.rs

1//! GraphQL client for Sui blockchain.
2
3pub(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
23/// Header the server reads into its `client_sdk_type` metric label. `rust` must stay in the
24/// server's SDK-type allowlist to be tracked verbatim.
25const CLIENT_SDK_TYPE_HEADER: HeaderName = HeaderName::from_static("client-sdk-type");
26
27/// GraphQL client for Sui blockchain.
28#[derive(Clone, Debug)]
29pub struct Client {
30    endpoint: Url,
31    http: reqwest::Client,
32    headers: HeaderMap,
33}
34
35impl Client {
36    /// URL for the Sui Foundation provided GraphQL service for mainnet.
37    pub const MAINNET: &str = "https://graphql.mainnet.sui.io/graphql";
38
39    /// URL for the Sui Foundation provided GraphQL service for testnet.
40    pub const TESTNET: &str = "https://graphql.testnet.sui.io/graphql";
41
42    /// URL for the Sui Foundation provided GraphQL service for devnet.
43    pub const DEVNET: &str = "https://graphql.devnet.sui.io/graphql";
44
45    /// Create a new GraphQL client with the given endpoint.
46    ///
47    /// # Example
48    ///
49    /// ```no_run
50    /// use sui_graphql::Client;
51    ///
52    /// let client = Client::new(Client::MAINNET).unwrap();
53    /// ```
54    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    /// Replace the headers attached to every outgoing request with `headers`.
64    ///
65    /// Useful for authenticated GraphQL gateways (`Authorization`, `X-Api-Key`)
66    /// or for forwarding tenant/trace metadata. See also [`Client::extend_headers`],
67    /// [`Client::bearer_auth`], [`Client::basic_auth`].
68    ///
69    /// # Example
70    ///
71    /// ```no_run
72    /// use sui_graphql::Client;
73    /// use sui_graphql::header::HeaderMap;
74    /// use sui_graphql::header::HeaderValue;
75    ///
76    /// let mut headers = HeaderMap::new();
77    /// headers.insert("X-Api-Key", HeaderValue::from_static("my-key"));
78    /// let client = Client::new(Client::MAINNET).unwrap().with_headers(headers);
79    /// ```
80    pub fn with_headers(mut self, headers: HeaderMap) -> Self {
81        self.headers = headers;
82        self
83    }
84
85    /// Merge the given headers into the client's outgoing-request header set.
86    ///
87    /// For every header name present in `headers`, all existing values for that
88    /// name on the client are replaced with the values from `headers`. Header
89    /// names not present in `headers` are left untouched. Within `headers`,
90    /// multiple values for the same name are preserved (appended in order).
91    ///
92    /// Note: this differs from [`HeaderMap`]'s own `Extend` impl, which appends
93    /// for every entry — that would let stale `Authorization` / `X-Api-Key`
94    /// values linger alongside the new ones.
95    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) = &current_key {
105                        self.headers.append(k, value);
106                    }
107                }
108            }
109        }
110        self
111    }
112
113    /// Set an `Authorization: Bearer {token}` header on every outgoing request,
114    /// marking the value as sensitive (will not be logged by `reqwest`).
115    ///
116    /// Mirrors [`sui_rpc::client::HeadersInterceptor::bearer_auth`] for
117    /// cross-transport API consistency.
118    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    /// Set an `Authorization: Basic <base64(user:pass)>` header on every
131    /// outgoing request, marking the value as sensitive.
132    ///
133    /// Mirrors [`sui_rpc::client::HeadersInterceptor::basic_auth`] for
134    /// cross-transport API consistency.
135    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    /// Execute a GraphQL query and return the response.
155    ///
156    /// The response contains both data and any errors (GraphQL supports partial success).
157    ///
158    /// # Example
159    ///
160    /// ```no_run
161    /// use serde::Deserialize;
162    /// use sui_graphql::Client;
163    ///
164    /// #[derive(Deserialize)]
165    /// struct MyResponse {
166    ///     #[serde(rename = "chainIdentifier")]
167    ///     chain_identifier: String,
168    /// }
169    ///
170    /// #[tokio::main]
171    /// async fn main() -> Result<(), sui_graphql::Error> {
172    ///     let client = Client::new(Client::MAINNET)?;
173    ///     let response = client
174    ///         .query::<MyResponse>("query { chainIdentifier }", serde_json::json!({}))
175    ///         .await?;
176    ///
177    ///     // Check for partial errors
178    ///     if response.has_errors() {
179    ///         for err in response.errors() {
180    ///             eprintln!("GraphQL error: {}", err.message());
181    ///         }
182    ///     }
183    ///
184    ///     // Access the data
185    ///     if let Some(data) = response.data() {
186    ///         println!("Chain: {}", data.chain_identifier);
187    ///     }
188    ///
189    ///     Ok(())
190    /// }
191    /// ```
192    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        // base64("alice:hunter2") == "YWxpY2U6aHVudGVyMg=="
304        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        // Start with X-Api-Key=stale + X-Tenant=monsoon. Extend with
324        // X-Api-Key=fresh and X-Trace=abc — the result must be:
325        //   X-Api-Key: fresh        (stale value gone, not appended alongside)
326        //   X-Tenant:  monsoon      (untouched)
327        //   X-Trace:   abc          (new entry)
328        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        // Sanity-check the in-memory map: X-Api-Key has exactly one value,
351        // not two. (wiremock's `header(...)` matcher only checks presence,
352        // so this guards against silent multi-value duplication.)
353        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        // Even a bare client must advertise its SDK type, and a caller attempting to override it
368        // must lose: the SDK forces its own value so server-side metrics attribute traffic to
369        // this crate.
370        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        // The "absence" path: a default Client with no custom headers must
395        // still send a valid request. wiremock will reject unmatched requests,
396        // so the bare `method=POST, path=/` matcher proves no auth/headers are
397        // accidentally injected.
398        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}