How to handle HTTP requests with Rust and Hyper

45 min read

A router matches requests against registered routes and invokes the associated request handler to return a response. There are many routers available, but they are often tied to a whole framework. Instead, this article explains how to handle HTTP requests with Hyper, how to route requests with Rust pattern matching, and how to handle query parameters, forms, cookies, and more. It assumes you are familiar with the basics of Linux, HTTP, and Rust. For full code samples, see the source files.

§
Introduction

The first step is to create a minimal HTTP server that you can build on for the rest of this article:

$ cargo new --bin web
     Created binary (application) `web` package

Add the crates cookie, form_urlencoded, hyper and tokio as dependencies (enable the full feature set so you can experiment freely):

Cargo.toml
[package]
name = "web"
version = "0.1.0"
edition = "2018"

[dependencies]
cookie = "*"
form_urlencoded = "*"
hyper = { version = "*", features = ["full"] }
tokio = { version = "*", features = ["full"] }

Import the required items:

src/bin/000-introduction.rs
use std::convert::Infallible;
use std::net::SocketAddr;

use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Request, Response, Server, StatusCode};

The following function builds a successful Response with the status 200 OK and an empty Body:

src/bin/000-introduction.rs
fn ok() -> Response<Body> {
    Response::builder()
        .status(StatusCode::OK)
        .body(Body::empty())
        .unwrap()
}

A handler is a function that takes a Request and returns a Response. Reuse the previous function to create handle, the primary request handler:

src/bin/000-introduction.rs
fn handle(_req: Request<Body>) -> Response<Body> {
    ok()
}

The main function instantiates an HTTP server listening on 127.0.0.1:3000 which invokes handle for each incoming request:

src/bin/000-introduction.rs
#[tokio::main]
async fn main() {
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));

    eprintln!("Listening on {}", addr);

    // The closure passed to `make_service_fn` is executed each time a new
    // connection is established and returns a future that resolves to a
    // service.
    let make_service = make_service_fn(|_conn| async {
        // The closure passed to `service_fn` is executed each time a request
        // arrives on the connection and returns a future that resolves
        // to a response.
        Ok::<_, Infallible>(service_fn(|req| async {
            // Call the request handler.
            Ok::<_, Infallible>(handle(req))
        }))
    });

    // Start the server.
    if let Err(e) = Server::bind(&addr).serve(make_service).await {
        eprintln!("Error: {:#}", e);
        std::process::exit(1);
    }
}

You can start this server using Cargo:

$ cargo run --bin 000-introduction
Listening on 127.0.0.1:3000

Then, query it with cURL (the -i option includes the response headers in the output):

$ curl -i http://localhost:3000/
HTTP/1.1 200 OK
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

§
HTTP

The target of an HTTP request corresponds to a resource (e.g., a list of tasks for /tasks, the first one for /tasks/1). A method defines an action on a particular resource (e.g., POST /tasks to create a new task, GET /tasks/1 to view the first one). When a resource does not exist, or the method for that resource is not allowed, the server returns an error to the client.

§
Resources

If there is no request handler associated with a resource, the server returns 404 Not Found:

src/bin/110-target.rs
fn not_found() -> Response<Body> {
    Response::builder()
        .status(StatusCode::NOT_FOUND)
        .body(Body::empty())
        .unwrap()
}

Create an handler index that always responds with 200 OK:

src/bin/110-target.rs
fn index(_req: &Request<Body>) -> Response<Body> {
    ok()
}

Update handle to route requests for / to index, or return 404 Not Found for any other target:

src/bin/110-target.rs
fn handle(req: Request<Body>) -> Response<Body> {
    match req.uri().path() {
        "/" => index(&req),
        _ => not_found(),
    }
}

A request for / returns 200 OK as expected:

$ curl -i http://localhost:3000/
HTTP/1.1 200 OK
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

For any other target, the server returns 404 Not Found:

$ curl -i http://localhost:3000/foo
HTTP/1.1 404 Not Found
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

§
Methods

The method defines the semantics of a request for a particular resource. A handler associated with a resource receives all the requests for this target, regardless of the method. Therefore, it is the responsibility of the handler to allow or deny specific methods, but as an additional guard, the server can deny methods it does not implement before calling any handler.

§
Method not allowed

A handler receiving a request with a forbidden method returns 405 Method Not Allowed with the Allow header to indicate the list of valid methods for that resource:

src/bin/121-method-not-allowed.rs
fn method_not_allowed<S: AsRef<str>>(methods: S) -> Response<Body> {
    Response::builder()
        .status(StatusCode::METHOD_NOT_ALLOWED)
        .header(header::ALLOW, methods.as_ref())
        .body(Body::empty())
        .unwrap()
}

Update index to restrict methods other than GET and HEAD:

src/bin/121-method-not-allowed.rs
fn index(req: &Request<Body>) -> Response<Body> {
    if !matches!(req.method(), &Method::GET | &Method::HEAD) {
        return method_not_allowed("GET, HEAD");
    }

    ok()
}

