nubis-sdk 1.1.0

Official Nubis Rust SDK
Documentation
use std::time::Duration;

use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use reqwest::{Client, Method};
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::{json, Value};

use crate::NubisError;

const DEFAULT_BASE_URL: &str = "https://nubis-core.onrender.com";
const DEFAULT_TIMEOUT_SECS: u64 = 30;

#[derive(Debug, Clone)]
pub struct NubisClient {
    http: Client,
    base_url: String,
    api_key: Option<String>,
}

#[derive(Debug, Clone)]
pub struct NubisClientBuilder {
    base_url: String,
    api_key: Option<String>,
    timeout: Duration,
    default_headers: HeaderMap,
}

impl NubisClientBuilder {
    pub fn new() -> Self {
        let mut default_headers = HeaderMap::new();
        default_headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
        Self {
            base_url: DEFAULT_BASE_URL.to_string(),
            api_key: None,
            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
            default_headers,
        }
    }

    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
        self.base_url = base_url.into();
        self
    }

    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
        self.api_key = Some(api_key.into());
        self
    }

    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    pub fn default_header(mut self, key: &'static str, value: impl AsRef<str>) -> Self {
        if let Ok(header_value) = HeaderValue::from_str(value.as_ref()) {
            self.default_headers.insert(key, header_value);
        }
        self
    }

    pub fn build(self) -> Result<NubisClient, NubisError> {
        let http = Client::builder()
            .timeout(self.timeout)
            .default_headers(self.default_headers)
            .build()?;

        Ok(NubisClient {
            http,
            base_url: self.base_url,
            api_key: self.api_key,
        })
    }
}

impl Default for NubisClientBuilder {
    fn default() -> Self {
        Self::new()
    }
}

impl NubisClient {
    pub fn builder() -> NubisClientBuilder {
        NubisClientBuilder::new()
    }

    pub fn new(api_key: impl Into<String>) -> Result<Self, NubisError> {
        Self::builder().api_key(api_key).build()
    }

    pub fn with_base_url(base_url: impl Into<String>) -> Result<Self, NubisError> {
        Self::builder().base_url(base_url).build()
    }

    pub async fn request_value<B: Serialize + ?Sized>(
        &self,
        method: Method,
        path_template: &str,
        path_params: &[(&str, &str)],
        query: Option<&[(&str, &str)]>,
        body: Option<&B>,
    ) -> Result<Value, NubisError> {
        let rendered_path = render_path(path_template, path_params);
        let url = build_url(&self.base_url, &rendered_path);

        let mut req = self.http.request(method, url);
        if let Some(api_key) = &self.api_key {
            req = req.header(AUTHORIZATION, format!("Bearer {api_key}"));
        }
        if let Some(query) = query {
            req = req.query(query);
        }
        if let Some(body) = body {
            req = req.header(CONTENT_TYPE, "application/json").json(body);
        }

        let response = req.send().await?;
        let status = response.status();
        let text = response.text().await?;
        let body_json = parse_body(&text);

        if !status.is_success() {
            let message = extract_error_message(
                &body_json,
                status.canonical_reason().unwrap_or("Request failed"),
            );
            return Err(NubisError::Http {
                status: status.as_u16(),
                message,
                body: body_json,
            });
        }

        Ok(body_json)
    }

    pub async fn request<T, B>(
        &self,
        method: Method,
        path_template: &str,
        path_params: &[(&str, &str)],
        query: Option<&[(&str, &str)]>,
        body: Option<&B>,
    ) -> Result<T, NubisError>
    where
        T: DeserializeOwned,
        B: Serialize + ?Sized,
    {
        let value = self
            .request_value(method, path_template, path_params, query, body)
            .await?;
        Ok(serde_json::from_value(value)?)
    }
}

fn parse_body(text: &str) -> Value {
    if text.trim().is_empty() {
        Value::Null
    } else {
        serde_json::from_str(text).unwrap_or_else(|_| json!(text))
    }
}

fn extract_error_message(body: &Value, fallback: &str) -> String {
    if let Some(message) = body
        .get("error")
        .and_then(|error| error.get("message"))
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
    {
        return message.to_string();
    }

    if let Some(message) = body
        .get("message")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
    {
        return message.to_string();
    }

    if let Some(message) = body.as_str().filter(|value| !value.trim().is_empty()) {
        return message.to_string();
    }

    fallback.to_string()
}

pub(crate) fn render_path(path_template: &str, path_params: &[(&str, &str)]) -> String {
    let mut rendered = path_template.to_string();
    for (name, value) in path_params {
        let placeholder = format!(":{name}");
        let encoded = utf8_percent_encode(value, NON_ALPHANUMERIC).to_string();
        rendered = rendered.replace(&placeholder, &encoded);
    }
    rendered
}

fn build_url(base_url: &str, path: &str) -> String {
    if path.starts_with("http://") || path.starts_with("https://") {
        return path.to_string();
    }
    format!(
        "{}/{}",
        base_url.trim_end_matches('/'),
        path.trim_start_matches('/')
    )
}