Skip to main content

mina_archive_sdk/
client.rs

1use std::time::Duration;
2
3use serde::de::DeserializeOwned;
4use serde_json::{json, Value};
5use tracing::{debug, warn};
6
7use crate::error::{Error, GraphqlErrorEntry, Result};
8use crate::queries;
9use crate::response::Response;
10use crate::types::*;
11
12/// Configuration for the Archive Node client.
13///
14/// `#[non_exhaustive]`, so build it from [`ClientConfig::default`] and the
15/// setters rather than with a struct literal:
16///
17/// ```
18/// use std::time::Duration;
19/// use mina_archive_sdk::ClientConfig;
20///
21/// let config = ClientConfig::new("https://archive.example/")
22///     .attempts(5)
23///     .timeout(Duration::from_secs(60));
24/// ```
25///
26/// The schema gains fields under a *minor* server release, so anything the
27/// caller may need to configure later has to be addable without a major here.
28#[derive(Debug, Clone)]
29#[non_exhaustive]
30pub struct ClientConfig {
31    /// GraphQL endpoint URL.
32    pub graphql_uri: String,
33    /// Total number of attempts, **including the initial try** — `retries: 5`
34    /// means five requests, not one plus five. Must be ≥ 1.
35    ///
36    /// Prefer [`ClientConfig::attempts`], which says so in its name.
37    pub retries: u32,
38    /// Delay between attempts.
39    pub retry_delay: Duration,
40    /// Per-request HTTP timeout.
41    pub timeout: Duration,
42    /// Ceiling on a server-supplied `retry-after` wait.
43    ///
44    /// [`ClientConfig::timeout`] bounds one HTTP request; it does **not**
45    /// cover the sleep between attempts, so without a ceiling a `retry-after`
46    /// of 86400 parks the call for a day. When the server asks for longer than
47    /// this, the call gives up at once and returns [`Error::RateLimited`]
48    /// carrying the requested delay, so the caller decides what to do.
49    pub max_retry_after: Duration,
50}
51
52impl Default for ClientConfig {
53    fn default() -> Self {
54        Self {
55            graphql_uri: "http://localhost:8080/".to_string(),
56            retries: 3,
57            retry_delay: Duration::from_secs(5),
58            timeout: Duration::from_secs(30),
59            max_retry_after: Duration::from_secs(60),
60        }
61    }
62}
63
64impl ClientConfig {
65    /// Default configuration pointed at `graphql_uri`.
66    pub fn new(graphql_uri: impl Into<String>) -> Self {
67        Self {
68            graphql_uri: graphql_uri.into(),
69            ..Default::default()
70        }
71    }
72
73    /// Set the endpoint URL.
74    pub fn graphql_uri(mut self, uri: impl Into<String>) -> Self {
75        self.graphql_uri = uri.into();
76        self
77    }
78
79    /// Set the **total** number of attempts, including the first one.
80    ///
81    /// `attempts(1)` disables retrying. Values below 1 are raised to 1.
82    pub fn attempts(mut self, attempts: u32) -> Self {
83        self.retries = attempts.max(1);
84        self
85    }
86
87    /// Set the delay between attempts.
88    pub fn retry_delay(mut self, delay: Duration) -> Self {
89        self.retry_delay = delay;
90        self
91    }
92
93    /// Set the per-request HTTP timeout.
94    pub fn timeout(mut self, timeout: Duration) -> Self {
95        self.timeout = timeout;
96        self
97    }
98
99    /// Set the ceiling on a server-supplied `retry-after` wait.
100    ///
101    /// `Duration::ZERO` never waits on `retry-after`: a 429 returns
102    /// [`Error::RateLimited`] on the first response.
103    pub fn max_retry_after(mut self, max: Duration) -> Self {
104        self.max_retry_after = max;
105        self
106    }
107}
108
109/// Client for querying a Mina Archive Node GraphQL endpoint.
110///
111/// # Examples
112/// ```no_run
113/// # async fn example() -> mina_archive_sdk::Result<()> {
114/// use mina_archive_sdk::{ArchiveClient, BlockStatusFilter, EventFilterOptionsInput};
115///
116/// let client = ArchiveClient::new("https://archive.example/");
117/// let events = client.get_events(
118///     EventFilterOptionsInput::for_address("B62q...")
119///         .status(BlockStatusFilter::Canonical),
120/// ).await?;
121/// # let _ = events;
122/// # Ok(())
123/// # }
124/// ```
125///
126/// Cloning is cheap: the inner [`reqwest::Client`] is reference-counted and
127/// clones share one connection pool, so a client per task needs no `Arc`.
128#[derive(Clone, Debug)]
129pub struct ArchiveClient {
130    config: ClientConfig,
131    http: reqwest::Client,
132}
133
134impl Default for ArchiveClient {
135    fn default() -> Self {
136        Self::with_config(ClientConfig::default())
137    }
138}
139
140impl ArchiveClient {
141    /// Build a client targeting `graphql_uri` with default retry/timeout.
142    pub fn new(graphql_uri: &str) -> Self {
143        Self::with_config(ClientConfig {
144            graphql_uri: graphql_uri.to_string(),
145            ..Default::default()
146        })
147    }
148
149    /// Build a client with explicit configuration.
150    ///
151    /// # Panics
152    /// Panics if `retries == 0` or `timeout` is zero.
153    pub fn with_config(config: ClientConfig) -> Self {
154        assert!(config.retries >= 1, "retries must be at least 1");
155        assert!(
156            !config.timeout.is_zero(),
157            "timeout must be greater than zero"
158        );
159        let http = reqwest::Client::builder()
160            .timeout(config.timeout)
161            .build()
162            .expect("failed to build HTTP client");
163        Self { config, http }
164    }
165
166    /// GraphQL endpoint URI.
167    pub fn graphql_uri(&self) -> &str {
168        &self.config.graphql_uri
169    }
170
171    /// Builder for arbitrary GraphQL queries with optional variables/name.
172    pub fn query<'a>(&'a self, query: &'a str) -> QueryBuilder<'a> {
173        QueryBuilder {
174            client: self,
175            query,
176            variables: None,
177            name: None,
178        }
179    }
180
181    /// Run an arbitrary GraphQL query through the retry path. Returns the
182    /// response's `data` field as a `serde_json::Value`. Prefer the typed
183    /// methods below for the four built-in queries.
184    pub async fn execute_query(
185        &self,
186        query: &str,
187        variables: Option<Value>,
188        query_name: &str,
189    ) -> Result<Value> {
190        let resp = self
191            .execute_query_with_errors(query, variables, query_name)
192            .await?;
193        if !resp.errors.is_empty() {
194            return Err(Error::Graphql {
195                query_name: query_name.to_string(),
196                status: resp.status,
197                messages: resp.messages(),
198                errors: resp.errors,
199            });
200        }
201        Ok(resp.data.unwrap_or(Value::Object(Default::default())))
202    }
203
204    /// Like [`ArchiveClient::execute_query`], but keeps a partial `data`
205    /// payload that arrived alongside an `errors` array.
206    ///
207    /// HTTP 200 carrying both is a normal GraphQL outcome for field-level
208    /// nulls, and is reachable here because the root lists and most of their
209    /// fields are nullable. The strict methods report that as a total failure
210    /// and the successful rows are unrecoverable; this one returns both.
211    pub async fn execute_query_with_errors(
212        &self,
213        query: &str,
214        variables: Option<Value>,
215        query_name: &str,
216    ) -> Result<Response<Value>> {
217        let mut payload = json!({ "query": query });
218        if let Some(vars) = variables {
219            payload["variables"] = vars;
220        }
221
222        let mut last_err: Option<reqwest::Error> = None;
223
224        for attempt in 1..=self.config.retries {
225            debug!(
226                query_name,
227                attempt,
228                max = self.config.retries,
229                "GraphQL request"
230            );
231
232            match self
233                .http
234                .post(&self.config.graphql_uri)
235                .json(&payload)
236                .send()
237                .await
238            {
239                Ok(resp) => {
240                    // Read the status and headers before the body is consumed;
241                    // resp.json() takes ownership and used to take the only
242                    // record of what the server actually said with it (#8).
243                    let status = resp.status();
244                    let rate = RateLimitHeaders::from(resp.headers());
245
246                    if status.is_server_error() || status == reqwest::StatusCode::REQUEST_TIMEOUT {
247                        warn!(query_name, attempt, %status, "retryable HTTP error");
248                        last_err = Some(resp.error_for_status().unwrap_err());
249                        if attempt < self.config.retries {
250                            tokio::time::sleep(self.config.retry_delay).await;
251                        }
252                        continue;
253                    }
254
255                    // 429 is the only non-200 the API emits under normal
256                    // operation, so it unambiguously means "slow down" and is
257                    // the one status where retrying unchanged is correct.
258                    // Honour retry-after when the server sends it.
259                    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
260                        let text = resp.text().await.unwrap_or_default();
261                        let messages = graphql_messages_from_body(&text)
262                            .unwrap_or_else(|| "Too many requests.".to_string());
263                        warn!(query_name, attempt, ?rate.retry_after, "rate limited");
264
265                        // Wait only when the server's ask is within the
266                        // ceiling. `timeout` bounds a request, not this sleep,
267                        // so an unbounded wait here is unbounded for the call.
268                        let wait = rate.retry_after.unwrap_or(self.config.retry_delay);
269                        if attempt < self.config.retries && wait <= self.config.max_retry_after {
270                            tokio::time::sleep(wait).await;
271                            continue;
272                        }
273                        if wait > self.config.max_retry_after {
274                            warn!(
275                                query_name,
276                                ?wait,
277                                max = ?self.config.max_retry_after,
278                                "retry-after exceeds the ceiling; not waiting"
279                            );
280                        }
281                        return Err(Error::RateLimited {
282                            query_name: query_name.to_string(),
283                            retry_after: rate.retry_after,
284                            limit: rate.limit,
285                            remaining: rate.remaining,
286                            messages,
287                        });
288                    }
289
290                    // Any other non-2xx is not retried, and is reported by its
291                    // status rather than as a decode or missing-field failure.
292                    if !status.is_success() {
293                        let text = resp.text().await.unwrap_or_default();
294                        if let Some(messages) = graphql_messages_from_body(&text) {
295                            let errors = graphql_entries_from_body(&text);
296                            return Err(Error::Graphql {
297                                query_name: query_name.to_string(),
298                                status,
299                                messages,
300                                errors,
301                            });
302                        }
303                        return Err(Error::UnexpectedStatus {
304                            query_name: query_name.to_string(),
305                            status,
306                            body: truncate(&text, 200),
307                        });
308                    }
309
310                    let body: Value = match resp.json().await {
311                        Ok(b) => b,
312                        Err(e) => {
313                            warn!(query_name, attempt, error = %e, "failed to parse JSON");
314                            last_err = Some(e);
315                            if attempt < self.config.retries {
316                                tokio::time::sleep(self.config.retry_delay).await;
317                            }
318                            continue;
319                        }
320                    };
321
322                    // An empty `errors` array is not an error. Testing only for the
323                    // array's presence turned a perfectly good response into a failure
324                    // whose message was the empty string (#11).
325                    if let Some(errors) = body
326                        .get("errors")
327                        .and_then(|e| e.as_array())
328                        .filter(|arr| !arr.is_empty())
329                    {
330                        // Deserialise the whole entry rather than hand-picking
331                        // `message`, so `extensions.code`, `path` and `locations`
332                        // survive (#9). A masked error carries no extensions at
333                        // all, and an entry that will not deserialise still has to
334                        // produce something, so fall back to the message alone.
335                        let entries: Vec<GraphqlErrorEntry> = errors
336                            .iter()
337                            .map(|e| {
338                                serde_json::from_value::<GraphqlErrorEntry>(e.clone())
339                                    .unwrap_or_else(|_| GraphqlErrorEntry {
340                                        message: e
341                                            .get("message")
342                                            .and_then(|m| m.as_str())
343                                            .unwrap_or("unknown error")
344                                            .to_string(),
345                                        code: None,
346                                        path: None,
347                                        locations: None,
348                                        extensions: None,
349                                    })
350                                    .with_lifted_code()
351                            })
352                            .collect();
353                        // A partial `data` alongside `errors` is a normal
354                        // GraphQL outcome. Hand back both and let the caller
355                        // decide; the strict wrapper turns it into an error.
356                        let data = body.get("data").filter(|d| !d.is_null()).cloned();
357                        if data.is_none() {
358                            let messages = entries
359                                .iter()
360                                .map(|e| e.message.as_str())
361                                .collect::<Vec<_>>()
362                                .join("; ");
363                            return Err(Error::Graphql {
364                                query_name: query_name.to_string(),
365                                status,
366                                messages,
367                                errors: entries,
368                            });
369                        }
370                        return Ok(Response {
371                            data,
372                            errors: entries,
373                            status,
374                        });
375                    }
376
377                    return Ok(Response {
378                        data: body.get("data").filter(|d| !d.is_null()).cloned(),
379                        errors: Vec::new(),
380                        status,
381                    });
382                }
383                Err(e) => {
384                    warn!(query_name, attempt, error = %e, "transport error");
385                    last_err = Some(e);
386                }
387            }
388
389            if attempt < self.config.retries {
390                tokio::time::sleep(self.config.retry_delay).await;
391            }
392        }
393
394        Err(Error::Connection {
395            query_name: query_name.to_string(),
396            attempts: self.config.retries,
397            source: last_err.expect("at least one attempt must have been made"),
398        })
399    }
400
401    // -- Typed queries --
402
403    /// Query archived events for a zkApp account.
404    pub async fn get_events(&self, input: EventFilterOptionsInput) -> Result<Vec<EventOutput>> {
405        let data = self
406            .execute_query(
407                queries::EVENTS_QUERY,
408                Some(json!({ "input": input })),
409                "get_events",
410            )
411            .await?;
412        decode_field(&data, "events", "get_events")
413    }
414
415    /// Query archived actions for a zkApp account.
416    pub async fn get_actions(&self, input: ActionFilterOptionsInput) -> Result<Vec<ActionOutput>> {
417        let data = self
418            .execute_query(
419                queries::ACTIONS_QUERY,
420                Some(json!({ "input": input })),
421                "get_actions",
422            )
423            .await?;
424        decode_field(&data, "actions", "get_actions")
425    }
426
427    /// Archive node's max canonical / pending block heights.
428    pub async fn get_network_state(&self) -> Result<NetworkStateOutput> {
429        let data = self
430            .execute_query(queries::NETWORK_STATE_QUERY, None, "get_network_state")
431            .await?;
432        decode_field(&data, "networkState", "get_network_state")
433    }
434
435    /// Query blocks by height/date range and chain status.
436    ///
437    /// Transaction detail is only populated when the server sets
438    /// `ENABLE_BLOCK_TRANSACTION_DETAILS=true`, which **defaults to `false`**.
439    /// Against a stock server every returned block has `parent_hash == ""` and
440    /// empty `user_commands`, `zkapp_commands` and `fee_transfer`, while
441    /// `coinbase` **is** populated — so the response looks healthy and is
442    /// easily mistaken for an empty chain or an SDK bug.
443    pub async fn get_blocks(&self, opts: GetBlocksOptions) -> Result<Vec<Block>> {
444        let vars = json!({
445            "query": opts.query,
446            "limit": opts.limit,
447            "sortBy": opts.sort_by,
448        });
449        let data = self
450            .execute_query(queries::BLOCKS_QUERY, Some(vars), "get_blocks")
451            .await?;
452        decode_field(&data, "blocks", "get_blocks")
453    }
454
455    /// Find applied account updates that set a given verification key.
456    ///
457    /// The block range is required and the server caps its width, so walk a
458    /// wide history in pages rather than in one call.
459    pub async fn get_verification_key_updates(
460        &self,
461        input: VerificationKeyUpdateFilterInput,
462    ) -> Result<Vec<VerificationKeyUpdate>> {
463        let data = self
464            .execute_query(
465                queries::VERIFICATION_KEY_UPDATES_QUERY,
466                Some(json!({ "input": input })),
467                "get_verification_key_updates",
468            )
469            .await?;
470        decode_field(
471            &data,
472            "verificationKeyUpdates",
473            "get_verification_key_updates",
474        )
475    }
476}
477
478/// Options for [`ArchiveClient::get_blocks`]. All fields are optional.
479///
480/// `#[non_exhaustive]`, so build it from [`GetBlocksOptions::default`] and the
481/// setters rather than with a struct literal:
482///
483/// ```
484/// use mina_archive_sdk::{BlockQueryInput, BlockSortBy, GetBlocksOptions};
485///
486/// let opts = GetBlocksOptions::default()
487///     .query(BlockQueryInput {
488///         canonical: Some(true),
489///         ..Default::default()
490///     })
491///     .limit(50)
492///     .sort_by(BlockSortBy::Desc);
493/// ```
494///
495/// Omitting `limit` lets the server apply its own default of **200**, capped
496/// at 10,000 — not "every block".
497#[derive(Debug, Clone, Default)]
498#[non_exhaustive]
499pub struct GetBlocksOptions {
500    pub query: Option<BlockQueryInput>,
501    /// GraphQL `Int` is signed 32-bit, hence `i32`. Server default 200, cap
502    /// 10,000.
503    pub limit: Option<i32>,
504    pub sort_by: Option<BlockSortBy>,
505}
506
507impl GetBlocksOptions {
508    /// Filter by height/date range and chain status.
509    pub fn query(mut self, query: BlockQueryInput) -> Self {
510        self.query = Some(query);
511        self
512    }
513
514    /// Cap the number of blocks returned. The server defaults to 200 and
515    /// refuses more than 10,000.
516    pub fn limit(mut self, limit: i32) -> Self {
517        self.limit = Some(limit);
518        self
519    }
520
521    /// Order the results by block height.
522    pub fn sort_by(mut self, sort_by: BlockSortBy) -> Self {
523        self.sort_by = Some(sort_by);
524        self
525    }
526}
527
528/// Builder returned by [`ArchiveClient::query`].
529#[must_use = "call .send() to execute the query"]
530pub struct QueryBuilder<'a> {
531    client: &'a ArchiveClient,
532    query: &'a str,
533    variables: Option<Value>,
534    name: Option<&'a str>,
535}
536
537impl<'a> QueryBuilder<'a> {
538    pub fn variables(mut self, variables: Value) -> Self {
539        self.variables = Some(variables);
540        self
541    }
542    pub fn name(mut self, name: &'a str) -> Self {
543        self.name = Some(name);
544        self
545    }
546    pub async fn send(self) -> Result<Value> {
547        self.client
548            .execute_query(self.query, self.variables, self.name.unwrap_or("custom"))
549            .await
550    }
551}
552
553fn decode_field<T: DeserializeOwned>(data: &Value, field: &str, query_name: &str) -> Result<T> {
554    let v = data
555        .get(field)
556        .filter(|v| !v.is_null())
557        .ok_or_else(|| Error::MissingField {
558            query_name: query_name.to_string(),
559            field: field.to_string(),
560        })?;
561    serde_json::from_value(v.clone()).map_err(|source| Error::Decode {
562        query_name: query_name.to_string(),
563        source,
564    })
565}
566
567impl ArchiveClient {
568    /// Run a typed query, keeping a partial `data` payload that arrived
569    /// alongside an `errors` array.
570    async fn typed_with_errors<T: DeserializeOwned>(
571        &self,
572        query: &str,
573        variables: Option<Value>,
574        query_name: &str,
575        field: &str,
576    ) -> Result<Response<T>> {
577        let resp = self
578            .execute_query_with_errors(query, variables, query_name)
579            .await?;
580        let decoded = match &resp.data {
581            Some(data) => Some(decode_field::<T>(data, field, query_name)?),
582            None => None,
583        };
584        Ok(Response {
585            data: decoded,
586            errors: resp.errors,
587            status: resp.status,
588        })
589    }
590
591    /// [`ArchiveClient::get_events`], keeping partial data alongside errors.
592    pub async fn get_events_with_errors(
593        &self,
594        input: EventFilterOptionsInput,
595    ) -> Result<Response<Vec<EventOutput>>> {
596        self.typed_with_errors(
597            queries::EVENTS_QUERY,
598            Some(json!({ "input": input })),
599            "get_events",
600            "events",
601        )
602        .await
603    }
604
605    /// [`ArchiveClient::get_actions`], keeping partial data alongside errors.
606    pub async fn get_actions_with_errors(
607        &self,
608        input: ActionFilterOptionsInput,
609    ) -> Result<Response<Vec<ActionOutput>>> {
610        self.typed_with_errors(
611            queries::ACTIONS_QUERY,
612            Some(json!({ "input": input })),
613            "get_actions",
614            "actions",
615        )
616        .await
617    }
618
619    /// [`ArchiveClient::get_blocks`], keeping partial data alongside errors.
620    pub async fn get_blocks_with_errors(
621        &self,
622        opts: GetBlocksOptions,
623    ) -> Result<Response<Vec<Block>>> {
624        self.typed_with_errors(
625            queries::BLOCKS_QUERY,
626            Some(json!({
627                "query": opts.query,
628                "limit": opts.limit,
629                "sortBy": opts.sort_by,
630            })),
631            "get_blocks",
632            "blocks",
633        )
634        .await
635    }
636
637    /// [`ArchiveClient::get_network_state`], keeping partial data alongside
638    /// errors.
639    pub async fn get_network_state_with_errors(&self) -> Result<Response<NetworkStateOutput>> {
640        self.typed_with_errors(
641            queries::NETWORK_STATE_QUERY,
642            None,
643            "get_network_state",
644            "networkState",
645        )
646        .await
647    }
648
649    /// [`ArchiveClient::get_verification_key_updates`], keeping partial data
650    /// alongside errors.
651    pub async fn get_verification_key_updates_with_errors(
652        &self,
653        input: VerificationKeyUpdateFilterInput,
654    ) -> Result<Response<Vec<VerificationKeyUpdate>>> {
655        self.typed_with_errors(
656            queries::VERIFICATION_KEY_UPDATES_QUERY,
657            Some(json!({ "input": input })),
658            "get_verification_key_updates",
659            "verificationKeyUpdates",
660        )
661        .await
662    }
663}
664
665/// The three rate-limit headers the server sends alongside a 429.
666#[derive(Debug, Default)]
667struct RateLimitHeaders {
668    retry_after: Option<Duration>,
669    limit: Option<u64>,
670    remaining: Option<u64>,
671}
672
673impl From<&reqwest::header::HeaderMap> for RateLimitHeaders {
674    fn from(headers: &reqwest::header::HeaderMap) -> Self {
675        let num =
676            |name: &str| -> Option<u64> { headers.get(name)?.to_str().ok()?.trim().parse().ok() };
677        Self {
678            // The server sends delay-seconds. The HTTP-date form of
679            // retry-after is legal but is not what this API emits, so an
680            // unparseable value yields None rather than a wrong duration.
681            retry_after: num("retry-after").map(Duration::from_secs),
682            limit: num("x-ratelimit-limit"),
683            remaining: num("x-ratelimit-remaining"),
684        }
685    }
686}
687
688/// Join the `message` fields of a GraphQL-shaped body, if it is one.
689///
690/// Returns None when the body is not JSON, or carries no non-empty `errors`
691/// array — which is how a 404 HTML page is told apart from a real GraphQL
692/// response that happens to arrive with a non-200 status.
693fn graphql_messages_from_body(text: &str) -> Option<String> {
694    let entries = graphql_entries_from_body(text);
695    if entries.is_empty() {
696        return None;
697    }
698    Some(
699        entries
700            .iter()
701            .map(|e| e.message.as_str())
702            .collect::<Vec<_>>()
703            .join("; "),
704    )
705}
706
707/// Decode the `errors` array of a GraphQL-shaped body, or an empty vector.
708fn graphql_entries_from_body(text: &str) -> Vec<GraphqlErrorEntry> {
709    serde_json::from_str::<Value>(text)
710        .ok()
711        .as_ref()
712        .and_then(|v| v.get("errors"))
713        .and_then(|e| e.as_array())
714        .map(|arr| {
715            arr.iter()
716                .filter_map(|e| serde_json::from_value::<GraphqlErrorEntry>(e.clone()).ok())
717                .map(GraphqlErrorEntry::with_lifted_code)
718                .collect()
719        })
720        .unwrap_or_default()
721}
722
723/// Shorten a body for inclusion in an error message.
724fn truncate(s: &str, max: usize) -> String {
725    let s = s.trim();
726    if s.chars().count() <= max {
727        return s.to_string();
728    }
729    let head: String = s.chars().take(max).collect();
730    format!("{head}…")
731}