1use std::io::{Read, Write};
14use std::net::{Ipv4Addr, SocketAddr, TcpStream};
15use std::time::Duration;
16
17const MAX_RESPONSE: usize = 8 * 1024;
20
21pub fn probe(port: u16, method: &str, path: &str, timeout: Duration) -> Option<(u16, String)> {
29 let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
30 let mut stream = TcpStream::connect_timeout(&addr, timeout).ok()?;
31 if stream.set_read_timeout(Some(timeout)).is_err()
32 || stream.set_write_timeout(Some(timeout)).is_err()
33 {
34 return None;
35 }
36
37 let request =
38 format!("{method} {path} HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\n\r\n");
39 if stream.write_all(request.as_bytes()).is_err() {
40 return None;
41 }
42
43 Some(parse_response(&read_response(&mut stream)).unwrap_or((0, String::new())))
44}
45
46pub fn probe_status(port: u16, method: &str, path: &str, timeout: Duration) -> Option<u16> {
50 probe(port, method, path, timeout).map(|(status, _)| status)
51}
52
53fn read_response(stream: &mut TcpStream) -> Vec<u8> {
57 let mut raw = Vec::new();
58 let mut chunk = [0u8; 1024];
59 loop {
60 match stream.read(&mut chunk) {
61 Ok(0) | Err(_) => break,
62 Ok(n) => {
63 raw.extend_from_slice(&chunk[..n]);
64 if raw.len() >= MAX_RESPONSE {
65 break;
66 }
67 }
68 }
69 }
70 raw
71}
72
73fn parse_response(raw: &[u8]) -> Option<(u16, String)> {
74 let text = std::str::from_utf8(raw).ok()?;
75 let status = parse_status(text.as_bytes())?;
76 let body = text.split_once("\r\n\r\n").map_or("", |(_, body)| body);
77 Some((status, body.to_string()))
78}
79
80fn parse_status(bytes: &[u8]) -> Option<u16> {
82 let text = std::str::from_utf8(bytes).ok()?;
83 let line = text.lines().next()?;
84 line.split_whitespace().nth(1)?.parse().ok()
85}
86
87#[cfg(test)]
88mod tests {
89 use std::net::TcpListener;
90 use std::thread;
91
92 use super::*;
93
94 fn answer_once(raw: &'static str) -> u16 {
96 let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
97 let port = listener.local_addr().unwrap().port();
98 thread::spawn(move || {
99 if let Ok((mut stream, _)) = listener.accept() {
100 let mut request = [0u8; 1024];
101 let _ = stream.read(&mut request);
102 let _ = stream.write_all(raw.as_bytes());
103 }
104 });
105 port
106 }
107
108 #[test]
109 fn parse_status_reads_code() {
110 assert_eq!(parse_status(b"HTTP/1.0 200 OK\r\n"), Some(200));
111 assert_eq!(parse_status(b"HTTP/1.1 404 Not Found\r\n"), Some(404));
112 assert_eq!(parse_status(b"garbage"), None);
113 }
114
115 #[test]
116 fn parse_response_separates_the_status_from_the_body() {
117 let raw = b"HTTP/1.0 200 OK\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}";
118 assert_eq!(
119 parse_response(raw),
120 Some((200, r#"{"status":"ok"}"#.to_string()))
121 );
122 }
123
124 #[test]
125 fn parse_response_tolerates_a_response_cut_short() {
126 assert_eq!(
127 parse_response(b"HTTP/1.0 200 OK\r\n"),
128 Some((200, String::new()))
129 );
130 assert_eq!(parse_response(b""), None);
131 }
132
133 #[test]
134 fn a_probe_returns_the_status_and_body_it_was_answered_with() {
135 let port = answer_once(
136 "HTTP/1.0 200 OK\r\nContent-Type: application/json\r\n\r\n{\"status\":\"ok\"}",
137 );
138 assert_eq!(
139 probe(port, "GET", "/api/health", Duration::from_secs(5)),
140 Some((200, r#"{"status":"ok"}"#.to_string()))
141 );
142 }
143
144 #[test]
145 fn a_probe_of_an_answer_that_is_not_http_reports_status_zero() {
146 let port = answer_once("hello");
147 assert_eq!(
148 probe_status(port, "GET", "/api/health", Duration::from_secs(5)),
149 Some(0)
150 );
151 }
152
153 #[test]
154 fn a_probe_of_a_port_nothing_is_listening_on_is_none() {
155 assert_eq!(
158 probe_status(1, "GET", "/api/health", Duration::from_millis(300)),
159 None
160 );
161 }
162}