hypixel-sdk 0.2.0

Async client for the Hypixel Public API with typed models and SkyBlock helpers
Documentation
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use serde::de::DeserializeOwned;

use crate::error::{Error, Result};
use crate::ratelimit::{RateLimit, SharedRateLimit};

const DEFAULT_BASE_URL: &str = "https://api.hypixel.net";
const API_KEY_HEADER: &str = "API-Key";

/// An asynchronous client for the Hypixel Public API.
///
/// Construct one with [`HypixelClient::new`] for a keyed client, or
/// [`HypixelClient::builder`] for finer control. The client is cheap to clone;
/// clones share the same connection pool, rate-limit state, and cache.
#[derive(Debug, Clone)]
pub struct HypixelClient {
    http: reqwest::Client,
    base_url: String,
    api_key: Option<String>,
    rate_limit: SharedRateLimit,
    retry_rate_limited: u32,
    cache: Option<ResponseCache>,
}

type CacheEntries = Arc<Mutex<HashMap<String, (Instant, Arc<[u8]>)>>>;

#[derive(Debug, Clone)]
struct ResponseCache {
    ttl: Duration,
    entries: CacheEntries,
}

impl ResponseCache {
    fn get(&self, key: &str) -> Option<Arc<[u8]>> {
        let entries = self.entries.lock().ok()?;
        let (stored_at, bytes) = entries.get(key)?;
        (stored_at.elapsed() < self.ttl).then(|| bytes.clone())
    }

    fn put(&self, key: String, bytes: Arc<[u8]>) {
        if let Ok(mut entries) = self.entries.lock() {
            entries.retain(|_, (stored_at, _)| stored_at.elapsed() < self.ttl);
            entries.insert(key, (Instant::now(), bytes));
        }
    }
}

impl HypixelClient {
    /// Create a client authenticated with the given API key.
    ///
    /// Keys are issued from the [Hypixel Developer Dashboard](https://developer.hypixel.net).
    pub fn new(api_key: impl Into<String>) -> Self {
        Self::builder().api_key(api_key).build()
    }

    /// Create a client without an API key.
    ///
    /// Only the keyless endpoints (all `resources/*`, plus the SkyBlock
    /// `auctions`, `auctions_ended`, `bazaar`, `firesales`, and `news`
    /// endpoints) may be called; any keyed endpoint returns
    /// [`Error::MissingApiKey`].
    pub fn unauthenticated() -> Self {
        Self::builder().build()
    }

    /// Start building a client with custom configuration.
    pub fn builder() -> ClientBuilder {
        ClientBuilder::default()
    }

    /// The most recent rate-limit state reported by the API.
    pub fn rate_limit(&self) -> RateLimit {
        self.rate_limit.load()
    }

    pub(crate) async fn request<T: DeserializeOwned>(
        &self,
        path: &str,
        query: &[(&str, &str)],
        requires_key: bool,
    ) -> Result<T> {
        if requires_key && self.api_key.is_none() {
            return Err(Error::MissingApiKey);
        }

        let cache_key = self.cache.as_ref().map(|cache| {
            let mut key = path.to_string();
            for (name, value) in query {
                key.push_str(&format!("&{name}={value}"));
            }
            (cache, key)
        });
        if let Some((cache, key)) = &cache_key {
            if let Some(bytes) = cache.get(key) {
                return Ok(serde_json::from_slice(&bytes)?);
            }
        }

        let mut attempt = 0;
        let bytes = loop {
            match self.send(path, query).await {
                Ok(bytes) => break bytes,
                Err(Error::RateLimited { retry_after }) if attempt < self.retry_rate_limited => {
                    attempt += 1;
                    tokio::time::sleep(retry_after.unwrap_or(Duration::from_secs(1))).await;
                }
                Err(err) => return Err(err),
            }
        };

        if let Some((cache, key)) = cache_key {
            cache.put(key, bytes.clone());
        }
        Ok(serde_json::from_slice(&bytes)?)
    }