A POST request for / returns 405 Method Not Allowed:

$ curl -i -X POST http://localhost:3000/
HTTP/1.1 405 Method Not Allowed
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

§
Not implemented

When a method is not allowed in any of the request handlers, the server can return 501 Not Implemented:

src/bin/122-method-not-implemented.rs
fn not_implemented() -> Response<Body> {
    Response::builder()
        .status(StatusCode::NOT_IMPLEMENTED)
        .body(Body::empty())
        .unwrap()
}

Since index only accepts GET or HEAD, you can update handle to return this status for any other method:

src/bin/122-method-not-implemented.rs
fn handle(req: Request<Body>) -> Response<Body> {
    if !matches!(req.method(), &Method::GET | &Method::HEAD) {
        return not_implemented();
    }

    match req.uri().path() {
        "/" => index(&req),
        _ => not_found(),
    }
}

With the method POST, the server returns 501 Not Implemented:

$ curl -i -X POST http://localhost:3000/
HTTP/1.1 501 Not Implemented
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

§
Errors

When the target is unknown, the server returns 404 Not Found, which is an example of a client error. On the contrary, being unable to connect to the database is an example of server error, and it returns 500 Internal Server Error to the client:

src/bin/130-errors.rs
fn internal_server_error() -> Response<Body> {
    Response::builder()
        .status(StatusCode::INTERNAL_SERVER_ERROR)
        .body(Body::empty())
        .unwrap()
}

In a resource handler, you may have to parse request parameters, query a database, render templates, all of which can produce errors. Therefore, the request handlers return a Result<T, E>, where T is a Response. Client errors are regular responses with the Ok variant, but server errors use the Err variant.

Define a generic Result<T> type for all the handlers (you can also use a type defined in crates such as anyhow):

src/bin/130-errors.rs
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + 'static>>;

Then, update index to return this type:

src/bin/130-errors.rs
fn index(req: &Request<Body>) -> Result<Response<Body>> {
    if !matches!(req.method(), &Method::GET | &Method::HEAD) {
        return Ok(method_not_allowed("GET, HEAD"));
    }

    Ok(ok())
}

Create the handler route, responsible for routing the requests as handle did previously, but instead returning a Result:

src/bin/130-errors.rs
fn route(req: &Request<Body>) -> Result<Response<Body>> {
    match req.uri().path() {
        "/" => index(req),
        "/error" => Err("This is an error".into()),
        _ => Ok(not_found()),
    }
}

handle has to return a Response for each request, so any error from route needs to be turned into a 500 Internal Server Error response:

src/bin/130-errors.rs
fn handle(req: Request<Body>) -> Response<Body> {
    route(&req).unwrap_or_else(|err| {
        eprintln!("Error: {:#}", err);
        internal_server_error()
    })
}

A request for / returns 200 OK:

$ curl -i http://localhost:3000/
HTTP/1.1 200 OK
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

A request for /error returns 500 Internal Server Error.

$ curl -i http://localhost:3000/error
HTTP/1.1 500 Internal Server Error
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

The error message is written to the console:

$ cargo run --bin 130-errors
Listening on 127.0.0.1:3000
Error: This is an error

§
Macros

You may have to repeat some operations in multiple handlers. 405 Method Not Allowed responses must have an Allow header, so you also have to return the list of methods in the pattern (unless you can tolerate a slight deviation from RFC7231).

To reduce code duplication and automatically generate the list of allowed methods, you can create a declarative macro:

src/bin/140-macros.rs
macro_rules! allow_method {
    ($l:expr, $($r:pat)|+) => {{
        use hyper::Method;

        if !matches!($l, $($r)|+) {
            let mut allowed = Vec::new();

            for method in &[
                Method::GET,
                Method::HEAD,
                Method::POST,
                Method::PUT,
                Method::PATCH,
                Method::DELETE,
            ] {
                if matches!(method, $($r)|+) {
                    allowed.push(method.as_str());
                }
            }

            return Ok(self::method_not_allowed(allowed.join(", ")));
        }
    }};
}

Update index to make use of it:

src/bin/140-macros.rs
fn index(req: &Request<Body>) -> Result<Response<Body>> {
    allow_method!(req.method(), &Method::GET | &Method::HEAD);

    Ok(ok())
}

With the method POST for /, the server returns 405 Method Not Allowed including the list of allowed methods:

$ curl -i -X POST http://localhost:3000/
HTTP/1.1 405 Method Not Allowed
allow: GET, HEAD
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

Procedural macros may provide a more efficient implementation, which I leave as an exercise for the reader...

§
Routing

A route associates an HTTP target with a resource. Up until now, route relies on a naive string comparison with the target. By leveraging Rust pattern matching, it is possible to match and extract parameters from dynamic routes.

§
Path segmentation

A URL is formed of / separated components. A simple way to match a URL is to split it into segments. For instance, /hello/world is "equivalent" to the segments ["hello", "world"].

