use std::future::IntoFuture;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use async_io::Timer;
use serde::Serialize;
use crate::body::Body;
use crate::browser_emulation::BrowserProfile;
use crate::client::RedirectPolicy;
use crate::client::Transport;
use crate::decode::CompressionMode;
use crate::error::{Error, ErrorKind, Result};
use crate::header::HeaderMap;
use crate::middleware::{Middleware, Next};
use crate::progress::ProgressConfig;
use crate::protocol::grpc::GrpcRequestBuilder;
use crate::protocol::websocket::WebSocketBuilder;
use crate::rate_limit::RateLimiter;
use crate::response::Response;
use crate::retry::RetryPolicy;
use crate::tls::{RootStore, TlsBackend, TlsConfig};
use crate::url::Url;
use crate::proxy::Proxy;
pub(crate) type ProgressCallback = Arc<dyn Fn(crate::Progress) + Send + Sync + 'static>;
pub(crate) type ProgressConfigValue = ProgressConfig;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Method {
Get,
Post,
Put,
Patch,
Delete,
Head,
Options,
Trace,
}
impl Method {
pub fn as_str(self) -> &'static str {
match self {
Method::Get => "GET",
Method::Post => "POST",
Method::Put => "PUT",
Method::Patch => "PATCH",
Method::Delete => "DELETE",
Method::Head => "HEAD",
Method::Options => "OPTIONS",
Method::Trace => "TRACE",
}
}
}
pub struct Request {
method: Method,
url: Url,
headers: HeaderMap,
cookies: Vec<(String, String)>,
timeout_config: TimeoutConfig,
protocol_policy: ProtocolPolicy,
retry_policy: RetryPolicy,
prior_knowledge_h2c: bool,
progress_callback: Option<ProgressCallback>,
progress_config: ProgressConfigValue,
h2_keepalive_config: H2KeepAliveConfig,
tls_config: TlsConfig,
proxy: Option<Proxy>,
compression_mode: CompressionMode,
body: Body,
browser_profile: Option<BrowserProfile>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TimeoutConfig {
pub total: Option<Duration>,
pub connect: Option<Duration>,
pub read: Option<Duration>,
pub write: Option<Duration>,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct H2KeepAliveConfig {
pub idle_timeout: Option<Duration>,
pub ack_timeout: Duration,
}
impl Default for H2KeepAliveConfig {
fn default() -> Self {
Self {
idle_timeout: Some(Duration::from_secs(30)),
ack_timeout: Duration::from_secs(10),
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ProtocolPolicy {
#[default]
Auto,
Http1Only,
PreferHttp2,
Http2Only,
PreferHttp3,
Http3Only,
}
impl Request {
pub(crate) fn new(
method: Method,
url: Url,
headers: HeaderMap,
cookies: Vec<(String, String)>,
timeout_config: TimeoutConfig,
protocol_policy: ProtocolPolicy,
retry_policy: RetryPolicy,
prior_knowledge_h2c: bool,
progress_callback: Option<ProgressCallback>,
progress_config: ProgressConfigValue,
h2_keepalive_config: H2KeepAliveConfig,
tls_config: TlsConfig,
proxy: Option<Proxy>,
compression_mode: CompressionMode,
body: Body,
browser_profile: Option<BrowserProfile>,
) -> Self {
Self {
method,
url,
headers,
cookies,
timeout_config,
protocol_policy,
retry_policy,
prior_knowledge_h2c,
progress_callback,
progress_config,
h2_keepalive_config,
tls_config,
proxy,
compression_mode,
body,
browser_profile,
}
}
pub fn method(&self) -> Method {
self.method
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn headers_mut(&mut self) -> &mut HeaderMap {
&mut self.headers
}
pub fn body(&self) -> &Body {
&self.body
}
pub fn body_mut(&mut self) -> &mut Body {
&mut self.body
}
pub fn cookies(&self) -> &[(String, String)] {
&self.cookies
}
pub fn timeout_config(&self) -> TimeoutConfig {
self.timeout_config
}
pub fn protocol_policy(&self) -> ProtocolPolicy {
self.protocol_policy
}
pub fn retry_policy(&self) -> RetryPolicy {
self.retry_policy
}
pub fn prior_knowledge_h2c(&self) -> bool {
self.prior_knowledge_h2c
}
pub fn tls_config(&self) -> &TlsConfig {
&self.tls_config
}
pub fn proxy(&self) -> Option<&Proxy> {
self.proxy.as_ref()
}
#[cfg(feature = "emulation")]
pub fn browser_profile(&self) -> Option<&BrowserProfile> {
self.browser_profile.as_ref()
}
#[cfg(feature = "emulation")]
pub(crate) fn emulation_profile(&self) -> Option<&BrowserProfile> {
self.browser_profile.as_ref()
}
#[cfg(not(feature = "emulation"))]
pub(crate) fn emulation_profile(&self) -> Option<&BrowserProfile> {
None
}
pub(crate) fn proxy_cloned(&self) -> Option<Proxy> {
self.proxy.clone()
}
pub fn compression_mode(&self) -> CompressionMode {
self.compression_mode
}
pub fn progress_callback(&self) -> Option<&ProgressCallback> {
self.progress_callback.as_ref()
}
pub fn progress_config(&self) -> ProgressConfigValue {
self.progress_config
}
pub fn h2_keepalive_config(&self) -> H2KeepAliveConfig {
self.h2_keepalive_config
}
pub fn cookies_mut(&mut self) -> &mut Vec<(String, String)> {
&mut self.cookies
}
pub fn url_mut(&mut self) -> &mut Url {
&mut self.url
}
pub(crate) fn into_parts(
self,
) -> (
Method,
Url,
HeaderMap,
Vec<(String, String)>,
TimeoutConfig,
ProtocolPolicy,
RetryPolicy,
bool,
Option<ProgressCallback>,
ProgressConfigValue,
H2KeepAliveConfig,
TlsConfig,
Option<Proxy>,
CompressionMode,
Body,
Option<BrowserProfile>,
) {
(
self.method,
self.url,
self.headers,
self.cookies,
self.timeout_config,
self.protocol_policy,
self.retry_policy,
self.prior_knowledge_h2c,
self.progress_callback,
self.progress_config,
self.h2_keepalive_config,
self.tls_config,
self.proxy,
self.compression_mode,
self.body,
self.browser_profile,
)
}
}
pub struct RequestBuilder {
transport: Arc<dyn Transport>,
method: Method,
url: String,
base_url: Option<Url>,
headers: HeaderMap,
cookies: Vec<(String, String)>,
query_pairs: Vec<(String, String)>,
redirect_policy: RedirectPolicy,
timeout_config: TimeoutConfig,
protocol_policy: ProtocolPolicy,
retry_policy: RetryPolicy,
prior_knowledge_h2c: bool,
middlewares: Vec<Arc<dyn Middleware>>,
progress_callback: Option<ProgressCallback>,
progress_config: ProgressConfigValue,
h2_keepalive_config: H2KeepAliveConfig,
tls_config: TlsConfig,
proxy: Option<Proxy>,
compression_mode: CompressionMode,
body: Body,
browser_profile: Option<BrowserProfile>,
rate_limiter: Option<RateLimiter>,
}
impl RequestBuilder {
pub(crate) fn new(
transport: Arc<dyn Transport>,
method: Method,
url: impl Into<String>,
) -> Self {
Self {
transport,
method,
url: url.into(),
base_url: None,
headers: HeaderMap::new(),
cookies: Vec::new(),
query_pairs: Vec::new(),
redirect_policy: RedirectPolicy::default(),
timeout_config: TimeoutConfig::default(),
protocol_policy: ProtocolPolicy::default(),
retry_policy: RetryPolicy::default(),
prior_knowledge_h2c: false,
middlewares: Vec::new(),
progress_callback: None,
progress_config: ProgressConfigValue::default(),
h2_keepalive_config: H2KeepAliveConfig::default(),
tls_config: TlsConfig::default(),
proxy: None,
compression_mode: CompressionMode::Auto,
body: Body::empty(),
browser_profile: None,
rate_limiter: None,
}
}
pub(crate) fn with_client_defaults(
mut self,
base_url: Option<Url>,
headers: HeaderMap,
cookies: Vec<(String, String)>,
redirect_policy: RedirectPolicy,
timeout_config: TimeoutConfig,
protocol_policy: ProtocolPolicy,
retry_policy: RetryPolicy,
prior_knowledge_h2c: bool,
middlewares: Vec<Arc<dyn Middleware>>,
progress_callback: Option<ProgressCallback>,
progress_config: ProgressConfigValue,
h2_keepalive_config: H2KeepAliveConfig,
tls_config: TlsConfig,
proxy: Option<Proxy>,
compression_mode: CompressionMode,
browser_profile: Option<BrowserProfile>,
) -> Self {
self.base_url = base_url;
self.headers = headers;
self.cookies = cookies;
self.redirect_policy = redirect_policy;
self.timeout_config = timeout_config;
self.protocol_policy = protocol_policy;
self.retry_policy = retry_policy;
self.prior_knowledge_h2c = prior_knowledge_h2c;
self.middlewares = middlewares;
self.progress_callback = progress_callback;
self.progress_config = progress_config;
self.h2_keepalive_config = h2_keepalive_config;
self.tls_config = tls_config;
if self.proxy.is_none() {
self.proxy = proxy;
}
self.compression_mode = compression_mode;
self.browser_profile = browser_profile;
self
}
pub fn base_url(mut self, url: impl AsRef<str>) -> Result<Self> {
self.base_url = Some(Url::parse(url)?);
Ok(self)
}
pub fn header(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Result<Self> {
self.headers.insert(name, value)?;
Ok(self)
}
pub fn headers<I, K, V>(mut self, headers: I) -> Result<Self>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
for (name, value) in headers {
self.headers.append(name, value)?;
}
Ok(self)
}
pub fn cookie(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Self {
self.cookies
.push((name.as_ref().to_owned(), value.as_ref().to_owned()));
self
}
pub fn cookies<I, K, V>(mut self, cookies: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
self.cookies.extend(
cookies
.into_iter()
.map(|(name, value)| (name.as_ref().to_owned(), value.as_ref().to_owned())),
);
self
}
pub fn query<I, K, V>(mut self, pairs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
self.query_pairs.extend(
pairs
.into_iter()
.map(|(key, value)| (key.as_ref().to_owned(), value.as_ref().to_owned())),
);
self
}
pub fn body(mut self, body: impl Into<Body>) -> Self {
self.body = body.into();
self
}
pub fn body_stream(mut self, stream: crate::BodyStream) -> Self {
self.body = Body::from_stream(stream);
self
}
pub fn text(mut self, body: impl Into<String>) -> Result<Self> {
self.body = body.into().into();
self.headers
.insert("content-type", "text/plain; charset=utf-8")?;
Ok(self)
}
pub fn json<T: Serialize>(mut self, value: &T) -> Result<Self> {
let body = serde_json::to_vec(value).map_err(|err| {
Error::with_source(ErrorKind::Decode, "failed to encode request json", err)
})?;
self.body = body.into();
self.headers.insert("content-type", "application/json")?;
Ok(self)
}
pub fn form<I, K, V>(mut self, pairs: I) -> Result<Self>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
let encoded = pairs
.into_iter()
.map(|(key, value)| {
format!(
"{}={}",
percent_encode(key.as_ref()),
percent_encode(value.as_ref())
)
})
.collect::<Vec<_>>()
.join("&");
self.body = encoded.into();
self.headers
.insert("content-type", "application/x-www-form-urlencoded")?;
Ok(self)
}
pub fn multipart_form(
mut self,
parts: impl IntoIterator<Item = MultipartPart>,
) -> Result<Self> {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let boundary = format!("----UgiBoundary{:08X}{:016X}", ts, seq);
let mut body: Vec<u8> = Vec::new();
for part in parts {
body.extend_from_slice(b"--");
body.extend_from_slice(boundary.as_bytes());
body.extend_from_slice(b"\r\n");
let mut disposition = format!("Content-Disposition: form-data; name=\"{}\"", part.name);
if let Some(ref fname) = part.filename {
disposition.push_str(&format!("; filename=\"{}\"", fname));
}
body.extend_from_slice(disposition.as_bytes());
body.extend_from_slice(b"\r\n");
if let Some(ref ct) = part.content_type {
body.extend_from_slice(b"Content-Type: ");
body.extend_from_slice(ct.as_bytes());
body.extend_from_slice(b"\r\n");
} else if part.filename.is_some() {
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n");
}
body.extend_from_slice(b"\r\n");
body.extend_from_slice(&part.data);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(b"--");
body.extend_from_slice(boundary.as_bytes());
body.extend_from_slice(b"--\r\n");
self.body = body.into();
self.headers.insert(
"content-type",
format!("multipart/form-data; boundary={}", boundary),
)?;
Ok(self)
}
pub fn bearer_auth(self, token: impl AsRef<str>) -> Result<Self> {
self.header("authorization", format!("Bearer {}", token.as_ref()))
}
pub fn basic_auth(self, username: impl AsRef<str>, password: impl AsRef<str>) -> Result<Self> {
let raw = format!("{}:{}", username.as_ref(), password.as_ref());
let encoded = encode_base64(raw.as_bytes());
self.header("authorization", format!("Basic {encoded}"))
}
pub fn last_event_id(self, id: impl AsRef<str>) -> Result<Self> {
self.header("last-event-id", id)
}
pub fn h2_keepalive(mut self, idle_timeout: Duration, ack_timeout: Duration) -> Self {
self.h2_keepalive_config = H2KeepAliveConfig {
idle_timeout: Some(idle_timeout),
ack_timeout,
};
self
}
pub fn disable_h2_keepalive(mut self) -> Self {
self.h2_keepalive_config.idle_timeout = None;
self
}
pub fn redirect(mut self, policy: RedirectPolicy) -> Self {
self.redirect_policy = policy;
self
}
pub fn protocol_policy(mut self, policy: ProtocolPolicy) -> Self {
self.protocol_policy = policy;
self
}
pub fn prefer_http3(mut self) -> Self {
self.protocol_policy = ProtocolPolicy::PreferHttp3;
self
}
pub fn prefer_http2(mut self) -> Self {
self.protocol_policy = ProtocolPolicy::PreferHttp2;
self
}
pub fn http1_only(mut self) -> Self {
self.protocol_policy = ProtocolPolicy::Http1Only;
self
}
pub fn http2_only(mut self) -> Self {
self.protocol_policy = ProtocolPolicy::Http2Only;
self
}
pub fn http3_only(mut self) -> Self {
self.protocol_policy = ProtocolPolicy::Http3Only;
self
}
pub fn prior_knowledge_h2c(mut self, enabled: bool) -> Self {
self.prior_knowledge_h2c = enabled;
self
}
pub fn tls_config(mut self, tls_config: TlsConfig) -> Self {
self.tls_config = tls_config;
self
}
pub fn root_store(mut self, root_store: RootStore) -> Self {
self.tls_config = self.tls_config.clone().root_store(root_store);
self
}
pub fn pin_certificate(
mut self,
domain: impl AsRef<str>,
fingerprint: impl AsRef<str>,
) -> Result<Self> {
self.tls_config = self
.tls_config
.clone()
.pin_certificate(domain, fingerprint)?;
Ok(self)
}
pub fn proxy(mut self, proxy: impl Into<Proxy>) -> Self {
self.proxy = Some(proxy.into());
self
}
pub fn compression_mode(mut self, compression_mode: CompressionMode) -> Self {
self.compression_mode = compression_mode;
self
}
pub fn danger_accept_invalid_certs(mut self, enabled: bool) -> Self {
self.tls_config = self.tls_config.clone().danger_accept_invalid_certs(enabled);
self
}
pub fn tls_backend(mut self, backend: TlsBackend) -> Self {
self.tls_config = self.tls_config.clone().backend(backend);
self
}
pub fn alpn_protocols<I, S>(mut self, protocols: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.tls_config = self.tls_config.clone().alpn_protocols(protocols);
self
}
pub fn disable_alpn(mut self) -> Self {
self.tls_config = self.tls_config.clone().disable_alpn();
self
}
pub fn on_progress<F>(mut self, callback: F, config: ProgressConfig) -> Self
where
F: Fn(crate::Progress) + Send + Sync + 'static,
{
self.progress_callback = Some(Arc::new(callback));
self.progress_config = config;
self
}
pub fn retry(mut self, policy: RetryPolicy) -> Self {
self.retry_policy = policy;
self
}
pub fn rate_limit(mut self, bytes_per_sec: u64) -> Self {
self.rate_limiter = Some(RateLimiter::new(bytes_per_sec));
self
}
pub fn rate_limit_shared(mut self, limiter: RateLimiter) -> Self {
self.rate_limiter = Some(limiter);
self
}
pub fn timeout(mut self, duration: Duration) -> Self {
self.timeout_config.total = Some(duration);
self
}
pub fn connect_timeout(mut self, duration: Duration) -> Self {
self.timeout_config.connect = Some(duration);
self
}
pub fn read_timeout(mut self, duration: Duration) -> Self {
self.timeout_config.read = Some(duration);
self
}
pub fn write_timeout(mut self, duration: Duration) -> Self {
self.timeout_config.write = Some(duration);
self
}
#[cfg(feature = "emulation")]
pub fn browser_profile(self, profile: BrowserProfile) -> Self {
self.emulation(profile)
}
#[cfg(feature = "emulation")]
pub fn emulation_profile(self, profile: BrowserProfile) -> Self {
self.emulation(profile)
}
#[cfg(feature = "emulation")]
pub fn emulation<T>(mut self, emulation: T) -> Self
where
T: Into<BrowserProfile>,
{
self.browser_profile = Some(emulation.into());
#[cfg(feature = "h2")]
if self.protocol_policy == ProtocolPolicy::Auto {
self.protocol_policy = ProtocolPolicy::PreferHttp2;
}
self
}
pub(crate) fn build_request(self) -> Result<Request> {
let mut builder = self;
builder.resolve_browser_profile()?;
let url = match builder.base_url {
Some(base_url) if !builder.url.contains("://") => base_url.join(&builder.url)?,
Some(_) | None => Url::parse(&builder.url)?,
};
let url = if builder.query_pairs.is_empty() {
url
} else {
url.with_query_pairs(
builder
.query_pairs
.iter()
.map(|(k, v)| (k.as_str(), v.as_str())),
)
};
Ok(Request::new(
builder.method,
url,
builder.headers,
builder.cookies,
builder.timeout_config,
builder.protocol_policy,
builder.retry_policy,
builder.prior_knowledge_h2c,
builder.progress_callback,
builder.progress_config,
builder.h2_keepalive_config,
builder.tls_config,
builder.proxy,
builder.compression_mode,
builder.body,
builder.browser_profile,
))
}
pub(crate) fn redirect_policy(&self) -> RedirectPolicy {
self.redirect_policy
}
#[cfg(feature = "emulation")]
fn resolve_browser_profile(&mut self) -> Result<()> {
if let Some(profile) = &self.browser_profile {
for (name, value) in profile.default_headers() {
if self.headers.get(name).is_none() {
self.headers.insert(name, value)?;
}
}
self.tls_config = self.tls_config.clone().ensure_emulation_backend()?;
if let Some(fingerprint) = profile.tls_fingerprint() {
if let Some(min) = &fingerprint.min_tls_version {
if self.tls_config.min_tls_version.is_none() {
self.tls_config = self.tls_config.clone().min_tls_version(min);
}
}
if let Some(max) = &fingerprint.max_tls_version {
if self.tls_config.max_tls_version.is_none() {
self.tls_config = self.tls_config.clone().max_tls_version(max);
}
}
}
if self.tls_config.boring_tls_fingerprint().is_none() {
if let Some(fingerprint) = profile.boring_tls_fingerprint() {
self.tls_config = self
.tls_config
.clone()
.with_boring_tls_fingerprint(fingerprint.clone());
}
}
}
Ok(())
}
#[cfg(not(feature = "emulation"))]
fn resolve_browser_profile(&mut self) -> Result<()> {
Ok(())
}
}
impl IntoFuture for RequestBuilder {
type Output = Result<Response>;
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send + 'static>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
let transport = Arc::clone(&self.transport);
let redirect_policy = self.redirect_policy();
let total_timeout = self.timeout_config.total;
let middlewares = self.middlewares.clone();
let request = self.build_request()?;
match total_timeout {
Some(duration) => {
futures_lite::future::or(
Next::new(&middlewares, Arc::clone(&transport), redirect_policy)
.run(request),
Box::pin(async move {
Timer::after(duration).await;
Err(Error::new(ErrorKind::Timeout, "request timed out"))
}),
)
.await
}
None => {
Next::new(&middlewares, transport, redirect_policy)
.run(request)
.await
}
}
})
}
}
pub fn request(url: impl AsRef<str>, method: Method) -> RequestBuilder {
crate::client::default_client().request(method, url.as_ref())
}
pub fn ws(url: impl AsRef<str>) -> WebSocketBuilder {
crate::client::default_client().ws(url.as_ref())
}
pub fn grpc(url: impl AsRef<str>) -> GrpcRequestBuilder {
crate::client::default_client().grpc(url.as_ref())
}
pub fn get(url: impl AsRef<str>) -> RequestBuilder {
request(url, Method::Get)
}
pub fn post(url: impl AsRef<str>) -> RequestBuilder {
request(url, Method::Post)
}
pub fn put(url: impl AsRef<str>) -> RequestBuilder {
request(url, Method::Put)
}
pub fn patch(url: impl AsRef<str>) -> RequestBuilder {
request(url, Method::Patch)
}
pub fn delete(url: impl AsRef<str>) -> RequestBuilder {
request(url, Method::Delete)
}
pub fn head(url: impl AsRef<str>) -> RequestBuilder {
request(url, Method::Head)
}
pub fn options(url: impl AsRef<str>) -> RequestBuilder {
request(url, Method::Options)
}
pub fn trace(url: impl AsRef<str>) -> RequestBuilder {
request(url, Method::Trace)
}
use std::future::Future;
#[derive(Clone, Debug)]
pub struct MultipartPart {
pub name: String,
pub filename: Option<String>,
pub content_type: Option<String>,
pub data: Vec<u8>,
}
impl MultipartPart {
pub fn text(name: impl Into<String>, value: impl Into<String>) -> Self {
MultipartPart {
name: name.into(),
filename: None,
content_type: None,
data: value.into().into_bytes(),
}
}
pub fn file(
name: impl Into<String>,
filename: impl Into<String>,
content_type: impl Into<String>,
data: impl Into<Vec<u8>>,
) -> Self {
MultipartPart {
name: name.into(),
filename: Some(filename.into()),
content_type: Some(content_type.into()),
data: data.into(),
}
}
}
fn percent_encode(input: &str) -> String {
let mut encoded = String::new();
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char)
}
b' ' => encoded.push_str("%20"),
_ => encoded.push_str(&format!("%{:02X}", byte)),
}
}
encoded
}
fn encode_base64(bytes: &[u8]) -> String {
crate::util::encode_base64(bytes)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{H2KeepAliveConfig, Method, MultipartPart, ProtocolPolicy, RequestBuilder};
use crate::ProgressConfig;
use crate::RedirectPolicy;
use crate::RetryPolicy;
use crate::TimeoutConfig;
use crate::TlsConfig;
use crate::client::shared_http1_transport;
#[cfg(feature = "emulation")]
use crate::{BrowserProfile, Emulation};
#[test]
fn builder_uses_client_base_url_for_relative_paths() {
let request = RequestBuilder::new(shared_http1_transport(), Method::Get, "/users")
.base_url("https://api.example.com")
.unwrap()
.build_request()
.unwrap();
assert_eq!(request.url().as_str(), "https://api.example.com/users");
}
#[test]
fn request_builder_base_url_overrides_client_default() {
let request = RequestBuilder::new(shared_http1_transport(), Method::Get, "/users")
.with_client_defaults(
Some(crate::Url::parse("https://prod.example.com").unwrap()),
crate::HeaderMap::new(),
Vec::new(),
RedirectPolicy::default(),
TimeoutConfig::default(),
ProtocolPolicy::default(),
RetryPolicy::default(),
false,
Vec::new(),
None,
crate::ProgressConfig::default(),
H2KeepAliveConfig::default(),
TlsConfig::default(),
None,
crate::CompressionMode::Auto,
None,
)
.base_url("https://staging.example.com")
.unwrap()
.build_request()
.unwrap();
assert_eq!(request.url().as_str(), "https://staging.example.com/users");
}
#[test]
fn request_headers_preserve_multi_values() {
let request = RequestBuilder::new(
shared_http1_transport(),
Method::Get,
"https://api.example.com",
)
.headers([("x-tag", "a"), ("x-tag", "b")])
.unwrap()
.build_request()
.unwrap();
assert_eq!(request.headers().get_all("x-tag"), vec!["a", "b"]);
}
#[test]
fn request_query_appends_pairs() {
let request = RequestBuilder::new(
shared_http1_transport(),
Method::Get,
"http://localhost/users",
)
.query([("q", "hello world"), ("page", "1")])
.build_request()
.unwrap();
assert_eq!(
request.url().as_str(),
"http://localhost/users?q=hello%20world&page=1"
);
}
#[test]
fn request_cookie_preserves_multi_values() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "http://localhost")
.cookie("session", "a")
.cookie("session", "b")
.build_request()
.unwrap();
assert_eq!(
request.cookies(),
&[
("session".to_owned(), "a".to_owned()),
("session".to_owned(), "b".to_owned())
]
);
}
#[test]
fn request_builder_sets_protocol_policy() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "https://example.com")
.protocol_policy(ProtocolPolicy::Http3Only)
.build_request()
.unwrap();
assert_eq!(request.protocol_policy(), ProtocolPolicy::Http3Only);
}
#[test]
fn request_builder_http3_only_sets_protocol_policy() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "https://example.com")
.http3_only()
.build_request()
.unwrap();
assert_eq!(request.protocol_policy(), ProtocolPolicy::Http3Only);
}
#[test]
fn request_builder_http2_only_sets_protocol_policy() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "https://example.com")
.http2_only()
.build_request()
.unwrap();
assert_eq!(request.protocol_policy(), ProtocolPolicy::Http2Only);
}
#[cfg(feature = "emulation")]
#[test]
fn request_builder_emulation_defaults_to_prefer_http2() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "https://example.com")
.emulation(Emulation::Safari18_4)
.build_request()
.unwrap();
#[cfg(feature = "h2")]
assert_eq!(request.protocol_policy(), ProtocolPolicy::PreferHttp2);
}
#[cfg(feature = "emulation")]
#[test]
fn request_builder_emulation_accepts_custom_profile() {
let profile = BrowserProfile::builder()
.default_header("user-agent", "CustomAgent/1.0")
.unwrap()
.build();
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "https://example.com")
.emulation(profile)
.build_request()
.unwrap();
assert_eq!(request.headers().get("user-agent"), Some("CustomAgent/1.0"));
#[cfg(feature = "h2")]
assert_eq!(request.protocol_policy(), ProtocolPolicy::PreferHttp2);
}
#[cfg(feature = "emulation")]
#[test]
fn request_builder_emulation_does_not_override_user_headers() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "https://example.com")
.header("user-agent", "MyAgent/1.0")
.unwrap()
.emulation(Emulation::Safari18_4)
.build_request()
.unwrap();
assert_eq!(request.headers().get("user-agent"), Some("MyAgent/1.0"));
}
#[test]
fn request_builder_json_sets_content_type() {
let request = RequestBuilder::new(
shared_http1_transport(),
Method::Post,
"https://example.com",
)
.json(&serde_json::json!({ "name": "alice" }))
.unwrap()
.build_request()
.unwrap();
assert_eq!(
request.headers().get("content-type"),
Some("application/json")
);
assert_eq!(
request.headers().get_all("content-type"),
vec!["application/json"]
);
}
#[test]
fn request_builder_json_overrides_existing_content_type() {
let request = RequestBuilder::new(
shared_http1_transport(),
Method::Post,
"https://example.com",
)
.header("content-type", "text/plain")
.unwrap()
.json(&serde_json::json!({ "name": "alice" }))
.unwrap()
.build_request()
.unwrap();
assert_eq!(
request.headers().get("content-type"),
Some("application/json")
);
assert_eq!(
request.headers().get_all("content-type"),
vec!["application/json"]
);
}
#[test]
fn request_builder_can_override_compression_mode() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "https://example.com")
.compression_mode(crate::CompressionMode::Manual)
.build_request()
.unwrap();
assert_eq!(request.compression_mode(), crate::CompressionMode::Manual);
}
#[test]
fn request_builder_can_enable_prior_knowledge_h2c() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "http://example.com")
.prior_knowledge_h2c(true)
.build_request()
.unwrap();
assert!(request.prior_knowledge_h2c());
}
#[test]
fn request_builder_can_disable_alpn() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "https://example.com")
.disable_alpn()
.build_request()
.unwrap();
assert!(
request
.tls_config()
.effective_alpn_protocols(ProtocolPolicy::Auto)
.is_empty()
);
}
#[test]
fn request_builder_can_set_progress_callback() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "https://example.com")
.on_progress(|_| {}, ProgressConfig::default())
.build_request()
.unwrap();
assert!(request.progress_callback().is_some());
}
#[test]
fn request_builder_can_set_retry_policy() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "https://example.com")
.retry(RetryPolicy::Limit(2))
.build_request()
.unwrap();
assert_eq!(request.retry_policy(), RetryPolicy::Limit(2));
}
#[test]
fn request_builder_can_set_last_event_id_header() {
let request = RequestBuilder::new(
shared_http1_transport(),
Method::Get,
"https://example.com/sse",
)
.last_event_id("evt-42")
.unwrap()
.build_request()
.unwrap();
assert_eq!(request.headers().get("last-event-id"), Some("evt-42"));
}
#[test]
fn request_builder_can_override_h2_keepalive() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "https://example.com")
.h2_keepalive(Duration::from_millis(25), Duration::from_millis(40))
.build_request()
.unwrap();
assert_eq!(
request.h2_keepalive_config(),
H2KeepAliveConfig {
idle_timeout: Some(Duration::from_millis(25)),
ack_timeout: Duration::from_millis(40),
}
);
}
#[test]
fn request_builder_can_disable_h2_keepalive() {
let request =
RequestBuilder::new(shared_http1_transport(), Method::Get, "https://example.com")
.disable_h2_keepalive()
.build_request()
.unwrap();
assert_eq!(request.h2_keepalive_config().idle_timeout, None);
}
#[test]
fn multipart_form_produces_valid_rfc2046_body() {
use crate::client::shared_http1_transport;
let mut request = RequestBuilder::new(
shared_http1_transport(),
Method::Post,
"https://example.com/upload",
)
.multipart_form([
MultipartPart::text("username", "alice"),
MultipartPart::file("avatar", "photo.jpg", "image/jpeg", b"JFIF".to_vec()),
])
.unwrap()
.build_request()
.unwrap();
let ct = request.headers().get("content-type").unwrap().to_owned();
assert!(
ct.starts_with("multipart/form-data; boundary=----UgiBoundary"),
"got: {ct}"
);
let boundary = ct.splitn(2, "boundary=").nth(1).unwrap().to_owned();
let body_bytes = request.body_mut().take_bytes().unwrap();
let body = std::str::from_utf8(&body_bytes).unwrap();
assert!(body.contains(&format!("--{boundary}\r\n")));
assert!(body.contains("Content-Disposition: form-data; name=\"username\""));
assert!(body.contains("alice"));
assert!(
body.contains(
"Content-Disposition: form-data; name=\"avatar\"; filename=\"photo.jpg\""
)
);
assert!(body.contains("Content-Type: image/jpeg"));
assert!(body.contains("JFIF"));
assert!(body.contains(&format!("--{boundary}--\r\n")));
}
#[test]
fn multipart_form_file_without_explicit_content_type_defaults_to_octet_stream() {
use crate::client::shared_http1_transport;
let part = MultipartPart {
name: "data".into(),
filename: Some("blob.bin".into()),
content_type: None,
data: vec![0x00, 0xFF],
};
let mut request = RequestBuilder::new(
shared_http1_transport(),
Method::Post,
"https://example.com",
)
.multipart_form([part])
.unwrap()
.build_request()
.unwrap();
let body_bytes = request.body_mut().take_bytes().unwrap();
let needle = b"Content-Type: application/octet-stream";
assert!(
body_bytes.windows(needle.len()).any(|w| w == needle),
"expected octet-stream content-type in body"
);
}
}