leyline-http 0.1.0

HTTP client that mimics browsers on the wire
Documentation
#![expect(
    clippy::unwrap_used,
    reason = "test/example harness: unwrap doubles as the assertion - a failed helper panics with the test location"
)]
#![allow(dead_code)]
use std::collections::VecDeque;
use std::net::SocketAddr;

use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[derive(Debug, Clone)]
pub struct CapturedRequest {
    pub raw: Vec<u8>,
    pub request_line: String,
    pub headers: Vec<(String, String)>,
}

impl CapturedRequest {
    pub fn header_values(&self, name: &str) -> Vec<String> {
        self.headers
            .iter()
            .filter(|(k, _)| k.eq_ignore_ascii_case(name))
            .map(|(_, v)| v.clone())
            .collect()
    }

    pub fn header_count(&self, name: &str) -> usize {
        self.headers
            .iter()
            .filter(|(k, _)| k.eq_ignore_ascii_case(name))
            .count()
    }

    pub fn text(&self) -> String {
        String::from_utf8_lossy(&self.raw).to_string()
    }

    fn from_raw(raw: Vec<u8>) -> Self {
        let text = String::from_utf8_lossy(&raw);
        let mut lines = text.split("\r\n");
        let request_line = lines.next().unwrap_or_default().to_string();
        let mut headers = Vec::new();
        for line in lines {
            if line.is_empty() {
                break;
            }
            if let Some((name, value)) = line.split_once(':') {
                headers.push((name.to_string(), value.trim_start().to_string()));
            }
        }

        Self {
            raw,
            request_line,
            headers,
        }
    }
}

#[derive(Debug, Clone)]
pub struct RawResponse {
    status: u16,
    reason: &'static str,
    headers: Vec<(String, String)>,
    body: Vec<u8>,
}

impl RawResponse {
    pub fn ok() -> Self {
        Self::new(200, "OK", b"ok".to_vec())
    }

    pub fn redirect(location: impl Into<String>) -> Self {
        Self::new(302, "Found", Vec::new()).header("location", location.into())
    }

    pub fn status(status: u16, reason: &'static str) -> Self {
        Self::new(status, reason, Vec::new())
    }

    fn new(status: u16, reason: &'static str, body: Vec<u8>) -> Self {
        Self {
            status,
            reason,
            headers: Vec::new(),
            body,
        }
    }

    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.push((name.into(), value.into()));
        self
    }

    fn to_bytes(&self) -> Vec<u8> {
        let mut out = Vec::new();
        out.extend_from_slice(format!("HTTP/1.1 {} {}\r\n", self.status, self.reason).as_bytes());
        out.extend_from_slice(format!("content-length: {}\r\n", self.body.len()).as_bytes());
        out.extend_from_slice(b"connection: close\r\n");
        for (name, value) in &self.headers {
            out.extend_from_slice(name.as_bytes());
            out.extend_from_slice(b": ");
            out.extend_from_slice(value.as_bytes());
            out.extend_from_slice(b"\r\n");
        }
        out.extend_from_slice(b"\r\n");
        out.extend_from_slice(&self.body);
        out
    }
}

pub struct RawServer {
    addr: SocketAddr,
    requests: tokio::sync::mpsc::Receiver<CapturedRequest>,
    task: tokio::task::JoinHandle<()>,
}

impl RawServer {
    pub async fn start(responses: Vec<RawResponse>) -> Self {
        Self::serve(Self::bind().await, responses)
    }

    pub async fn bind() -> tokio::net::TcpListener {
        tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap()
    }

    pub fn serve(listener: tokio::net::TcpListener, responses: Vec<RawResponse>) -> Self {
        let addr = listener.local_addr().unwrap();
        let (tx, rx) = tokio::sync::mpsc::channel(responses.len().max(1));
        let task = tokio::spawn(async move {
            let mut responses = VecDeque::from(responses);
            while let Some(response) = responses.pop_front() {
                let (mut socket, _) = listener.accept().await.unwrap();
                let raw = read_request_head(&mut socket).await.unwrap();
                tx.send(CapturedRequest::from_raw(raw)).await.unwrap();
                socket.write_all(&response.to_bytes()).await.unwrap();
                socket.flush().await.unwrap();
            }
        });

        Self {
            addr,
            requests: rx,
            task,
        }
    }

    pub fn url(&self, path: &str) -> String {
        format!("http://{}{}", self.addr, path)
    }

    pub async fn next_request(&mut self) -> CapturedRequest {
        self.requests
            .recv()
            .await
            .expect("raw server closed before capturing request")
    }

    pub async fn finish(self) {
        self.task.await.unwrap();
    }
}

async fn read_request_head(socket: &mut tokio::net::TcpStream) -> std::io::Result<Vec<u8>> {
    let mut raw = Vec::new();
    let mut buf = [0u8; 2048];
    loop {
        let n = socket.read(&mut buf).await?;
        if n == 0 {
            break;
        }
        raw.extend_from_slice(&buf[..n]);
        if raw.windows(4).any(|w| w == b"\r\n\r\n") {
            break;
        }
    }
    Ok(raw)
}