use std::io::{Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
use std::time::Duration;
const MAX_RESPONSE: usize = 8 * 1024;
pub fn probe(port: u16, method: &str, path: &str, timeout: Duration) -> Option<(u16, String)> {
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let mut stream = TcpStream::connect_timeout(&addr, timeout).ok()?;
if stream.set_read_timeout(Some(timeout)).is_err()
|| stream.set_write_timeout(Some(timeout)).is_err()
{
return None;
}
let request =
format!("{method} {path} HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\n\r\n");
if stream.write_all(request.as_bytes()).is_err() {
return None;
}
Some(parse_response(&read_response(&mut stream)).unwrap_or((0, String::new())))
}
pub fn probe_status(port: u16, method: &str, path: &str, timeout: Duration) -> Option<u16> {
probe(port, method, path, timeout).map(|(status, _)| status)
}
fn read_response(stream: &mut TcpStream) -> Vec<u8> {
let mut raw = Vec::new();
let mut chunk = [0u8; 1024];
loop {
match stream.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(n) => {
raw.extend_from_slice(&chunk[..n]);
if raw.len() >= MAX_RESPONSE {
break;
}
}
}
}
raw
}
fn parse_response(raw: &[u8]) -> Option<(u16, String)> {
let text = std::str::from_utf8(raw).ok()?;
let status = parse_status(text.as_bytes())?;
let body = text.split_once("\r\n\r\n").map_or("", |(_, body)| body);
Some((status, body.to_string()))
}
fn parse_status(bytes: &[u8]) -> Option<u16> {
let text = std::str::from_utf8(bytes).ok()?;
let line = text.lines().next()?;
line.split_whitespace().nth(1)?.parse().ok()
}
#[cfg(test)]
mod tests {
use std::net::TcpListener;
use std::thread;
use super::*;
fn answer_once(raw: &'static str) -> u16 {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = listener.local_addr().unwrap().port();
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut request = [0u8; 1024];
let _ = stream.read(&mut request);
let _ = stream.write_all(raw.as_bytes());
}
});
port
}
#[test]
fn parse_status_reads_code() {
assert_eq!(parse_status(b"HTTP/1.0 200 OK\r\n"), Some(200));
assert_eq!(parse_status(b"HTTP/1.1 404 Not Found\r\n"), Some(404));
assert_eq!(parse_status(b"garbage"), None);
}
#[test]
fn parse_response_separates_the_status_from_the_body() {
let raw = b"HTTP/1.0 200 OK\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}";
assert_eq!(
parse_response(raw),
Some((200, r#"{"status":"ok"}"#.to_string()))
);
}
#[test]
fn parse_response_tolerates_a_response_cut_short() {
assert_eq!(
parse_response(b"HTTP/1.0 200 OK\r\n"),
Some((200, String::new()))
);
assert_eq!(parse_response(b""), None);
}
#[test]
fn a_probe_returns_the_status_and_body_it_was_answered_with() {
let port = answer_once(
"HTTP/1.0 200 OK\r\nContent-Type: application/json\r\n\r\n{\"status\":\"ok\"}",
);
assert_eq!(
probe(port, "GET", "/api/health", Duration::from_secs(5)),
Some((200, r#"{"status":"ok"}"#.to_string()))
);
}
#[test]
fn a_probe_of_an_answer_that_is_not_http_reports_status_zero() {
let port = answer_once("hello");
assert_eq!(
probe_status(port, "GET", "/api/health", Duration::from_secs(5)),
Some(0)
);
}
#[test]
fn a_probe_of_a_port_nothing_is_listening_on_is_none() {
assert_eq!(
probe_status(1, "GET", "/api/health", Duration::from_millis(300)),
None
);
}
}