HTTP servers play a fundamental role in distributing content and services on the internet. Implementing a simple HTTP server can be an excellent opportunity to understand the basic principles of web communication and explore a modern language like Rust.

In this post, we’ll walk through creating a simple server that serves static files using TCP. Of course, a real HTTP server is much more complex than this.

How does HTTP work?

HTTP

HTTP (Hypertext Transfer Protocol) is an application protocol that operates over TCP and is widely used to transfer data on the web. It follows a client-server model, where the client sends requests to the server and the server responds with the requested resources.

If you want to read about it, we have a complete article about it.

TCP

TCP

TCP is a reliable, connection-oriented communications protocol that operates at the transport layer of the OSI model. It is responsible for establishing connections between two hosts on the internet and ensuring the orderly and reliable delivery of data between them.

TCP uses a three-way process (handshake) to establish a connection between a client and a server. This handshake consists of three steps: SYN, SYN-ACK and ACK. After the connection is established, data is transferred in packets, which are reassembled and reordered at the destination to ensure data integrity and order.

Why Rust?

Why Rust?

Rust is a modern and efficient programming language valued for its memory safety and performance. The choice of Rust in this project, however, is quite simple: I decided to explore it as part of my learning process. I’ve been really interested in this language lately and opted for it. This leads me to a suggestion: why not redo this exercise using your favorite language?

Where do we start?

Before we start, if you’ve never had contact with Rust, I recommend taking a look at its excellent documentation or in this course that the community prepared, it will help you understand Rust in an easy way.

Hands-on

Now, we’re all ready to start, let’s get started.

Starting a Rust project

I’m going to assume that you already have Rust installed and configured on your machine, if you don’t have it, take a look here, where I teach how to install asdf and install several languages, including Rust.

With everything done, let’s create a new project using Cargo:

cargo new http-server

We will then have the http-server directory created with the basic structure of a Rust project, now just open this directory with your favorite IDE.

Handling TCP Connections

The heart of our server is the ability to handle incoming TCP connections. We use the std::net::TcpListener module to associate the server with a specific IP address and port. The bind() function allows us to bind the server to a specific network interface and port, and then we start listening for connections with incoming().

use std::net::TcpListener;

// Constants for server configuration
const HOST: &str = "127.0.0.1";
const PORT: &str = "8477";

fn main() {
    // Bind to the host and port
    let endpoint = format!("{}:{}", HOST, PORT);
    let listener = TcpListener::bind(endpoint).unwrap();

    for _ in listener.incoming() {
        println!("Connection established!")
    }
}

With this we define two constants HOST, which will be the localhost IP and PORT which will be the port that our server will be listening to. With a simple implementation we can test by running:

cargo run

And accessing the address http://127.0.0.1:8477/ in any browser, we will not have any page displayed, but we will see in the terminal:

Connection established!

Having then successfully established a TCP connection.

Analyzing HTTP Requests

Inside the handle_connection() function, we read the data from the client’s HTTP request and interpret it. First, we read the request data into a buffer and convert it to a string.

use std::io::prelude::*;
use std::net::{TcpListener, TcpStream};

// Constants for server configuration
const HOST: &str = "127.0.0.1";
const PORT: &str = "8477";

fn main() {
    // Bind to the host and port
    let endpoint = format!("{}:{}", HOST, PORT);
    let listener = TcpListener::bind(endpoint).unwrap();
    println!("Web server is listening at port {}", PORT);

    // Accept incoming connections
    for incoming_stream in listener.incoming() {
        let mut stream = incoming_stream.unwrap();
        handle_connection(&mut stream);
    }
}

fn handle_connection(stream: &mut TcpStream) {
    // Buffer to read the incoming request
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    // Convert the request buffer to a string
    let request_str = String::from_utf8_lossy(&buffer);

    println!("Request: {}", request_str);
}

This string contains all the information about the HTTP request, including the method, path, headers and body, if any. An example:

Request: GET / HTTP/1.1
Host: 127.0.0.1:8477
Connection: keep-alive
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: pt-BR,pt;q=0.9,en-IN;q=0.8,en;q=0.7,en-US;q=0.6,ja;q=0.5

Obviously this will vary depending on the operating system and browser you are using, but notice the first line:

Request: GET / HTTP/1.1
...

