Building a simple Rust webserver

Building a simple Rust webserver

What comes to mind when you think of building a web server with Rust? I bet you think of using Axum, Rocket, Actix, etc., right? These are mature frameworks for building web applications with Rust.

By the end of this article, we’ll learn how to build a web server of our own that will receive a get or post request and respond to it without any of those shiny frameworks.

Prerequisites

Before we begin, make sure you have Rust installed on your system. You can download and install Rust by following the instructions on the official Rust website.

Getting Started

Run mkdir webserver & cd webserver to create a new directory and navigate into it and cargo init --bin to initialize a new Rust project with Cargo.

We’ll use the features in the Rust standard library TcpListener and TcpStream to power our web server.

The TcpStream is an implementation that allows you to create a TCP connection in your Rust app. While the TcpListener allows your application to wait for incoming TCP connections to your server.

So, we’ll first import those two packages and then we’ll also import the Rust Read, Write module as well to enable us to read and write to the TCP stream. So, the imports will look like so:

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

Next, we’ll create the stream handler. So, that when the request is received we can read and write back to the stream.

fn handle_stream(mut stream: TcpStream) {
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    let response = r#"HTTP/1.1 200 OK
Content-Type: application/json

{"message": "Hello from Rust Web Server!", "status": "success"}"#;

    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}

Let’s explain what is going on here a little bit.

The function receives a TCP Stream as an argument. This is the stream that will be read and written to.

let mut buffer = [0; 1024];: is a mutable array with a fixed size of 1024, with each item of the array initialized with 0 – 1024 is a reasonable size that should be enough for most requests. This buffer will be used to store data read from the incoming network stream.

Next, we read the stream into the buffer stream.read(&mut buffer).unwrap();

Now, that we’ve got the stream, let’s create a response we’ll return to the client.

let response = r#"HTTP/1.1 200 OK

Content-Type: application/json

{"message": "Hello from Rust Web Server!", "status": "success"}"#;

Finally, we’ll write the response to the stream and flush the stream — Flushing helps avoid any data being stuck in the buffer.

stream.write(response.as_bytes()).unwrap();
stream.flush().unwrap();

Now, we have a clear understanding of what our handler will look like. Note that this will default to handling only GET requests. We’ll see how to implement both POST and GET requests later on in this guide.

Now, let’s implement the main function. Add the following function to the main.rs file as shown below:

fn main() {
    let listener = TcpListener::bind("127.0.0.1:8080").expect("Failed to bind to address");

    for stream in listener.incoming() {

        match stream {
            Ok(stream) => {
                std::thread::spawn(|| {
                    handle_client(stream);
                });
            }
            Err(e) => {
                eprintln!("Error: {}", e);
            }
        }
    }
}

In the code above, we spun up the TCP listener and bound it to an IP address and port. We then go ahead to loop over the incoming connections to handle them individually to handle incoming connections. For each successful incoming connection (Ok(stream)), a new thread is spawned to handle the client. The spawn function from the std::thread module is used to create a new thread that runs the code inside the closure:

(|| {
      handle_client(stream);
 });

And that’s all there is to it.

We have our shiny little server.

Run cargo run on your Terminal and make a GET request on Postman or visit 127.0.0.1:8080 on your browser. The response should look like this:

Handling POST and GET request

To handle a POST request, we’ll have to update the handler function to handle both GET and Post requests differently. Update the handler function with this code:

fn handle_client(mut stream: TcpStream) {
    let mut buffer = [0; 1024];
    let bytes_read = stream.read(&mut buffer).unwrap();
    
    let request = String::from_utf8_lossy(&buffer[..bytes_read]);
    
    if request.starts_with("GET /") {
        // Handling a GET request
        let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"message\": \"Hello from Rust Web Server!\"}";

        stream.write(response.as_bytes()).unwrap();
    } else if request.starts_with("POST /") {
        // Handling a POST request
        if let Some(body_start) = request.find("\r\n\r\n") {
            let body = &request[(body_start + 4)..];

            // handle POST request body
            
            // Process the received data and generate a response
            let response = format!(
                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{}", body
            );
            
            stream.write(response.as_bytes()).unwrap();
        }
    }
    
    stream.flush().unwrap();
}

Let’s get an understanding of the new improvements we added. We introduced a request variable

let request = String::from_utf8_lossy(&buffer[..bytes_read]);

Let’s again, break down each part of it for a better understanding of what is going on there:

  • &buffer[..bytes_read]: This takes a slice of the buffer array containing the bytes that were actually read from the TcpStream. The bytes_read variable holds the number of bytes read from the stream. We need to do this so that we can focus only on the part of the buffer with the data that was read otherwise, we’ll get some unwanted characters.
  • String::from_utf8_lossy(...): This function converts a sequence of bytes (&[u8]) into a String, while handling any invalid UTF-8 sequences by replacing them with the Unicode replacement character (�).

And then finally we use the request.starts_with("GET /") to get the type of request and process it accordingly. That’s all we need to do to allow both a POST and a GET request.

Go ahead and try the POST request. The response should look like this:

Conclusion

Congratulations! You’ve successfully built a basic web server in Rust. This tutorial covered how to handle client connections, read and write data, create HTTP responses, and set up a server to listen for incoming connections. This is just the beginning of what you can achieve with Rust’s powerful capabilities in networking and systems programming. Feel free to explore further and build more advanced web servers and applications. You can find the full code on GitHub.

Disclaimer: This is not suitable for a real-world application, if you are looking to build something real, you should use a framework like Axum, Actix, or Rocket.

Happy hacking!

Buy Me A Coffee

Published by Eze Sunday Eze

Hi, welcome to my blog. I am Software Engineer and Technical Writer. And in this blog, I focus on sharing my views on the tech and tools I use. If you love my content and wish to stay in the loop, then by all means share this page and bookmark this website.

Leave a Reply

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