Skip to main content

hypixel/
client.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3use std::time::{Duration, Instant};
4
5use serde::de::DeserializeOwned;
6
7use crate::error::{Error, Result};
8use crate::ratelimit::{RateLimit, SharedRateLimit};
9
10const DEFAULT_BASE_URL: &str = "https://api.hypixel.net";
11const API_KEY_HEADER: &str = "API-Key";
12
13/// An asynchronous client for the Hypixel Public API.
14///
15/// Construct one with [`HypixelClient::new`] for a keyed client, or
16/// [`HypixelClient::builder`] for finer control. The client is cheap to clone;
17/// clones share the same connection pool, rate-limit state, and cache.
18#[derive(Debug, Clone)]
19pub struct HypixelClient {
20    http: reqwest::Client,
21    base_url: String,
22    api_key: Option<String>,
23    rate_limit: SharedRateLimit,
24    retry_rate_limited: u32,
25    cache: Option<ResponseCache>,
26}
27
28type CacheEntries = Arc<Mutex<HashMap<String, (Instant, Arc<[u8]>)>>>;
29
30#[derive(Debug, Clone)]
31struct ResponseCache {
32    ttl: Duration,
33    entries: CacheEntries,
34}
35
36impl ResponseCache {
37    fn get(&self, key: &str) -> Option<Arc<[u8]>> {
38        let entries = self.entries.lock().ok()?;
39        let (stored_at, bytes) = entries.get(key)?;
40        (stored_at.elapsed() < self.ttl).then(|| bytes.clone())
41    }
42
43    fn put(&self, key: String, bytes: Arc<[u8]>) {
44        if let Ok(mut entries) = self.entries.lock() {
45            entries.retain(|_, (stored_at, _)| stored_at.elapsed() < self.ttl);
46            entries.insert(key, (Instant::now(), bytes));
47        }
48    }
49}
50
51impl HypixelClient {
52    /// Create a client authenticated with the given API key.
53    ///
54    /// Keys are issued from the [Hypixel Developer Dashboard](https://developer.hypixel.net).
55    pub fn new(api_key: impl Into<String>) -> Self {
56        Self::builder().api_key(api_key).build()
57    }
58
59    /// Create a client without an API key.
60    ///
61    /// Only the keyless endpoints (all `resources/*`, plus the SkyBlock
62    /// `auctions`, `auctions_ended`, `bazaar`, `firesales`, and `news`
63    /// endpoints) may be called; any keyed endpoint returns
64    /// [`Error::MissingApiKey`].
65    pub fn unauthenticated() -> Self {
66        Self::builder().build()
67    }
68
69    /// Start building a client with custom configuration.
70    pub fn builder() -> ClientBuilder {
71        ClientBuilder::default()
72    }
73
74    /// The most recent rate-limit state reported by the API.
75    pub fn rate_limit(&self) -> RateLimit {
76        self.rate_limit.load()
77    }
78
79    pub(crate) async fn request<T: DeserializeOwned>(
80        &self,
81        path: &str,
82        query: &[(&str, &str)],
83        requires_key: bool,
84    ) -> Result<T> {
85        if requires_key && self.api_key.is_none() {
86            return Err(Error::MissingApiKey);
87        }
88
89        let cache_key = self.cache.as_ref().map(|cache| {
90            let mut key = path.to_string();
91            for (name, value) in query {
92                key.push_str(&format!("&{name}={value}"));
93            }
94            (cache, key)
95        });
96        if let Some((cache, key)) = &cache_key {
97            if let Some(bytes) = cache.get(key) {
98                return Ok(serde_json::from_slice(&bytes)?);
99            }
100        }
101
102        let mut attempt = 0;
103        let bytes = loop {
104            match self.send(path, query).await {
105                Ok(bytes) => break bytes,
106                Err(Error::RateLimited { retry_after }) if attempt < self.retry_rate_limited => {
107                    attempt += 1;
108                    tokio::time::sleep(retry_after.unwrap_or(Duration::from_secs(1))).await;
109                }
110                Err(err) => return Err(err),
111            }
112        };
113
114        if let Some((cache, key)) = cache_key {
115            cache.put(key, bytes.clone());
116        }
117        Ok(serde_json::from_slice(&bytes)?)
118    }
119
120    async fn send(&self, path: &str, query: &[(&str, &str)]) -> Result<Arc<[u8]>> {
121        let url = format!("{}{}", self.base_url, path);
122        let mut request = self.http.get(url);
123        if let Some(key) = &self.api_key {
124            request = request.header(API_KEY_HEADER, key);
125        }
126        if !query.is_empty() {
127            request = request.query(query);
128        }
129
130        let response = request.send().await?;
131        let status = response.status();
132        let headers = response.headers().clone();
133        self.rate_limit.store(RateLimit::from_headers(&headers));
134
135        if status.is_success() {
136            let bytes = response.bytes().await?;
137            return Ok(Arc::from(bytes.as_ref()));
138        }
139
140        match status.as_u16() {
141            403 => Err(Error::InvalidApiKey),
142            429 => {
143                let retry_after = headers
144                    .get("retry-after")
145                    .and_then(|v| v.to_str().ok())
146                    .and_then(|v| v.trim().parse().ok())
147                    .map(Duration::from_secs);
148                Err(Error::RateLimited { retry_after })
149            }
150            code => {
151                let cause = response
152                    .json::<ErrorBody>()
153                    .await
154                    .ok()
155                    .and_then(|b| b.cause)
156                    .unwrap_or_else(|| "unknown error".to_string());
157                Err(Error::Api {
158                    status: code,
159                    cause,
160                })
161            }
162        }
163    }
164}
165
166#[derive(serde::Deserialize)]
167struct ErrorBody {
168    cause: Option<String>,
169}
170
171/// Builder for [`HypixelClient`].
172#[derive(Debug, Default)]
173pub struct ClientBuilder {
174    api_key: Option<String>,
175    base_url: Option<String>,
176    http: Option<reqwest::Client>,
177    timeout: Option<Duration>,
178    retry_rate_limited: u32,
179    cache_ttl: Option<Duration>,
180}
181
182impl ClientBuilder {
183    /// Set the API key used to authenticate keyed endpoints.
184    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
185        self.api_key = Some(api_key.into());
186        self
187    }
188
189    /// Override the API base URL. Primarily useful for testing.
190    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
191        self.base_url = Some(base_url.into());
192        self
193    }
194
195    /// Supply a pre-configured [`reqwest::Client`] (proxies, custom TLS, etc.).
196    pub fn http_client(mut self, http: reqwest::Client) -> Self {
197        self.http = Some(http);
198        self
199    }
200
201    /// Set a per-request timeout. Ignored if a custom client is supplied.
202    pub fn timeout(mut self, timeout: Duration) -> Self {
203        self.timeout = Some(timeout);
204        self
205    }
206
207    /// Automatically retry rate-limited requests up to `retries` times,
208    /// waiting out the `Retry-After` interval (or one second) between
209    /// attempts. Off by default.
210    pub fn retry_on_rate_limit(mut self, retries: u32) -> Self {
211        self.retry_rate_limited = retries;
212        self
213    }
214
215    /// Cache successful responses in memory for `ttl`, keyed by path and
216    /// query. Off by default.
217    ///
218    /// Repeated calls to snapshot endpoints (bazaar, resources, auctions)
219    /// within the TTL are then served without hitting the API. Hypixel
220    /// refreshes most snapshots every ~60 seconds, so TTLs beyond that only
221    /// trade staleness for fewer requests.
222    pub fn cache_ttl(mut self, ttl: Duration) -> Self {
223        self.cache_ttl = Some(ttl);
224        self
225    }
226
227    /// Build the [`HypixelClient`].
228    pub fn build(self) -> HypixelClient {
229        let http = self.http.unwrap_or_else(|| {
230            let mut builder = reqwest::Client::builder();
231            if let Some(timeout) = self.timeout {
232                builder = builder.timeout(timeout);
233            }
234            builder.build().unwrap_or_default()
235        });
236        HypixelClient {
237            http,
238            base_url: self
239                .base_url
240                .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
241            api_key: self.api_key,
242            rate_limit: SharedRateLimit::default(),
243            retry_rate_limited: self.retry_rate_limited,
244            cache: self.cache_ttl.map(|ttl| ResponseCache {
245                ttl,
246                entries: Arc::default(),
247            }),
248        }
249    }
250}