First, add a new function to build a 200 OK response with an UTF-8 encoded plain text body:

src/bin/210-segments.rs
fn ok_with_text<S: Into<String>>(text: S) -> Response<Body> {
    Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
        .body(Body::from(text.into()))
        .unwrap()
}

Add the handler hello, that returns a custom "Hello, World!" based on its argument name:

src/bin/210-segments.rs
fn hello(req: &Request<Body>, name: &str) -> Result<Response<Body>> {
    allow_method!(req.method(), &Method::GET | &Method::HEAD);

    Ok(ok_with_text(format!("Hello, {}!", name)))
}

Update route to accept segments as a parameter and match against them:

src/bin/210-segments.rs
fn route(req: &Request<Body>, segments: &[&str]) -> Result<Response<Body>> {
    match segments {
        ["hello"] => hello(req, "World"),
        _ => Ok(not_found()),
    }
}

Update handle to split the path into segments (dropping empty components between duplicate /):

src/bin/210-segments.rs
fn handle(req: Request<Body>) -> Response<Body> {
    // Extract the segments from the URI path.
    let path = req.uri().path().to_owned();
    let segments: Vec<&str> =
        path.split('/').filter(|s| !s.is_empty()).collect();

    // Pass the segments to the routing handler.
    route(&req, &segments).unwrap_or_else(|err| {
        eprintln!("Error: {:#}", err);
        internal_server_error()
    })
}

It is impossible to distinguish /hello and /hello/ from the segments alone, but you will see how to handle this situation in § Handlers § Trailing slashes.

§
Normalization

Akin to filesystem paths, URL paths can contain . and .. components, such that /world/../hello/. is equivalent to /hello. Transforming a path into its shortest equivalent, by eliminating these components, is called normalization. Behind a reverse proxy and with well-behaved clients, you may already receive normalized URLs, otherwise, you can add normalization to handle:

src/bin/220-normalization.rs
fn handle(req: Request<Body>) -> Response<Body> {
    /*
     * Extract and normalize the segments from the URI path.
     */

    let path = req.uri().path().to_owned();
    let mut segments = Vec::new();

    for s in path.split('/') {
        match s {
            "" | "." => {}
            ".." => {
                segments.pop();
            }
            s => segments.push(s),
        }
    }

    // Pass the segments to the routing handler.
    route(&req, &segments).unwrap_or_else(|err| {
        eprintln!("Error: {:#}", err);
        internal_server_error()
    })
}

Test with /world/../hello/.:

$ curl -i http://localhost:3000/world/../hello/.
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 13
date: Thu, 1 Jan 1970 00:00:00 GMT

Hello, World!%

§
Segment matching

As opposed to static URLs, dynamic URLs contain parameters. For example, /tasks/3 and /tasks/42 correspond to the same pattern /tasks/<id>, where the id segment is dynamic. These parameters must be extracted from a routing handler, and passed to a resource handler as arguments.

§
Match a single segment

You can use Rust pattern matching to bind the second segment to the variable name and pass it as an argument to hello:

src/bin/231-extract-segment.rs
fn route(req: &Request<Body>, segments: &[&str]) -> Result<Response<Body>> {
    match segments {
        ["hello"] => hello(req, "World"),
        ["hello", name] => hello(req, name),
        _ => Ok(not_found()),
    }
}

Try /hello/Jane:

curl -i http://localhost:3000/hello/Jane
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 12
date: Thu, 1 Jan 1970 00:00:00 GMT

Hello, Jane!%

§
Match multiple segments

You can also bind multiple segments (forming a sub-slice) to a variable with the .. placeholder (matching zero or more elements). To extract a path, you can join these segments with /:

src/bin/232-extract-segments.rs
fn route(req: &Request<Body>, segments: &[&str]) -> Result<Response<Body>> {
    match segments {
        ["hello"] => hello(req, "World"),
        ["hello", name] => hello(req, name),
        ["hello", s @ ..] => hello(req, &s.join("/")),
        _ => Ok(not_found()),
    }
}

Try /hello/r/rust:

curl -i http://localhost:3000/hello/r/rust
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 14
date: Thu, 1 Jan 1970 00:00:00 GMT

Hello, r/rust!%

§
Additional patterns

There are a number of patterns that you can play with:

match segments {
	// Or pattern.
	[] | ["index.html" | "index.htm"] => ...,

	// One trailing segment.
	["hello1", _] => ...,

	// One or more trailing segment.
	["hello+", s @ ..] if !s.empty() => ...,

	// Zero or more trailing segments.
	["hello*", ..] => ...,

	// Last segment.
	["hello$", .., last] => ...,

	// Custom guard.
	s if is_match(s) => ...,

	_ => Ok(not_found()),
}

§
Nested routing

If you can extract multiple segments from a URL, you can pass them to a sub-routing handler:

