use std::fmt;
use std::io::Read;
use std::time::Duration;
use iri_string::types::UriAbsoluteStr;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Options {
pub timeout: Duration,
pub user_agent: String,
pub headers: Vec<(String, String)>,
}
impl Default for Options {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
user_agent: format!("lgwks-std/{}", env!("CARGO_PKG_VERSION")),
headers: Vec::new(),
}
}
}
impl Options {
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn idempotency_key(mut self, key: &str) -> Self {
self.headers
.push(("Idempotency-Key".to_owned(), key.to_owned()));
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Response {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl Response {
pub fn text(&self) -> Result<&str, Error> {
std::str::from_utf8(&self.body).map_err(|utf8_error| {
Error::Transport(format!("response body is not valid UTF-8: {utf8_error}"))
})
}
#[must_use]
pub fn text_lossy(&self) -> std::borrow::Cow<'_, str> {
String::from_utf8_lossy(&self.body)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
InvalidUrl,
Timeout,
Transport(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::InvalidUrl => {
write!(f, "invalid http(s) URL (absolute http(s) URI required)")
}
Self::Timeout => write!(f, "request timed out"),
Self::Transport(ref cause) => write!(f, "transport failure: {cause}"),
}
}
}
impl std::error::Error for Error {}
pub fn validate_url(url: &str) -> Result<(), Error> {
UriAbsoluteStr::new(url).map_err(|_malformed| Error::InvalidUrl)?;
let Some(scheme) = url.split_once(':').map(|(scheme, _)| scheme) else {
return Err(Error::InvalidUrl);
};
if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
return Err(Error::InvalidUrl);
}
let Some(rest) = scheme
.len()
.checked_add(1)
.and_then(|after| url.get(after..))
else {
return Err(Error::InvalidUrl);
};
let Some(authority) = rest.strip_prefix("//") else {
return Err(Error::InvalidUrl);
};
let host_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
if authority[..host_end].is_empty() {
return Err(Error::InvalidUrl);
}
Ok(())
}
fn agent(options: &Options) -> ureq::Agent {
let config = ureq::Agent::config_builder()
.timeout_global(Some(options.timeout))
.http_status_as_error(false)
.user_agent(&options.user_agent)
.build();
ureq::Agent::new_with_config(config)
}
fn response_of(mut response: ureq::http::Response<ureq::Body>) -> Result<Response, Error> {
let status = response.status().as_u16();
let headers = response
.headers()
.iter()
.map(|(name, value)| {
(
name.to_string(),
String::from_utf8_lossy(value.as_bytes()).into_owned(),
)
})
.collect();
let mut body = Vec::new();
response
.body_mut()
.as_reader()
.read_to_end(&mut body)
.map_err(|read_error| Error::Transport(read_error.to_string()))?;
Ok(Response {
status,
headers,
body,
})
}
fn map_error(error: ureq::Error) -> Error {
match error {
ureq::Error::Timeout(_) => Error::Timeout,
ureq::Error::BadUri(_) => Error::InvalidUrl,
other => Error::Transport(other.to_string()),
}
}
pub fn get_response(url: &str) -> Result<Response, Error> {
get_with(url, &Options::default())
}
pub fn get_with(url: &str, options: &Options) -> Result<Response, Error> {
validate_url(url)?;
let mut call = agent(options).get(url);
for header in &options.headers {
call = call.header(header.0.as_str(), header.1.as_str());
}
call.call().map_err(map_error).and_then(response_of)
}
pub fn post(url: &str, content_type: &str, body: &[u8]) -> Result<Response, Error> {
post_with(url, content_type, body, &Options::default())
}
pub fn post_with(
url: &str,
content_type: &str,
body: &[u8],
options: &Options,
) -> Result<Response, Error> {
validate_url(url)?;
let mut request = agent(options)
.post(url)
.header("Content-Type", content_type);
for header in &options.headers {
request = request.header(header.0.as_str(), header.1.as_str());
}
let response = request.send(body).map_err(map_error)?;
response_of(response)
}
#[cfg(test)]
#[expect(
clippy::disallowed_methods,
reason = "loopback test servers need a real thread, and holding one open past a read timeout needs a real sleep"
)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
const ECHO: &str = "echo-body-123";
fn serve(
replies: Vec<(&'static str, &'static str)>,
) -> std::io::Result<(u16, thread::JoinHandle<std::io::Result<()>>)> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let handle = thread::spawn(move || -> std::io::Result<()> {
for (status, body) in replies {
let (mut stream, _) = listener.accept()?;
let mut request = vec![0u8; 4096];
let mut head = Vec::new();
loop {
let n = stream.read(&mut request)?;
head.extend_from_slice(&request[..n]);
if head.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let header_end = head
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|position| position.saturating_add(4))
.unwrap_or(head.len());
let text = String::from_utf8_lossy(&head[..header_end]);
let content_length = text
.lines()
.filter_map(|line| line.split_once(':'))
.find(|entry| entry.0.eq_ignore_ascii_case("content-length"))
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
.unwrap_or(0);
let mut received = head.len().saturating_sub(header_end);
while received < content_length {
let n = stream.read(&mut request)?;
head.extend_from_slice(&request[..n]);
received = received.saturating_add(n);
}
let full = String::from_utf8_lossy(&head);
let echoed = full.contains(ECHO);
let payload = if echoed { ECHO } else { body };
let reply = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{payload}",
payload.len()
);
stream.write_all(reply.as_bytes())?;
}
Ok(())
});
Ok((port, handle))
}
fn join_server(
server: thread::JoinHandle<std::io::Result<()>>,
) -> Result<(), Box<dyn std::error::Error>> {
let served = server
.join()
.map_err(|_| "the canned server thread panicked before replying")?;
served?;
Ok(())
}
#[test]
fn default_option_wrappers_reach_the_same_path() -> Result<(), Box<dyn std::error::Error>> {
let (port, server) = serve(vec![("200 OK", "hello"), ("200 OK", "ok")])?;
let url = format!("http://127.0.0.1:{port}/");
let got = get_response(&url)?;
assert_eq!(got.status, 200);
assert_eq!(got.body, b"hello");
let posted = post(&url, "text/plain", ECHO.as_bytes())?;
assert_eq!(posted.status, 200);
assert_eq!(posted.text()?, ECHO);
join_server(server)?;
Ok(())
}
fn quiet() -> Options {
Options {
timeout: Duration::from_secs(5),
user_agent: "lgwks-std-test".into(),
headers: Vec::new(),
}
}
#[test]
fn gets_status_headers_and_body() -> Result<(), Box<dyn std::error::Error>> {
let (port, server) = serve(vec![("200 OK", "hello")])?;
let response = get_with(&format!("http://127.0.0.1:{port}/"), &quiet())?;
assert_eq!(response.status, 200);
assert_eq!(response.body, b"hello");
assert_eq!(response.text()?, "hello");
assert!(
response
.headers
.iter()
.any(|header| header.0.eq_ignore_ascii_case("content-length"))
);
join_server(server)?;
Ok(())
}
#[test]
fn error_statuses_are_responses_not_errors() -> Result<(), Box<dyn std::error::Error>> {
let (port, server) = serve(vec![("404 Not Found", "missing")])?;
let response = get_with(&format!("http://127.0.0.1:{port}/"), &quiet())?;
assert_eq!(response.status, 404);
assert_eq!(response.text()?, "missing");
join_server(server)?;
Ok(())
}
#[test]
fn posts_body_with_content_type() -> Result<(), Box<dyn std::error::Error>> {
let (port, server) = serve(vec![("200 OK", "")])?;
let response = post_with(
&format!("http://127.0.0.1:{port}/"),
"text/plain",
ECHO.as_bytes(),
&quiet(),
)?;
assert_eq!(response.status, 200);
assert_eq!(response.text()?, ECHO);
join_server(server)?;
Ok(())
}
#[test]
fn rejects_non_http_urls_before_dialing() {
assert!(matches!(
get_with("not a url", &quiet()),
Err(Error::InvalidUrl)
));
assert!(matches!(
get_with("/relative/path", &quiet()),
Err(Error::InvalidUrl)
));
assert!(matches!(
get_with("ftp://127.0.0.1/file", &quiet()),
Err(Error::InvalidUrl)
));
assert!(matches!(
get_with("http:user:SECRET@host", &quiet()),
Err(Error::InvalidUrl)
));
assert!(matches!(
get_with("https://", &quiet()),
Err(Error::InvalidUrl)
));
}
#[test]
fn refused_connection_is_transport_not_timeout() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
let Err(error) = get_with(&format!("http://127.0.0.1:{port}/"), &quiet()) else {
return Err("a refused connection must not yield a response".into());
};
assert!(matches!(error, Error::Transport(_)));
Ok(())
}
#[test]
fn custom_headers_reach_the_server() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let handle = thread::spawn(move || -> std::io::Result<String> {
let (mut stream, _) = listener.accept()?;
let mut request = vec![0u8; 4096];
let mut head = Vec::new();
loop {
let n = stream.read(&mut request)?;
head.extend_from_slice(&request[..n]);
if head.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let text = String::from_utf8_lossy(&head).into_owned();
let reply = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok";
stream.write_all(reply.as_bytes())?;
Ok(text)
});
let options = Options {
timeout: Duration::from_secs(5),
user_agent: "lgwks-std-test".into(),
headers: vec![("Authorization".into(), "Bearer test-token".into())],
};
let response = post_with(
&format!("http://127.0.0.1:{port}/"),
"text/plain",
b"hi",
&options,
)?;
assert_eq!(response.status, 200);
let seen = handle
.join()
.map_err(|_| "the recording server thread panicked before replying")??;
assert!(
seen.to_ascii_lowercase()
.contains("authorization: bearer test-token"),
"server never saw the Authorization header:\n{seen}"
);
Ok(())
}
#[test]
fn silent_server_hits_timeout() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let handle = thread::spawn(move || -> std::io::Result<()> {
let (_stream, _) = listener.accept()?;
thread::sleep(Duration::from_secs(30));
Ok(())
});
let options = Options {
timeout: Duration::from_millis(200),
user_agent: "lgwks-std-test".into(),
headers: Vec::new(),
};
let Err(error) = get_with(&format!("http://127.0.0.1:{port}/"), &options) else {
return Err("a silent server must hit the read timeout".into());
};
assert_eq!(error, Error::Timeout);
drop(handle);
Ok(())
}
}