    async fn send(&self, path: &str, query: &[(&str, &str)]) -> Result<Arc<[u8]>> {
        let url = format!("{}{}", self.base_url, path);
        let mut request = self.http.get(url);
        if let Some(key) = &self.api_key {
            request = request.header(API_KEY_HEADER, key);
        }
        if !query.is_empty() {
            request = request.query(query);
        }

        let response = request.send().await?;
        let status = response.status();
        let headers = response.headers().clone();
        self.rate_limit.store(RateLimit::from_headers(&headers));

        if status.is_success() {
            let bytes = response.bytes().await?;
            return Ok(Arc::from(bytes.as_ref()));
        }

        match status.as_u16() {
            403 => Err(Error::InvalidApiKey),
            429 => {
                let retry_after = headers
                    .get("retry-after")
                    .and_then(|v| v.to_str().ok())
                    .and_then(|v| v.trim().parse().ok())
                    .map(Duration::from_secs);
                Err(Error::RateLimited { retry_after })
            }
            code => {
                let cause = response
                    .json::<ErrorBody>()
                    .await
                    .ok()
                    .and_then(|b| b.cause)
                    .unwrap_or_else(|| "unknown error".to_string());
                Err(Error::Api {
                    status: code,
                    cause,
                })
            }
        }
    }
}

#[derive(serde::Deserialize)]
struct ErrorBody {
    cause: Option<String>,
}

/// Builder for [`HypixelClient`].
#[derive(Debug, Default)]
pub struct ClientBuilder {
    api_key: Option<String>,
    base_url: Option<String>,
    http: Option<reqwest::Client>,
    timeout: Option<Duration>,
    retry_rate_limited: u32,
    cache_ttl: Option<Duration>,
}

impl ClientBuilder {
    /// Set the API key used to authenticate keyed endpoints.
    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
        self.api_key = Some(api_key.into());
        self
    }

    /// Override the API base URL. Primarily useful for testing.
    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
        self.base_url = Some(base_url.into());
        self
    }

    /// Supply a pre-configured [`reqwest::Client`] (proxies, custom TLS, etc.).
    pub fn http_client(mut self, http: reqwest::Client) -> Self {
        self.http = Some(http);
        self
    }

    /// Set a per-request timeout. Ignored if a custom client is supplied.
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = Some(timeout);
        self
    }

    /// Automatically retry rate-limited requests up to `retries` times,
    /// waiting out the `Retry-After` interval (or one second) between
    /// attempts. Off by default.
    pub fn retry_on_rate_limit(mut self, retries: u32) -> Self {
        self.retry_rate_limited = retries;
        self
    }

    /// Cache successful responses in memory for `ttl`, keyed by path and
    /// query. Off by default.
    ///
    /// Repeated calls to snapshot endpoints (bazaar, resources, auctions)
    /// within the TTL are then served without hitting the API. Hypixel
    /// refreshes most snapshots every ~60 seconds, so TTLs beyond that only
    /// trade staleness for fewer requests.
    pub fn cache_ttl(mut self, ttl: Duration) -> Self {
        self.cache_ttl = Some(ttl);
        self
    }

    /// Build the [`HypixelClient`].
    pub fn build(self) -> HypixelClient {
        let http = self.http.unwrap_or_else(|| {
            let mut builder = reqwest::Client::builder();
            if let Some(timeout) = self.timeout {
                builder = builder.timeout(timeout);
            }
            builder.build().unwrap_or_default()
        });
        HypixelClient {
            http,
            base_url: self
                .base_url
                .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
            api_key: self.api_key,
            rate_limit: SharedRateLimit::default(),
            retry_rate_limited: self.retry_rate_limited,
            cache: self.cache_ttl.map(|ttl| ResponseCache {
                ttl,
                entries: Arc::default(),
            }),
        }
    }
}