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}