Phillip England
into the networking abyss
12/16/2024
The sandbox for this article is in this
repo.
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.
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.
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.
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:
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."
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.
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.
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.
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"));
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},
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;
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?