src/bin/240-nested-route.rs
fn route_hello(
    req: &Request<Body>,
    segments: &[&str],
) -> Result<Response<Body>> {
    match segments {
        [] => hello(req, "World"),
        [name] => hello(req, name),
        _ => Ok(not_found()),
    }
}

From route, call route_hello with the remaining segments:

src/bin/240-nested-route.rs
fn route(req: &Request<Body>, segments: &[&str]) -> Result<Response<Body>> {
    match segments {
        ["hello", s @ ..] => route_hello(req, s),
        _ => Ok(not_found()),
    }
}

§
Handlers

The routing handlers extract segments from the target and pass them as a string to the resource handlers. Then, it is the responsibility of the resource handler to parse these parameters into their expected type.

§
Trailing slashes

Since both /hello and /hello/ correspond to the same segment hello, a request to any of these targets is routed to the hello handler:

$ curl -i http://localhost:3000/hello
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 13
date: Thu, 1 Jan 1970 00:00:00 GMT

Hello, World!%
$ curl -i http://localhost:3000/hello/
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 13
date: Thu, 1 Jan 1970 00:00:00 GMT

Hello, World!%

Although these two URLs point to the same resource, they are not strictly identical. The page is duplicated, which is undesirable from an SEO perspective. Additionally, it would be extremely surprising if /hello and /hello/ pointed to a different resource. Therefore, each handler should enforce a canonical URL with or without a trailing /.

From an historical point of view, a trailing / indicates a directory, whereas no trailing / indicates a file. For directories, the server returns the content of ./index.html. For example, a static website might have the following layout:

website
├── index.html
├── posts
│   ├── index.html
│   └── my-first-post
│       ├── image.jpg
│       └── index.html
└── static
    └── main.css

Each folder encapsulates a piece of content. From /website/posts/my-first-post/, you can link to the image easily with ./image.jpg.

Dynamic web servers can return whatever they want for any URL, but I try to follow these rules:

  • For static assets, no trailing slashes.
  • For HTML pages, trailing slashes.
  • For API endpoints, no trailing slashes.

Feel free to follow any rules you want, as long as you stay consistent. To redirect the client, use a 308 Permanent Redirect and a Location header for the target URL:

src/bin/310-redirect.rs
fn permanent_redirect<S: AsRef<str>>(url: S) -> Response<Body> {
    Response::builder()
        .status(StatusCode::PERMANENT_REDIRECT)
        .header(header::LOCATION, url.as_ref())
        .body(Body::empty())
        .unwrap()
}

If you want to enforce no trailing slashes, you can redirect the client when the path ends with /:

src/bin/310-redirect.rs
fn hello(req: &Request<Body>, name: &str) -> Result<Response<Body>> {
    allow_method!(req.method(), &Method::GET | &Method::HEAD);

    // Redirect if the path ends with `/`.
    if req.uri().path().ends_with('/') {
        let path = req.uri().path().trim_end_matches('/');

        // Do not redirect the index (always has a trailing `/`).
        if !path.is_empty() {
            // Keep query parameters.
            if let Some(q) = req.uri().query() {
                let mut path = path.to_owned();

                path.push('?');
                path.push_str(q);

                return Ok(permanent_redirect(path));
            }

            return Ok(permanent_redirect(path));
        }
    }

    Ok(ok_with_text(format!("Hello, {}!", name)))
}

Now, /hello/ redirects permanently to /hello:

$ curl -i --head http://localhost:3000/hello/
HTTP/1.1 308 Permanent Redirect
location: /hello
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

You can handle the redirection in a routing handler (or even the reverse proxy) if all the resource handlers use the same convention, but if at some point you want to serve something that resembles a file, like an Atom feed, then it becomes an issue.

Finally, to reduce code duplication, you can use the following macros:

src/bin/310-redirect.rs
macro_rules! redirect_trailing_slash {
    ($req:expr) => {{
        let path = $req.uri().path();

        if !path.ends_with('/') {
            let mut path = path.to_owned();

            path.push('/');

            if let Some(q) = $req.uri().query() {
                path.push('?');
                path.push_str(q);
            }

            return Ok(self::permanent_redirect(path));
        }
    }};
}

macro_rules! redirect_no_trailing_slash {
    ($req:expr) => {{
        let path = $req.uri().path();

        if path.ends_with('/') {
            let path = path.trim_end_matches('/');

            if !path.is_empty() {
                if let Some(q) = $req.uri().query() {
                    let mut path = path.to_owned();

                    path.push('?');
                    path.push_str(q);

                    return Ok(self::permanent_redirect(path));
                }

                return Ok(self::permanent_redirect(path));
            }
        }
    }};
}

§
Path parameters

The URL segments are string slices, so you have to parse them into their expected type. For an invalid request, the server returns 400 Bad Request:

src/bin/320-parse-parameter.rs
fn bad_request<S: Into<String>>(text: S) -> Response<Body> {
    Response::builder()
        .status(StatusCode::BAD_REQUEST)
        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
        .body(Body::from(text.into()))
        .unwrap()
}

