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):
[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:
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
:
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:
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:
#[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
:
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
:
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:
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:
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
:
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
:
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:
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:
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
):
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + 'static>>;
Then, update index
to return this type:
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
:
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:
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:
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:
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:
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
:
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:
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 /
):
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
:
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
:
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 /
:
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:
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:
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:
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 /
:
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:
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
:
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
:
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
:
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
:
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
withasync 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):
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
:
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
:
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:
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):
#[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:
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:
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
:
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:
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
:
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:
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:
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
:
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
:
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
:
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:
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:
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
:
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:
#[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.