nucleus-sdk 0.1.0

Rust SDK for Nucleus vault management
Documentation
use reqwest::{Client as ReqwestClient, header};
use serde::{de::DeserializeOwned};
use serde_json::Value;
use std::collections::HashMap;
use crate::error::{Result, APIError, InvalidInputsError};
use crate::calldata_queue::CalldataQueue;
use crate::utils::checksum_addresses_in_json;
use crate::config::{ADDRESS_BOOK_ENDPOINT, USER_AGENT};
use crate::Arc;
use crate::config::DEFAULT_BASE_URL;

#[derive(Debug, Clone)]
pub struct Client {
    pub http_client: ReqwestClient,
    pub api_key: String,
    pub base_url: String,
    pub address_book: HashMap<String, Value>,
}

impl Client {
    // construct a new client with an api key and base url
    pub async fn new(api_key: String, base_url: Option<String>) -> Result<Self> {

        // if no base url is provided, use the default base url from the config 
        let base_url = base_url.unwrap_or_else(|| 
            DEFAULT_BASE_URL.to_string()
        );

        // Setup session headers
        let mut headers = header::HeaderMap::new();
        headers.insert(
            "x-api-key",
            header::HeaderValue::from_str(&api_key)
                .expect("Invalid API key format"),
        );
        headers.insert(
            "Content-Type",
            header::HeaderValue::from_static("application/json"),
        );
        headers.insert(
            "User-Agent",
            header::HeaderValue::from_static(USER_AGENT),
        );

        // create a new http client with the headers
        let http_client = ReqwestClient::builder()
            .default_headers(headers)
            .build()
            .expect("Failed to create HTTP client");

        // Fetch address book during initialization
        let address_book_response = ReqwestClient::new()
            .get(ADDRESS_BOOK_ENDPOINT)
            .send()
            .await
            .map_err(|e| APIError::new(e.to_string(), 500))?;
        
        // convert the address book response to a hashmap
        let address_book: HashMap<String, Value> = address_book_response
            .json()
            .await
            .map_err(|e| APIError::new(e.to_string(), 500))?;

        // checksum the addresses in the address book
        let address_book = checksum_addresses_in_json(address_book)?;

        // return the client with the http client, api key, base url, and address book
        Ok(Self {
            http_client,
            api_key,
            base_url,
            address_book,
        })
    }

    // helper function to make a request to the api
    async fn request<T: DeserializeOwned>(
        &self,
        method: reqwest::Method,
        endpoint: &str,
        params: Option<&HashMap<String, String>>,
        json_data: Option<&Value>,
    ) -> Result<T> {
        let url = format!("{}{}", self.base_url, endpoint);
        
        let mut request = self.http_client
            .request(method, &url);

        // if there are params, add them to the request and query
        if let Some(params) = params {
            request = request.query(params);
        }

        // if there is json data, add it to the request and convert it to json
        if let Some(data) = json_data {
            request = request.json(data);
        }

        // send the request and handle the response
        let response = request
            .send()
            .await
            .map_err(|e| APIError::new(e.to_string(), 500))?;

        let status = response.status();

        // if the response is not successful, return an error
        if !status.is_success() {
            let error_text = response.text().await
                .unwrap_or_else(|_| "No error message".to_string());
            
            // Try to parse error message from JSON response
            if let Ok(error_json) = serde_json::from_str::<Value>(&error_text) {
                if let Some(message) = error_json.get("message")
                    .and_then(|m| m.as_str())
                    .filter(|m| !m.is_empty()) 
                {
                    return Err(APIError::new(message.to_string(), status.as_u16() as i32).into());
                }
            }
            
            return Err(APIError::new(error_text, status.as_u16() as i32).into());
        }
        // otherwise return the response as a json
        response.json::<T>()
            .await
            .map_err(|e| APIError::new(e.to_string(), 500).into())
    }

    // spawn a new manager call from the client
    pub async fn create_calldata_queue(
        &self,
        chain_id: u64,
        strategist_address: String,
        rpc_url: String,
        symbol: String,
    ) -> Result<CalldataQueue> {
        CalldataQueue::new(
            chain_id,
            strategist_address,
            rpc_url,
            symbol,
            Arc::new(self.clone())
        ).await
    }

    // get request helper function
    pub async fn get<T: DeserializeOwned>(
        &self,
        endpoint: &str,
        params: Option<&HashMap<String, String>>,
    ) -> Result<T> {
        self.request(reqwest::Method::GET, endpoint, params, None).await
    }

    // post request helper function
    pub async fn post<T: DeserializeOwned>(
        &self,
        endpoint: &str,
        data: Option<&Value>,
    ) -> Result<T> {
        self.request(reqwest::Method::POST, endpoint, None, data).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use crate::calldata_queue::CalldataQueue;

    /// This test verifies that when a Client is created with a given address book,
    /// the address_book field is correctly stored.
    #[tokio::test]
    async fn test_address_book() {
        let client = Client::new(
            "test_api_key".to_string(),
            None
        ).await.expect("Failed to create client");
        
        // Verify that address_book contains expected structure
        assert!(client.address_book.contains_key("1"));
    }

    /// This async test verifies that calling post on an unknown endpoint returns an error
    /// with the expected error message.
    #[tokio::test]
    async fn test_post_unknown_endpoint() {
        let client = Client::new(
            "test_api_key".to_string(),
            None
        ).await.expect("Failed to create client");
        
        let result: Result<serde_json::Value> = client.post("unknown_endpoint", None).await;
        assert!(result.is_err());
        let err_msg = format!("{}", result.unwrap_err());
        assert!(err_msg.contains("API Error (403): Missing Authentication Token"));
    }
}