In the following example, the parameter is parsed as an usize that indicates the language id for the response. If the parameter is not a number, the handler returns 400 Bad Request, if it is out-of-range, it returns 404 Not Found:

src/bin/320-parse-parameter.rs
fn hello(req: &Request<Body>, id: &str) -> Result<Response<Body>> {
    allow_method!(req.method(), &Method::GET | &Method::HEAD);
    redirect_no_trailing_slash!(req);

    let id = match id.parse::<usize>() {
        Ok(n) => n,
        Err(e) => return Ok(bad_request(e.to_string())),
    };

    let hello = match ["Hello", "Nǐn hǎo", "Namaste", "Hola"].get(id) {
        Some(&s) => s,
        None => return Ok(not_found()),
    };

    Ok(ok_with_text(hello))
}

Update route to pass the id to hello:

src/bin/320-parse-parameter.rs
fn route(req: &Request<Body>, segments: &[&str]) -> Result<Response<Body>> {
    match segments {
        ["hello", id] => hello(req, id),
        _ => Ok(not_found()),
    }
}

With 3, it works as expected:

$ curl -i http://localhost:3000/hello/3
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 4
date: Thu, 1 Jan 1970 00:00:00 GMT

Hola%

With 42, it returns 404 Not Found, since the index is out-of-range:

$ curl -i http://localhost:3000/hello/42
HTTP/1.1 404 Not Found
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

Finally, if the parameter contains characters other than digits, the server returns 400 Bad Request with an error message:

$ curl -i http://localhost:3000/hello/world
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 29
date: Thu, 1 Jan 1970 00:00:00 GMT

invalid digit found in string%

§
Query parameters

Besides the method and the target, a request can contain URL encoded query parameters after the delimiter ?, such as the parameter lang with the value en in /hello?lang=en.

These parameters are accessible with req.uri().query(), and you can parse them as a key/value list with the crate form_urlencoded:

src/bin/330-query-parameter.rs
fn hello(req: &Request<Body>) -> Result<Response<Body>> {
    allow_method!(req.method(), &Method::GET | &Method::HEAD);
    redirect_no_trailing_slash!(req);

    let mut lang = "en".to_owned();

    if let Some(query) = req.uri().query() {
        for (k, v) in form_urlencoded::parse(query.as_bytes()) {
            if k == "lang" {
                lang = v.into_owned();
            }
        }
    }

    let hello = match &*lang {
        "en" => "Hello",
        "zh" => "Nǐn hǎo",
        "hi" => "Namaste",
        "es" => "Hola",
        _ => {
            return Ok(bad_request(format!("Unknown language code `{}`", lang)))
        }
    };

    Ok(ok_with_text(hello))
}

Try /hello?lang=hi:

$ curl -i 'http://localhost:3000/hello?lang=hi'
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 7
date: Thu, 1 Jan 1970 00:00:00 GMT

Namaste%

§
Form parameters

HTML forms commonly use the same URL encoding to send their data, except when the method attribute on the form element is set to POST. In this case, the parameters are submitted through the body of the request.

Reading from the body is an asynchronous operation, so you need to make hello, route, and handle asynchronous by:

  • Replacing fn with async fn.
  • Calling them with .await.

Update hello to allow POST and extract the name parameter from the request body (you need a mutable reference to the request for this operation consumes the body):

src/bin/340-form-parameters.rs
async fn hello(req: &mut Request<Body>) -> Result<Response<Body>> {
    allow_method!(req.method(), &Method::GET | &Method::HEAD | &Method::POST);
    redirect_no_trailing_slash!(req);

    if req.method() == Method::POST {
        let body = hyper::body::to_bytes(req.body_mut()).await?;
        let mut name = None;

        for (k, v) in form_urlencoded::parse(&body) {
            if k == "name" {
                name = Some(v.into_owned());
            }
        }

        let name = match name {
            Some(s) => s,
            None => return Ok(bad_request("Missing `name`")),
        };

        Ok(ok_with_text(format!("Hello, {}!", name)))
    } else {
        Ok(ok_with_text("Hello, World!"))
    }
}

Update route to pass a &mut Request:

src/bin/340-form-parameters.rs
async fn route(
    req: &mut Request<Body>,
    segments: &[&str],
) -> Result<Response<Body>> {
    match segments {
        ["hello"] => hello(req).await,
        _ => Ok(not_found()),
    }
}

In handle, mark the request as mutable and pass a mutable reference to route:

src/bin/340-form-parameters.rs
async fn handle(mut req: Request<Body>) -> Response<Body> {
    // Extract the segments from the URI path.
    let path = req.uri().path().to_owned();
    let segments: Vec<&str> =
        path.split('/').filter(|s| !s.is_empty()).collect();

    route(&mut req, &segments).await.unwrap_or_else(|err| {
        eprintln!("Error: {:#}", err);
        internal_server_error()
    })
}

With the method GET:

$ curl -i http://localhost:3000/hello
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 13
date: Thu, 1 Jan 1970 00:00:00 GMT

