use std::time::Duration;
use crate::error::FetchError;
use crate::types::{EventId, GlwEvent, GlwEventKey};
pub const DEFAULT_GLW_HOST: &str = "http://globalwind.net";
const GLW_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const GLW_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_GLW_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
#[expect(
clippy::module_name_repetitions,
reason = "build_http_client reads clearly at the call sites in this and the cache module"
)]
#[must_use]
pub fn build_http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(GLW_REQUEST_TIMEOUT)
.connect_timeout(GLW_CONNECT_TIMEOUT)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
async fn read_body_capped(
mut response: reqwest::Response,
max_bytes: usize,
) -> Result<String, FetchError> {
let limit_u64 = u64::try_from(max_bytes).unwrap_or(u64::MAX);
if response.content_length().is_some_and(|len| len > limit_u64) {
return Err(FetchError::ResponseTooLarge { limit: max_bytes });
}
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = response.chunk().await? {
if buf.len().saturating_add(chunk.len()) > max_bytes {
return Err(FetchError::ResponseTooLarge { limit: max_bytes });
}
buf.extend_from_slice(&chunk);
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
pub const DEFAULT_GLW_VERSION: &str = "glw127";
const GLW_DATA_REQ_PATH: &str = "glwDataReq.php";
pub fn default_base_url() -> Result<url::Url, FetchError> {
let raw = format!("{DEFAULT_GLW_HOST}/{DEFAULT_GLW_VERSION}/");
url::Url::parse(&raw).map_err(FetchError::from)
}
pub fn base_url_for_version(version: &str) -> Result<url::Url, FetchError> {
let raw = format!("{DEFAULT_GLW_HOST}/{version}/");
url::Url::parse(&raw).map_err(FetchError::from)
}
#[expect(
clippy::module_name_repetitions,
reason = "GlwClient is the primary public type of this module"
)]
#[derive(Debug, Clone)]
pub struct GlwClient {
http: reqwest::Client,
base_url: url::Url,
}
impl GlwClient {
pub fn new() -> Result<Self, FetchError> {
Self::with_base_url(default_base_url()?)
}
pub fn with_base_url(base_url: url::Url) -> Result<Self, FetchError> {
Ok(Self {
http: build_http_client(),
base_url,
})
}
pub fn with_glw_version(version: &str) -> Result<Self, FetchError> {
Self::with_base_url(base_url_for_version(version)?)
}
#[must_use]
pub const fn with_client(http: reqwest::Client, base_url: url::Url) -> Self {
Self { http, base_url }
}
#[must_use]
pub const fn base_url(&self) -> &url::Url {
&self.base_url
}
#[must_use]
pub const fn http(&self) -> &reqwest::Client {
&self.http
}
#[tracing::instrument(skip(self))]
pub async fn fetch_event_by_id(&self, id: EventId) -> Result<Option<GlwEvent>, FetchError> {
let (event, _policy) = fetch_event_by_id(&self.http, &self.base_url, id, None).await?;
Ok(event)
}
#[tracing::instrument(skip(self))]
pub async fn fetch_event_by_key(
&self,
key: &GlwEventKey,
) -> Result<Option<GlwEvent>, FetchError> {
let (event, _policy) = fetch_event_by_key(&self.http, &self.base_url, key, None).await?;
Ok(event)
}
}
type CachedEvent = (Option<GlwEvent>, http_cache_semantics::CachePolicy);
#[tracing::instrument(skip(client, cached))]
pub async fn fetch_event_by_id(
client: &reqwest::Client,
base_url: &url::Url,
id: EventId,
cached: Option<CachedEvent>,
) -> Result<CachedEvent, FetchError> {
let request = build_request(client, base_url, "id", &id.get().to_string())?;
fetch_one(client, request, cached).await
}
#[tracing::instrument(skip(client, cached))]
pub async fn fetch_event_by_key(
client: &reqwest::Client,
base_url: &url::Url,
key: &GlwEventKey,
cached: Option<CachedEvent>,
) -> Result<CachedEvent, FetchError> {
let request = build_request(client, base_url, "key", key.as_str())?;
fetch_one(client, request, cached).await
}
fn build_request(
client: &reqwest::Client,
base_url: &url::Url,
param_name: &str,
param_value: &str,
) -> Result<reqwest::Request, FetchError> {
let mut url = base_url.join(GLW_DATA_REQ_PATH)?;
url.query_pairs_mut().append_pair(param_name, param_value);
let request = client.get(url).build().map_err(FetchError::from)?;
Ok(request)
}
async fn fetch_one(
client: &reqwest::Client,
request: reqwest::Request,
cached: Option<CachedEvent>,
) -> Result<CachedEvent, FetchError> {
if let Some((cached_value, cache_policy)) = cached {
let now = std::time::SystemTime::now();
if let http_cache_semantics::BeforeRequest::Fresh(_) =
cache_policy.before_request(&request, now)
{
tracing::debug!("Using cached GLW event/absence");
return Ok((cached_value, cache_policy));
}
}
let to_send = request
.try_clone()
.ok_or(FetchError::FailedToCloneRequest)?;
let response = client.execute(to_send).await?;
let cache_policy = http_cache_semantics::CachePolicy::new(&request, &response);
let status = response.status();
if status == reqwest::StatusCode::NOT_FOUND {
tracing::debug!("GLW server returned 404 for event lookup");
return Ok((None, cache_policy));
}
if !status.is_success() {
let url = response.url().to_string();
let body = read_body_capped(response, MAX_GLW_RESPONSE_BYTES)
.await
.unwrap_or_default();
return Err(FetchError::BadStatus { status, url, body });
}
let body = read_body_capped(response, MAX_GLW_RESPONSE_BYTES).await?;
let trimmed = body.trim();
if trimmed.is_empty() || trimmed == "{}" {
tracing::debug!("GLW server returned empty body for event lookup");
return Ok((None, cache_policy));
}
let event: GlwEvent = serde_json::from_str(&body)?;
Ok((Some(event), cache_policy))
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn default_base_url_parses() -> Result<(), Box<dyn std::error::Error>> {
let url = default_base_url()?;
assert_eq!(url.scheme(), "http");
assert_eq!(url.host_str(), Some("globalwind.net"));
assert_eq!(url.path(), "/glw127/");
Ok(())
}
#[test]
fn version_override_composes() -> Result<(), Box<dyn std::error::Error>> {
let url = base_url_for_version("glw128")?;
assert_eq!(url.path(), "/glw128/");
Ok(())
}
#[test]
fn url_joins_glw_data_req_path() -> Result<(), Box<dyn std::error::Error>> {
let base = default_base_url()?;
let joined = base.join(GLW_DATA_REQ_PATH)?;
assert_eq!(joined.path(), "/glw127/glwDataReq.php");
Ok(())
}
}