use std::fmt;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Get,
Post,
Put,
Delete,
Patch,
Head,
}
impl Method {
pub fn as_str(self) -> &'static str {
match self {
Method::Get => "GET",
Method::Post => "POST",
Method::Put => "PUT",
Method::Delete => "DELETE",
Method::Patch => "PATCH",
Method::Head => "HEAD",
}
}
}
#[derive(Debug, Clone)]
pub enum FetchError {
Network(String),
Status(u16),
Decode(String),
}
impl fmt::Display for FetchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FetchError::Network(m) => write!(f, "network error: {m}"),
FetchError::Status(c) => write!(f, "HTTP status {c}"),
FetchError::Decode(m) => write!(f, "decode error: {m}"),
}
}
}
impl std::error::Error for FetchError {}
#[derive(Debug, Clone)]
pub struct Request {
method: Method,
url: String,
headers: Vec<(String, String)>,
body: Option<String>,
timeout: Option<Duration>,
error: Option<FetchError>,
}
impl Request {
pub fn new(method: Method, url: impl Into<String>) -> Self {
Self {
method,
url: url.into(),
headers: Vec::new(),
body: None,
timeout: None,
error: None,
}
}
pub fn get(url: impl Into<String>) -> Self {
Self::new(Method::Get, url)
}
pub fn post(url: impl Into<String>) -> Self {
Self::new(Method::Post, url)
}
pub fn put(url: impl Into<String>) -> Self {
Self::new(Method::Put, url)
}
pub fn delete(url: impl Into<String>) -> Self {
Self::new(Method::Delete, url)
}
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
pub fn timeout(mut self, dur: Duration) -> Self {
self.timeout = Some(dur);
self
}
#[cfg(feature = "json")]
pub fn json_body<B: serde::Serialize>(mut self, body: &B) -> Self {
match serde_json::to_string(body) {
Ok(json) => {
self.headers
.push(("Content-Type".to_string(), "application/json".to_string()));
self.body = Some(json);
}
Err(err) => self.error = Some(FetchError::Decode(err.to_string())),
}
self
}
#[cfg(feature = "json")]
pub async fn send_json<T: serde::de::DeserializeOwned>(self) -> Result<T, FetchError> {
let body = self.send().await?;
decode_json(&body)
}
pub async fn send(self) -> Result<String, FetchError> {
let bytes = self.send_bytes().await?;
String::from_utf8(bytes).map_err(|e| FetchError::Decode(e.to_string()))
}
#[cfg(not(web))]
pub async fn send_bytes(self) -> Result<Vec<u8>, FetchError> {
if let Some(err) = self.error {
return Err(err);
}
blocking::unblock(move || {
let mut req = ureq::request(self.method.as_str(), &self.url);
for (name, value) in &self.headers {
req = req.set(name, value);
}
if let Some(dur) = self.timeout {
req = req.timeout(dur);
}
let result = match &self.body {
Some(b) => req.send_string(b),
None => req.call(),
};
match result {
Ok(resp) => {
use std::io::Read;
let mut body = Vec::new();
resp.into_reader()
.take(MAX_RESPONSE_BYTES as u64 + 1)
.read_to_end(&mut body)
.map_err(|e| FetchError::Decode(e.to_string()))?;
if body.len() > MAX_RESPONSE_BYTES {
return Err(FetchError::Decode(format!(
"response body over {MAX_RESPONSE_BYTES} bytes"
)));
}
Ok(body)
}
Err(ureq::Error::Status(code, _)) => Err(FetchError::Status(code)),
Err(e) => Err(FetchError::Network(e.to_string())),
}
})
.await
}
#[cfg(web)]
pub async fn send_bytes(self) -> Result<Vec<u8>, FetchError> {
if let Some(err) = self.error {
return Err(err);
}
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
let window = web_sys::window().ok_or_else(|| FetchError::Network("no window".into()))?;
let init = web_sys::RequestInit::new();
init.set_method(self.method.as_str());
if !self.headers.is_empty() {
let headers =
web_sys::Headers::new().map_err(|e| FetchError::Network(format!("{e:?}")))?;
for (name, value) in &self.headers {
headers
.append(name, value)
.map_err(|e| FetchError::Network(format!("{e:?}")))?;
}
init.set_headers(&headers);
}
if let Some(b) = &self.body {
init.set_body(&JsValue::from_str(b));
}
let controller = if self.timeout.is_some() {
let c = web_sys::AbortController::new()
.map_err(|e| FetchError::Network(format!("{e:?}")))?;
init.set_signal(Some(&c.signal()));
Some(c)
} else {
None
};
let request = web_sys::Request::new_with_str_and_init(&self.url, &init)
.map_err(|e| FetchError::Network(format!("{e:?}")))?;
let mut _timer_closure = None;
let timeout_handle = if let (Some(dur), Some(controller)) = (self.timeout, controller) {
let ms = dur.as_millis().min(i32::MAX as u128) as i32;
let closure = Closure::<dyn FnMut()>::new(move || controller.abort());
let handle = window
.set_timeout_with_callback_and_timeout_and_arguments_0(
closure.as_ref().unchecked_ref(),
ms,
)
.map_err(|e| FetchError::Network(format!("{e:?}")))?;
_timer_closure = Some(closure);
Some(handle)
} else {
None
};
let resp_val = JsFuture::from(window.fetch_with_request(&request)).await;
if let Some(handle) = timeout_handle {
window.clear_timeout_with_handle(handle);
}
let resp_val = resp_val.map_err(|e| FetchError::Network(format!("{e:?}")))?;
let resp: web_sys::Response = resp_val
.dyn_into()
.map_err(|_| FetchError::Network("invalid response".into()))?;
if !resp.ok() {
return Err(FetchError::Status(resp.status()));
}
let buffer_promise = resp
.array_buffer()
.map_err(|e| FetchError::Decode(format!("{e:?}")))?;
let buffer = JsFuture::from(buffer_promise)
.await
.map_err(|e| FetchError::Decode(format!("{e:?}")))?;
let bytes = js_sys::Uint8Array::new(&buffer).to_vec();
if bytes.len() > MAX_RESPONSE_BYTES {
return Err(FetchError::Decode(format!(
"response body over {MAX_RESPONSE_BYTES} bytes"
)));
}
Ok(bytes)
}
}
pub async fn fetch(url: impl Into<String>) -> Result<String, FetchError> {
Request::get(url).send().await
}
pub async fn fetch_bytes(url: impl Into<String>) -> Result<Vec<u8>, FetchError> {
Request::get(url).send_bytes().await
}
pub const MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
#[cfg(feature = "json")]
fn decode_json<T: serde::de::DeserializeOwned>(body: &str) -> Result<T, FetchError> {
serde_json::from_str(body).map_err(|err| FetchError::Decode(err.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_display_is_readable() {
assert_eq!(FetchError::Status(404).to_string(), "HTTP status 404");
assert!(FetchError::Network("dns".into())
.to_string()
.contains("dns"));
assert!(FetchError::Decode("utf8".into())
.to_string()
.contains("utf8"));
}
#[test]
fn method_verbs() {
assert_eq!(Method::Get.as_str(), "GET");
assert_eq!(Method::Post.as_str(), "POST");
assert_eq!(Method::Delete.as_str(), "DELETE");
}
#[test]
fn builder_accumulates_headers_body_and_timeout() {
let r = Request::post("https://example.com/api")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.body("{}")
.timeout(Duration::from_secs(5));
assert_eq!(r.method, Method::Post);
assert_eq!(r.url, "https://example.com/api");
assert_eq!(
r.headers,
vec![
("Content-Type".to_string(), "application/json".to_string()),
("Accept".to_string(), "application/json".to_string()),
]
);
assert_eq!(r.body.as_deref(), Some("{}"));
assert_eq!(r.timeout, Some(Duration::from_secs(5)));
}
#[test]
fn fetch_shortcut_is_a_bare_get() {
let r = Request::get("https://example.com");
assert_eq!(r.method, Method::Get);
assert!(r.headers.is_empty());
assert!(r.body.is_none());
assert!(r.timeout.is_none());
}
#[cfg(feature = "json")]
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
struct Point {
x: i32,
y: i32,
}
#[cfg(feature = "json")]
#[test]
fn json_body_serializes_and_sets_content_type() {
let r = Request::post("https://example.com").json_body(&Point { x: 1, y: 2 });
assert_eq!(r.body.as_deref(), Some(r#"{"x":1,"y":2}"#));
assert!(r
.headers
.iter()
.any(|(k, v)| k == "Content-Type" && v == "application/json"));
assert!(r.error.is_none());
}
#[cfg(feature = "json")]
#[test]
fn decode_json_maps_valid_and_invalid_bodies() {
let ok: Point = decode_json(r#"{"x":3,"y":4}"#).expect("a valid JSON body");
assert_eq!(ok, Point { x: 3, y: 4 });
let bad = decode_json::<Point>("not json");
assert!(
matches!(bad, Err(FetchError::Decode(_))),
"an unreadable body -> Decode"
);
}
#[test]
fn the_bytes_shortcut_is_the_same_bare_get() {
let r = Request::get("https://example.com");
assert_eq!(r.method, Method::Get);
assert!(r.headers.is_empty() && r.body.is_none() && r.timeout.is_none());
}
#[test]
fn the_response_cap_is_the_documented_one() {
assert_eq!(MAX_RESPONSE_BYTES, 32 * 1024 * 1024);
}
}