Hello, World!%

With the method POST, pass a name with the -d option:

$ curl -i -X POST -d name=Jane http://localhost:3000/hello
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 12
date: Thu, 1 Jan 1970 00:00:00 GMT

Hello, Jane!%

Without a name, you get 400 Bad Request:

$ curl -i -X POST -d foo=bar http://localhost:3000/hello
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 14
date: Thu, 1 Jan 1970 00:00:00 GMT

Missing `name`%

§
Middlewares

With a router, middlewares are needed to perform operations before or after the handler associated with a route is invoked. With an explicit routing approach, you can perform any operation directly.

§
Logging

Update handle to log some information about the connection, the request, and the response:

src/bin/410-logging.rs
fn handle(req: Request<Body>, remote_addr: IpAddr) -> Response<Body> {
    let time = Instant::now();

    let path = req.uri().path().to_owned();
    let segments: Vec<&str> =
        path.split('/').filter(|s| !s.is_empty()).collect();

    let resp = route(&req, &segments).unwrap_or_else(|err| {
        eprintln!("Error: {:#}", err);
        internal_server_error()
    });

    eprintln!(
        "{} {} {} {} {:?}",
        remote_addr,
        req.method(),
        req.uri(),
        resp.status(),
        time.elapsed(),
    );

    resp
}

You need to edit main to pass the remote IP address to handle (add move on the inner closure and async blocks):

src/bin/410-logging.rs
#[tokio::main]
async fn main() {
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));

    eprintln!("Listening on {}", addr);

    let make_service = make_service_fn(|conn: &AddrStream| {
        // Get the remote IP address.
        let remote_addr = conn.remote_addr().ip();
        // Move it to this async block.
        async move {
            // Move it to the closure, and move it again to the inner async
            // block.
            Ok::<_, Infallible>(service_fn(move |req| async move {
                Ok::<_, Infallible>(handle(req, remote_addr))
            }))
        }
    });

    if let Err(e) = Server::bind(&addr).serve(make_service).await {
        eprintln!("Error: {:#}", e);
        std::process::exit(1);
    }
}

After these changes, make some requests and inspect the logs:

$ cargo run --bin 410-logging
Listening on 127.0.0.1:3000
127.0.0.1:36180 GET / 200 OK 127.046µs
127.0.0.1:36182 GET /foo 404 Not Found 22.246µs
127.0.0.1:36184 POST /foo 501 Not Implemented 8.398µs

§
Headers

Behind a reverse proxy, the remote address corresponds to the IP address of the proxy itself. The real client IP address can be transmitted in the header X-Forwarded-For. You can extend the request to extract and save this address in its extension map:

src/bin/420-client-addr.rs
struct ClientAddr(IpAddr);

trait ClientAddrExt {
    fn client_addr(&self) -> Option<IpAddr>;
    fn set_client_addr(&mut self, remote_addr: IpAddr);
}

impl ClientAddrExt for Request<Body> {
    fn client_addr(&self) -> Option<IpAddr> {
        self.extensions().get::<ClientAddr>().map(|a| a.0)
    }

    fn set_client_addr(&mut self, remote_addr: IpAddr) {
        let addr = self
            .headers()
            .get("X-Forwarded-For")
            .and_then(|v| v.to_str().ok())
            .and_then(|s| s.split(',').next())
            .and_then(|s| s.trim().parse::<IpAddr>().ok())
            .unwrap_or(remote_addr);

        self.extensions_mut().insert(ClientAddr(addr));
    }
}

Update handle to use this extension:

src/bin/420-client-addr.rs
fn handle(mut req: Request<Body>, remote_addr: IpAddr) -> Response<Body> {
    let time = Instant::now();

    let path = req.uri().path().to_owned();
    let segments: Vec<&str> =
        path.split('/').filter(|s| !s.is_empty()).collect();

    req.set_client_addr(remote_addr);

    let resp = route(&req, &segments).unwrap_or_else(|err| {
        eprintln!("Error: {:#}", err);
        internal_server_error()
    });

    eprintln!(
        "{} {} {} {} {:?}",
        req.client_addr().unwrap(),
        req.method(),
        req.uri(),
        resp.status(),
        time.elapsed(),
    );

    resp
}

Make a regular request:

$ curl http://localhost:3000/ > /dev/null

Then, make another request with the header X-Forwarded-For, as if the server was behind a reverse proxy:

$ curl -H 'X-Forwarded-For: 192.0.2.1' http://localhost:3000/ > /dev/null

You can see both addresses in the log:

$ cargo run --bin 420-client-addr
Listening on 127.0.0.1:3000
127.0.0.1 GET / 200 OK 174.476µs
192.0.2.1 GET / 200 OK 246.638µs

§
Cookies

A server can set cookies for a client with the header Set-Cookie. On each subsequent request, the client sends back the cookies in the header Cookie. The crate cookie manages cookies in a CookieJar: you can lookup their values, add new ones, modify them, and most importantly, get the delta after you made changes.

