use super::error::{Error, Result};
use super::response::Response;
use super::ssrf::{is_private_address, validate_authority, validate_percent_encoding};
pub use crate::request::Method;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Scheme {
Http,
Https,
}
impl std::fmt::Display for Scheme {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Http => f.write_str("http"),
Self::Https => f.write_str("https"),
}
}
}
#[derive(Debug, Clone)]
#[must_use = "request must be sent with .send_with()"]
#[non_exhaustive]
pub struct ClientRequest {
method: Method,
url: String,
headers: Vec<(String, String)>,
body: Option<Vec<u8>>,
timeout_ns: Option<u64>,
deny_private_ips: bool,
}
impl ClientRequest {
#[must_use]
pub fn new(method: Method, url: &str) -> Self {
Self {
method,
url: url.to_string(),
headers: Vec::new(),
body: None,
timeout_ns: None,
deny_private_ips: false,
}
}
#[must_use]
pub fn header(mut self, name: &str, value: &str) -> Self {
assert!(
!value.contains('\r') && !value.contains('\n'),
"Header value must not contain CR or LF characters (header injection)"
);
self.headers.push((name.to_string(), value.to_string()));
self
}
#[must_use]
pub fn with_trace_id(self, trace_id: Option<&str>) -> Self {
use crate::constants::HEADER_TRACE_ID_TITLE;
match trace_id {
Some(id) => self.header(HEADER_TRACE_ID_TITLE, id),
None => self,
}
}
#[must_use]
pub fn body(mut self, body: &[u8]) -> Self {
self.body = Some(body.to_vec());
self
}
#[must_use]
pub fn json(mut self, body: &[u8]) -> Self {
use crate::constants::{HEADER_CONTENT_TYPE_TITLE, MIME_JSON};
self.headers
.push((HEADER_CONTENT_TYPE_TITLE.to_string(), MIME_JSON.to_string()));
self.body = Some(body.to_vec());
self
}
#[must_use]
pub const fn timeout_ms(mut self, ms: u64) -> Self {
self.timeout_ns = Some(ms.saturating_mul(1_000_000));
self
}
#[must_use]
pub const fn timeout_ns(mut self, ns: u64) -> Self {
self.timeout_ns = Some(ns);
self
}
#[must_use]
pub const fn deny_private_ips(mut self) -> Self {
self.deny_private_ips = true;
self
}
#[must_use]
pub const fn denies_private_ips(&self) -> bool {
self.deny_private_ips
}
#[must_use]
pub const fn method(&self) -> Method {
self.method
}
#[inline]
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
#[inline]
#[must_use]
pub fn headers(&self) -> &[(String, String)] {
&self.headers
}
#[inline]
#[must_use]
pub fn body_bytes(&self) -> Option<&[u8]> {
self.body.as_deref()
}
#[must_use]
pub const fn timeout(&self) -> Option<u64> {
self.timeout_ns
}
#[must_use]
pub const fn is_private_ips_denied(&self) -> bool {
self.deny_private_ips
}
pub fn send_with<F>(self, sender: F) -> Result<Response>
where
F: FnOnce(&Self) -> Result<Response>,
{
let _ = self.parse_url()?;
sender(&self)
}
pub fn parse_url(&self) -> Result<(Scheme, String, String)> {
let (scheme, rest) = if self.url.starts_with("https://") {
(Scheme::Https, &self.url[8..])
} else if self.url.starts_with("http://") {
(Scheme::Http, &self.url[7..])
} else {
return Err(Error::InvalidUrl(format!(
"URL must start with `http://` or `https://`: `{}`",
self.url
)));
};
let (authority, path) = rest
.find('/')
.map_or((rest, "/"), |idx| (&rest[..idx], &rest[idx..]));
if authority.is_empty() {
return Err(Error::InvalidUrl("missing host in URL".to_string()));
}
validate_authority(authority)?;
if self.deny_private_ips && is_private_address(authority) {
return Err(Error::InvalidUrl(format!(
"request to private/internal address denied: `{authority}`"
)));
}
validate_percent_encoding(path)?;
Ok((scheme, authority.to_string(), path.to_string()))
}
}
#[must_use]
pub fn get(url: &str) -> ClientRequest {
ClientRequest::new(Method::Get, url)
}
#[must_use]
pub fn post(url: &str) -> ClientRequest {
ClientRequest::new(Method::Post, url)
}
#[must_use]
pub fn put(url: &str) -> ClientRequest {
ClientRequest::new(Method::Put, url)
}
#[must_use]
pub fn delete(url: &str) -> ClientRequest {
ClientRequest::new(Method::Delete, url)
}
#[must_use]
pub fn patch(url: &str) -> ClientRequest {
ClientRequest::new(Method::Patch, url)
}
#[must_use]
pub fn head(url: &str) -> ClientRequest {
ClientRequest::new(Method::Head, url)
}
#[must_use]
pub fn options(url: &str) -> ClientRequest {
ClientRequest::new(Method::Options, url)
}
#[must_use]
pub fn request(method: Method, url: &str) -> ClientRequest {
ClientRequest::new(method, url)
}