phillip england

[rust hyper]

written 12/16/2024

into the networking abyss

Sandbox

The sandbox for this article is in this repo.

Overview

My goal is to learn about how to use hyper, but more importantly, I want to take a close look at how to set up middleware.

In this article, I will be working my way through hyper and trying to understand how to use it to build a web application.

The Basics

Hyper offers some pretty good docs to get going. I ended up with something like this off the bat:

 1#[tokio::main]
 2async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
 3    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
 4    let listener = TcpListener::bind(addr).await?;
 5    loop {
 6        let (stream, _) = listener.accept().await?;
 7        let io = TokioIo::new(stream);
 8        tokio::task::spawn(async move {
 9            if let Err(err) = http1::Builder::new()
10                .serve_connection(io, service_fn(hello))
11                .await
12            {
13                eprintln!("Error serving connection: {:?}", err);
14            }
15        });
16    }
17}
18
19async fn hello(_: Request<hyper::body::Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
20    Ok(Response::new(Full::new(Bytes::from("Hello, World!"))))
21}

In the above example, the function hello is basically acting as a "catch-all" for all incoming requests. So, from within hello, we can get the request, determine the path, and perform some action depending on the path.

This is exactly what we want.

What About These Types

I think it is worth it to take some time to see how hyper implements the Request type.

I found its documentation here.

We can do this to get the request method:

1async fn hello(req: Request<hyper::body::Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
2
3    let method = req.method();
4    println!("{}", method);
5
6    Ok(Response::new(Full::new(Bytes::from("Hello, World!"))))
7}

and then I ended up here:

 1async fn hello(req: Request<hyper::body::Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
 2
 3    let method = req.method();
 4    let uri = req.uri();
 5
 6    let req_data = format!("URI: {}
 7METHOD: {}", uri, method);
 8    println!("{}", req_data);
 9
10    Ok(Response::new(Full::new(Bytes::from(req_data))))
11}

OK, so we can set up basic routing pretty easily.

Asking ChatGPT

I asked ChatGPT to help me understand these types a bit more. I said, "what are we saying when we put <hyper::body::Incoming> next to Request", here is the response:

What is hyper::body::Incoming?

hyper::body::Incoming is the specific type used for the body of the request when it's received by the server.

It represents a streaming body of data that is incoming from a client. This body type supports asynchronous operations, allowing the server to handle large or streamed payloads efficiently.

By specifying Incoming as the type parameter, we're saying:

"The Request object contains a body that can be streamed or processed incrementally as it arrives."

What Other Types Can The Request Be?

I dug a bit deeper and learned a bunch of interesting stuff about how the Request type can be used.

Here is a link to the full conversation.

One tid-bit I'd like to place here is:

15. Custom Types
2
3Description: Any custom type that implements the HttpBody trait or is converted from the Request body.
4
5Use Case: For structured data like JSON or XML, you can use types like serde_json::Value or even your own deserialized structs.

I think this line of thinking is going to be common around Rust.

Basic Routing

The docs walk you through how to set up routing using a match table and it looks pretty nice. I wanted to go a simple path and build up. Here is what I got working for catching 404s:

 1async fn hello(req: Request<hyper::body::Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
 2
 3    
 4    if req.method() == "GET" && req.uri().path() == "/" {
 5        return Ok(Response::new(Full::new(Bytes::from("Hello, World!"))));
 6    }
 7
 8    Ok(Response::new(Full::new(Bytes::from("404 not found"))))
 9
10}

But there is a problem: we are not properly setting our status codes or headers. To fix this, we will have to start creating and modeling Responses.

Crafting Responses

I found myself wanting to be able to make changes to my responses prior to sending them. Hyper offers a page on how to make a routing table. I made a few changes to the example and ended up with this:

 1// function to catch all incoming requests
 2async fn catch_all(req: Request<hyper::body::Incoming>) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
 3    match req.uri().path() {
 4        "/" => {
 5            match req.method() {
 6                &Method::GET => {
 7                    let mut res = Response::new(box_response("<h1>Hello, World!</h1>"));
 8                    res.headers_mut().insert("Content-Type", HeaderValue::from_static("text/html"));
 9                    return Ok(res)
10                },
11                _ => {
12                    let mut invalid_method = Response::new(box_response("invalid method"));
13                    *invalid_method.status_mut() = StatusCode::METHOD_NOT_ALLOWED;
14                    return Ok(invalid_method)
15                }
16            }
17
18        },
19        _ => {
20            let mut not_found = Response::new(box_response("<h1>404 not found</h1>"));
21            *not_found.status_mut() = StatusCode::NOT_FOUND;
22            not_found.headers_mut().insert("Content-Type", HeaderValue::from_static("text/html"));
23            return Ok(not_found)
24        }
25    }
26}
27
28// utility function to box up our response body
29fn box_response<T: Into<Bytes>>(chunk: T) -> BoxBody<Bytes, hyper::Error> {
30    Full::new(chunk.into())
31        .map_err(|never| match never {})
32        .boxed()
33}

That's a lot of code, so let's breakdown some things I learned.

Setting Response headers

Response headers are set using the Response.headers_mut().insert() method. When using this, to pass strings in as the header value, I had to use HeaderValue::from_static() and that allowed me to properly set the headers on my response types. Here is an example:

1res.headers_mut().insert("Content-Type", HeaderValue::from_static("text/html"));

Catch-All In Match Expressions

I found it useful to use a catch-all for the match expressions when checking which method a request had as it came into the server.

This type of pattern feels very Go-ish. I like checking the path first, and then dealing with the method. This approach gave me a clean way to do this.

This is what I mean:

 1"/" => {
 2    match req.method() {
 3        &Method::GET => {
 4            let mut res = Response::new(box_response("<h1>Hello, World!</h1>"));
 5            res.headers_mut().insert("Content-Type", HeaderValue::from_static("text/html"));
 6            return Ok(res)
 7        },
 8        _ => {
 9            let mut invalid_method = Response::new(box_response("invalid method"));
10            *invalid_method.status_mut() = StatusCode::METHOD_NOT_ALLOWED;
11            return Ok(invalid_method)
12        }
13    }
14},

Setting Status Codes

Very similar to headers, status codes are set using a method, Response.status_mut() and we use it like:

1let mut not_found = Response::new(box_response("<h1>404 not found</h1>"));
2*not_found.status_mut() = StatusCode::NOT_FOUND;

Conclusion

I think hyper is a great way to get going with building web applications in Rust, especially if you are interested in creating libraries or frameworks. It is non-invasive and extremely minimal. All it really does is provide a few core types to make handling requests and responses easier. That is really the whole deal.

I dug into middleware and I think I will do a whole post dedicated to setting up middleware in hyper. It looks like they recently did an overhaul and stopped using tower which is how they handled middleware previously.

All in all, I am going to learn this tool a bit deeper and come back with more content regarding middleware and route management.

I also think I need to learn a bit more about how to think in Rust. I came across this snippet of code and it made me realize I need to brush up on iterators in Rust:

 1let frame_stream = req.into_body().map_frame(|frame| {
 2    let frame = if let Ok(data) = frame.into_data() {
 3        // Convert every byte in every Data frame to uppercase
 4        data.iter()
 5            .map(|byte| byte.to_ascii_uppercase())
 6            .collect::<Bytes>()
 7    } else {
 8        Bytes::new()
 9    };
10
11    Frame::data(frame)
12});

NOTE TO SELF: Did you do an article about iterators in Rust?