use std::fs;
use std::io::Read;
#[derive(Debug, thiserror::Error)]
pub enum HttpFetchError {
#[error("invalid URL: {0}")]
InvalidUrl(String),
#[error("HTTP {status}: {url}")]
Status { url: String, status: u16 },
#[error("network error fetching {url}: {source}")]
Network {
url: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
}
impl HttpFetchError {
pub fn is_transient(&self) -> bool {
matches!(self, Self::Network { .. })
}
}
pub trait HttpFetcher: Send + Sync {
fn fetch(&self, url: &str) -> std::result::Result<Vec<u8>, HttpFetchError>;
}
pub struct UreqFetcher {
agent: ureq::Agent,
}
impl UreqFetcher {
pub fn new() -> Self {
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(5))
.timeout_read(std::time::Duration::from_secs(20))
.timeout(std::time::Duration::from_secs(60))
.build();
Self { agent }
}
}
impl Default for UreqFetcher {
fn default() -> Self {
Self::new()
}
}
impl HttpFetcher for UreqFetcher {
fn fetch(&self, url: &str) -> std::result::Result<Vec<u8>, HttpFetchError> {
if let Some(rest) = url.strip_prefix("file://") {
return fs::read(rest).map_err(|e| HttpFetchError::Network {
url: url.to_string(),
source: Box::new(e),
});
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err(HttpFetchError::InvalidUrl(format!(
"unsupported URL scheme: {url}"
)));
}
match self.agent.get(url).call() {
Ok(resp) => {
let mut reader = resp.into_reader();
let mut bytes = Vec::new();
reader
.read_to_end(&mut bytes)
.map_err(|e| HttpFetchError::Network {
url: url.to_string(),
source: Box::new(e),
})?;
Ok(bytes)
}
Err(ureq::Error::Status(code, _)) => Err(HttpFetchError::Status {
url: url.to_string(),
status: code,
}),
Err(e) => Err(HttpFetchError::Network {
url: url.to_string(),
source: Box::new(e),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn file_url_reads_local_file() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"hello externals").unwrap();
let url = format!("file://{}", f.path().display());
let bytes = UreqFetcher::new().fetch(&url).unwrap();
assert_eq!(bytes, b"hello externals");
}
#[test]
fn missing_file_url_is_network_error() {
let url = "file:///definitely/not/a/real/path/external.bin";
let err = UreqFetcher::new().fetch(url).unwrap_err();
assert!(err.is_transient(), "should be transient: {err:?}");
}
#[test]
fn rejects_unknown_scheme() {
let err = UreqFetcher::new().fetch("ftp://example.com/x").unwrap_err();
assert!(matches!(err, HttpFetchError::InvalidUrl(_)));
}
}