Creating a Static HTTP Server with Rust – Part 1

In this series, we will create a basic static HTTP 1.0 server with Rust. At the end of Part 1 of this tutorial, our server will do the following:

  • Listen for and handle TCP connections on a specific port
  • Accept HTTP 1.0 GET requests
  • Parse and validate requests for further use
  • Log incoming requests

We will avoid using libraries that make this trivial (i.e. the http crate) and focus on the fundamentals of how a server works. Let’s get started.

Setup

Install Rust and Cargo.

Run cargo new rust_http_server in a directory of your choice to initialize a new Rust project.

Add the following crates that we will be using under [dependencies] in your Cargo.toml:

bufstream = "0.1.4"
chrono = "0.4"

Listen for Connections

The first thing we need to do is open a socket, or an endpoint that can be connected to. We then need to accept and handle new connections on the socket appropriately. Your main.rs should look like the following.

use std::io;
use std::net::TcpListener;

use rust_http_server;

extern crate chrono;


fn main() -> io::Result<()> {
    println!("Starting server...");

    let listener = TcpListener::bind("127.0.0.1:8001")?;

    println!("Server started!");

    for stream in listener.incoming() {
        match stream {
            Ok(stream) => {
                match rust_http_server::handle_client(stream) {
                    Err(e) => eprintln!("Error handling client: {}", e),
                    _ => (),
                }
            },
            Err(e) => eprintln!("Connection failed: {}", e),
        }
    }

    Ok(())
}

We use TcpListener to open a TCP socket on 127.0.0.1, port 8001. We then listen for new connections by looping over the iterator returned by listener.incoming(). This loop will run indefinitely, or until the server is stopped.

for stream in listener.incoming() {
    match stream {
        Ok(stream) => {
            match rust_http_server::handle_client(stream) {
                Err(e) => eprintln!("Error handling client: {}", e),
                _ => (),
            }
        },
        Err(e) => eprintln!("Connection failed: {}", e),
    }
}

Although it looks like there is a lot going on here, we are essentially calling the handle_client function (that we have yet to create) for each new connection. We are passing it the TcpStream. The match blocks just handle error cases by logging to standard error rather than stopping the server altogether.

Handle a Connection

Next, we need to create the handle_client function, which will eventually take a stream, read its data (the request), and generate a response. Now that we have handled the details of opening a socket and accepting connections on it in main.rs, the rest of our business logic will reside in src/lib.rs. Create this file with the following code.

use std::io;
use std::io::prelude::*;
use std::net::TcpStream;

use bufstream::BufStream;
use chrono::prelude::*;


pub fn handle_client(stream: TcpStream) -> io::Result<()> {
    let mut buf = BufStream::new(stream);
    let mut request_line = String::new();

    // Get only the first line of the request, since this
    // is a static HTTP 1.0 server.
    buf.read_line(&mut request_line)?;
    match parse_request(&mut request_line) {
        Ok(request) => {
            log_request(&request);
        },
        Err(()) => {
            println!("Bad request: {}", &request_line);
        },
    }

    Ok(())
}

Let’s break this down a bit.

Read the Request

let mut buf = BufStream::new(stream);
let mut request_line = String::new();

// Get only the first line of the request, since this
// is a static HTTP 1.0 server.
buf.read_line(&mut request_line)?;

First, we create a new BufStream from the TcpStream. BufStream is more efficient than TcpStream, in that each read or write to a TcpStream results in a system call, whereas BufStream buffers these reads and writes and does them in bulk, infrequently.

Next, we read only the first line of the stream into a string. We are able to do this, since we only accept bare-bones HTTP 1.0 requests. For example: GET /index.html HTTP/1.0

Handle the Parsed Request

match parse_request(&mut request_line) {
    Ok(request) => {
        log_request(&request);
    },
    Err(()) => {
        eprintln!("Bad request: {}", &request_line);
    },
}

Ok(())

Here, we call the parse_request function with the request string. We will flesh out this function in a moment, but the general idea is that we will pass in the raw request string and it will return a request object that is easier to work with. For Part 1, we will log the details of the request to standard output with log_request, and output to standard error if parsing is unsuccessful.

