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"}