Creating a Static HTTP Server with Rust – Part 2

In this series, we are creating a basic static HTTP 1.0 server with Rust. If you haven’t seen Part 1 yet, go do that first. At the end of Part 2, our server will do the following:

  • Read and serve files from a predefined directory on the host server
  • Generate appropriate HTTP responses to incoming requests
  • Log information about the response to standard output

Define the Document Root

Before we can start serving documents from our server, we have to define which directory will act as our document root. This directory is where our server will look for requested files. Update your main.rs with 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")?;
    const STATIC_ROOT: &str = "/static_root";

    println!("Server started!");

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

    Ok(())
}

 

Here, we are defining a constant STATIC_ROOT with the directory /static_root. We pass this constant to handle_client for future use.

Now, add a static_root parameter to handle_client in lib.rs.

pub fn handle_client(stream: TcpStream, static_root: &str) -> io::Result<()> {

Create the Response Structs

The next step for our server is to take the Request object we created in Part 1 and generate a response with the appropriate status code, headers, and bytes of the requested file in the body. Like with requests, it is best to organize this information in a series of structs and enums in order to keep our code organized and flexible.

For our simple server, a response has three components that we need:

  • Headers – namely the Content-Type
  • Status code
  • Body – potentially containing the bytes of a requested file

ContentType

Let’s start with the headers. First, let’s create a ContentType enum, containing the basic file types and extensions we expect to handle.

enum ContentType {
    CSS,
    GIF,
    HTML,
    JPEG,
    PNG,
    SVG,
    TEXT,
    XML,
}

impl ContentType {
    fn from_file_ext(ext: &str) -> ContentType {
        match ext {
            "css" => ContentType::CSS,
            "gif" => ContentType::GIF,
            "htm" => ContentType::HTML,
            "html" => ContentType::HTML,
            "jpeg" => ContentType::JPEG,
            "jpg" => ContentType::JPEG,
            "png" => ContentType::PNG,
            "svg" => ContentType::SVG,
            "txt" => ContentType::TEXT,
            "xml" => ContentType::XML,
            _ => ContentType::TEXT,
        }
    }

    fn value(&self) -> &str {
        match *self {
            ContentType::CSS => "text/css",
            ContentType::GIF => "image/gif",
            ContentType::HTML => "text/html",
            ContentType::JPEG => "image/jpeg",
            ContentType::PNG => "image/png",
            ContentType::SVG => "image/svg+xml",
            ContentType::TEXT => "text/plain",
            ContentType::XML => "application/xml",
        }
    }
}

Our enum has two methods. from_file_ext will allow us to get the proper value from our enum by passing in a file extension. value will return the appropriate string that we can add to our response.

ResponseHeaders

Although we only are making use of one header right now, it will be nice to keep all headers in one struct for future flexibility. Let’s create a ResponseHeaders struct for this purpose.

struct ResponseHeaders {
    content_type: Option<ContentType>,
}

impl ResponseHeaders {
    fn new() -> ResponseHeaders {
        ResponseHeaders {
            content_type: None,
        }
    }
}

We make content_type an Option<ContentType> that defaults to None because we may not always need to give it a value (e.g. when a requested file doesn’t exist).

StatusCode

Rather than reinvent the wheel by creating our own status code enum, I am going to use the StatusCode enum from the http crate. Feel free to implement this yourself. Otherwise, add the following to your Cargo.toml under [dependencies].

http = "0.1.17"

Finally, import StatusCode in lib.rs.

use http::StatusCode;

Response

Finally, let’s create the Response struct, bringing all of the pieces together.

struct Response {
    body: Option<Vec<u8>>,
    headers: ResponseHeaders,
    status: StatusCode,
}

impl Response {
    fn new() -> Response {
        Response {
            body: None,
            headers: ResponseHeaders::new(),
            status: StatusCode::OK,
        }
    }
}

Build the Response

Next, we need a function to build the response based on a Request instance. This will involve verifying that the request method is GET and attempting to retrieve the file at the requested path.

fn build_response(request: &Request, static_root: &str) -> Response {
    let mut response = Response::new();
    if request.method != "GET" {
        response.status = StatusCode::METHOD_NOT_ALLOWED;
    } else {
        add_file_to_response(&request.path, &mut response, static_root);
    }

    response
}

First, we need this build_response function, which simply verifies that the request is GET. If it is not, we want to set the appropriate 405 status code on the response and return it. Otherwise, we call an add_file_to_response function, which will retrieve and add the file to the Response instance. Now, let’s create this function.

// --snip--
use std::{io, fs};
use std::io::ErrorKind;
// --snip--

fn add_file_to_response(path: &String, response: &mut Response, static_root: &str) {
    let path = format!("{}{}", static_root, path);
    let contents = fs::read(&path);
    match contents {
        Ok(contents) => {
            response.body = Some(contents);
            let ext = path.split(".").last().unwrap_or("");
            response.headers.content_type = Some(ContentType::from_file_ext(ext));
        },
        Err(e) => {
            response.status = match e.kind() {
                ErrorKind::NotFound => StatusCode::NOT_FOUND,
                ErrorKind::PermissionDenied => StatusCode::FORBIDDEN,
                _ => StatusCode::INTERNAL_SERVER_ERROR,
            }
        }
    }
}

Let’s break this down.

let path = format!("{}{}", static_root, path);
let contents = fs::read(&path);

First, we create the path to the requested file, using static_root as the base directory. We attempt to read the file’s contents and store the result in contents.

match contents {
    Ok(contents) => {
        response.body = Some(contents);
        let ext = path.split(".").last().unwrap_or("");
        response.headers.content_type = Some(ContentType::from_file_ext(ext));
    },
    // --snip--
}

If the file was successfully read, we add the bytes to our response, and determine and set the content type header via our ContentType enum.

match contents {
    // --snip--
    Err(e) => {
        response.status = match e.kind() {
            ErrorKind::NotFound => StatusCode::NOT_FOUND,
            ErrorKind::PermissionDenied => StatusCode::FORBIDDEN,
            _ => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

If the file was not successfully read, we need to find out the reason in order to set the appropriate status code.

Lastly, we need to make use of these new functions in handle_client.

pub fn handle_client(stream: TcpStream, static_root: &str) -> 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)?;
    let response = match parse_request(&mut request_line) {
        Ok(request) => {
            let response = build_response(&request, static_root);
            log_request(&request);
            response
        },
        Err(()) => {
            println!("Bad request: {}", &request_line);
        },
    };

    Ok(())
}

Log the Response

Now that we are able to build Response objects, it would be helpful to log the response’s status code. We can add this to our log_request function, like so.

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

In handle_client, pass in response as an argument to log_request.

pub fn handle_client(stream: TcpStream, static_root: &str) -> io::Result<()> {
    // --snip--

    let response = match parse_request(&mut request_line) {
        Ok(request) => {
            let response = build_response(&request, static_root);
            log_request(&request, &response);
            response
        },
        Err(()) => {
            println!("Bad request: {}", &request_line);
        },
    };

    // --snip--
}

Handle Bad Requests

At this point, we still are not handling an Err response from parse_request in handle_client. If you recall from Part 1, this happens when the request does not match the format we expect. For this case, we need to return a 400 Bad Request response. Create the following function to create the response.

fn create_bad_request_response() -> Response {
    let mut response = Response::new();
    response.status = StatusCode::BAD_REQUEST;

    response
}

In handle_client, return one of these responses from the Err case in the parse_request match block.

pub fn handle_client(stream: TcpStream, static_root: &str) -> io::Result<()> {
    // --snip--

    let response = match parse_request(&mut request_line) {
        Ok(request) => {
            let response = build_response(&request, static_root);
            log_request(&request, &response);
            response
        },
        Err(()) => create_bad_request_response(),
    };

    // --snip--
}

Format the Response

We are now able to create Response objects, but we need a way to format them as strings in order to write them back to our BufStream. An example HTTP 1.0 response looks like the following:

HTTP/1.0 200 OK
Allow: GET
Content-type: text/html

<h1>Success!</h1>

We need to convert our Response objects into strings matching this format. Let’s create a format_response function to do just this.

fn format_response(response: Response) -> Vec<u8> {
    let mut result;
    let status_reason = match response.status.canonical_reason() {
        Some(reason) => reason,
        None => "",
    };
    result = format!(
        "HTTP/1.0 {} {}\n",
        response.status.as_str(),
        status_reason,
    );
    result = format!("{}Allow: GET\n", result);

    match response.headers.content_type {
        Some(content_type) => {
            result = format!(
                "{}Content-type: {}\n", result, content_type.value());
        },
        _ => (),
    }

    let mut bytes = result.as_bytes().to_vec();

    match response.body {
        Some(mut body) => {
            bytes.append(&mut "\n".as_bytes().to_vec());
            bytes.append(&mut body);
        },
        _ => (),
    }

    bytes
}

Here, we make use of the format! macro to build our response string. We return the string as a Vec<u8> (bytes), as this is what we will write to the BufStream.

Return the Response

For the finishing touch, update handle_client to get the formatted response bytes and write them to the BufStream; returning the response to the client.

pub fn handle_client(stream: TcpStream, static_root: &str) -> 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)?;
    let response = match parse_request(&mut request_line) {
        Ok(request) => {
            let response = build_response(&request, static_root);
            log_request(&request, &response);
            response
        },
        Err(()) => create_bad_request_response(),
    };

    let formatted = format_response(response);
    buf.write_all(&formatted)?;

    Ok(())
}