Extend Request to extract the original cookies from the headers and return a CookieJar:

src/bin/430-cookies.rs
trait CookiesExt {
    fn cookies(&self) -> CookieJar;
}

impl CookiesExt for Request<Body> {
    fn cookies(&self) -> CookieJar {
        let mut jar = CookieJar::new();

        // Iterate on the Cookie header instances.
        for value in self.headers().get_all(header::COOKIE) {
            // Get the name-value pairs separated by semicolons.
            let it = match value.to_str() {
                Ok(s) => s.split(';').map(str::trim),
                Err(_) => continue,
            };

            // Iterate on the pairs.
            for s in it {
                // Parse and add the cookie to the jar.
                if let Ok(c) = Cookie::parse(s.to_owned()) {
                    jar.add_original(c);
                }
            }
        }

        jar
    }
}

This example uses a cookie to perform authentication. It relies on a session cookie named session_id with a special value for administrator sessions:

src/bin/430-cookies.rs
const ADMIN_SESSION_ID: &str = "aec070645fe53ee3b3763059376134f0";

In practice, you would generate a new id for each session, and store it in a database with the associated user, privileges, expiration time, etc. If a client tries to access a restricted page without the appropriate permission, the server responds with 403 Forbidden:

src/bin/430-cookies.rs
fn forbidden() -> Response<Body> {
    Response::builder()
        .status(StatusCode::FORBIDDEN)
        .body(Body::empty())
        .unwrap()
}

The admin handler authenticates the client by comparing the session cookie value with the admin session id:

src/bin/430-cookies.rs
async fn admin(req: &mut Request<Body>) -> Result<Response<Body>> {
    allow_method!(req.method(), &Method::GET | &Method::HEAD);
    redirect_no_trailing_slash!(req);

    // Match session_id against ADMIN_SESSION_ID.
    if req.cookies().get("session_id").map(Cookie::value)
        != Some(ADMIN_SESSION_ID)
    {
        return Ok(forbidden());
    }

    Ok(ok_with_text("Authenticated"))
}

To login, the client must send a valid password to receive the admin session cookie:

src/bin/430-cookies.rs
async fn login(req: &mut Request<Body>) -> Result<Response<Body>> {
    allow_method!(req.method(), &Method::POST);
    redirect_no_trailing_slash!(req);

    let body = hyper::body::to_bytes(req.body_mut()).await?;
    let mut password = None;

    for (k, v) in form_urlencoded::parse(&body) {
        if k == "password" {
            password = Some(v.into_owned());
        }
    }

    let password = match password {
        Some(s) => s,
        None => return Ok(bad_request("Missing `password`")),
    };

    if password != "hunter2" {
        return Ok(forbidden());
    }

    // Get the cookie jar.
    let mut jar = req.cookies();

    // Build a session cookie.
    let cookie = Cookie::build("session_id", ADMIN_SESSION_ID)
        .path("/")
        .secure(false) // Do not require HTTPS.
        .http_only(true)
        .same_site(cookie::SameSite::Lax)
        .finish();

    // Add it to the jar.
    jar.add(cookie);

    // Prepare the response to redirect to /admin.
    let mut resp = Response::builder()
        .status(StatusCode::SEE_OTHER)
        .header(header::LOCATION, "/admin");

    // Set the changed cookies.
    for cookie in jar.delta() {
        resp = resp.header(header::SET_COOKIE, cookie.to_string());
    }

    // Return with an empty body.
    Ok(resp.body(Body::empty()).unwrap())
}

Update route to forward requests to admin and login:

src/bin/430-cookies.rs
async fn route(
    req: &mut Request<Body>,
    segments: &[&str],
) -> Result<Response<Body>> {
    match segments {
        ["admin"] => admin(req).await,
        ["login"] => login(req).await,
        _ => Ok(not_found()),
    }
}

Without a valid session id, the admin page returns 403 Forbidden:

curl -i http://localhost:3000/admin
HTTP/1.1 403 Forbidden
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

To get the admin cookie, POST the password to the login endpoint (a web browser would then save this cookie and follow the redirection):

curl -i -X POST -d password=hunter2 http://localhost:3000/login
HTTP/1.1 303 See Other
location: /admin
set-cookie: session_id=aec070645fe53ee3b3763059376134f0; HttpOnly; SameSite=Lax; Path=/
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

Finally, request the admin page with this cookie:

curl -i --cookie 'session_id=aec070645fe53ee3b3763059376134f0' http://localhost:3000/admin
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 13
date: Thu, 1 Jan 1970 00:00:00 GMT

Authenticated%

§
Context

In a real web application, the handlers would need to access external or shared resources: the application settings, a database, a templating engine, a job queue. These resources can be shared with the handlers through a context passed along with the request.

To share the same data between multiple threads, you need to use an Arc<T> pointer, where T is your Context. It contains a boolean setting to enable debug mode and a Vec<String> that emulates a table in a database with a single text column. To allow mutable access, place the Vec inside a tokio::sync::Mutex:

