Tearing Apart the HTTP Protocol: Implementing a Simple HTTP Server from Scratch

The HTTP protocol is the cornerstone of the modern web, yet many developers use it daily without a deep understanding of its underlying details. This article will guide you through implementing a simple HTTP server from scratch using the Rust programming language, allowing you to “tear apart” the mysteries of the HTTP protocol.

The Core Components of the HTTP Protocol

The communication in the HTTP protocol mainly consists of two parts: requests and responses. We will first abstract these two core concepts through data structures.

The Data Structure of an HTTP Request

An HTTP request consists of three parts: the request line, request headers, and the request body. The corresponding data structure is designed as follows:

#[derive(Debug, Clone, PartialEq)]
pub struct HttpRequest {
    pub method: String,  // Request method (GET/POST, etc.)
    pub path: String,    // Request path
    pub version: String, // HTTP version
    pub headers: Vec<String>, // Request headers
    pub body: String,    // Request body
}

Parsing the HTTP Request

Parsing the HTTP request from the raw TCP stream is a key step. We need to read the data line by line, first parsing the request line, then reading the request headers, and finally processing the request body:

impl HttpRequest {
    fn new(request: &str) -> Self {
        let mut lines = request.lines();
        // Parse the request line (format: METHOD PATH VERSION)
        let req_line = lines.next().unwrap();
        // Parse the request headers (until an empty line)
        let headers = lines
            .clone()
            .take_while(|line| !line.is_empty())
            .map(|line| line.to_string())
            .collect();
        // Parse the request body (remaining content)
        let body = lines
            .map(|line| line.to_string())
            .collect::<Vec<String>>()
            .join("\n");
        
        // Extract method, path, and version from the request line
        let method = req_line.split(' ').nth(0).unwrap().to_string();
        let path = req_line.split(' ').nth(1).unwrap().to_string();
        let version = req_line.split(' ').nth(2).unwrap().to_string();
        
        Self {
            method,
            path,
            version,
            headers,
            body,
        }
    }
}

Implementing the HTTP Response

Corresponding to the request, the HTTP response also contains a status line, response headers, and a response body:

The Data Structure of the Response

#[derive(Debug, Clone, PartialEq)]
pub struct HttpResponse {
    pub version: String,  // HTTP version
    pub status: u16,      // Status code
    pub reason: String,   // Status description
    pub headers: Vec<String>, // Response headers
    pub body: String,     // Response body
}

Formatting the Response Output

The HTTP response needs to be converted to a string in a specific format before being sent over TCP:

impl Display for HttpResponse {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_'>) -> std::fmt::Result {
        write!(
            f,
            "{} {} {}\r\n{}\r\n{}",
            self.version,
            self.status,
            self.reason,
            self.headers.join("\r\n"),
            self.body
        )
    }
}

Note the <span>\r\n</span> delimiter in the format, which is the line break method specified by the HTTP protocol; it cannot simply be replaced with <span>\n</span>.

Implementing the TCP Server

With the request and response handling in place, we also need a TCP server to listen on a port, receive requests, and return responses:

fn main() {
    // Bind to local port 6167
    let listener = TcpListener::bind("127.0.0.1:6167").unwrap();

    println!("server listening on port 6167");
    
    // Loop to accept connections
    for stream in listener.incoming() {
        let mut stream = stream.unwrap();
        let mut buffer = [0; 1024]; // Buffer
        
        // Read request data
        let len = stream.read(&mut buffer).unwrap();
        // Convert to string and parse into HttpRequest
        let request: HttpRequest = String::from_utf8_lossy(&buffer[..len]).to_string().into();
        println!("request: {:?}", request);
        
        // Handle specific path requests
        if request.path.eq("/hello") {
            let html_content = read_html(); // Read HTML file content
            // Build response
            let response: HttpResponse = HttpResponse {
                version: "HTTP/1.1".to_string(),
                status: 200,
                reason: "OK".to_string(),
                headers: vec![
                    "Content-Type: text/html; charset=utf-8".to_string(),
                    format!("Content-Length: {}", html_content.len()),
                ],
                body: html_content,
            };
            
            // Send response
            stream.write(response.to_string().as_bytes()).unwrap();
            stream.flush().unwrap();
        }
    }
}

The content of the read index.html file

<html>
    <head>  
        <title>Rust Service</title>
    </head>

    <body>
        <h1>Welcome to Rust Service</h1>
        <p>This is a simple Rust service.</p>
    </body>
</html>

Request effect demonstration:Tearing Apart the HTTP Protocol: Implementing a Simple HTTP Server from Scratch

Summary of Core Principles

Through this simple implementation, we can clearly see the workflow of the HTTP protocol:

  1. The client sends a request text in HTTP format via a TCP connection
  2. The server parses the request text and extracts key information
  3. The server processes the request information (in this case, reading the HTML file in the current directory)
  4. The server constructs a response text in HTTP format
  5. The server returns the response text via the TCP connection

Currently, mainstream services like Tomcat and Nginx are built upon more refined implementations of this code, primarily focusing on the request and response handlers, allowing us to concentrate on the main logic during coding.

By implementing this simple server yourself, you will gain a deeper understanding of the HTTP protocol, moving beyond surface-level knowledge to the essence of the protocol’s text format and interaction. This is the significance of “tearing apart” the protocol—seeing the essence through abstraction.

Leave a Comment