use std::{collections::BTreeMap, io};
use simvar::switchy::tcp::TcpStream;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
#[derive(Debug)]
pub struct HttpResponse {
pub status_code: u16,
pub headers: BTreeMap<String, String>,
pub body: String,
}
#[must_use]
pub fn headers_contains_in_order(
expected: &[(String, String)],
actual: &BTreeMap<String, String>,
) -> bool {
let mut iter = actual.iter();
for expected in expected {
loop {
if let Some(actual) = iter.next() {
if &expected.0 == actual.0 && &expected.1 == actual.1 {
break;
}
} else {
return false;
}
}
}
true
}
pub async fn http_request(method: &str, stream: &mut TcpStream, path: &str) -> io::Result<String> {
let host = "127.0.0.1";
let request = format!(
"{method} {path} HTTP/1.1\r\n\
Host: {host}\r\n\
Connection: close\r\n\
\r\n"
);
let bytes = request.as_bytes();
log::trace!(
"http_request: method={method} path={path} sending {} bytes",
bytes.len()
);
stream.write_all(bytes).await?;
stream.write_all(&[]).await?;
stream.flush().await?;
let mut response = String::new();
stream.read_to_string(&mut response).await?;
Ok(response)
}
pub fn parse_http_response(raw_response: &str) -> Result<HttpResponse, &'static str> {
let parts: Vec<&str> = raw_response.split("\r\n\r\n").collect();
if parts.len() < 2 {
return Err("Invalid HTTP response format");
}
let headers_section = parts[0];
let body = parts[1..].join("\r\n\r\n");
let header_lines: Vec<&str> = headers_section.split("\r\n").collect();
if header_lines.is_empty() {
return Err("No headers found");
}
let status_line = header_lines[0];
let status_parts: Vec<&str> = status_line.split_whitespace().collect();
if status_parts.len() < 3 {
return Err("Invalid status line");
}
let Ok(status_code) = status_parts[1].parse::<u16>() else {
return Err("Invalid status code");
};
let mut headers = BTreeMap::new();
for line in header_lines.into_iter().skip(1) {
if let Some(colon_pos) = line.find(':') {
let key = line[..colon_pos].trim();
let value = line[colon_pos + 1..].trim();
headers.insert(key.to_string(), value.to_string());
}
}
if let Some(content_length_str) = headers.iter().find_map(|(key, value)| {
if key == "Content-Length" {
Some(value)
} else {
None
}
}) && let Ok(content_length) = content_length_str.parse::<usize>()
{
if body.len() >= content_length {
let truncated_body = &body[..content_length];
return Ok(HttpResponse {
status_code,
headers,
body: truncated_body.to_string(),
});
}
}
Ok(HttpResponse {
status_code,
headers,
body,
})
}
#[cfg(test)]
mod tests {
use super::*;
mod headers_contains_in_order {
use super::*;
#[test_log::test]
fn returns_true_for_empty_expected_headers() {
let expected: &[(String, String)] = &[];
let actual = BTreeMap::new();
assert!(headers_contains_in_order(expected, &actual));
}
#[test_log::test]
fn returns_true_when_single_header_matches_exactly() {
let expected = vec![("content-type".to_string(), "application/json".to_string())];
let mut actual = BTreeMap::new();
actual.insert("content-type".to_string(), "application/json".to_string());
assert!(headers_contains_in_order(&expected, &actual));
}
#[test_log::test]
fn returns_true_when_headers_appear_in_order_with_extras() {
let expected = vec![
("a".to_string(), "1".to_string()),
("c".to_string(), "3".to_string()),
];
let mut actual = BTreeMap::new();
actual.insert("a".to_string(), "1".to_string());
actual.insert("b".to_string(), "2".to_string());
actual.insert("c".to_string(), "3".to_string());
assert!(headers_contains_in_order(&expected, &actual));
}
#[test_log::test]
fn returns_false_when_expected_header_is_missing() {
let expected = vec![("missing".to_string(), "value".to_string())];
let actual = BTreeMap::new();
assert!(!headers_contains_in_order(&expected, &actual));
}
#[test_log::test]
fn returns_false_when_header_value_does_not_match() {
let expected = vec![("content-type".to_string(), "text/html".to_string())];
let mut actual = BTreeMap::new();
actual.insert("content-type".to_string(), "application/json".to_string());
assert!(!headers_contains_in_order(&expected, &actual));
}
#[test_log::test]
fn returns_false_when_headers_appear_out_of_order() {
let expected = vec![
("z".to_string(), "26".to_string()),
("a".to_string(), "1".to_string()),
];
let mut actual = BTreeMap::new();
actual.insert("a".to_string(), "1".to_string());
actual.insert("z".to_string(), "26".to_string());
assert!(!headers_contains_in_order(&expected, &actual));
}
}
mod parse_http_response {
use super::*;
#[test_log::test]
fn parses_basic_http_response() {
let raw = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"healthy\":true}";
let result = parse_http_response(raw).unwrap();
assert_eq!(result.status_code, 200);
assert_eq!(
result.headers.get("Content-Type"),
Some(&"application/json".to_string())
);
assert_eq!(result.body, "{\"healthy\":true}");
}
#[test_log::test]
fn parses_response_with_multiple_headers() {
let raw = "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nX-Custom: value\r\n\r\nNot found";
let result = parse_http_response(raw).unwrap();
assert_eq!(result.status_code, 404);
assert_eq!(result.headers.len(), 2);
assert_eq!(
result.headers.get("Content-Type"),
Some(&"text/plain".to_string())
);
assert_eq!(result.headers.get("X-Custom"), Some(&"value".to_string()));
assert_eq!(result.body, "Not found");
}
#[test_log::test]
fn handles_body_with_crlf_crlf_sequence() {
let raw = "HTTP/1.1 200 OK\r\n\r\nLine1\r\n\r\nLine2";
let result = parse_http_response(raw).unwrap();
assert_eq!(result.status_code, 200);
assert_eq!(result.body, "Line1\r\n\r\nLine2");
}
#[test_log::test]
fn truncates_body_to_content_length_when_specified() {
let raw = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello World Extra";
let result = parse_http_response(raw).unwrap();
assert_eq!(result.status_code, 200);
assert_eq!(result.body, "Hello");
}
#[test_log::test]
fn does_not_truncate_when_body_shorter_than_content_length() {
let raw = "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nShort";
let result = parse_http_response(raw).unwrap();
assert_eq!(result.body, "Short");
}
#[test_log::test]
fn returns_error_for_missing_body_separator() {
let raw = "HTTP/1.1 200 OK\r\nContent-Type: text/plain";
let result = parse_http_response(raw);
assert_eq!(result.unwrap_err(), "Invalid HTTP response format");
}
#[test_log::test]
fn returns_error_for_invalid_status_line() {
let raw = "HTTP/1.1\r\n\r\nBody";
let result = parse_http_response(raw);
assert_eq!(result.unwrap_err(), "Invalid status line");
}
#[test_log::test]
fn returns_error_for_non_numeric_status_code() {
let raw = "HTTP/1.1 ABC OK\r\n\r\nBody";
let result = parse_http_response(raw);
assert_eq!(result.unwrap_err(), "Invalid status code");
}
#[test_log::test]
fn parses_response_with_empty_body() {
let raw = "HTTP/1.1 204 No Content\r\n\r\n";
let result = parse_http_response(raw).unwrap();
assert_eq!(result.status_code, 204);
assert_eq!(result.body, "");
}
#[test_log::test]
fn parses_various_status_codes() {
let test_cases = [
("HTTP/1.1 100 Continue\r\n\r\n", 100),
("HTTP/1.1 301 Moved Permanently\r\n\r\n", 301),
("HTTP/1.1 400 Bad Request\r\n\r\n", 400),
("HTTP/1.1 500 Internal Server Error\r\n\r\n", 500),
];
for (raw, expected_code) in test_cases {
let result = parse_http_response(raw).unwrap();
assert_eq!(result.status_code, expected_code, "Failed for input: {raw}");
}
}
#[test_log::test]
fn handles_headers_with_colons_in_value() {
let raw = "HTTP/1.1 200 OK\r\nDate: Mon, 01 Jan 2024 00:00:00 GMT\r\n\r\nBody";
let result = parse_http_response(raw).unwrap();
assert_eq!(
result.headers.get("Date"),
Some(&"Mon, 01 Jan 2024 00:00:00 GMT".to_string())
);
}
#[test_log::test]
fn trims_whitespace_from_header_keys_and_values() {
let raw = "HTTP/1.1 200 OK\r\n Key : Value \r\n\r\nBody";
let result = parse_http_response(raw).unwrap();
assert_eq!(result.headers.get("Key"), Some(&"Value".to_string()));
}
}
}