use std::time::Duration;
use reqwest::Client as HttpClient;
use serde::Serialize;
use serde_json::Value;
use crate::error::ClientError;
pub mod exchange;
pub mod explorer;
pub mod info;
#[derive(Debug, Clone)]
pub struct RestClient {
base_url: String,
http: HttpClient,
}
impl RestClient {
pub fn new(base_url: impl Into<String>) -> Result<Self, ClientError> {
let base_url = base_url.into();
if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
return Err(ClientError::Builder(format!(
"base_url must start with http(s)://, got `{base_url}`"
)));
}
let base_url = base_url.trim_end_matches('/').to_string();
let http = HttpClient::builder()
.user_agent(concat!("metaflux-client/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(30))
.pool_idle_timeout(Duration::from_secs(60))
.build()
.map_err(|e| ClientError::Builder(e.to_string()))?;
Ok(Self { base_url, http })
}
#[must_use]
pub fn from_http(base_url: impl Into<String>, http: HttpClient) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
Self { base_url, http }
}
#[must_use]
pub fn info(&self) -> info::Info<'_> {
info::Info { client: self }
}
#[must_use]
pub fn exchange(&self) -> exchange::Exchange<'_> {
exchange::Exchange { client: self }
}
#[must_use]
pub fn explorer(&self) -> explorer::Explorer<'_> {
explorer::Explorer { client: self }
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
#[allow(dead_code)]
pub(crate) fn http(&self) -> &HttpClient {
&self.http
}
pub(crate) async fn post_json<Req, Resp>(
&self,
path: &str,
body: &Req,
) -> Result<Resp, ClientError>
where
Req: Serialize + ?Sized,
Resp: serde::de::DeserializeOwned,
{
let url = format!("{}{path}", self.base_url);
let resp = self.http.post(&url).json(body).send().await?;
let status = resp.status();
let bytes = resp.bytes().await?;
if !status.is_success() {
if let Ok(env) = serde_json::from_slice::<Value>(&bytes) {
if let Some(msg) = env.get("error").and_then(Value::as_str) {
return Err(ClientError::ProtocolError {
code: status.as_u16(),
msg: msg.into(),
});
}
}
return Err(ClientError::ProtocolError {
code: status.as_u16(),
msg: String::from_utf8_lossy(&bytes).into_owned(),
});
}
let value: Value = serde_json::from_slice(&bytes)?;
let payload = peel_envelope(value);
serde_json::from_value(payload).map_err(ClientError::from)
}
}
fn peel_envelope(value: Value) -> Value {
if let Value::Object(ref map) = value {
if map.contains_key("data") && map.contains_key("type") {
if let Value::Object(mut map) = value {
return map.remove("data").unwrap_or(Value::Null);
}
}
}
value
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_non_http_url() {
let err = RestClient::new("ftp://devnet-gateway.mtf.exchange").unwrap_err();
assert!(matches!(err, ClientError::Builder(_)));
}
#[test]
fn strips_trailing_slash() {
let c = RestClient::new("https://devnet-gateway.mtf.exchange/").unwrap();
assert_eq!(c.base_url(), "https://devnet-gateway.mtf.exchange");
}
#[test]
fn peels_data_from_typed_envelope() {
let env = serde_json::json!({
"type": "node_info",
"data": { "chain_id": 114514, "epoch": 1 }
});
let inner = super::peel_envelope(env);
assert_eq!(inner, serde_json::json!({ "chain_id": 114514, "epoch": 1 }));
}
#[test]
fn passes_bare_object_through_unchanged() {
let bare = serde_json::json!({ "accepted": true, "mempool_depth": 3 });
assert_eq!(super::peel_envelope(bare.clone()), bare);
}
#[test]
fn passes_array_through_unchanged() {
let arr = serde_json::json!([1, 2, 3]);
assert_eq!(super::peel_envelope(arr.clone()), arr);
}
#[test]
fn does_not_peel_a_data_field_without_type() {
let payload = serde_json::json!({ "data": { "x": 1 } });
assert_eq!(super::peel_envelope(payload.clone()), payload);
}
}