Add multiple features
This commit is contained in:
11
crates/wtfnet-http/Cargo.toml
Normal file
11
crates/wtfnet-http/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "wtfnet-http"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.11", features = ["rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["net", "time"] }
|
||||
url = "2"
|
||||
182
crates/wtfnet-http/src/lib.rs
Normal file
182
crates/wtfnet-http/src/lib.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::net::lookup_host;
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HttpError {
|
||||
#[error("invalid url: {0}")]
|
||||
Url(String),
|
||||
#[error("request error: {0}")]
|
||||
Request(String),
|
||||
#[error("response error: {0}")]
|
||||
Response(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpTiming {
|
||||
pub total_ms: u128,
|
||||
pub dns_ms: Option<u128>,
|
||||
pub connect_ms: Option<u128>,
|
||||
pub tls_ms: Option<u128>,
|
||||
pub ttfb_ms: Option<u128>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpReport {
|
||||
pub url: String,
|
||||
pub final_url: Option<String>,
|
||||
pub method: String,
|
||||
pub status: Option<u16>,
|
||||
pub http_version: Option<String>,
|
||||
pub resolved_ips: Vec<String>,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Option<String>,
|
||||
pub timing: HttpTiming,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum HttpMethod {
|
||||
Head,
|
||||
Get,
|
||||
}
|
||||
|
||||
impl HttpMethod {
|
||||
fn to_reqwest(self) -> Method {
|
||||
match self {
|
||||
HttpMethod::Head => Method::HEAD,
|
||||
HttpMethod::Get => Method::GET,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpRequestOptions {
|
||||
pub method: HttpMethod,
|
||||
pub timeout_ms: u64,
|
||||
pub follow_redirects: Option<u32>,
|
||||
pub max_body_bytes: usize,
|
||||
pub show_headers: bool,
|
||||
pub show_body: bool,
|
||||
pub http1_only: bool,
|
||||
pub http2_only: bool,
|
||||
}
|
||||
|
||||
pub async fn request(url: &str, opts: HttpRequestOptions) -> Result<HttpReport, HttpError> {
|
||||
let parsed = Url::parse(url).map_err(|err| HttpError::Url(err.to_string()))?;
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| HttpError::Url("missing host".to_string()))?;
|
||||
let port = parsed
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| HttpError::Url("missing port".to_string()))?;
|
||||
|
||||
let mut resolved_ips = Vec::new();
|
||||
let dns_start = Instant::now();
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
resolved_ips.push(ip.to_string());
|
||||
} else {
|
||||
let addrs = lookup_host((host, port))
|
||||
.await
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
for addr in addrs {
|
||||
resolved_ips.push(addr.ip().to_string());
|
||||
}
|
||||
resolved_ips.sort();
|
||||
resolved_ips.dedup();
|
||||
if resolved_ips.is_empty() {
|
||||
return Err(HttpError::Request("no addresses resolved".to_string()));
|
||||
}
|
||||
}
|
||||
let dns_ms = dns_start.elapsed().as_millis();
|
||||
|
||||
let mut builder = Client::builder().timeout(Duration::from_millis(opts.timeout_ms));
|
||||
builder = if let Some(max) = opts.follow_redirects {
|
||||
builder.redirect(reqwest::redirect::Policy::limited(max as usize))
|
||||
} else {
|
||||
builder.redirect(reqwest::redirect::Policy::none())
|
||||
};
|
||||
|
||||
if opts.http1_only {
|
||||
builder = builder.http1_only();
|
||||
}
|
||||
if opts.http2_only {
|
||||
builder = builder.http2_prior_knowledge();
|
||||
}
|
||||
|
||||
if let Some(first) = resolved_ips.first() {
|
||||
if let Ok(ip) = first.parse::<IpAddr>() {
|
||||
let addr = SocketAddr::new(ip, port);
|
||||
builder = builder.resolve(host, addr);
|
||||
}
|
||||
}
|
||||
|
||||
let client = builder.build().map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let start = Instant::now();
|
||||
let response = client
|
||||
.request(opts.method.to_reqwest(), parsed.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let ttfb_ms = start.elapsed().as_millis();
|
||||
|
||||
let status = response.status();
|
||||
let final_url = response.url().to_string();
|
||||
let version = response.version();
|
||||
let headers = if opts.show_headers {
|
||||
response
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
let value = value.to_str().unwrap_or("-").to_string();
|
||||
(name.to_string(), value)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let body = if opts.show_body {
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| HttpError::Response(err.to_string()))?;
|
||||
let sliced = if bytes.len() > opts.max_body_bytes {
|
||||
&bytes[..opts.max_body_bytes]
|
||||
} else {
|
||||
&bytes
|
||||
};
|
||||
Some(String::from_utf8_lossy(sliced).to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let total_ms = start.elapsed().as_millis();
|
||||
|
||||
Ok(HttpReport {
|
||||
url: url.to_string(),
|
||||
final_url: Some(final_url),
|
||||
method: match opts.method {
|
||||
HttpMethod::Head => "HEAD".to_string(),
|
||||
HttpMethod::Get => "GET".to_string(),
|
||||
},
|
||||
status: status_code(status),
|
||||
http_version: Some(format!("{version:?}")),
|
||||
resolved_ips,
|
||||
headers,
|
||||
body,
|
||||
timing: HttpTiming {
|
||||
total_ms,
|
||||
dns_ms: Some(dns_ms),
|
||||
connect_ms: None,
|
||||
tls_ms: None,
|
||||
ttfb_ms: Some(ttfb_ms),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn status_code(status: StatusCode) -> Option<u16> {
|
||||
Some(status.as_u16())
|
||||
}
|
||||
Reference in New Issue
Block a user