Create the Request Struct

So that each function that needs information from the request doesn’t have to parse the string to get it, we need to extract this data into an object for easier use.

A basic HTTP 1.0 GET request looks like this:

GET /index.html HTTP/1.0

That consists of the method (GET), the request path (/index.html), and the HTTP version (HTTP/1.0).

Let’s create a Request struct containing these elements.

struct Request {
    http_version: String,
    method: String,
    path: String,
    time: DateTime<Local>,
}

I have included the time attribute as well, as we will want to log the time at which the request was received.

Parse the Request

Now that we have a Request struct, we need to create the parse_request function, which will take a raw request string and turn it into a Request instance.

fn parse_request(request: &mut String) -> Result<Request, ()> {
    let mut parts = request.split(" ");
    let method = match parts.next() {
        Some(method) => method.trim().to_string(),
        None => return Err(()),
    };
    let path = match parts.next() {
        Some(path) => path.trim().to_string(),
        None => return Err(()),
    };
    let http_version = match parts.next() {
        Some(version) => version.trim().to_string(),
        None => return Err(()),
    };
    let time = Local::now();

    Ok( Request {
        http_version: http_version,
        method: method,
        path: path,
        time: time
    } )
}

This function returns a Result<Request, ()> so we can indicate that parsing was unsuccessful by returning an instance of Err. Otherwise, this function is straightforward. We split the string into three parts, record the current time, and create a Request instance.

Log the Request

The last piece of our server in Part 1 is logging each request to standard output. We want to log all aspects of the request.

fn log_request(request: &Request) {
    println!(
        "[{}] \"{} {} {}\"",
        request.time,
        request.method,
        request.path,
        request.http_version,
    );
}

Final Result

Your src/lib.rs should end up looking like the following.

use std::io;
use std::io::prelude::*;
use std::net::TcpStream;

use bufstream::BufStream;
use chrono::prelude::*;


struct Request {
    http_version: String,
    method: String,
    path: String,
    time: DateTime<Local>,
}

pub fn handle_client(stream: TcpStream) -> io::Result<()> {
    let mut buf = BufStream::new(stream);
    let mut request_line = String::new();

    // Get only the first line of the request, since this
    // is a static HTTP 1.0 server.
    buf.read_line(&mut request_line)?;
    match parse_request(&mut request_line) {
        Ok(request) => {
            log_request(&request);
        },
        Err(()) => {
            println!("Bad request: {}", &request_line);
        },
    }

    Ok(())
}

fn parse_request(request: &mut String) -> Result<Request, ()> {
    let mut parts = request.split(" ");
    let method = match parts.next() {
        Some(method) => method.trim().to_string(),
        None => return Err(()),
    };
    let path = match parts.next() {
        Some(path) => path.trim().to_string(),
        None => return Err(()),
    };
    let http_version = match parts.next() {
        Some(version) => version.trim().to_string(),
        None => return Err(()),
    };
    let time = Local::now();

    Ok( Request {
        http_version: http_version,
        method: method,
        path: path,
        time: time
    } )
}

fn log_request(request: &Request) {
    println!(
        "[{}] \"{} {} {}\"",
        request.time,
        request.method,
        request.path,
        request.http_version,
    );
}

Running the Server

Running the server is as simple as running cargo run. You should see output similar to the following:

   Compiling rust_http_server v0.1.0 (/home/levi/Projects/blog-rust-http-server)
    Finished dev [unoptimized + debuginfo] target(s) in 0.68s
     Running `target/debug/rust_http_server`
Starting server...
Server started!

Use curl or your browser to make a request to the server:

curl -0 localhost:8001/index.html

In your server output, you should see something similar to the following:

[2019-05-11 12:21:19.818388128 -04:00] "GET /index.html HTTP/1.0"

Closing Thoughts

We have successfully created the first part of a basic HTTP server with nothing but our bare hands (well, almost). In Part 2, we will expand our server to construct and return success or error responses, as well as read requested files from a document root and write them to the response.

See full example code for Part 1 on GitHub.

Continue with Part 2.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.