Intermediate Projects
Simple HTTP Client Using Standard-Library Networking
Building an HTTP/1.1 client using only std::net::TcpStream demonstrates how text-based application protocols operate over raw TCP sockets: parsing a URL (http://host:port/path), formatting request headers terminated by CRLF (\r\n), reading the status line and response headers via BufReader, and decoding the response body.
HTTP/1.1 Request/Response Wire Framing over `std::net::TcpStream`:
Client Sends:
"GET /v1/status HTTP/1.1\r\n"
"Host: 127.0.0.1:8080\r\n"
"Connection: close\r\n"
"\r\n" <--- Blank `\r\n` line marks end of headers!
Server Replies:
"HTTP/1.1 200 OK\r\n" <--- Status Line (`version`, `status_code`, `reason`)
"Content-Type: application/json\r\n"
"Content-Length: 15\r\n"
"\r\n" <--- Blank `\r\n` line separates headers from body!
"{\"healthy\":true}" <--- Response Body (`Content-Length` bytes or until EOF)
Key Components of the Standard-Library HTTP/1.1 Client
| Component | Type | Responsibility |
|---|---|---|
HttpUrl |
Struct (host, port, path) |
Parses "http://host[:port][/path]" URLs (defaulting to port 80 and path "/"). |
HttpResponse |
Struct (status_code, reason, headers, body) |
Holds the parsed status code, lowercase-normalized headers map, and body bytes. |
http_get(url) |
Function | Connects via TcpStream, sends a CRLF-framed GET request with Connection: close, and parses the status line, headers, and body. |
Complete Runnable Implementation (Self-Contained Loopback Server + Client)
use std::collections::HashMap;
use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
use std::time::Duration;
#[derive(Debug, PartialEq, Eq)]
pub struct HttpUrl {
pub host: String,
pub port: u16,
pub path: String,
}
impl HttpUrl {
pub fn parse(raw: &str) -> Result<Self, &'static str> {
let rest = raw
.strip_prefix("http://")
.ok_or("only http:// scheme is supported by std::net")?;
let (authority, path) = match rest.find('/') {
Some(idx) => (&rest[..idx], &rest[idx..]),
None => (rest, "/"),
};
if authority.is_empty() {
return Err("missing host in URL");
}
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) => (h, p.parse::<u16>().map_err(|_| "invalid port")?),
None => (authority, 80),
};
Ok(Self {
host: host.to_string(),
port,
path: path.to_string(),
})
}
}
#[derive(Debug)]
pub struct HttpResponse {
pub status_code: u16,
pub reason: String,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
}
impl HttpResponse {
pub fn body_str(&self) -> std::borrow::Cow<'_, str> {
String::from_utf8_lossy(&self.body)
}
}
pub fn http_get(url: &str) -> io::Result<HttpResponse> {
let parsed = HttpUrl::parse(url)
.map_err(|msg| io::Error::new(io::ErrorKind::InvalidInput, msg))?;
let mut stream = TcpStream::connect((parsed.host.as_str(), parsed.port))?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
// 1. Write HTTP/1.1 GET request with explicit `\r\n` CRLF line endings:
write!(
stream,
"GET {} HTTP/1.1\r\nHost: {}:{}\r\nUser-Agent: rust-std-client/1.0\r\nConnection: close\r\n\r\n",
parsed.path, parsed.host, parsed.port
)?;
stream.flush()?;
// 2. Read status line (`HTTP/1.1 200 OK\r\n`):
let mut reader = BufReader::new(stream);
let mut status_line = String::new();
reader.read_line(&mut status_line)?;
let mut parts = status_line.trim_end_matches(['\r', '\n']).splitn(3, ' ');
let _http_ver = parts.next().unwrap_or("");
let status_code = parts
.next()
.and_then(|s| s.parse::<u16>().ok())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "malformed status line"))?;
let reason = parts.next().unwrap_or("").to_string();
// 3. Read headers until the blank `\r\n` separator line:
let mut headers = HashMap::new();
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
break;
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
break; // End of HTTP headers!
}
if let Some((k, v)) = trimmed.split_once(':') {
headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string());
}
}
// 4. Read response body (bounded by Content-Length if present, or until EOF):
let mut body = Vec::new();
if let Some(len_str) = headers.get("content-length") {
let len = len_str.parse::<usize>().unwrap_or(0);
body.resize(len, 0);
reader.read_exact(&mut body)?;
} else {
reader.read_to_end(&mut body)?;
}
Ok(HttpResponse {
status_code,
reason,
headers,
body,
})
}
fn main() -> io::Result<()> {
// Spawn a minimal loopback HTTP/1.1 test server on an ephemeral port:
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let server = thread::spawn(move || -> io::Result<()> {
let (mut stream, _) = listener.accept()?;
let mut reader = BufReader::new(&stream);
let mut req_line = String::new();
reader.read_line(&mut req_line)?;
// Drain client request headers until blank line:
let mut hdr = String::new();
while reader.read_line(&mut hdr)? > 0 {
if hdr.trim().is_empty() {
break;
}
hdr.clear();
}
let payload = r#"{"service":"auth","status":"ok"}"#;
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
payload.len(),
payload
)?;
Ok(())
});
let url = format!("http://127.0.0.1:{port}/v1/health");
let response = http_get(&url)?;
server.join().expect("server panicked")?;
assert_eq!(response.status_code, 200);
assert_eq!(
response.headers.get("content-type").map(String::as_str),
Some("application/json")
);
println!(
"HTTP {} {} -> Body: {}",
response.status_code,
response.reason,
response.body_str()
);
Ok(())
}
HTTP 200 OK -> Body: {"service":"auth","status":"ok"}