#![cfg(feature = "ureq")]
use std::time::Duration;
use ureq::tls::TlsProvider;
use ureq::{Agent, Body, http::Response};
use super::{HeaderMap, HttpClient, HttpResponse};
use crate::{Error, Result, errors::status_to_error};
#[cfg(any(not(feature = "reqwest"), test))]
type UreqRootCerts = std::sync::Arc<Vec<ureq::tls::Certificate<'static>>>;
enum UreqInner {
Default,
Injected(Agent),
#[cfg(any(not(feature = "reqwest"), test))]
Certs(UreqRootCerts),
}
pub struct UreqClient(UreqInner);
impl Default for UreqClient {
fn default() -> Self {
Self(UreqInner::Default)
}
}
impl From<Agent> for UreqClient {
fn from(agent: Agent) -> Self {
Self(UreqInner::Injected(agent))
}
}
fn build_call_agent(
timeout: Option<Duration>,
#[cfg(any(not(feature = "reqwest"), test))] root_certs: Option<UreqRootCerts>,
) -> Agent {
use ureq::tls::TlsConfig;
#[cfg(feature = "rustls")]
let provider = TlsProvider::Rustls;
#[cfg(not(feature = "rustls"))]
let provider = TlsProvider::NativeTls;
#[cfg(any(not(feature = "reqwest"), test))]
let mut tls = TlsConfig::builder().provider(provider);
#[cfg(all(feature = "reqwest", not(test)))]
let tls = TlsConfig::builder().provider(provider);
#[cfg(any(not(feature = "reqwest"), test))]
if let Some(certs) = root_certs {
tls = tls.root_certs(ureq::tls::RootCerts::Specific(certs));
}
let config = Agent::config_builder()
.tls_config(tls.build())
.timeout_global(timeout)
.proxy(ureq::Proxy::try_from_env())
.http_status_as_error(false)
.build();
Agent::new_with_config(config)
}
#[cfg(any(not(feature = "reqwest"), test))]
impl UreqClient {
pub(crate) fn build_with_certs(
certs: &[crate::tls::Certificate],
) -> std::result::Result<
std::sync::Arc<dyn crate::http_client::HttpClient>,
crate::http_client::ClientBuildError,
> {
let mut ureq_certs = Vec::with_capacity(certs.len());
for cert in certs {
let c = if cert.is_pem() {
ureq::tls::Certificate::from_pem(cert.bytes())
.map_err(|e| format!("invalid PEM certificate: {e}"))?
} else {
ureq::tls::Certificate::from_der(cert.bytes())
};
ureq_certs.push(c.to_owned());
}
Ok(std::sync::Arc::new(UreqClient(UreqInner::Certs(
std::sync::Arc::new(ureq_certs),
))))
}
}
impl HttpClient for UreqClient {
fn get(
&self,
url: &str,
headers: &HeaderMap,
timeout: Option<Duration>,
) -> Result<Box<dyn HttpResponse>> {
let built_agent;
let (agent, is_injected): (&Agent, bool) = match &self.0 {
UreqInner::Injected(agent) => (agent, true),
UreqInner::Default => {
built_agent = build_call_agent(
timeout,
#[cfg(any(not(feature = "reqwest"), test))]
None,
);
(&built_agent, false)
}
#[cfg(any(not(feature = "reqwest"), test))]
UreqInner::Certs(certs) => {
built_agent = build_call_agent(timeout, Some(certs.clone()));
(&built_agent, false)
}
};
let mut req = agent.get(url);
for (key, value) in headers.iter() {
req = req.header(key, value);
}
let res = match req.call() {
Ok(r) => r,
Err(ureq::Error::StatusCode(code)) if is_injected => {
return Err(status_to_error(code, url));
}
Err(e) => return Err(Error::Transport(Box::new(e))),
};
if !res.status().is_success() {
return Err(status_to_error(res.status().as_u16(), url));
}
Ok(Box::new(res))
}
}
impl HttpResponse for Response<Body> {
fn headers(&self) -> &HeaderMap<http::HeaderValue> {
Response::headers(self)
}
fn body(self: Box<Self>) -> Box<dyn std::io::Read> {
Box::new((*self).into_body().into_reader())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read as _, Write as _};
use std::net::TcpListener;
fn stub(status: &'static str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}/", listener.local_addr().unwrap());
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = "err";
let out = format!(
"HTTP/1.1 {}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
status,
body.len(),
body
);
let _ = stream.write_all(out.as_bytes());
let _ = stream.flush();
}
});
base
}
fn get_default(status: &'static str) -> Error {
let client = UreqClient::default();
let base = stub(status);
client
.get(&base, &HeaderMap::new(), None)
.err()
.expect("non-2xx must be an Err")
}
fn get_injected(status: &'static str) -> Error {
let agent = ureq::Agent::new_with_config(ureq::Agent::config_builder().build());
let client = UreqClient::from(agent);
let base = stub(status);
client
.get(&base, &HeaderMap::new(), None)
.err()
.expect("non-2xx must be an Err")
}
fn get_injected_no_status_error(status: &'static str) -> Error {
let agent = ureq::Agent::new_with_config(
ureq::Agent::config_builder()
.http_status_as_error(false)
.build(),
);
let client = UreqClient::from(agent);
let base = stub(status);
client
.get(&base, &HeaderMap::new(), None)
.err()
.expect("non-2xx must be an Err")
}
#[test]
fn build_with_certs_rejects_non_pem() {
let res = UreqClient::build_with_certs(&[crate::tls::Certificate::from_pem(
b"not a pem certificate".to_vec(),
)]);
assert!(
res.is_err(),
"bytes with no PEM certificate must be rejected at build time, got Ok"
);
}
#[test]
fn injected_agent_no_status_error_falls_through_to_is_success_check() {
let err = get_injected_no_status_error("404 Not Found");
assert!(
matches!(err, Error::NotFound { .. }),
"injected no-status-error 404 must map to Error::NotFound via is_success(), got {:?}",
err
);
assert_eq!(err.http_status(), Some(404));
let err = get_injected_no_status_error("500 Internal Server Error");
assert!(
matches!(err, Error::HttpStatus { status: 500, .. }),
"injected no-status-error 500 must map to Error::HttpStatus(500), got {:?}",
err
);
assert_eq!(err.http_status(), Some(500));
}
#[test]
fn injected_agent_status_code_arm_maps_404_to_not_found() {
let err = get_injected("404 Not Found");
assert!(
matches!(err, Error::NotFound { .. }),
"injected-agent 404 must map to Error::NotFound, got {:?}",
err
);
assert_eq!(err.http_status(), Some(404));
}
#[test]
fn injected_agent_status_code_arm_maps_401_and_403_to_unauthorized() {
let err = get_injected("401 Unauthorized");
assert!(
matches!(err, Error::Unauthorized { status: 401, .. }),
"injected-agent 401 must map to Error::Unauthorized(401), got {:?}",
err
);
let err = get_injected("403 Forbidden");
assert!(
matches!(err, Error::Unauthorized { status: 403, .. }),
"injected-agent 403 must map to Error::Unauthorized(403), got {:?}",
err
);
}
#[test]
fn injected_agent_status_code_arm_maps_500_and_400_to_http_status() {
let err = get_injected("500 Internal Server Error");
assert!(
matches!(err, Error::HttpStatus { status: 500, .. }),
"injected-agent 500 must map to Error::HttpStatus(500), got {:?}",
err
);
let err = get_injected("400 Bad Request");
assert!(
matches!(err, Error::HttpStatus { status: 400, .. }),
"injected-agent 400 must map to Error::HttpStatus(400), got {:?}",
err
);
}
#[test]
fn default_agent_path_maps_statuses_identically_to_injected() {
assert!(matches!(
get_default("404 Not Found"),
Error::NotFound { .. }
));
assert!(matches!(
get_default("401 Unauthorized"),
Error::Unauthorized { status: 401, .. }
));
assert!(matches!(
get_default("403 Forbidden"),
Error::Unauthorized { status: 403, .. }
));
assert!(matches!(
get_default("503 Service Unavailable"),
Error::HttpStatus { status: 503, .. }
));
}
#[test]
fn transport_failure_maps_to_transport_variant() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let url = format!("http://{}/", addr);
let client = UreqClient::default();
let err = client
.get(&url, &HeaderMap::new(), None)
.err()
.expect("connection refused must be an Err");
assert!(
matches!(err, Error::Transport(_)),
"a failed (uncompleted) request must map to Error::Transport, got {:?}",
err
);
assert_eq!(err.http_status(), None, "Transport has no HTTP status code");
}
}