Use Your Server

First, create the document root on your system and create one or more test files in it.

$ sudo mkdir /static_root
$ echo '<h1>Success!</h1>' | sudo tee /static_root/test.html

Start up your server.

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

Make a request to your server with curl.

$ curl -0 -v localhost:8001/test.html
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8001 (#0)
> GET /test.html HTTP/1.0
> Host: localhost:8001
> User-Agent: curl/7.58.0
> Accept: */*
> 
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Allow: GET
< Content-type: text/html
< 
<h1>Success!</h1>
* Closing connection 0

Observe that the request has been logged in your server’s standard output.

[2019-05-27 08:26:38.730841252 -04:00] "GET /test.html HTTP/1.0" 200

Make a POST request to observe a 405 response.

$ curl -0 -v -X POST localhost:8001/test.html
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8001 (#0)
> POST /test.html HTTP/1.0
> Host: localhost:8001
> User-Agent: curl/7.58.0
> Accept: */*
> 
* HTTP 1.0, assume close after body
< HTTP/1.0 405 Method Not Allowed
< Allow: GET
* Closing connection 0

Request a non-existent file to observe a 404 response.

$ curl -0 -v localhost:8001/does_not_exist.html
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8001 (#0)
> GET /does_not_exist.html HTTP/1.0
> Host: localhost:8001
> User-Agent: curl/7.58.0
> Accept: */*
> 
* HTTP 1.0, assume close after body
< HTTP/1.0 404 Not Found
< Allow: GET
* Closing connection 0

Create a file in the document root with limited permissions and request it to observe a 403 response.

$ echo '<h1>Forbidden!</h1>' | sudo tee /static_root/forbidden.html
$ sudo chmod 600 /static_root/forbidden.html
$ curl -0 -v localhost:8001/forbidden.html
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8001 (#0)
> GET /forbidden.html HTTP/1.0
> Host: localhost:8001
> User-Agent: curl/7.58.0
> Accept: */*
> 
* HTTP 1.0, assume close after body
< HTTP/1.0 403 Forbidden
< Allow: GET
* Closing connection 0

Finally, add a photo to your document root and request it via your browser. Ensure the image is displayed appropriately.

Closing Thoughts

In this series, we have created a fully-functional HTTP 1.0 server for static files. Hopefully this has given you some insight into the basics of how HTTP servers work, as well as some of the capabilities of Rust.

This server is intentionally barebones. I encourage you to play around with the implementation and add new features, such as making the server port and document root configurable, supporting other HTTP methods, or even adding concurrency. The possibilities are endless.

For full example code, see the repository on GitHub.

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.