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(),
})
}