Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/mock-scaleway/src/http.rs

//! **The socket**: HTTP/1.1 on std's `TcpListener`, a thread per connection, keep-alive, and a
//! tiny blocking client ([`call`]) the tests (and any harness) speak to it with.

use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::time::Duration;

use crate::account::Answer;
use crate::Mock;

/// Serve `mock` on `addr` (`127.0.0.1:0` for any port) until the process ends; the port.
pub fn serve(mock: Arc<Mock>, addr: &str) -> std::io::Result<u16> {
    let listener = TcpListener::bind(addr)?;
    let port = listener.local_addr()?.port();
    std::thread::Builder::new()
        .name("mock-scaleway".into())
        .spawn(move || {
            for sock in listener.incoming().flatten() {
                let _ = sock.set_nodelay(true);
                let m = mock.clone();
                std::thread::spawn(move || {
                    let _ = connection(&m, sock);
                });
            }
        })?;
    Ok(port)
}

struct Request {
    method: String,
    target: String,
    headers: Vec<(String, String)>,
    body: Vec<u8>,
}

impl Request {
    fn header(&self, k: &str) -> Option<&str> {
        self.headers
            .iter()
            .find(|(h, _)| h.eq_ignore_ascii_case(k))
            .map(|(_, v)| v.as_str())
    }
}

fn read_request(r: &mut BufReader<TcpStream>) -> std::io::Result<Option<Request>> {
    let mut line = String::new();
    if r.read_line(&mut line)? == 0 {
        return Ok(None);
    }
    let mut parts = line.split_whitespace();
    let (method, target) = (
        parts.next().unwrap_or("").to_string(),
        parts.next().unwrap_or("/").to_string(),
    );
    let mut headers = Vec::new();
    loop {
        let mut h = String::new();
        if r.read_line(&mut h)? == 0 {
            return Ok(None);
        }
        let h = h.trim_end();
        if h.is_empty() {
            break;
        }
        if let Some((k, v)) = h.split_once(':') {
            headers.push((k.trim().to_string(), v.trim().to_string()));
        }
    }
    let mut req = Request {
        method,
        target,
        headers,
        body: Vec::new(),
    };
    if req
        .header("transfer-encoding")
        .is_some_and(|t| t.eq_ignore_ascii_case("chunked"))
    {
        loop {
            let mut size = String::new();
            r.read_line(&mut size)?;
            let n = usize::from_str_radix(size.trim().split(';').next().unwrap_or("0"), 16)
                .unwrap_or(0);
            let mut chunk = vec![0u8; n + 2];
            r.read_exact(&mut chunk)?;
            if n == 0 {
                break;
            }
            req.body.extend_from_slice(&chunk[..n]);
        }
    } else if let Some(n) = req
        .header("content-length")
        .and_then(|v| v.parse::<usize>().ok())
    {
        req.body = vec![0u8; n];
        r.read_exact(&mut req.body)?;
    }
    Ok(Some(req))
}

fn decode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let mut i = 0;
    while i < b.len() {
        match b[i] {
            b'%' if i + 2 < b.len() => {
                if let Some(v) = std::str::from_utf8(&b[i + 1..i + 3])
                    .ok()
                    .and_then(|h| u8::from_str_radix(h, 16).ok())
                {
                    out.push(v);
                    i += 3;
                    continue;
                }
                out.push(b'%');
            }
            b'+' => out.push(b' '),
            c => out.push(c),
        }
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// `path?a=b&c=d` → the path and the decoded pairs.
pub fn split_target(target: &str) -> (String, Vec<(String, String)>) {
    let (path, q) = target.split_once('?').unwrap_or((target, ""));
    let query = q.split('&').filter(|p| !p.is_empty()).map(|p| {
        let (k, v) = p.split_once('=').unwrap_or((p, ""));
        (decode(k), decode(v))
    });
    (path.to_string(), query.collect())
}

fn reason(status: u16) -> &'static str {
    match status {
        200 => "OK",
        201 => "Created",
        202 => "Accepted",
        204 => "No Content",
        400 => "Bad Request",
        401 => "Unauthorized",
        403 => "Forbidden",
        404 => "Not Found",
        409 => "Conflict",
        412 => "Precondition Failed",
        _ => "Status",
    }
}

