Skip to main content

hypixel/
client.rs

1use std::time::Duration;
2
3use serde::de::DeserializeOwned;
4
5use crate::error::{Error, Result};
6use crate::ratelimit::{RateLimit, SharedRateLimit};
7
8const DEFAULT_BASE_URL: &str = "https://api.hypixel.net";
9const API_KEY_HEADER: &str = "API-Key";
10
11/// An asynchronous client for the Hypixel Public API.
12///
13/// Construct one with [`HypixelClient::new`] for a keyed client, or
14/// [`HypixelClient::builder`] for finer control. The client is cheap to clone;
15/// clones share the same connection pool and rate-limit state.
16#[derive(Debug, Clone)]
17pub struct HypixelClient {
18    http: reqwest::Client,
19    base_url: String,
20    api_key: Option<String>,
21    rate_limit: SharedRateLimit,
22}
23
24impl HypixelClient {
25    /// Create a client authenticated with the given API key.
26    ///
27    /// Keys are issued from the [Hypixel Developer Dashboard](https://developer.hypixel.net).
28    pub fn new(api_key: impl Into<String>) -> Self {
29        Self::builder().api_key(api_key).build()
30    }
31
32    /// Create a client without an API key.
33    ///
34    /// Only the keyless endpoints (all `resources/*`, plus the SkyBlock
35    /// `auctions`, `auctions_ended`, `bazaar`, `firesales`, and `news`
36    /// endpoints) may be called; any keyed endpoint returns
37    /// [`Error::MissingApiKey`].
38    pub fn unauthenticated() -> Self {
39        Self::builder().build()
40    }
41
42    /// Start building a client with custom configuration.
43    pub fn builder() -> ClientBuilder {
44        ClientBuilder::default()
45    }
46
47    /// The most recent rate-limit state reported by the API.
48    pub fn rate_limit(&self) -> RateLimit {
49        self.rate_limit.load()
50    }
51
52    pub(crate) async fn request<T: DeserializeOwned>(
53        &self,
54        path: &str,
55        query: &[(&str, &str)],
56        requires_key: bool,
57    ) -> Result<T> {
58        if requires_key && self.api_key.is_none() {
59            return Err(Error::MissingApiKey);
60        }
61
62        let url = format!("{}{}", self.base_url, path);
63        let mut request = self.http.get(url);
64        if let Some(key) = &self.api_key {
65            request = request.header(API_KEY_HEADER, key);
66        }
67        if !query.is_empty() {
68            request = request.query(query);
69        }
70
71        let response = request.send().await?;
72        let status = response.status();
73        let headers = response.headers().clone();
74        self.rate_limit.store(RateLimit::from_headers(&headers));
75
76        if status.is_success() {
77            let bytes = response.bytes().await?;
78            return Ok(serde_json::from_slice(&bytes)?);
79        }
80
81        match status.as_u16() {
82            403 => Err(Error::InvalidApiKey),
83            429 => {
84                let retry_after = headers
85                    .get("retry-after")
86                    .and_then(|v| v.to_str().ok())
87                    .and_then(|v| v.trim().parse().ok())
88                    .map(Duration::from_secs);
89                Err(Error::RateLimited { retry_after })
90            }
91            code => {
92                let cause = response
93                    .json::<ErrorBody>()
94                    .await
95                    .ok()
96                    .and_then(|b| b.cause)
97                    .unwrap_or_else(|| "unknown error".to_string());
98                Err(Error::Api {
99                    status: code,
100                    cause,
101                })
102            }
103        }
104    }
105}
106
107#[derive(serde::Deserialize)]
108struct ErrorBody {
109    cause: Option<String>,
110}
111
112/// Builder for [`HypixelClient`].
113#[derive(Debug, Default)]
114pub struct ClientBuilder {
115    api_key: Option<String>,
116    base_url: Option<String>,
117    http: Option<reqwest::Client>,
118    timeout: Option<Duration>,
119}
120
121impl ClientBuilder {
122    /// Set the API key used to authenticate keyed endpoints.
123    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
124        self.api_key = Some(api_key.into());
125        self
126    }
127
128    /// Override the API base URL. Primarily useful for testing.
129    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
130        self.base_url = Some(base_url.into());
131        self
132    }
133
134    /// Supply a pre-configured [`reqwest::Client`] (proxies, custom TLS, etc.).
135    pub fn http_client(mut self, http: reqwest::Client) -> Self {
136        self.http = Some(http);
137        self
138    }
139
140    /// Set a per-request timeout. Ignored if a custom client is supplied.
141    pub fn timeout(mut self, timeout: Duration) -> Self {
142        self.timeout = Some(timeout);
143        self
144    }
145
146    /// Build the [`HypixelClient`].
147    pub fn build(self) -> HypixelClient {
148        let http = self.http.unwrap_or_else(|| {
149            let mut builder = reqwest::Client::builder();
150            if let Some(timeout) = self.timeout {
151                builder = builder.timeout(timeout);
152            }
153            builder.build().unwrap_or_default()
154        });
155        HypixelClient {
156            http,
157            base_url: self
158                .base_url
159                .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
160            api_key: self.api_key,
161            rate_limit: SharedRateLimit::default(),
162        }
163    }
164}