hotpot-sdk 0.2.2

Rust SDK for interacting with the HotPot cross-chain DEX aggregator API.
Documentation
//! Hotpot API client implementation.

use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::Client as HttpClient;
use reqwest::StatusCode;
use serde::{de::DeserializeOwned, Serialize};
use url::Url;
use uuid::Uuid;

use crate::{
    types::{
        intents::{AddApprovalRequest, CreateIntentRequest, CreateIntentResponse},
        networks::Network,
        pagination::PaginatedResponse,
        quotes::{GetTheBestQuoteRequest, Quote},
        swaps::{ListHistoryParams, SwapWithAdditionalInfo},
        tokens::{ListTokensParams, Token},
    },
    ApiClientError, ErrorResponse,
};

const AUTH_HEADER: &str = "X-Api-Key";

/// Hotpot API client.
#[derive(Clone)]
pub struct Client {
    base_url: Url,
    http: HttpClient,
}

impl Client {
    /// Creates a new client with the given API key and base URL.
    pub fn new(api_key: String, base_url: String) -> Result<Client, ApiClientError> {
        let mut base_url = Url::parse(&base_url)
            .map_err(|e| ApiClientError::InvalidUrl(format!("Invalid base_url: {}", e)))?;

        if !base_url.path().ends_with('/') {
            let new_path = format!("{}/", base_url.path());
            base_url.set_path(&new_path);
        }

        let mut headers = HeaderMap::with_capacity(1);
        let header_value = HeaderValue::from_str(&api_key)
            .map_err(|e| ApiClientError::InvalidApiKey(e.to_string()))?;
        headers.insert(AUTH_HEADER, header_value);

        let http = HttpClient::builder()
            .default_headers(headers)
            .build()
            .map_err(|e| ApiClientError::ClientInitialization(e.to_string()))?;

        Ok(Self { base_url, http })
    }

    /// Lists all supported blockchain networks. [API docs](https://docs.hotpot.tech/api/networks-and-tokens#list-networks)
    pub async fn list_networks(&self) -> Result<Vec<Network>, ApiClientError> {
        let url = self
            .base_url
            .join("v1/networks")
            .map_err(|e| ApiClientError::InvalidUrl(e.to_string()))?;

        self.get(url.as_str().to_owned()).await
    }

    /// Lists all supported tokens with optional filters. [API docs](https://docs.hotpot.tech/api/networks-and-tokens#list-all-tokens)
    pub async fn list_tokens(
        &self,
        params: ListTokensParams,
    ) -> Result<PaginatedResponse<Token>, ApiClientError> {
        let mut url = self
            .base_url
            .join("v1/tokens")
            .map_err(|e| ApiClientError::InvalidUrl(e.to_string()))?;

        let query = serde_urlencoded::to_string(&params)
            .map_err(|e| ApiClientError::SerializationError(e.to_string()))?;

        url.set_query(Some(&query));
        self.get(url.as_str().to_owned()).await
    }

    /// Gets the best available quote for a token swap. [API docs](https://docs.hotpot.tech/api/quotes)
    pub async fn get_quote(&self, req: GetTheBestQuoteRequest) -> Result<Quote, ApiClientError> {
        let url = self
            .base_url
            .join("v1/quotes/best")
            .map_err(|e| ApiClientError::InvalidUrl(e.to_string()))?;

        self.post(url.as_str().to_owned(), &req).await
    }

    /// Creates a new swap intent. [API docs](https://docs.hotpot.tech/api/intents/creation)
    pub async fn create_intent(
        &self,
        req: CreateIntentRequest,
    ) -> Result<CreateIntentResponse, ApiClientError> {
        let url = self
            .base_url
            .join("v1/intents")
            .map_err(|e| ApiClientError::InvalidUrl(e.to_string()))?;

        self.post(url.as_str().to_owned(), &req).await
    }

