use std::time::Duration;
use reqwest::{Client, StatusCode};
use serde::de::DeserializeOwned;
use crate::types::BridgeError;
use super::{
const_::{
NEAR_INTENTS_ATTESTATION_TIMEOUT_MS, NEAR_INTENTS_BASE_URL,
NEAR_INTENTS_DEFAULT_TIMEOUT_MS, NEAR_INTENTS_QUOTE_TIMEOUT_MS,
},
types::{
DefuseToken, NearAttestationRequest, NearAttestationResponse, NearExecutionStatusResponse,
NearQuoteRequest, NearQuoteResponse,
},
};
#[derive(Clone, Debug)]
pub struct NearIntentsApi {
client: Client,
base_url: String,
api_key: Option<String>,
}
impl Default for NearIntentsApi {
fn default() -> Self {
Self::new()
}
}
impl NearIntentsApi {
#[must_use]
pub fn new() -> Self {
Self { client: Client::new(), base_url: NEAR_INTENTS_BASE_URL.to_owned(), api_key: None }
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
#[must_use]
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn get_tokens(&self) -> Result<Vec<DefuseToken>, BridgeError> {
let url = format!("{}/v0/tokens", self.base_url);
let builder =
self.client.get(url).timeout(Duration::from_millis(NEAR_INTENTS_DEFAULT_TIMEOUT_MS));
send_and_parse(self.attach_auth_to(builder), "GET /v0/tokens").await
}
pub async fn get_quote(
&self,
body: &NearQuoteRequest,
) -> Result<NearQuoteResponse, BridgeError> {
let url = format!("{}/v0/quote", self.base_url);
let builder = self
.client
.post(url)
.timeout(Duration::from_millis(NEAR_INTENTS_QUOTE_TIMEOUT_MS))
.json(body);
send_and_parse_with_quote_error_mapping(self.attach_auth_to(builder), "POST /v0/quote")
.await
}
pub async fn get_execution_status(
&self,
deposit_address: &str,
) -> Result<NearExecutionStatusResponse, BridgeError> {
let url = format!("{}/v0/execution-status/{deposit_address}", self.base_url);
let builder =
self.client.get(url).timeout(Duration::from_millis(NEAR_INTENTS_DEFAULT_TIMEOUT_MS));
send_and_parse(self.attach_auth_to(builder), "GET /v0/execution-status").await
}
pub async fn get_attestation(
&self,
body: &NearAttestationRequest,
) -> Result<NearAttestationResponse, BridgeError> {
let url = format!("{}/v0/attestation", self.base_url);
let builder = self
.client
.post(url)
.timeout(Duration::from_millis(NEAR_INTENTS_ATTESTATION_TIMEOUT_MS))
.json(body);
send_and_parse(self.attach_auth_to(builder), "POST /v0/attestation").await
}
fn attach_auth_to(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(key) = &self.api_key { req.bearer_auth(key) } else { req }
}
}
async fn send_and_parse<T: DeserializeOwned>(
req: reqwest::RequestBuilder,
label: &'static str,
) -> Result<T, BridgeError> {
let resp = req
.send()
.await
.map_err(|e| BridgeError::ApiError(format!("{label}: transport error: {e}")))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| BridgeError::ApiError(format!("{label}: body read failed: {e}")))?;
if !status.is_success() {
return Err(BridgeError::ApiError(format!("{label}: HTTP {status}: {text}")));
}
serde_json::from_str::<T>(&text)
.map_err(|e| BridgeError::InvalidApiResponse(format!("{label}: {e} (body: {text})")))
}
async fn send_and_parse_with_quote_error_mapping<T: DeserializeOwned>(
req: reqwest::RequestBuilder,
label: &'static str,
) -> Result<T, BridgeError> {
let resp = req
.send()
.await
.map_err(|e| BridgeError::ApiError(format!("{label}: transport error: {e}")))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| BridgeError::ApiError(format!("{label}: body read failed: {e}")))?;
if !status.is_success() {
if text.to_lowercase().contains("amount is too low") {
return Err(BridgeError::SellAmountTooSmall);
}
return Err(map_http_error(status, text, label));
}
serde_json::from_str::<T>(&text)
.map_err(|e| BridgeError::InvalidApiResponse(format!("{label}: {e} (body: {text})")))
}
fn map_http_error(status: StatusCode, text: String, label: &'static str) -> BridgeError {
BridgeError::ApiError(format!("{label}: HTTP {status}: {text}"))
}
#[cfg(all(test, not(target_arch = "wasm32")))]
#[allow(clippy::tests_outside_test_module, reason = "inner module + cfg guard for WASM test skip")]
mod tests {
use super::*;
#[test]
fn new_uses_default_base_url() {
let api = NearIntentsApi::new();
assert_eq!(api.base_url(), NEAR_INTENTS_BASE_URL);
}
#[test]
fn with_base_url_overrides() {
let api = NearIntentsApi::new().with_base_url("https://example.com");
assert_eq!(api.base_url(), "https://example.com");
}
#[test]
fn default_matches_new() {
let a = NearIntentsApi::default();
let b = NearIntentsApi::new();
assert_eq!(a.base_url(), b.base_url());
}
#[test]
fn api_key_is_stored() {
let api = NearIntentsApi::new().with_api_key("super-secret");
assert_eq!(api.api_key.as_deref(), Some("super-secret"));
}
}