src/bin/510-context.rs
struct Context {
    debug: bool,
    data: Mutex<Vec<String>>,
}

For the method GET, the list handler returns the list of rows; for the method POST, it adds a new row based on the form parameter text:

src/bin/510-context.rs
async fn list(
    ctx: &Context,
    req: &mut Request<Body>,
) -> Result<Response<Body>> {
    allow_method!(req.method(), &Method::GET | &Method::HEAD | &Method::POST);

    if req.method() == Method::POST {
        let body = hyper::body::to_bytes(req.body_mut()).await?;
        let mut text = None;

        for (k, v) in form_urlencoded::parse(&body) {
            if k == "text" {
                text = Some(v.into_owned());
            }
        }

        let text = match text {
            Some(s) => s,
            None => return Ok(bad_request("Missing `text`")),
        };

        let mut v = ctx.data.lock().await;

        v.push(text);

        return Ok(created(format!("/{}", v.len() - 1)));
    }

    let mut output = String::new();

    for (id, text) in ctx.data.lock().await.iter().enumerate() {
        writeln!(&mut output, "{} | {}", id, text).unwrap();
    }

    Ok(ok_with_text(output))
}

The details handler returns the text from the given row:

src/bin/510-context.rs
async fn details(
    ctx: &Context,
    req: &Request<Body>,
    id: &str,
) -> Result<Response<Body>> {
    allow_method!(req.method(), &Method::GET | &Method::HEAD);

    let id = match id.parse::<usize>() {
        Ok(n) => n,
        Err(e) => return Ok(bad_request(e.to_string())),
    };

    match ctx.data.lock().await.get(id) {
        Some(s) => Ok(ok_with_text(s)),
        None => Ok(not_found()),
    }
}

Update route to call these two resource handlers and pass a mutable request:

src/bin/510-context.rs
async fn route(
    ctx: &Context,
    req: &mut Request<Body>,
    segments: &[&str],
) -> Result<Response<Body>> {
    if ctx.debug {
        eprintln!("{:?}", req);
    }

    match segments {
        [] => list(ctx, req).await,
        [id] => details(ctx, req, id).await,
        _ => Ok(not_found()),
    }
}

Update handle to pass a mutable request to route:

src/bin/510-context.rs
async fn handle(ctx: Arc<Context>, mut req: Request<Body>) -> Response<Body> {
    let path = req.uri().path().to_owned();
    let segments: Vec<&str> =
        path.split('/').filter(|s| !s.is_empty()).collect();

    route(&ctx, &mut req, &segments)
        .await
        .unwrap_or_else(|err| {
            eprintln!("Error: {:#}", err);
            internal_server_error()
        })
}

In main, the context is initialized, put inside an Arc, then it is cloned and shared with the handlers:

src/bin/510-context.rs
#[tokio::main]
async fn main() {
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));

    eprintln!("Listening on {}", addr);

    // Initialize the context.
    let ctx = Arc::new(Context {
        debug: true,
        data: Mutex::new(Vec::new()),
    });

    let make_service = make_service_fn(|_conn| {
        // Clone the pointer for each connection.
        let ctx = Arc::clone(&ctx);
        async move {
            Ok::<_, Infallible>(service_fn(move |req| {
                // Clone the pointer for each request.
                let ctx = Arc::clone(&ctx);
                async move { Ok::<_, Infallible>(handle(ctx, req).await) }
            }))
        }
    });

    if let Err(e) = Server::bind(&addr).serve(make_service).await {
        eprintln!("Error: {:#}", e);
        std::process::exit(1);
    }
}

Initially, there is no data:

$ curl -i http://localhost:3000/
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

With the method POST, you can add new rows:

$ curl -i -X POST -d text=hello http://localhost:3000/
HTTP/1.1 201 Created
location: /0
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT
$ curl -i -X POST -d text=hola http://localhost:3000/
HTTP/1.1 201 Created
location: /1
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT
$ curl -i -X POST -d text=aloha http://localhost:3000/
HTTP/1.1 201 Created
location: /2
content-length: 0
date: Thu, 1 Jan 1970 00:00:00 GMT

The new rows appear in the list:

$ curl -i http://localhost:3000/
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 11
date: Thu, 1 Jan 1970 00:00:00 GMT

0 | hello
1 | hola
2 | aloha

You can view a single one given its id:

$ curl -i http://localhost:3000/0
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 5
date: Thu, 1 Jan 1970 00:00:00 GMT

hello%

§
Conclusion

If you decide to build a web server from scratch, I think Rust and Hyper are solid options against micro-frameworks such as Flask. Hyper provides the building blocks for asynchronous, safe, "low-level" HTTP handling, and Rust pattern matching is powerful enough to replace a router. Starting from here, you can add anything to the resource handlers. For example, this website relies on the same techniques, with the addition of content and asset management, an SQLite database, HTML templating, etc.