    /// Adds user approval (signature) to an intent. [API docs](https://docs.hotpot.tech/api/intents/approvals)
    pub async fn add_approval_to_intent(
        &self,
        intent_id: Uuid,
        req: AddApprovalRequest,
    ) -> Result<(), ApiClientError> {
        let path = format!("v1/intents/{intent_id}/approvals");

        let url = self
            .base_url
            .join(&path)
            .map_err(|e| ApiClientError::InvalidUrl(e.to_string()))?;

        self.post(url.as_str().to_owned(), &req).await
    }

    /// Gets the status and details of a swap intent. [API docs](https://docs.hotpot.tech/api/intents/retrieving-intent-status)
    pub async fn get_swap_by_intent_id(
        &self,
        intent_id: Uuid,
    ) -> Result<SwapWithAdditionalInfo, ApiClientError> {
        let path = format!("v1/intents/{intent_id}");

        let url = self
            .base_url
            .join(&path)
            .map_err(|e| ApiClientError::InvalidUrl(e.to_string()))?;

        self.get(url.as_str().to_owned()).await
    }

    /// Gets swap history for a wallet address or user ID. [API docs](https://docs.hotpot.tech/api/swap-history)
    pub async fn list_swap_history(
        &self,
        params: ListHistoryParams,
    ) -> Result<PaginatedResponse<SwapWithAdditionalInfo>, ApiClientError> {
        let mut url = self
            .base_url
            .join("v1/swaps/history")
            .map_err(|e| ApiClientError::InvalidUrl(e.to_string()))?;

        let mut query = serde_urlencoded::to_string(&params)
            .map_err(|e| ApiClientError::SerializationError(e.to_string()))?;

        for wallet in &params.wallets {
            if !query.is_empty() {
                query.push('&');
            }
            query.push_str(&format!("wallet={}", urlencoding::encode(wallet)));
        }

        url.set_query(Some(&query));

        self.get(url.as_str().to_owned()).await
    }

    async fn get<RS>(&self, url: String) -> Result<RS, ApiClientError>
    where
        RS: DeserializeOwned,
    {
        let response = self.http.get(&url).send().await?;

        let status = response.status();

        if status.is_success() {
            let result = response
                .json::<RS>()
                .await
                .map_err(|e| ApiClientError::DeserializationError(e.to_string()))?;
            return Ok(result);
        }

        if status == StatusCode::NOT_FOUND {
            return Err(ApiClientError::NotFound);
        }

        if status.is_client_error() || status.is_server_error() {
            let body = response
                .text()
                .await
                .map_err(|e| ApiClientError::Network(e.to_string()))?;

            if let Ok(error_response) = serde_json::from_str::<ErrorResponse>(&body) {
                return Err(ApiClientError::ServerErrorResponse(error_response));
            }

            return Err(ApiClientError::DeserializationError(format!(
                "Failed to parse error response: {}",
                body
            )));
        }

        Err(ApiClientError::Network(format!(
            "Unexpected status code: {}",
            status
        )))
    }

    async fn post<RQ, RS>(&self, url: String, request: &RQ) -> Result<RS, ApiClientError>
    where
        RQ: Serialize + ?Sized,
        RS: DeserializeOwned,
    {
        let response = self.http.post(&url).json(request).send().await?;

        let status = response.status();

        if status.is_success() {
            let result = response
                .json::<RS>()
                .await
                .map_err(|e| ApiClientError::DeserializationError(e.to_string()))?;
            return Ok(result);
        }

        if status == StatusCode::NOT_FOUND {
            return Err(ApiClientError::NotFound);
        }

        if status.is_client_error() || status.is_server_error() {
            let body = response
                .text()
                .await
                .map_err(|e| ApiClientError::Network(e.to_string()))?;

            if let Ok(error_response) = serde_json::from_str::<ErrorResponse>(&body) {
                return Err(ApiClientError::ServerErrorResponse(error_response));
            }

            return Err(ApiClientError::DeserializationError(format!(
                "Failed to parse error response: {}",
                body
            )));
        }

        Err(ApiClientError::Network(format!(
            "Unexpected status code: {}",
            status
        )))
    }
}