use std::time::Duration;
use crate::error::Error;
pub use crate::error::Result;
#[cfg(not(target_arch = "wasm32"))]
mod native;
#[cfg(not(target_arch = "wasm32"))]
mod ws;
#[cfg(not(target_arch = "wasm32"))]
pub use crate::io::runtime::{AsyncConn, Runtime};
#[cfg(all(feature = "tokio-rt", not(target_arch = "wasm32")))]
pub use crate::io::tokio::{TokioConn, TokioRuntime};
#[cfg(not(target_arch = "wasm32"))]
pub use native::{get, post, request};
#[cfg(not(target_arch = "wasm32"))]
pub use ws::WebSocket;
#[cfg(all(feature = "tokio-rt", not(target_arch = "wasm32")))]
pub type TokioWebSocket = WebSocket<TokioConn>;
#[cfg(target_arch = "wasm32")]
mod wasm;
#[cfg(target_arch = "wasm32")]
pub use wasm::{get, post, request, WebSocket, WsSink, WsStream};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Response {
pub status: u16,
pub reason: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl Response {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
pub fn is_success(&self) -> bool {
(200..300).contains(&self.status)
}
pub fn text(&self) -> Result<String> {
match self.charset().as_deref() {
None | Some("utf-8") | Some("utf8") | Some("us-ascii") | Some("ascii") => {
Ok(String::from_utf8_lossy(&self.body).into_owned())
}
Some("iso-8859-1") | Some("iso8859-1") | Some("latin1") => {
Ok(self.body.iter().map(|&b| b as char).collect())
}
Some(other) => Err(Error::Decode(format!(
"unsupported Content-Type charset {other:?}; \
use Response::body for the raw {} bytes",
self.body.len()
))),
}
}
#[cfg(feature = "json")]
pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
serde_json::from_slice(&self.body).map_err(|e| Error::Decode(format!("json: {e}")))
}
pub fn error_for_status(self) -> Result<Self> {
if self.status >= 400 {
Err(Error::Status {
code: self.status,
reason: self.reason.clone(),
})
} else {
Ok(self)
}
}
pub fn into_body(self) -> Vec<u8> {
self.body
}
fn charset(&self) -> Option<String> {
let ct = self.header("content-type")?;
ct.split(';').skip(1).find_map(|param| {
let (k, v) = param.split_once('=')?;
if !k.trim().eq_ignore_ascii_case("charset") {
return None;
}
Some(v.trim().trim_matches('"').to_ascii_lowercase())
})
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Request {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
pub follow_redirects: bool,
pub decompress: bool,
pub timeout: Option<Duration>,
tls: TlsOptions,
}
pub const MAX_REDIRECTS: usize = 10;
macro_rules! tls_setters {
() => {
tls_setters! {
verify_tls(on: bool) => |s| s.verify = on;
ca_bundle(path: &str) => |s| s.ca_bundle = Some(path.to_string());
ca_path(dir: &str) => |s| s.ca_path = Some(dir.to_string());
crl_file(path: &str) => |s| s.crl_file = Some(path.to_string());
client_cert(path: &str) => |s| s.client_cert = Some(path.to_string());
client_key(path: &str) => |s| s.client_key = Some(path.to_string());
client_key_pass(pass: &str) => |s| s.client_key_pass = Some(pass.to_string());
cert_type_der(der: bool) => |s| s.cert_is_der = der;
key_type_der(der: bool) => |s| s.key_is_der = der;
pinned_pubkey(spec: &str) => |s| s.pinned_pubkey = Some(spec.to_string());
ciphers(list: &str) => |s| s.ciphers = Some(list.to_string());
tls13_ciphers(list: &str) => |s| s.tls13_ciphers = Some(list.to_string());
tls_min_version(v: crate::tls::ProtocolVersion) => |s| s.min_version = Some(v);
tls_max_version(v: crate::tls::ProtocolVersion) => |s| s.max_version = Some(v);
tls_verify_callback(cb: crate::tls::VerifyCallback) => |s| s.verify_callback = Some(cb);
}
};
($($(#[$doc:meta])* $name:ident($arg:ident: $ty:ty) => |$s:ident| $apply:expr;)*) => {$(
$(#[$doc])*
///
/// Ignored on wasm, where the browser owns TLS.
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
pub fn $name(mut self, $arg: $ty) -> Self {
#[cfg(not(target_arch = "wasm32"))]
{
let $s = self.tls_settings_mut();
$apply;
}
#[cfg(target_arch = "wasm32")]
let _ = $arg;
self
}
)*};
}
impl Request {
pub fn new(method: impl Into<String>, url: impl Into<String>) -> Self {
Request {
method: method.into(),
url: url.into(),
headers: Vec::new(),
body: Vec::new(),
follow_redirects: false,
decompress: true,
timeout: None,
tls: TlsOptions::default(),
}
}
pub fn get(url: impl Into<String>) -> Self {
Request::new("GET", url)
}
pub fn post(url: impl Into<String>, body: impl Into<Vec<u8>>) -> Self {
Request::new("POST", url).body(body)
}
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<Vec<u8>>) -> Self {
self.body = body.into();
self
}
#[deprecated(since = "0.1.8", note = "renamed to `body`, matching the blocking API")]
pub fn with_body(self, body: impl Into<Vec<u8>>) -> Self {
self.body(body)
}
pub fn follow_redirects(mut self, on: bool) -> Self {
self.follow_redirects = on;
self
}
pub fn decompress(mut self, on: bool) -> Self {
self.decompress = on;
self
}
pub fn timeout(mut self, dur: impl Into<Option<Duration>>) -> Self {
self.timeout = dur.into();
self
}
pub fn tls(mut self, opts: TlsOptions) -> Self {
self.tls = opts;
self
}
#[cfg(not(target_arch = "wasm32"))]
fn tls_settings_mut(&mut self) -> &mut crate::tls::TlsSettings {
&mut self.tls.settings
}
tls_setters!();
}
#[derive(Clone, Default)]
pub struct TlsOptions {
#[cfg(not(target_arch = "wasm32"))]
settings: crate::tls::TlsSettings,
}
impl TlsOptions {
pub fn new() -> Self {
Self::default()
}
#[cfg(not(target_arch = "wasm32"))]
fn tls_settings_mut(&mut self) -> &mut crate::tls::TlsSettings {
&mut self.settings
}
tls_setters!();
}
#[cfg(not(target_arch = "wasm32"))]
impl std::fmt::Debug for TlsOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = &self.settings;
f.debug_struct("TlsOptions")
.field("verify", &s.verify)
.field("ca_bundle", &s.ca_bundle)
.field("ca_path", &s.ca_path)
.field("crl_file", &s.crl_file)
.field("client_cert", &s.client_cert)
.field("client_key", &s.client_key)
.field(
"client_key_pass",
&s.client_key_pass.as_ref().map(|_| "<redacted>"),
)
.field("cert_is_der", &s.cert_is_der)
.field("key_is_der", &s.key_is_der)
.field("pinned_pubkey", &s.pinned_pubkey)
.field("ciphers", &s.ciphers)
.field("tls13_ciphers", &s.tls13_ciphers)
.field("min_version", &s.min_version)
.field("max_version", &s.max_version)
.field("verify_callback", &s.verify_callback)
.finish()
}
}
#[cfg(target_arch = "wasm32")]
impl std::fmt::Debug for TlsOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TlsOptions").finish_non_exhaustive()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WsMessage {
Text(String),
Binary(Vec<u8>),
}
impl WsMessage {
pub fn as_bytes(&self) -> &[u8] {
match self {
WsMessage::Text(s) => s.as_bytes(),
WsMessage::Binary(b) => b,
}
}
pub fn as_text(&self) -> Option<&str> {
match self {
WsMessage::Text(s) => Some(s),
WsMessage::Binary(_) => None,
}
}
pub fn is_text(&self) -> bool {
matches!(self, WsMessage::Text(_))
}
pub fn is_binary(&self) -> bool {
matches!(self, WsMessage::Binary(_))
}
pub fn into_bytes(self) -> Vec<u8> {
match self {
WsMessage::Text(s) => s.into_bytes(),
WsMessage::Binary(b) => b,
}
}
}
impl From<String> for WsMessage {
fn from(s: String) -> Self {
WsMessage::Text(s)
}
}
impl From<&str> for WsMessage {
fn from(s: &str) -> Self {
WsMessage::Text(s.to_string())
}
}
impl From<Vec<u8>> for WsMessage {
fn from(b: Vec<u8>) -> Self {
WsMessage::Binary(b)
}
}
impl From<&[u8]> for WsMessage {
fn from(b: &[u8]) -> Self {
WsMessage::Binary(b.to_vec())
}
}
#[cfg(test)]
mod shared_tests {
use super::*;
fn resp(headers: Vec<(String, String)>, body: Vec<u8>) -> Response {
Response {
status: 200,
reason: "OK".into(),
headers,
body,
}
}
#[test]
fn response_header_lookup_is_case_insensitive() {
let r = resp(
vec![("Content-Type".into(), "text/plain".into())],
Vec::new(),
);
assert_eq!(r.header("content-TYPE"), Some("text/plain"));
assert_eq!(r.header("missing"), None);
assert!(r.is_success());
}
#[test]
fn text_honours_declared_charset() {
let latin1 = resp(
vec![(
"Content-Type".into(),
"text/plain; charset=ISO-8859-1".into(),
)],
vec![0xE9], );
assert_eq!(latin1.text().unwrap(), "é");
let utf8 = resp(
vec![("Content-Type".into(), "text/plain".into())],
vec![0xE9],
);
assert_eq!(utf8.text().unwrap(), "\u{fffd}");
let unknown = resp(
vec![(
"Content-Type".into(),
"text/plain; charset=shift_jis".into(),
)],
vec![0xE9],
);
assert!(matches!(unknown.text(), Err(Error::Decode(_))));
}
#[test]
fn error_for_status_maps_4xx_to_err() {
let mut bad = resp(Vec::new(), Vec::new());
bad.status = 404;
bad.reason = "Not Found".into();
assert!(!bad.is_success());
assert!(matches!(
bad.error_for_status(),
Err(Error::Status { code: 404, .. })
));
}
#[test]
fn request_builder_defaults_and_overrides() {
let req = Request::get("http://x/")
.header("A", "1")
.body(b"hi".to_vec())
.follow_redirects(true)
.decompress(false)
.timeout(Duration::from_secs(5));
assert_eq!(req.method, "GET");
assert_eq!(req.headers, vec![("A".to_string(), "1".to_string())]);
assert_eq!(req.body, b"hi");
assert!(req.follow_redirects);
assert!(!req.decompress);
assert_eq!(req.timeout, Some(Duration::from_secs(5)));
assert_eq!(Request::get("http://x/").timeout(None).timeout, None);
}
#[test]
fn ws_message_conversions() {
assert_eq!(WsMessage::from("hi"), WsMessage::Text("hi".into()));
assert_eq!(WsMessage::from(vec![1u8, 2]), WsMessage::Binary(vec![1, 2]));
assert!(WsMessage::from("hi").is_text());
assert!(WsMessage::from(&b"x"[..]).is_binary());
assert_eq!(WsMessage::Text("hi".into()).into_bytes(), b"hi");
}
}