use std::fmt;
use std::io::Read;
use std::time::Duration;
use iri_string::types::UriAbsoluteStr;
#[derive(Debug, Clone)]
pub struct Options {
pub timeout: Duration,
pub user_agent: String,
}
impl Default for Options {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
user_agent: format!("lgwks-std/{}", env!("CARGO_PKG_VERSION")),
}
}
}
#[derive(Debug, Clone)]
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}"))
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
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(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(|cause| {
eprintln!("lgwks_std::http: rejecting malformed URL: {cause}");
Error::InvalidUrl
})?;
let Some(scheme) = url.split_once(':').map(|(scheme, _)| scheme) else {
eprintln!("lgwks_std::http: rejecting URL with no scheme");
return Err(Error::InvalidUrl);
};
if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
eprintln!("lgwks_std::http: rejecting non-http(s) scheme {scheme:?}");
return Err(Error::InvalidUrl);
}
let Some(authority) = url[scheme.len() + 1..].strip_prefix("//") else {
eprintln!("lgwks_std::http: rejecting URL without authority");
return Err(Error::InvalidUrl);
};
let host_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
if authority[..host_end].is_empty() {
eprintln!("lgwks_std::http: rejecting URL with empty host");
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(|e| Error::Transport(e.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)?;
agent(options)
.get(url)
.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)?;
eprintln!(
"lgwks_std::http: POST ({content_type:?}, {} bytes)",
body.len()
);
let request = agent(options)
.post(url)
.header("Content-Type", content_type);
let response = request.send(body).map_err(map_error)?;
response_of(response)
}
#[cfg(test)]
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)>) -> (u16, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = thread::spawn(move || {
for (status, body) in replies {
let (mut stream, _) = listener.accept().unwrap();
let mut request = vec![0u8; 4096];
let mut head = Vec::new();
loop {
let n = stream.read(&mut request).unwrap();
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(|p| p + 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(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
.unwrap_or(0);
let mut received = head.len() - header_end;
while received < content_length {
let n = stream.read(&mut request).unwrap();
head.extend_from_slice(&request[..n]);
received += 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()).unwrap();
}
});
(port, handle)
}
#[test]
fn default_option_wrappers_reach_the_same_path() {
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).unwrap();
assert_eq!(got.status, 200);
assert_eq!(got.body, b"hello");
let posted = post(&url, "text/plain", ECHO.as_bytes()).unwrap();
assert_eq!(posted.status, 200);
assert_eq!(posted.text().unwrap(), ECHO);
server.join().unwrap();
}
fn quiet() -> Options {
Options {
timeout: Duration::from_secs(5),
user_agent: "lgwks-std-test".into(),
}
}
#[test]
fn gets_status_headers_and_body() {
let (port, server) = serve(vec![("200 OK", "hello")]);
let response = get_with(&format!("http://127.0.0.1:{port}/"), &quiet()).unwrap();
assert_eq!(response.status, 200);
assert_eq!(response.body, b"hello");
assert_eq!(response.text().unwrap(), "hello");
assert!(
response
.headers
.iter()
.any(|(n, _)| n.eq_ignore_ascii_case("content-length"))
);
server.join().unwrap();
}
#[test]
fn error_statuses_are_responses_not_errors() {
let (port, server) = serve(vec![("404 Not Found", "missing")]);
let response = get_with(&format!("http://127.0.0.1:{port}/"), &quiet()).unwrap();
assert_eq!(response.status, 404);
assert_eq!(response.text().unwrap(), "missing");
server.join().unwrap();
}
#[test]
fn posts_body_with_content_type() {
let (port, server) = serve(vec![("200 OK", "")]);
let response = post_with(
&format!("http://127.0.0.1:{port}/"),
"text/plain",
ECHO.as_bytes(),
&quiet(),
)
.unwrap();
assert_eq!(response.status, 200);
assert_eq!(response.text().unwrap(), ECHO);
server.join().unwrap();
}
#[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() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let error = get_with(&format!("http://127.0.0.1:{port}/"), &quiet()).unwrap_err();
assert!(matches!(error, Error::Transport(_)));
}
#[test]
fn silent_server_hits_timeout() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = thread::spawn(move || {
let (_stream, _) = listener.accept().unwrap();
thread::sleep(Duration::from_secs(30));
});
let options = Options {
timeout: Duration::from_millis(200),
user_agent: "lgwks-std-test".into(),
};
let error = get_with(&format!("http://127.0.0.1:{port}/"), &options).unwrap_err();
assert_eq!(error, Error::Timeout);
drop(handle);
}
}