August 04, 2021

A Simple Web Server

This article is part of the series Async Rust From The Ground Up.

As a starting point for our journey into asynchronous programming, we'll write a simple web server. Web servers are great examples of massively concurrent programs. Our end goal is to create a performant and efficient server capable of powering high-traffic websites, but we'll start small and build our way up from there.

As you probably know, the HTTP protocol is the basis for comunication across the web. Clients, typically web browsers, send HTTP requests to servers:

GET /home HTTP/1.1
Host: google.com

And servers responds with some metadata about the reponse, and the requested content:

HTTP/1.1 200 OK
Content-Length: 121
Content-Type: text/html

<!DOCTYPE html>
<html>
    <body>
        <h1>Enter a search term below:</h1>
        <input type="text" />
    </body>
</html>

Newer versions of the HTTP specification have evolved past this simple text format, but HTTP/1.1 is still in wide use, and we'll be implementing a limited form of it today.

TCP is the transport layer upon which HTTP messages are sent. A client establishes a TCP connection, and HTTP messages are sent across that connection. As a server, it's our job to listen for incoming connections. We can do so by creating a TcpListener.

use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("localhost:3000").expect("failed to create TCP listener");
}

We bind the listener to specified address consisting of a host address, and a port. We'll pass localhost, our local computer, as the host, and 3000 as the port. This is the address we'll enter into the browser to access our server.

We can accept TCP connections via the Incoming iterator:

use std::net::{TcpListener, TcpStream};

fn main() {
    let listener = TcpListener::bind("localhost:3000").expect("failed to create TCP listener");

    for connection in listener.incoming() {
        let stream: TcpStream = connection.expect("client connection failed");
    }
}

A TCP connection is represented as a TcpStream, a stream of data between a client and a server. We can read or write data from this stream without worrying about lower-level implementation details of the protocol.

Let's delegate handling each connection to a separate method:

fn main() {
    let listener = TcpListener::bind("localhost:3000").expect("failed to create TCP listener");

    for connection in listener.incoming() {
        let stream = connection.expect("client connection failed");
        handle_connection(stream)
    }
}

fn handle_connection(stream: TcpStream) {
    // ...
}

We'll first read an HTTP request from the stream. We don't really care about the contents of the request, but if not just to print something to the console, we'll read the first line of the request:

use std::io::{BufRead, BufReader};

fn handle_connection(mut stream: TcpStream) {
    let mut request = Vec::new();
    let mut reader = BufReader::new(&mut stream);
    reader
        .read_until(b'\n', &mut request)
        .expect("failed to read from stream");
}

Wrapping the stream in a BufReader gives us the ability to read until a specific byte. It will take care of reading from the stream efficiently internally.

Now we have the request as a vector of bytes. To print it out in a human readable format, we have to convert it to a string:

fn handle_connection(mut stream: TcpStream) {
    // ...
    let request = String::from_utf8(request).expect("malformed request line");
    print!("HTTP request line: {}", request);
}

We've read the request, now we must reply with an HTTP response. We'll simply write out "hello world" and flush the stream to make sure the message is written immediately:

use std::io::Write;

fn handle_connection(mut stream: TcpStream) {
    // ...
    let response = concat!(
        "HTTP/1.1 200 OK\r\n\r\n",
        "Hello world!"
    );

    stream.write(response.as_bytes()).expect("failed to write to stream");
    stream.flush().expect("failed to flush stream");
}

That's all for our simple web server. Our implementation of HTTP is definitely not production ready, but it will work for our learning purposes.

use std::net::{TcpListener, TcpStream};
use std::io::{BufRead, BufReader, Write};

fn main() {
    let listener = TcpListener::bind("localhost:3000").expect("failed to create TCP listener");

    for connection in listener.incoming() {
        let stream = connection.expect("client connection failed");
        handle_connection(stream)
    }
}

fn handle_connection(mut stream: TcpStream) {
    // === READ RAW BYTES ===
    let mut request = Vec::new();
    let mut reader = BufReader::new(&mut stream);
    reader
        .read_until(b'\n', &mut request)
        .expect("failed to read from stream");

    // === CONVERT TO STRING ===
    let request = String::from_utf8(request).expect("malformed request line");
    print!("HTTP request line: {}", request);

    // === WRITE RESPONSE ===
    let response = concat!(
        "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n",
        "Hello world!"
    );
    stream
        .write(response.as_bytes())
        .expect("failed to write to stream");
    stream.flush().expect("failed to flush stream");
}

We can test the server by running cargo run and visiting "localhost:3000" in the browser:

~/server $ cargo run
   Compiling server v0.1.0 (/server)
    Finished dev [unoptimized + debuginfo] target(s) in 0.17s
     Running `target/debug/server`

> HTTP request line: GET / HTTP/1.1

Hello world!

The server works as expected! We'll be using in coming posts to learn more about networking and concurrency in Rust. In the next part of this series, we'll uncover some problems with our current server implementation and discuss some possible solutions.