main
Drew Owens 0.3.3 source + crate build: BOM exported from the SOLIDWORKS assembly, build log §1–11, release script 477e64f 1mo ago
//! A minimal blocking HTTP/1.1 client — loopback only, no dependencies.
//!
//! Used for exactly one thing in production: calling ab back on its direct API
//! (`swx_screenshot` delegates to ab's generic `desktop_screenshot_window`).
//! That is one POST to 127.0.0.1 with a couple of headers, which does not
//! justify pulling an HTTP client crate into a bridge whose whole selling point
//! is being one small static binary.
//!
//! The integration tests reuse it to drive the real server exactly as ab does.

use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;

#[derive(Debug)]
pub struct Response {
    pub status: u16,
    pub body: String,
}

impl Response {
    /// The body parsed as JSON (an error string if it is not JSON).
    pub fn json(&self) -> Result<serde_json::Value, String> {
        serde_json::from_str(&self.body).map_err(|e| format!("not JSON ({e}): {}", self.body))
    }
}

pub fn get(url: &str, timeout: Duration) -> Result<Response, String> {
    request("GET", url, &[], None, timeout)
}

pub fn post_json(
    url: &str,
    headers: &[(String, String)],
    body: &str,
    timeout: Duration,
) -> Result<Response, String> {
    request("POST", url, headers, Some(body), timeout)
}

fn request(
    method: &str,
    url: &str,
    headers: &[(String, String)],
    body: Option<&str>,
    timeout: Duration,
) -> Result<Response, String> {
    let (host_port, path) = split_url(url)?;
    let stream = TcpStream::connect(&host_port).map_err(|e| format!("connect {host_port}: {e}"))?;
    stream.set_read_timeout(Some(timeout)).ok();
    stream.set_write_timeout(Some(timeout)).ok();
    let mut stream = stream;

    let mut req = format!("{method} {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n");
    for (k, v) in headers {
        req.push_str(&format!("{k}: {v}\r\n"));
    }
    if let Some(b) = body {
        req.push_str("Content-Type: application/json\r\n");
        req.push_str(&format!("Content-Length: {}\r\n", b.len()));
    }
    req.push_str("\r\n");
    if let Some(b) = body {
        req.push_str(b);
    }
    stream.write_all(req.as_bytes()).map_err(|e| format!("write: {e}"))?;
    stream.flush().ok();

    let mut raw = Vec::new();
    stream.read_to_end(&mut raw).map_err(|e| format!("read: {e}"))?;
    parse_response(&raw)
}

fn split_url(url: &str) -> Result<(String, String), String> {
    let rest = url
        .strip_prefix("http://")
        .ok_or_else(|| format!("only plain http:// loopback URLs are supported: {url}"))?;
    let (host_port, path) = match rest.find('/') {
        Some(i) => (&rest[..i], &rest[i..]),
        None => (rest, "/"),
    };
    let host_port =
        if host_port.contains(':') { host_port.to_string() } else { format!("{host_port}:80") };
    Ok((host_port, path.to_string()))
}

fn parse_response(raw: &[u8]) -> Result<Response, String> {
    let split = raw
        .windows(4)
        .position(|w| w == b"\r\n\r\n")
        .ok_or_else(|| "malformed response: no header terminator".to_string())?;
    let head = String::from_utf8_lossy(&raw[..split]).to_string();
    let body_bytes = &raw[split + 4..];

    let mut lines = head.lines();
    let status_line = lines.next().unwrap_or_default();
    let status: u16 = status_line
        .split_whitespace()
        .nth(1)
        .and_then(|s| s.parse().ok())
        .ok_or_else(|| format!("malformed status line: {status_line}"))?;

    let chunked = lines.any(|l| {
        let l = l.to_ascii_lowercase();
        l.starts_with("transfer-encoding:") && l.contains("chunked")
    });
    let body = if chunked { dechunk(body_bytes) } else { body_bytes.to_vec() };
    Ok(Response { status, body: String::from_utf8_lossy(&body).to_string() })
}

fn dechunk(mut input: &[u8]) -> Vec<u8> {
    let mut out = Vec::new();
    loop {
        let nl = match input.windows(2).position(|w| w == b"\r\n") {
            Some(i) => i,
            None => break,
        };
        let size_line = String::from_utf8_lossy(&input[..nl]).to_string();
        let size = usize::from_str_radix(size_line.trim().split(';').next().unwrap_or("0"), 16)
            .unwrap_or(0);
        input = &input[nl + 2..];
        if size == 0 || input.len() < size {
            out.extend_from_slice(&input[..input.len().min(size)]);
            break;
        }
        out.extend_from_slice(&input[..size]);
        input = &input[size..];
        if input.starts_with(b"\r\n") {
            input = &input[2..];
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn urls_split_into_authority_and_path() {
        assert_eq!(
            split_url("http://127.0.0.1:9000/command").unwrap(),
            ("127.0.0.1:9000".into(), "/command".into())
        );
        assert_eq!(split_url("http://localhost").unwrap(), ("localhost:80".into(), "/".into()));
        assert!(split_url("https://example.com").is_err());
    }

    #[test]
    fn parses_content_length_and_chunked_bodies() {
        let r = parse_response(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi").unwrap();
        assert_eq!((r.status, r.body.as_str()), (200, "hi"));
        let r = parse_response(
            b"HTTP/1.1 404 Not Found\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\n\r\n",
        )
        .unwrap();
        assert_eq!((r.status, r.body.as_str()), (404, "abc"));
    }
}