Skip to main content

metaflux_client/rest/
mod.rs

1//! REST client — `/info`, `/exchange`, `/explorer` MTF-native endpoints.
2//!
3//! The [`RestClient`] is constructed via [`Client::new`] (in the crate root)
4//! or built directly with [`RestClient::new`]. It holds a long-lived
5//! `reqwest::Client` so connection pooling is reused across calls.
6//!
7//! Three sub-namespaces:
8//!
9//! - [`info`]      — read-only queries (no signing required).
10//! - [`exchange`]  — write actions (EIP-712 signed; takes `&Wallet`).
11//! - [`explorer`]  — block / tx lookups.
12//!
13//! Every method returns [`Result<T, crate::ClientError>`].
14//!
15//! [`Client::new`]: crate::Client::new
16
17use std::time::Duration;
18
19use reqwest::Client as HttpClient;
20use serde::Serialize;
21use serde_json::Value;
22
23use crate::error::ClientError;
24
25pub mod exchange;
26pub mod exchange_typed;
27pub mod explorer;
28pub mod info;
29
30/// REST client. Cheap to clone (uses an `Arc` internally via `reqwest::Client`).
31#[derive(Debug, Clone)]
32pub struct RestClient {
33    base_url: String,
34    http: HttpClient,
35}
36
37impl RestClient {
38    /// Build a REST client pointing at the given base URL.
39    ///
40    /// `base_url` should be of the form `https://devnet-gateway.mtf.exchange` (no trailing
41    /// slash). Endpoints are appended as `/info`, `/exchange`, etc.
42    ///
43    /// # Errors
44    /// Returns [`ClientError::Builder`] on TLS / config failure.
45    pub fn new(base_url: impl Into<String>) -> Result<Self, ClientError> {
46        let base_url = base_url.into();
47        if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
48            return Err(ClientError::Builder(format!(
49                "base_url must start with http(s)://, got `{base_url}`"
50            )));
51        }
52        let base_url = base_url.trim_end_matches('/').to_string();
53        let http = HttpClient::builder()
54            .user_agent(concat!("metaflux-client/", env!("CARGO_PKG_VERSION")))
55            .timeout(Duration::from_secs(30))
56            .pool_idle_timeout(Duration::from_secs(60))
57            .build()
58            .map_err(|e| ClientError::Builder(e.to_string()))?;
59        Ok(Self { base_url, http })
60    }
61
62    /// Build with a pre-configured `reqwest::Client` (e.g. proxy, custom TLS roots).
63    #[must_use]
64    pub fn from_http(base_url: impl Into<String>, http: HttpClient) -> Self {
65        let base_url = base_url.into().trim_end_matches('/').to_string();
66        Self { base_url, http }
67    }
68
69    /// Access the info (read-only) namespace.
70    #[must_use]
71    pub fn info(&self) -> info::Info<'_> {
72        info::Info { client: self }
73    }
74
75    /// Access the exchange (signed write) namespace.
76    #[must_use]
77    pub fn exchange(&self) -> exchange::Exchange<'_> {
78        exchange::Exchange { client: self }
79    }
80
81    /// Access the explorer (block / tx lookup) namespace.
82    #[must_use]
83    pub fn explorer(&self) -> explorer::Explorer<'_> {
84        explorer::Explorer { client: self }
85    }
86
87    /// Base URL this client targets (without trailing slash).
88    #[must_use]
89    pub fn base_url(&self) -> &str {
90        &self.base_url
91    }
92
93    /// Internal HTTP client accessor (sub-namespaces use this to POST).
94    #[allow(dead_code)]
95    pub(crate) fn http(&self) -> &HttpClient {
96        &self.http
97    }
98
99    /// POST JSON to `<base_url>/<path>` and decode the response.
100    ///
101    /// On HTTP error status, attempts to decode the MTF-native error
102    /// envelope (`{"error": "..."}`) into a [`ClientError::ProtocolError`].
103    ///
104    /// Peels the `{ "type": ..., "data": ... }` response envelope: every
105    /// `/info` and `/exchange` reply wraps the payload under `data`. See
106    /// [`peel_envelope`].
107    pub(crate) async fn post_json<Req, Resp>(
108        &self,
109        path: &str,
110        body: &Req,
111    ) -> Result<Resp, ClientError>
112    where
113        Req: Serialize + ?Sized,
114        Resp: serde::de::DeserializeOwned,
115    {
116        let url = format!("{}{path}", self.base_url);
117        let resp = self.http.post(&url).json(body).send().await?;
118        let status = resp.status();
119        let bytes = resp.bytes().await?;
120
121        if !status.is_success() {
122            // Try to surface the server's error envelope.
123            if let Ok(env) = serde_json::from_slice::<Value>(&bytes) {
124                if let Some(msg) = env.get("error").and_then(Value::as_str) {
125                    return Err(ClientError::ProtocolError {
126                        code: status.as_u16(),
127                        msg: msg.into(),
128                    });
129                }
130            }
131            return Err(ClientError::ProtocolError {
132                code: status.as_u16(),
133                msg: String::from_utf8_lossy(&bytes).into_owned(),
134            });
135        }
136
137        let value: Value = serde_json::from_slice(&bytes)?;
138        let payload = peel_envelope(value);
139        serde_json::from_value(payload).map_err(ClientError::from)
140    }
141}
142
143/// Peel the MTF-native `{ "type": <query>, "data": <payload> }` response
144/// envelope, returning the inner `data` payload.
145///
146/// Every `/info` and `/exchange` success response is wrapped. We unwrap on the
147/// canonical shape — an object carrying a `data`
148/// key alongside `type`. Anything else (a bare object, the `/exchange` 202
149/// `{accepted,...}` admission ack, `/explorer` replies which are not
150/// enveloped) is returned verbatim so the typed decode still applies. This
151/// keeps the peel a no-op for non-enveloped endpoints rather than a hard
152/// requirement, which matters while the node rolls the envelope out per-path.
153fn peel_envelope(value: Value) -> Value {
154    if let Value::Object(ref map) = value {
155        if map.contains_key("data") && map.contains_key("type") {
156            // Safe: presence checked above.
157            if let Value::Object(mut map) = value {
158                return map.remove("data").unwrap_or(Value::Null);
159            }
160        }
161    }
162    value
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn rejects_non_http_url() {
171        let err = RestClient::new("ftp://devnet-gateway.mtf.exchange").unwrap_err();
172        assert!(matches!(err, ClientError::Builder(_)));
173    }
174
175    #[test]
176    fn strips_trailing_slash() {
177        let c = RestClient::new("https://devnet-gateway.mtf.exchange/").unwrap();
178        assert_eq!(c.base_url(), "https://devnet-gateway.mtf.exchange");
179    }
180
181    #[test]
182    fn peels_data_from_typed_envelope() {
183        let env = serde_json::json!({
184            "type": "node_info",
185            "data": { "chain_id": 114514, "epoch": 1 }
186        });
187        let inner = super::peel_envelope(env);
188        assert_eq!(inner, serde_json::json!({ "chain_id": 114514, "epoch": 1 }));
189    }
190
191    #[test]
192    fn passes_bare_object_through_unchanged() {
193        // No `data`/`type` pair → not an envelope, returned verbatim.
194        let bare = serde_json::json!({ "accepted": true, "mempool_depth": 3 });
195        assert_eq!(super::peel_envelope(bare.clone()), bare);
196    }
197
198    #[test]
199    fn passes_array_through_unchanged() {
200        let arr = serde_json::json!([1, 2, 3]);
201        assert_eq!(super::peel_envelope(arr.clone()), arr);
202    }
203
204    #[test]
205    fn does_not_peel_a_data_field_without_type() {
206        // A payload whose own field happens to be named `data` is not an
207        // envelope unless it also carries the `type` discriminator.
208        let payload = serde_json::json!({ "data": { "x": 1 } });
209        assert_eq!(super::peel_envelope(payload.clone()), payload);
210    }
211}