hypixel-sdk 0.1.0

Async client for the Hypixel Public API with typed models and SkyBlock helpers
Documentation
use std::time::Duration;

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 and rate-limit state.
#[derive(Debug, Clone)]
pub struct HypixelClient {
    http: reqwest::Client,
    base_url: String,
    api_key: Option<String>,
    rate_limit: SharedRateLimit,
}

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 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(serde_json::from_slice(&bytes)?);
        }

        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>,
}

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
    }

    /// 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(),
        }
    }
}