Skip to main content

rightkit_qa/
http.rs

1//! Minimal blocking HTTP/1.1 client over loopback `TcpStream`. Every call has an
2//! explicit deadline: a request that never answers is a failed step, not a hang.
3use crate::util::{err, Result};
4use std::io::{Read, Write};
5use std::net::{SocketAddr, TcpStream};
6use std::time::{Duration, Instant};
7
8#[derive(Debug, Clone)]
9pub struct Response {
10    pub status: u16,
11    pub content_type: String,
12    pub body: Vec<u8>,
13}
14
15impl Response {
16    pub fn text(&self) -> String {
17        String::from_utf8_lossy(&self.body).into_owned()
18    }
19}
20
21/// One request. `auth` is the full `Authorization` header value when present.
22pub fn request(
23    addr: SocketAddr,
24    method: &str,
25    path: &str,
26    auth: Option<&str>,
27    body: Option<&str>,
28    timeout: Duration,
29) -> Result<Response> {
30    if !addr.ip().is_loopback() {
31        return err(format!(
32            "QA control endpoint must be loopback, received {addr}"
33        ));
34    }
35    let deadline = Instant::now() + timeout;
36    let mut s = TcpStream::connect_timeout(&addr, timeout.min(Duration::from_secs(5)))?;
37    s.set_write_timeout(Some(timeout))?;
38    let mut head =
39        format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\nAccept: */*\r\n");
40    if let Some(a) = auth {
41        head.push_str(&format!("Authorization: {a}\r\n"));
42    }
43    let payload = body.unwrap_or("");
44    if body.is_some() {
45        head.push_str(&format!(
46            "Content-Type: application/json\r\nContent-Length: {}\r\n",
47            payload.len()
48        ));
49    }
50    head.push_str("\r\n");
51    s.write_all(head.as_bytes())?;
52    s.write_all(payload.as_bytes())?;
53    let mut buf = Vec::new();
54    let mut chunk = [0u8; 16384];
55    loop {
56        let left = deadline.saturating_duration_since(Instant::now());
57        if left.is_zero() {
58            return err(format!(
59                "{method} {path} exceeded {}ms",
60                timeout.as_millis()
61            ));
62        }
63        s.set_read_timeout(Some(left))?;
64        match s.read(&mut chunk) {
65            Ok(0) => break,
66            Ok(n) => buf.extend_from_slice(&chunk[..n]),
67            Err(e)
68                if matches!(
69                    e.kind(),
70                    std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
71                ) =>
72            {
73                return err(format!(
74                    "{method} {path} exceeded {}ms",
75                    timeout.as_millis()
76                ))
77            }
78            Err(e) => return Err(e.into()),
79        }
80    }
81    let split = buf
82        .windows(4)
83        .position(|w| w == b"\r\n\r\n")
84        .ok_or_else(|| crate::util::Error("malformed HTTP response".into()))?;
85    let head = String::from_utf8_lossy(&buf[..split]).to_string();
86    let status = head
87        .lines()
88        .next()
89        .and_then(|l| l.split_whitespace().nth(1))
90        .and_then(|c| c.parse().ok())
91        .unwrap_or(0);
92    let content_type = head
93        .lines()
94        .find_map(|l| {
95            l.to_ascii_lowercase()
96                .strip_prefix("content-type:")
97                .map(|v| v.trim().to_string())
98        })
99        .unwrap_or_default();
100    Ok(Response {
101        status,
102        content_type,
103        body: buf[split + 4..].to_vec(),
104    })
105}
106
107pub fn loopback(port: u16) -> SocketAddr {
108    SocketAddr::from(([127, 0, 0, 1], port))
109}
110
111pub fn port_open(port: u16) -> bool {
112    TcpStream::connect_timeout(&loopback(port), Duration::from_millis(250)).is_ok()
113}