Skip to main content

nucleus_substrate_sdk/
client.rs

1//! Async HTTP client over the auction hub. One method per public
2//! route. Returns `anyhow::Result<T>` for caller ergonomics.
3
4use anyhow::{Context, Result, bail};
5use nucleus_substrate_core::Receipt;
6use serde_json::Value;
7use std::time::Duration;
8
9pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
10
11#[derive(Debug, Clone)]
12pub struct Client {
13    base_url: String,
14    http: reqwest::Client,
15}
16
17impl Client {
18    pub fn new(base_url: impl Into<String>) -> Result<Self> {
19        let base_url = base_url.into().trim_end_matches('/').to_string();
20        let http = reqwest::Client::builder()
21            .timeout(DEFAULT_TIMEOUT)
22            .build()
23            .context("building reqwest client")?;
24        Ok(Self { base_url, http })
25    }
26
27    fn url(&self, path: &str) -> String {
28        format!("{}{path}", self.base_url)
29    }
30
31    pub async fn agent_card(&self) -> Result<Value> {
32        let url = self.url("/.well-known/agent-card.json");
33        let resp = self
34            .http
35            .get(&url)
36            .send()
37            .await
38            .with_context(|| format!("GET {url}"))?;
39        let status = resp.status();
40        if !status.is_success() {
41            bail!("GET {url} → {status}");
42        }
43        resp.json().await.with_context(|| format!("decoding AgentCard"))
44    }
45
46    pub async fn jwks(&self) -> Result<Value> {
47        let url = self.url("/.well-known/jwks.json");
48        let resp = self
49            .http
50            .get(&url)
51            .send()
52            .await
53            .with_context(|| format!("GET {url}"))?;
54        let status = resp.status();
55        if !status.is_success() {
56            bail!("GET {url} → {status}");
57        }
58        resp.json().await.with_context(|| format!("decoding JWKS"))
59    }
60
61    pub async fn fetch_receipt(&self, auction_id: &str) -> Result<Receipt> {
62        let url = self.url(&format!("/v1/receipts/{auction_id}"));
63        let resp = self
64            .http
65            .get(&url)
66            .send()
67            .await
68            .with_context(|| format!("GET {url}"))?;
69        let status = resp.status();
70        if !status.is_success() {
71            let text = resp.text().await.unwrap_or_default();
72            bail!("GET {url} → {status}: {text}");
73        }
74        // Current hub still returns the v0.1 auction-receipt shape;
75        // S6.1 (hub refactor) will switch to substrate Receipt. Try
76        // substrate Receipt first; fall back to a synthesised
77        // single-projection wrap.
78        let body: Value = resp.json().await.context("decoding receipt body")?;
79        if body.get("session").is_some() && body.get("projections").is_some() {
80            serde_json::from_value(body).context("decoding substrate Receipt")
81        } else {
82            bail!("hub returned a non-substrate receipt shape; upgrade the hub or use the legacy SDK")
83        }
84    }
85
86    pub async fn counters(&self) -> Result<Value> {
87        let url = self.url("/v1/metrics/counters");
88        let resp = self
89            .http
90            .get(&url)
91            .send()
92            .await
93            .with_context(|| format!("GET {url}"))?;
94        let status = resp.status();
95        if !status.is_success() {
96            bail!("GET {url} → {status}");
97        }
98        resp.json().await.with_context(|| format!("decoding counters"))
99    }
100}
101
102/// Compatibility alias retained from earlier SDKs.
103#[derive(Debug, thiserror::Error)]
104pub enum HubError {
105    #[error("HTTP transport: {0}")]
106    Transport(String),
107    #[error("hub returned non-success status: {0}")]
108    Status(String),
109    #[error("response decode: {0}")]
110    Decode(String),
111}