We can see that we have an HTTP GET request at path /, if we try to access http://127.0.0.1:8477/123 , we will have

Request: GET /123 HTTP/1.1
...

Handling HTTP requests

Let’s now take the requests and return a response, for this we just add HTTP/1.1 200 OK\r\n\r\n before sending our response, note that the number 200 is the status code, which means success.

use std::io::prelude::*;
use std::net::{TcpListener, TcpStream};

// Constants for server configuration
const HOST: &str = "127.0.0.1";
const PORT: &str = "8477";

fn main() {
    // Bind to the host and port
    let endpoint = format!("{}:{}", HOST, PORT);
    let listener = TcpListener::bind(endpoint).unwrap();
    println!("Web server is listening at port {}", PORT);

    // Accept incoming connections
    for incoming_stream in listener.incoming() {
        let mut stream = incoming_stream.unwrap();
        handle_connection(&mut stream);
    }
}

fn handle_connection(stream: &mut TcpStream) {
    // Buffer to read the incoming request
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    let response = "HTTP/1.1 200 OK\r\n\r\n";
    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}

With this we will have a blank page in our browser, but when inspecting it and going to the network tab we will see a status code 200.

Status 200

Serving Static Files

Even though we did something in the previous step, we still need to display something in the browser, our beautiful Hello, world! message.

In the root of the project we will create a directory www and inside this folder a file index.html, inside it paste:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Hello, world!</title>
</head>
<body>
  Hello, world!
</body>
</html>

And in our main.rs file we will add two methods: parse_request_path and serve_requested_file, the first will get the path and the second the file, thus having a simple HTTP server that will serve static files, let’s update our file for the last time:

use std::fs;
use std::io::prelude::*;
use std::net::{TcpListener, TcpStream};
use std::path::Path;

// Constants for server configuration
const HOST: &str = "127.0.0.1";
const PORT: &str = "8477";
const ROOT_DIR: &str = "www";

fn main() {
    // Bind to the host and port
    let endpoint = format!("{}:{}", HOST, PORT);
    let listener = TcpListener::bind(endpoint).unwrap();
    println!("Web server is listening at port {}", PORT);

    // Accept incoming connections
    for incoming_stream in listener.incoming() {
        let mut stream = incoming_stream.unwrap();
        handle_connection(&mut stream);
    }
}

fn handle_connection(stream: &mut TcpStream) {
    // Buffer to read the incoming request
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    // Convert the request buffer to a string
    let request_str = String::from_utf8_lossy(&buffer);

    // Parse the request path
    let request_path = parse_request_path(&request_str);

    // Serve the requested file
    serve_requested_file(&request_path, stream);
}

fn parse_request_path(request: &str) -> String {
    // Extract the path part of the request
    request.split_whitespace().nth(1).unwrap_or("/").to_string()
}

fn serve_requested_file(file_path: &str, stream: &mut TcpStream) {
    // Construct the full file path, if "/" the use index.html
    let file_path = if file_path == "/" {
        format!("{}/index.html", ROOT_DIR)
    } else {
        format!("{}/{}", ROOT_DIR, &file_path[1..])
    };

    let path = Path::new(&file_path);

    // Generate the HTTP response
    let response = match fs::read_to_string(&path) {
        Ok(contents) => format!(
            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
            contents.len(),
            contents
        ),
        Err(_) => {
            let not_found = "404 Not Found.";
            format!(
                "HTTP/1.1 404 NOT FOUND\r\nContent-Length: {}\r\n\r\n{}",
                not_found.len(),
                not_found
            )
        }
    };

    // Send the response over the TCP stream
    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}

Note that we use two more packages:

  • std::path::Path so we can get the file path.
  • std::fs So we can read the files.

We also created a new constant called ROOT_DIR that passes the path where we will place the files.

parse_request_path

The parse_request_path function will extract the path of the HTTP request.

serve_requested_file

The serve_requested_file function takes the path and tries to read the file inside, where it will return two possible responses:

  1. If the file is found: It will respond with status code 200 and the file as content.
  2. If the file is not found: It will respond with status code 404 and the text 404 Not Found as content.

Final considerations

Now that we understand how to create a simple HTTP server in Rust, the possibilities are endless. We can explore more features of the HTTP language and protocol to create more advanced and robust servers. However, this is a great starting point.

Repository with the complete code.