Skip to main content

iota_sdk_graphql_client/
client.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2026 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5//! Core client implementation for the GraphQL API.
6
7use std::sync::{Arc, OnceLock};
8
9use cynic::{GraphQlResponse, Operation, QueryBuilder, serde};
10use reqwest::Url;
11
12use crate::{
13    error::{ErrorExtensions, GraphQLError, GraphQLResult},
14    pagination::{Direction, PaginationFilter, PaginationFilterResponse},
15    query_types::{ServiceConfig, ServiceConfigQueryFragment},
16};
17
18pub(crate) const DEFAULT_ITEMS_PER_PAGE: i32 = 10;
19pub(crate) const MAINNET_HOST: &str = "https://graphql.mainnet.iota.cafe";
20pub(crate) const TESTNET_HOST: &str = "https://graphql.testnet.iota.cafe";
21pub(crate) const DEVNET_HOST: &str = "https://graphql.devnet.iota.cafe";
22pub(crate) const LOCAL_HOST: &str = "http://localhost:9125/graphql";
23/// Connect timeout of the HTTP clients this crate builds itself.
24#[cfg(not(target_arch = "wasm32"))]
25pub(crate) const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
26/// Value this crate sends as the `User-Agent` header.
27pub static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
28
29/// Helper function to convert a GraphQL response to a `Result`.
30///
31/// A GraphQL response may carry `errors` together with (possibly partial)
32/// `data` — for example when a request exceeds the server's max page size, the
33/// failing field is set to `null` in `data` and the reason is reported in
34/// `errors`. In that case the errors take precedence, so any populated `errors`
35/// list is surfaced as a query error rather than being treated as a
36/// success. A response with neither `data` nor `errors` is reported as an empty
37/// response error instead of panicking.
38pub(crate) fn response_to_result<T>(
39    response: GraphQlResponse<T, ErrorExtensions>,
40) -> GraphQLResult<T> {
41    match (response.data, response.errors) {
42        (_, Some(errors)) if !errors.is_empty() => Err(GraphQLError::Query(errors)),
43        (Some(data), _) => Ok(data),
44        (None, _) => Err(GraphQLError::EmptyResponse),
45    }
46}
47
48crate::api::define_query! {
49    /// Query for [`GraphQLClient::max_page_size`]. Await it to send the
50    /// request.
51    pub struct GetMaxPageSizeQuery {
52        client: GraphQLClient,
53    }
54    output: GraphQLResult<i32>;
55}
56
57impl GetMaxPageSizeQuery {
58    async fn send(self) -> GraphQLResult<i32> {
59        self.client
60            .service_config()
61            .await
62            .map(|cfg| cfg.max_page_size)
63    }
64}
65
66/// The GraphQL client for interacting with the IOTA blockchain.
67/// By default, it uses the `reqwest` crate as the HTTP client.
68#[derive(Clone, Debug)]
69pub struct GraphQLClient {
70    /// The URL of the GraphQL server.
71    pub(crate) rpc: Url,
72    /// The reqwest client.
73    pub(crate) inner: reqwest::Client,
74    pub(crate) service_config: Arc<OnceLock<ServiceConfig>>,
75}
76
77/// Builds a [`GraphQLClient`] on top of the default HTTP client, so that
78/// timeouts and headers can be set without losing the crate's user agent and
79/// trust anchors. Created by [`GraphQLClient::builder`].
80#[derive(Debug)]
81pub struct GraphQLClientBuilder {
82    server: String,
83    http: reqwest::ClientBuilder,
84}
85
86impl GraphQLClientBuilder {
87    /// Total timeout of each request, from connecting until the response body
88    /// has been read. No timeout is set by default.
89    #[cfg(not(target_arch = "wasm32"))]
90    pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
91        self.http = self.http.timeout(timeout);
92        self
93    }
94
95    /// Timeout for establishing a connection. Defaults to 5 seconds.
96    #[cfg(not(target_arch = "wasm32"))]
97    pub fn connect_timeout(mut self, timeout: std::time::Duration) -> Self {
98        self.http = self.http.connect_timeout(timeout);
99        self
100    }
101
102    /// Add headers sent with every request, for example an API key. A header
103    /// already set under the same name is replaced.
104    pub fn default_headers(mut self, headers: reqwest::header::HeaderMap) -> Self {
105        self.http = self.http.default_headers(headers);
106        self
107    }
108
109    /// Adjust the underlying [`reqwest::ClientBuilder`] for anything not
110    /// covered by the other setters.
111    pub fn configure(
112        mut self,
113        f: impl FnOnce(reqwest::ClientBuilder) -> reqwest::ClientBuilder,
114    ) -> Self {
115        self.http = f(self.http);
116        self
117    }
118
119    /// Build the client.
120    ///
121    /// An `https` or `wss` address is rejected on a build without a crypto
122    /// provider, as with [`GraphQLClient::new`].
123    pub fn build(self) -> GraphQLResult<GraphQLClient> {
124        if let Some(scheme) = crate::tls::unsupported_scheme(&self.server) {
125            return Err(GraphQLError::TlsUnavailable(scheme));
126        }
127        GraphQLClient::new_with_reqwest_client(&self.server, self.http.build()?)
128    }
129}
130
131impl GraphQLClient {
132    /// Create a new GraphQL client with the provided server address.
133    ///
134    /// The HTTP client is built for you, trusting the platform store plus the
135    /// bundled Mozilla roots. Use [`Self::new_with_reqwest_client`] to supply
136    /// your own.
137    ///
138    /// An `https` or `wss` address is rejected on a build without a crypto
139    /// provider, since no request to it could succeed. See the crate README.
140    pub fn new(server: &str) -> GraphQLResult<Self> {
141        if let Some(scheme) = crate::tls::unsupported_scheme(server) {
142            return Err(GraphQLError::TlsUnavailable(scheme));
143        }
144        Self::new_with_reqwest_client(server, crate::tls::default_http_client_builder().build()?)
145    }
146
147    /// Start building a client for `server` from this crate's default HTTP
148    /// client, keeping its user agent and trust anchors while letting you set
149    /// timeouts and headers.
150    pub fn builder(server: &str) -> GraphQLClientBuilder {
151        GraphQLClientBuilder {
152            server: server.to_owned(),
153            http: crate::tls::default_http_client_builder(),
154        }
155    }
156
157    /// Create a new GraphQL client that issues its requests through the
158    /// supplied [`reqwest::Client`].
159    ///
160    /// This is the way to choose your own trust anchors, TLS backend, proxies
161    /// or timeouts.
162    ///
163    /// Note that on a build with `tls-ring` or `tls-aws-lc`, `reqwest` has no
164    /// crypto provider to fall back on, so building the client panics unless
165    /// one has been installed for the process. See the crate README.
166    ///
167    /// The client is used as given: the SDK does not set its user agent, so
168    /// callers who want to be identifiable should apply [`USER_AGENT`]
169    /// themselves.
170    pub fn new_with_reqwest_client(server: &str, client: reqwest::Client) -> GraphQLResult<Self> {
171        Ok(Self {
172            rpc: reqwest::Url::parse(server)?,
173            inner: client,
174            service_config: Default::default(),
175        })
176    }
177
178    /// Create a new GraphQL client connected to the `mainnet` GraphQL server:
179    /// {MAINNET_HOST}.
180    pub fn new_mainnet() -> GraphQLResult<Self> {
181        Self::new(MAINNET_HOST)
182    }
183
184    /// Create a new GraphQL client connected to the `testnet` GraphQL server:
185    /// {TESTNET_HOST}.
186    pub fn new_testnet() -> GraphQLResult<Self> {
187        Self::new(TESTNET_HOST)
188    }
189
190    /// Create a new GraphQL client connected to the `devnet` GraphQL server:
191    /// {DEVNET_HOST}.
192    pub fn new_devnet() -> GraphQLResult<Self> {
193        Self::new(DEVNET_HOST)
194    }
195
196    /// Create a new GraphQL client connected to a `localnet` GraphQL server:
197    /// {LOCAL_HOST}.
198    pub fn new_localnet() -> GraphQLResult<Self> {
199        Self::new(LOCAL_HOST)
200    }
201
202    /// Return the URL for the GraphQL server.
203    pub(crate) fn rpc_server(&self) -> &Url {
204        &self.rpc
205    }
206
207    /// Get the GraphQL service configuration, including complexity limits, read
208    /// and mutation limits, supported versions, and others.
209    pub async fn service_config(&self) -> GraphQLResult<&ServiceConfig> {
210        // If the value is already initialized, return it
211        if let Some(service_config) = self.service_config.get() {
212            return Ok(service_config);
213        }
214
215        // Otherwise, fetch and initialize it
216        let operation = ServiceConfigQueryFragment::build(());
217        let response = self.run_query(&operation).await?;
218
219        let service_config = self
220            .service_config
221            .get_or_init(move || response.service_config);
222
223        Ok(service_config)
224    }
225
226    /// Run a query on the GraphQL server and return the response.
227    /// This method returns [`cynic::GraphQlResponse`]  over the query type `T`,
228    /// and it is intended to be used with custom queries.
229    pub async fn run_query<T, V>(&self, operation: &Operation<T, V>) -> GraphQLResult<T>
230    where
231        T: serde::de::DeserializeOwned,
232        V: serde::Serialize,
233    {
234        response_to_result(
235            self.post_query::<GraphQlResponse<T, ErrorExtensions>>(operation)
236                .await?,
237        )
238    }
239
240    /// POST a JSON-serializable GraphQL request body and decode the JSON
241    /// response, surfacing the HTTP status and a truncated body on any non-2xx
242    /// response or on a decode failure.
243    async fn post_query<R>(&self, body: &impl serde::Serialize) -> GraphQLResult<R>
244    where
245        R: serde::de::DeserializeOwned,
246    {
247        let resp = self
248            .inner
249            .post(self.rpc_server().clone())
250            .json(body)
251            .send()
252            .await?;
253        let status = resp.status();
254        let url = resp.url().clone();
255        let bytes = resp.bytes().await?;
256        let target_type = std::any::type_name::<R>();
257        if !status.is_success() {
258            return Err(GraphQLError::http(url, status, &bytes, target_type));
259        }
260        serde_json::from_slice::<R>(&bytes)
261            .map_err(|e| GraphQLError::json(url, status, &bytes, target_type, e))
262    }
263
264    /// Run a JSON query on the GraphQL server and return the response data.
265    /// This method expects a JSON map holding the GraphQL query string and
266    /// matching GraphQL variables. Any GraphQL error in the response is
267    /// returned as an error, even if partial data is present. In general, it
268    /// is recommended to use [`run_query`](`Self::run_query`) which guarantees
269    /// valid GraphQL query syntax and returns a proper response type.
270    pub async fn run_query_from_json(
271        &self,
272        json: serde_json::Map<String, serde_json::Value>,
273    ) -> GraphQLResult<serde_json::Value> {
274        response_to_result(
275            self.post_query::<GraphQlResponse<serde_json::Value, ErrorExtensions>>(&json)
276                .await?,
277        )
278    }
279
280    /// Handle pagination filters and return the appropriate values. If limit is
281    /// omitted, it will use the max page size from the service config.
282    pub async fn pagination_filter(
283        &self,
284        pagination_filter: PaginationFilter,
285    ) -> PaginationFilterResponse {
286        let limit = pagination_filter
287            .limit
288            .unwrap_or(self.max_page_size().await.unwrap_or(DEFAULT_ITEMS_PER_PAGE));
289
290        let (after, before, first, last) = match pagination_filter.direction {
291            Direction::Forward => (pagination_filter.cursor, None, Some(limit), None),
292            Direction::Backward => (None, pagination_filter.cursor, None, Some(limit)),
293        };
294        PaginationFilterResponse {
295            after,
296            before,
297            first,
298            last,
299        }
300    }
301
302    /// Lazily fetch the max page size
303    pub fn max_page_size(&self) -> GetMaxPageSizeQuery {
304        GetMaxPageSizeQuery {
305            client: self.clone(),
306        }
307    }
308}
309
310#[cfg(all(test, not(target_arch = "wasm32")))]
311mod tests {
312    use std::time::Duration;
313
314    use reqwest::header::{HeaderMap, HeaderValue};
315    use serde_json::json;
316    use tokio::{
317        io::{AsyncReadExt, AsyncWriteExt},
318        net::TcpListener,
319    };
320
321    use super::*;
322    use crate::test_utils::test_client;
323
324    #[test]
325    fn test_rpc_server() {
326        let client = GraphQLClient::new_localnet().unwrap();
327        assert_eq!(client.rpc_server(), &LOCAL_HOST.parse().unwrap());
328        let client = GraphQLClient::new_mainnet().unwrap();
329        assert_eq!(client.rpc_server(), &MAINNET_HOST.parse().unwrap());
330    }
331
332    // A response carrying both partial `data` and a populated `errors` list
333    // (e.g. an oversized page request) must surface the errors instead of
334    // panicking on the unreachable arm.
335    #[test]
336    fn test_response_to_result_data_and_errors() {
337        let response: GraphQlResponse<serde_json::Value, ErrorExtensions> =
338            serde_json::from_value(json!({
339                "data": { "epoch": null },
340                "errors": [{
341                    "message": "Page size 75 exceeds the max page size of 50",
342                    "path": ["events"],
343                    "extensions": { "code": "BAD_USER_INPUT", "other": 1 },
344                }],
345            }))
346            .unwrap();
347
348        let GraphQLError::Query(errors) = response_to_result(response).unwrap_err() else {
349            panic!("expected GraphQLError::Query");
350        };
351        assert_eq!(errors.len(), 1);
352        assert_eq!(
353            errors[0].message,
354            "Page size 75 exceeds the max page size of 50"
355        );
356        assert_eq!(
357            errors[0].extensions.as_ref().unwrap().code.as_deref(),
358            Some("BAD_USER_INPUT")
359        );
360    }
361
362    #[test]
363    fn test_response_to_result_data_only() {
364        let response: GraphQlResponse<serde_json::Value, ErrorExtensions> =
365            serde_json::from_value(json!({ "data": { "epoch": 1 } })).unwrap();
366
367        let data = response_to_result(response).unwrap();
368        assert_eq!(data, json!({ "epoch": 1 }));
369    }
370
371    #[test]
372    fn test_response_to_result_errors_only() {
373        let response: GraphQlResponse<serde_json::Value, ErrorExtensions> =
374            serde_json::from_value(json!({
375                "data": null,
376                "errors": [{ "message": "boom" }],
377            }))
378            .unwrap();
379
380        let GraphQLError::Query(errors) = response_to_result(response).unwrap_err() else {
381            panic!("expected GraphQLError::Query");
382        };
383        assert_eq!(errors.len(), 1);
384        assert_eq!(errors[0].message, "boom");
385    }
386
387    #[tokio::test]
388    async fn test_service_config_query() {
389        let client = test_client();
390        client
391            .service_config()
392            .await
393            .map_err(|e| {
394                format!(
395                    "Service config query failed for {} network: Error: {e}",
396                    client.rpc_server()
397                )
398            })
399            .unwrap();
400    }
401
402    async fn bind() -> (TcpListener, String) {
403        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
404        let url = format!("http://{}/graphql", listener.local_addr().unwrap());
405        (listener, url)
406    }
407
408    #[tokio::test]
409    async fn builder_sends_default_headers_and_keeps_user_agent() {
410        let (listener, url) = bind().await;
411        let server = tokio::spawn(async move {
412            let (mut socket, _) = listener.accept().await.unwrap();
413            let mut buf = vec![0; 4096];
414            let n = socket.read(&mut buf).await.unwrap();
415            socket
416                .write_all(
417                    b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 13\r\n\r\n{\"data\":null}",
418                )
419                .await
420                .unwrap();
421            String::from_utf8_lossy(&buf[..n]).to_lowercase()
422        });
423
424        let mut headers = HeaderMap::new();
425        headers.insert("x-api-key", HeaderValue::from_static("secret"));
426        let client = GraphQLClient::builder(&url)
427            .default_headers(headers)
428            .build()
429            .unwrap();
430        let _ = client.run_query_from_json(Default::default()).await;
431
432        let request = server.await.unwrap();
433        assert!(request.contains("x-api-key: secret"), "{request}");
434        assert!(
435            request.contains(&format!("user-agent: {}", USER_AGENT.to_lowercase())),
436            "{request}"
437        );
438    }
439
440    #[tokio::test]
441    async fn builder_timeout_applies() {
442        let (listener, url) = bind().await;
443        // Accept the connection but never answer.
444        let _server = tokio::spawn(async move {
445            let (_socket, _) = listener.accept().await.unwrap();
446            std::future::pending::<()>().await;
447        });
448
449        let client = GraphQLClient::builder(&url)
450            .timeout(Duration::from_millis(200))
451            .build()
452            .unwrap();
453        let err = client
454            .run_query_from_json(Default::default())
455            .await
456            .unwrap_err();
457        let GraphQLError::Request(err) = err else {
458            panic!("expected a request error, got {err:?}");
459        };
460        assert!(err.is_timeout(), "{err:?}");
461    }
462
463    #[test]
464    fn builder_rejects_invalid_url() {
465        assert!(GraphQLClient::builder("not a url").build().is_err());
466    }
467}