fn connection(mock: &Mock, sock: TcpStream) -> std::io::Result<()> {
    sock.set_read_timeout(Some(Duration::from_secs(120)))?;
    let mut w = sock.try_clone()?;
    let mut r = BufReader::new(sock);
    while let Some(req) = read_request(&mut r)? {
        let (path, query) = split_target(&req.target);
        let answer = mock.with(|a| {
            a.handle(
                &req.method,
                &path,
                &query,
                req.header("x-auth-token"),
                req.header("content-type"),
                &req.body,
            )
        });
        let (status, ctype, body, total) = match answer {
            Answer::Json {
                status,
                body,
                total,
            } => (
                status,
                "application/json",
                serde_json::to_vec(&body).unwrap_or_default(),
                total,
            ),
            Answer::Text { status, body } => (status, "text/plain", body, None),
            Answer::Empty { status } => (status, "application/json", Vec::new(), None),
        };
        let mut head = format!(
            "HTTP/1.1 {status} {}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\n",
            reason(status),
            body.len()
        );
        if let Some(t) = total {
            head.push_str(&format!("X-Total-Count: {t}\r\n"));
        }
        let close = req
            .header("connection")
            .is_some_and(|c| c.eq_ignore_ascii_case("close"));
        head.push_str(if close {
            "Connection: close\r\n\r\n"
        } else {
            "Connection: keep-alive\r\n\r\n"
        });
        let mut out = head.into_bytes();
        out.extend_from_slice(&body);
        w.write_all(&out)?;
        if close {
            break;
        }
    }
    Ok(())
}

/// What [`call`] got back.
#[derive(Debug, Clone)]
pub struct Reply {
    pub status: u16,
    pub headers: Vec<(String, String)>,
    pub body: Vec<u8>,
}

impl Reply {
    pub fn json(&self) -> serde_json::Value {
        serde_json::from_slice(&self.body).unwrap_or(serde_json::Value::Null)
    }
    pub fn text(&self) -> String {
        String::from_utf8_lossy(&self.body).into_owned()
    }
    pub fn header(&self, k: &str) -> Option<&str> {
        self.headers
            .iter()
            .find(|(h, _)| h.eq_ignore_ascii_case(k))
            .map(|(_, v)| v.as_str())
    }
}

/// **One blocking request** to `base` (`http://127.0.0.1:P`): a fresh connection, closed after.
pub fn call(
    base: &str,
    method: &str,
    path: &str,
    token: Option<&str>,
    content_type: Option<&str>,
    body: &[u8],
) -> std::io::Result<Reply> {
    let host = base.trim_start_matches("http://").trim_end_matches('/');
    let mut s = TcpStream::connect(host)?;
    s.set_read_timeout(Some(Duration::from_secs(60)))?;
    let mut head = format!(
        "{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nContent-Length: {}\r\n",
        body.len()
    );
    if let Some(t) = token {
        head.push_str(&format!("X-Auth-Token: {t}\r\n"));
    }
    if let Some(c) = content_type {
        head.push_str(&format!("Content-Type: {c}\r\n"));
    }
    head.push_str("\r\n");
    s.write_all(head.as_bytes())?;
    s.write_all(body)?;
    let mut raw = Vec::new();
    s.read_to_end(&mut raw)?;
    let split = raw
        .windows(4)
        .position(|w| w == b"\r\n\r\n")
        .ok_or_else(|| std::io::Error::other("no header end"))?;
    let head = String::from_utf8_lossy(&raw[..split]).into_owned();
    let mut lines = head.lines();
    let status = lines
        .next()
        .and_then(|l| l.split_whitespace().nth(1))
        .and_then(|s| s.parse().ok())
        .unwrap_or(0);
    let headers = lines
        .filter_map(|l| {
            l.split_once(':')
                .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
        })
        .collect();
    Ok(Reply {
        status,
        headers,
        body: raw[split + 4..].to_vec(),
    })
}