axum
Welcome to the axum by example book.
Thanks
Many of the examples are based on the ones in the axum source code. Without those and without the developer of axum this book would not be possible.
Goals
- Provide an easy way to use ready-made examples with explanations.
- A way to learn using axum.
- The book can be found at axum.code-maven.com.
Prerequisites
What do you need to know and what do you need to have on your computer to follow this book?
- You will need to have Rust, more specifically the Rust compiler and the toolchain installed on your computer.
- axum and the other dependencies will be installes using the Rust toolchain, you don’t have to worry about that.
- You will have to be somewhat familiar with Rust. I’d recommend going through the Rust book
TODO: A little explanation about Rust and crates.
HTTP request methods
Web browsers (or clients) communicate with servers using the HTTP protocol. The protocol defines a number of methods. The most commonly used ones are called GET and POST. GET is usually used when you visit pages and POST is usually used when you submit forms. We’ll see them through the examples.
See the full list of HTTP request methods
HTTP response status codes
When a web sever responds to a request it includes a status code. The status codes include a number and a default name. For exampl “200 OK” means success. “404 NOT FOUND” means the requested page does not exist. “500 INTERNAL SERVER ERROR” means the server crashed. (e.g. there was an unhandled exception in the code.)
See the full list of HTTP response status codes.
Installing Rust
You can use Rust on all the major desktop operating systems.
Follow the instruction on the official web site of the Rust programming language.
How to run the examples?
You can run the examples directly from the git repository of the book.
git clone https://github.com/szabgab/axum.code-maven.com.git
cd axum.code-maven.com
cd src/examples/NAME
For example, in order to run the hello-world example you need to execute:
$ cd src/examples/hello-world
$ cargo run
Then you can visit the web site using this address: http://localhost:3000/.
How to run the tests
I am a trong beliver (with evidence) of writing tests. So each example comes with a bunch of test.
You can run the tests in each folder by typing the command:
$ cargo test
axum ecosystem
Moved to the awesome-axum GitHub repository.
Tools
The Rust crates and other tools that we are going to use in this book.
Run time crates
Testing crates
- headers.
- http-body-util.
- tower.
- serde.
- mime.
Helper tools
- curl.
Introduction
The first steps writing a web application using axum.
Simple handling of parameters in GET and POST requests and as part of the path.
Hello world on the command line
Before we start building web applications using axum, first let’s go over step-by-step how you can start building an application.
In order to get started open a terminal window and type in the following:
cargo new hello-world
This will create a folder called hello-world with some basic files. Using the tree command available on Linux machines we can see the layout of the folder:
$ tree hello-world/
hello-world/
├── Cargo.toml
└── src
└── main.rs
2 directories, 2 files
The Cargo.toml file contains the meta-data of the project sucha as the name of the crate thatwill be hello-world and the dependencies that we’ll add soon.
[package]
name = "hello-world"
version = "0.1.0"
edition = "2024"
[dependencies]
The main.rs file contains a very basic Rust application printing Hello, world! on the screen.
fn main() {
println!("Hello, world!");
}
In addition a .gitignore file is also created
/target
and a git repository is set up for the project. So you’ll also find the .git folder.
Run the “application”
Let’s change into the directory of the project.
cd hello-world
and run the application:
cargo run
This will compile the application in the target/ folder (hence it was listed in the .gitignore file.),
eventualy creating the target/debug/hello-world executable. This command will also run the executable
printing Hello, world! to the screen.
It will also create a file called Cargo.lock that will hold the exact version of all the dependencies (both direct and indirect) of the project. For now it is mostly empty.
What’s next?
On the next pages we’ll see a number of examples. You can create a new crate for each example or your can simply replace the files in the current folder with the files of the example.
Hello plain text world!
In the very first example with axum we try to show Hello World!. In bold.
The application depends on axum and tokio. We also have tests. They have some other dependencies. See the Cargo.toml file for the list of dependencies.
You can either edit the Cargo.toml file manually to add the dependencies or you can run the following commands:
Add axum as a dependency: Mind you our examples use version 0.8.x and acording to the source of axum the 0.9.x versions will have some breaking changes. I am planning to update the examples once 0.9.x is released, bu you have to make sure you use the correct version of axum.
$ cargo add axum
We also need to add tokio with the full feature:
$ cargo add tokio -F full
The dependencies needed for testing:
$ cargo add --dev headers
$ cargo add --dev http-body-util
$ cargo add --dev tower -F util
Cargo.toml
[package]
name = "hello-plain-world"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
The code
At the bottom of this page you can find the full content of the main.rs and tests.rs files, but let’s go over them step-by-step.
We’ll use the get and Router objects of axum so in order to be able to write the short name we need to import them.
#![allow(unused)]
fn main() {
use axum::{routing::get, Router};
}
We create a function that will handle the request. axum is asynchronous, so we prefix our function definition with async.
The name of the function is arbitrary. Later we’ll have a separate entry that maps the URL path to this function.
For now, for this simple case our function returns a static string which is just an HTML snippet.
#![allow(unused)]
fn main() {
async fn handle_main_page() -> &'static str {
"<h1>Hello, World!</h1>"
}
}
The next thing is mapping the URL path(es) to the appropriate function(s). Currently we have one path mapped to one function, but of course in a bigger application we’ll have many pathes mapped to many functions. The idea of this mapping is usually called “routing” in the web development world. So we create a Router object and map the / path to the handle_main_page function for http GET requests.
#![allow(unused)]
fn main() {
fn create_router() -> Router {
Router::new().route("/", get(handle_main_page))
}
}
Finally let’s put these to gether. We have an async main function that will be the entry point for tokio, the async system axum built on top of.
- We create the application.
- We setup a
TcpListeneron127.0.0.1making it accessible only from our local computer. We use port 3000. - The we call
axum::servethat will enter a loop waiting for connections.
#[tokio::main]
async fn main() {
// build our application with a route
let app = create_router();
// run it
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
That’s it for the application.
Run with
cargo run
then visit http://localhost:3000/
You will see <h1>Hello, World!</h1>, yes including the HTML tags. That happens as the Content-type of the response is text/plain instead of text/html.
We can see this by using curl in another terminal with the -i flag and later in the tests.
$ curl -i http://localhost:3000
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 22
date: Tue, 15 Apr 2025 09:48:31 GMT
<h1>Hello, World!</h1>
In the next example we’ll see how to make axum set the content type to text/html to convince the browser to interpret the HTML.
Testing
Before we go on to the next example, let’s see how can we write a test for this one.
We can put the tests in the main.rs, but it is probably better to put them in a separate file called tests.rs.
Still we need to make our code aware of the tests by adding the following lines at the end of the main.rs file.
It will compile the tests when we try to run them.
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests;
}
In the tests.rs first we import a bunch of external dependencies:
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use headers::ContentType;
use http_body_util::BodyExt;
use tower::ServiceExt;
}
Then all the functions from the main.rs file.
#![allow(unused)]
fn main() {
use super::*;
}
In reality we only need the create_router function so we could have written:
#![allow(unused)]
fn main() {
use super::create_router;
}
The testing code is rather verbose, but in a nutshel, it uses the oneshot method to send in a single get request to the / path. It does not launch a web server, it does not listen on any port. It runs it internally in axum.
We can then interrogate the response we received.
- Check the status code.
- Check the
Content-typein two different ways! One seems to be more simple, the other one usesheaders::ContentType. - Check the body of the response, the actual HTML.
main.rs
use axum::{Router, routing::get};
async fn handle_main_page() -> &'static str {
"<h1>Hello, World!</h1>"
}
fn create_router() -> Router {
Router::new().route("/", get(handle_main_page))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
tests.rs
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use headers::ContentType;
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/plain; charset=utf-8");
let content_type = response
.headers()
.get("content-type")
.map(|header| header.to_str().unwrap().parse::<ContentType>().unwrap());
assert_eq!(content_type, Some(ContentType::text_utf8()));
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "<h1>Hello, World!</h1>");
}
}
Troubleshooting: Address already in use
When you try to run your application you might encounter this error
thread 'main' (81033) panicked at src/main.rs:17:10:
called `Result::unwrap()` on an `Err` value: Os { code: 98, kind: AddrInUse, message: "Address already in use" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
This means that there is another application running on the same port. (port 3000) It might be that you have already tried to run an example in a different window and you have not shut it down.
On Linux or Mac it might be that you used Ctrl-Z to stop the program. Which actually only suspends it, but keeps the port used.
The solution is to either find the other instance and close it or to launch this instance on a different port.
You can do the latter by editing the main.rs file and replacing the port number.
There are a number of tools that can help you find the other application using this port. See the tools:
Linux
netstat -nlp | grep 3000
Linux and macOS
lsof -P -i :3000
Windows
netstat -ano | findstr 3000
Hello HTML World
In the previous version of the standard “Hello World” application we sent some HTML, but the content type was set by axum to be text/plain and thus the browser showed the HTML tags in their natural beauty without renedring them.
Let’s improve that.
You can reuse the same crate or create a new one. It is up to you.
This is how our Cargo.toml file looks like. I think besides the name, it is the same as in the previous example.
[package]
name = "hello-html-world"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
The code
Let’s only focus on the differences from the previous example.
We are also going to use the Html struct from axum::response so we import it:
#![allow(unused)]
fn main() {
use axum::{response::Html, routing::get, Router};
}
In the function that handles the request we wrap the result in the Html struct. We need to do it both in the signature of the function and in the actual return value.
#![allow(unused)]
fn main() {
async fn handle_main_page() -> Html<&'static str> {
Html("<h1>Hello, World!</h1>")
}
}
All the rest is the same.
To run the application type in the following command:
$ cargo run
This will compile the code, run the server and print the following to the terminal:
listening on http://127.0.0.1:3000
You can then open your browser and visit that address.
You should see Hello World! in big letters.
Handling other pages
If you try to visit some other page on your server e.g. http://127.0.0.1:3000/hi you will get a blank page.
We’ll see what happens there and how to make axum to display a custom 404 error page.
Checking with curl
The curl command allows you to access web sites from the command line. It is a very handy tool.
Let’s see how can we use it with our web site.
Accessing the main page:
$ curl http://localhost:3000/
<h1>Hello, World!</h1>
We can also observe what happens if we try to access a page that does not exist. It seems that nothing happens which is rather inconvenient. This is the blank page we saw earlier.
$ curl http://localhost:3000/hi
Using the -i flag we can ask curl to also display the HTTP header the server sent us.
Using the upper-case -I flag curl would print only the header that was sent by the server.
It might be more convenient.
Accessing the main page we get the following:
$ curl -I http://localhost:3000/
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 22
date: Fri, 14 Mar 2025 08:27:44 GMT
<h1>Hello, World!</h1>
The first line includes the status code. This time it is 200 OK success status.
Accessing a page that does not exists we get a 404 Not Found error status.
$ curl -I http://localhost:3000/hi
HTTP/1.1 404 Not Found
date: Fri, 14 Mar 2025 08:27:41 GMT
Shutting down our local server
Once you are done with this experiment you will want to shut down this local web server. Return to the terminal where you ran it and press Ctrl-C.
Editing this example
Feel free to edit this example and see what happens. However, remember that after each change you’ll need to stop the server and start it again. This is rather inconvenient. Later we’ll see how to make Rust automatically recompile and restart the server every time to make some changes.
Improving the 404 page
You might dislike the fact visiting a non-existent path returns a blank page.
Check out the example showing the 404 handler.
The full example
use axum::{Router, response::Html, routing::get};
async fn handle_main_page() -> Html<&'static str> {
Html("<h1>Hello, World!</h1>")
}
fn create_router() -> Router {
Router::new().route("/", get(handle_main_page))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
tests.rs
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "<h1>Hello, World!</h1>");
}
#[tokio::test]
async fn test_missing_page() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/other")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// No content type
assert!(response.headers().get("content-type").is_none());
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "");
}
}
Echo GET - accepting Query params
Show how to accept parameters in a GET request.
Dependencies
In addition to the dependencies already used in the earlier example we’ll also need to use serde for serialization.
Pages
There are two function handling two paths:
The main page is static HTML
The rust code is the same as in previous example, but this time the HTML is a bit more complex. The browser will display a form with an entry box and a button that says “Echo”.
async fn main_page() -> Html<&'static str> {
Html(
r#"
<form method="get" action="/echo">
<input type="text" name="text">
<input type="submit" value="Echo">
</form>
"#,
)
}
Clicking on that button after typing in the text “Hello World” will bring the browser to a url:
/echo?text=Hello+World. (The space was replaced by a + sign.)
We need to create a struct representing the parameters of this query:
Struct describing the parameters
#![allow(unused)]
fn main() {
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Params {
text: String,
}
}
The echo page is dynamic String
A function that accepts a Params struct wrapped in a Query struct. The variable params will be the deserialized value from the Query string. We can use it to create the string we are returning. We are now returning a String and not a static str because we need to create the string on the fly.
#![allow(unused)]
fn main() {
async fn echo(Query(params): Query<Params>) -> Html<String> {
println!("params: {:?}", params);
Html(format!(r#"You said: <b>{}</b>"#, params.text))
}
}
Mapping the routes to functions
Then we map the two pathes to the appropriate functions:
#![allow(unused)]
fn main() {
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/echo", get(echo))
}
}
Running
$ cargo run
GET the main page using curl
$ curl http://localhost:3000/
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 131
date: Tue, 18 Mar 2025 08:04:53 GMT
<form method="get" action="/echo">
<input type="text" name="text">
<input type="submit" value="Echo">
</form>
GET request with parameter
$ curl -i http://localhost:3000/echo?text=Hello+World!
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 29
date: Tue, 18 Mar 2025 08:06:31 GMT
You said: <b>Hello World!</b>
GET request without the parameter
$ curl -i http://localhost:3000/echo
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 56
date: Tue, 18 Mar 2025 08:05:13 GMT
Failed to deserialize query string: missing field `text`
GET request with parameter name but without value
$ curl -i http://localhost:3000/echo?text=
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 17
date: Tue, 18 Mar 2025 08:07:04 GMT
You said: <b></b>
Cargo.toml
[package]
name = "echo-get"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
The full example
use axum::{Router, extract::Query, response::Html, routing::get};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Params {
text: String,
}
async fn main_page() -> Html<&'static str> {
Html(
r#"
<form method="get" action="/echo">
<input type="text" name="text">
<input type="submit" value="Echo">
</form>
"#,
)
}
async fn echo(Query(params): Query<Params>) -> Html<String> {
println!("params: {:?}", params);
Html(format!(r#"You said: <b>{}</b>"#, params.text))
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/echo", get(echo))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
Testing
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<form method="get" action="/echo">"#));
}
#[tokio::test]
async fn test_echo_with_data() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/echo?text=Hello+World!")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "You said: <b>Hello World!</b>");
}
#[tokio::test]
async fn test_echo_without_data() {
let response = create_router()
.oneshot(Request::builder().uri("/echo").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST); // 400
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(
html,
"Failed to deserialize query string: missing field `text`"
);
}
#[tokio::test]
async fn test_echo_missing_value() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/echo?text=")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "You said: <b></b>");
}
#[tokio::test]
async fn test_echo_extra_param() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/echo?text=Hello&extra=123")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "You said: <b>Hello</b>");
}
}
Echo POST
Show how to accept parameters in a POST request.
Running
cargo run
GET the main page
$ curl -i http://localhost:3000/
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 132
date: Tue, 18 Mar 2025 08:21:36 GMT
<form method="post" action="/echo">
<input type="text" name="text">
<input type="submit" value="Echo">
</form>
POST request setting the header and the data
$ curl -i -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "text=Hello World!" \
http://localhost:3000/echo
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 29
date: Tue, 18 Mar 2025 08:23:51 GMT
You said: <b>Hello World!</b>
POST missing parameter
$ curl -i -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
http://localhost:3000/echo
HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
content-length: 53
date: Tue, 18 Mar 2025 08:25:39 GMT
Failed to deserialize form body: missing field `text`
[package]
name = "echo-post"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
mime = "0.3.17"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
The code
use axum::{
Form, Router,
response::Html,
routing::{get, post},
};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Params {
text: String,
}
async fn main_page() -> Html<&'static str> {
Html(
r#"
<form method="post" action="/echo">
<input type="text" name="text">
<input type="submit" value="Echo">
</form>
"#,
)
}
async fn echo(Form(params): Form<Params>) -> Html<String> {
println!("params: {:?}", params);
Html(format!(r#"You said: <b>{}</b>"#, params.text))
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/echo", post(echo))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
Testing
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{self, Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<form method="post" action="/echo">"#));
}
#[tokio::test]
async fn test_echo_with_data() {
let response = create_router()
.oneshot(
Request::builder()
.method(http::Method::POST)
.uri("/echo")
.header(
http::header::CONTENT_TYPE,
mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(),
)
.body(Body::from("text=Hello+World!"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "You said: <b>Hello World!</b>");
}
#[tokio::test]
async fn test_echo_without_data() {
let response = create_router()
.oneshot(
Request::builder()
.method(http::Method::POST)
.uri("/echo")
.header(
http::header::CONTENT_TYPE,
mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); // 422
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(
html,
"Failed to deserialize form body: missing field `text`"
);
}
#[tokio::test]
async fn test_echo_missing_value() {
let response = create_router()
.oneshot(
Request::builder()
.method(http::Method::POST)
.uri("/echo")
.header(
http::header::CONTENT_TYPE,
mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(),
)
.body(Body::from("text="))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "You said: <b></b>");
}
#[tokio::test]
async fn test_echo_extra_param() {
let response = create_router()
.oneshot(
Request::builder()
.method(http::Method::POST)
.uri("/echo")
.header(
http::header::CONTENT_TYPE,
mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(),
)
.body(Body::from("text=Hello&extra=123"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "You said: <b>Hello</b>");
}
}
Path parameters
Show how to accept parameters in the path of the request. For example we to accept all the paths that look like this: https://example.org/user/foobar. This will accept any value after /user/ except a slash.
So /user/foo/bar will not match. For that see the Wildcard Path parameters.
Running
cargo run
GET the main page
$ curl -i http://localhost:3000/
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 89
date: Tue, 18 Mar 2025 09:32:55 GMT
<a href="/user/foo">/user/foo</a><br>
<a href="/user/bar">/user/bar</a><br>
Getting user Foo
$ curl -i http://localhost:3000/user/Foo
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 11
date: Tue, 18 Mar 2025 09:35:45 GMT
Hello, Foo!
Try without a username
$ curl -i http://localhost:3000/user/
HTTP/1.1 404 Not Found
content-length: 0
date: Tue, 18 Mar 2025 09:36:15 GMT
Cargo.toml
[package]
name = "path-parameters"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
The whole example
use axum::{Router, extract::Path, response::Html, routing::get};
async fn main_page() -> Html<&'static str> {
Html(
r#"
<a href="/user/foo">/user/foo</a><br>
<a href="/user/bar">/user/bar</a><br>
"#,
)
}
async fn user_page(Path(name): Path<String>) -> Html<String> {
println!("user: {}", name);
Html(format!("Hello, {}!", name))
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/user/{name}", get(user_page))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<a href="/user/foo">/user/foo</a><br>"#));
}
#[tokio::test]
async fn test_user_page() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/user/qqrq")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "Hello, qqrq!");
}
#[tokio::test]
async fn test_path_with_slash() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/user/some/thing")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// No content-type
assert!(response.headers().get("content-type").is_none());
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "");
}
}
Testing
Writing automated tests for your application can save you a lot of time down the road and you might even develop you application much faster if instead of checking it in a browser you write test. This is especially true if you are implementing an API which is designed to be consumed by other software anyway.
In main.rs we need to mention the test module:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests;
}
Wildcard Path Parameters
We can put a star * in-front of the name we use for the path.
That means it will accept slashed as well and it will capture any path.
This can be useful if you’d like to map a filesystem, or if for some reason a / is an acceptable
value in the specific field. For example git allows branch-names to contain a slash /.
Cargo.toml
[package]
name = "path-parameters"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
The whole example
use axum::{Router, extract::Path, response::Html, routing::get};
async fn main_page() -> Html<&'static str> {
Html(
r#"
<a href="/root/">/root/</a><br>
<a href="/root/etc">/root/etc</a><br>
<a href="/root/var/log/nginx.log">/root/var/log/nginx.log</a><br>
"#,
)
}
async fn access_path(Path(name): Path<String>) -> Html<String> {
Html(format!("Path: <b>{}</b>", name))
}
async fn handle_root() -> Html<String> {
Html(String::from("Root"))
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/root/{*name}", get(access_path))
.route("/root/", get(handle_root))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<a href="/root/var/log/nginx.log">/root/var/log/nginx.log</a><br>"#));
}
#[tokio::test]
async fn test_root() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/root/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "Root");
}
#[tokio::test]
async fn test_root_etc() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/root/etc")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "Path: <b>etc</b>");
}
#[tokio::test]
async fn test_root_file() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/root/var/log/nginx.log")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "Path: <b>var/log/nginx.log</b>");
}
}
Overlapping pathes an path precedence
If there are two routes that match the exact same path and handle the same http method then we’ll get a run-time panic.
#![allow(unused)]
fn main() {
async fn special_page() -> Html<String> {
Html(String::from("Special"))
}
async fn other_page() -> Html<String> {
Html(String::from("Other"))
}
fn create_router() -> Router {
Router::new()
.route("/special", get(special_page))
.route("/special", get(other_page))
}
}
This is the panic:
Overlapping method route. Handler for `GET /special` already exists
Though it would be nicer if this was recognized during the compilation already, but
the panic happens when we try to create the Router before the server starts, so
the simplest test that calls the create_router function will catch this problem.
It is fine to map the same path for different HTTP methods (eg. one of them for GET and the other one for POST).
It can still happen that there are two routes that match a given path if one of them is a capturing variable. In that case the more specific matches. The order of the declaration does not matter.
Cargo.toml
[package]
name = "path-parameters"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
The whole example
use axum::{Router, extract::Path, response::Html, routing::get};
async fn main_page() -> Html<&'static str> {
Html(
r#"
<a href="/foo">/foo</a><br>
<a href="/special">/special</a><br>
"#,
)
}
async fn name_page(Path(name): Path<String>) -> Html<String> {
Html(format!("Hello, {}!", name))
}
async fn special_page() -> Html<String> {
Html(String::from("Special"))
}
//async fn other_page() -> Html<String> {
// Html(String::from("Other"))
//}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/{name}", get(name_page))
.route("/special", get(special_page))
//.route("/special", get(other_page))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<a href="/foo">/foo</a><br>"#));
}
#[tokio::test]
async fn test_regular_page() {
let response = create_router()
.oneshot(Request::builder().uri("/foo").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "Hello, foo!");
}
#[tokio::test]
async fn test_special_page() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/special")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "Special");
}
}
Input validation
For GET, POST, and path parameters.
- Accepting strings.
- Accepting values in other types (numbers, booleans).
- Accepting only IDs that are in the database.
- Accepting a limited set of values that can be defined in an enum.
GET input validation minimum length
[package]
edition = "2021"
name = "example-validator"
publish = false
version = "0.1.0"
[dependencies]
axum = "0.8.8"
serde = { version = "1.0", features = ["derive"] }
thiserror = "2"
tokio = { version = "1.0", features = ["full"] }
validator = { version = "0.20.0", features = ["derive"] }
[dev-dependencies]
http-body-util = "0.1.0"
tower = { version = "0.5.2", features = ["util"] }
Code
use axum::{
extract::{rejection::FormRejection, Form, FromRequest, Request},
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
Router,
};
use serde::{de::DeserializeOwned, Deserialize};
use thiserror::Error;
use tokio::net::TcpListener;
use validator::Validate;
fn create_router() -> Router {
Router::new()
.route("/", get(handle_main_page))
.route("/echo", get(echo))
}
#[derive(Debug, Deserialize, Validate)]
pub struct TextInput {
#[validate(length(min = 6, message = "Must be at least 6 characters"))]
pub text: String,
}
async fn handle_main_page() -> Html<&'static str> {
Html(
r#"<h1>Echo</h1>
<form method="GET" action="/echo">
<input name="text">
<input type="submit" value="Echo">
</form>
"#,
)
}
async fn echo(ValidatedForm(input): ValidatedForm<TextInput>) -> Html<String> {
Html(format!("<h1>You typed in, <b>{}</b>!</h1>", input.text))
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ValidatedForm<T>(pub T);
impl<T, S> FromRequest<S> for ValidatedForm<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
Form<T>: FromRequest<S, Rejection = FormRejection>,
{
type Rejection = ServerError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Form(value) = Form::<T>::from_request(req, state).await?;
value.validate()?;
Ok(ValidatedForm(value))
}
}
#[derive(Debug, Error)]
pub enum ServerError {
#[error(transparent)]
ValidationError(#[from] validator::ValidationErrors),
#[error(transparent)]
AxumFormRejection(#[from] FormRejection),
}
impl IntoResponse for ServerError {
fn into_response(self) -> Response {
match self {
ServerError::ValidationError(_) => {
let message = format!("Input validation error: [{self}]").replace('\n', ", ");
(StatusCode::BAD_REQUEST, message)
}
ServerError::AxumFormRejection(_) => (StatusCode::BAD_REQUEST, self.to_string()),
}
.into_response()
}
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
async fn get_html(response: Response<Body>) -> String {
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::get("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let html = get_html(response).await;
assert!(html.contains("<h1>Echo</h1>"));
assert!(html.contains(r#"<form method="GET" action="/echo">"#));
}
#[tokio::test]
async fn test_no_param() {
let response = create_router()
.oneshot(Request::get("/echo").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let html = get_html(response).await;
assert_eq!(html, "Failed to deserialize form: missing field `text`");
}
#[tokio::test]
async fn test_with_param_without_value() {
let response = create_router()
.oneshot(Request::get("/echo?text=").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let html = get_html(response).await;
assert_eq!(
html,
"Input validation error: [text: Must be at least 6 characters]"
);
}
#[tokio::test]
async fn test_with_param_with_short_value() {
let response = create_router()
.oneshot(
Request::get("/echo?text=short")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let html = get_html(response).await;
assert_eq!(
html,
"Input validation error: [text: Must be at least 6 characters]"
);
}
#[tokio::test]
async fn test_with_param_and_value() {
let response = create_router()
.oneshot(
Request::get("/echo?text=Long+text")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let html = get_html(response).await;
assert_eq!(html, "<h1>You typed in, <b>Long text</b>!</h1>");
}
}
versioning - path parameter with fixed values
Sometimes the path-parameter is from a fixed set of values. For example if we are build an API and the first part of the path is the version number of the API then we might accept the strings v1, v2, v3, but no other value.
In this example we see exactly that.
[package]
name = "versioning"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
mime = "0.3.17"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{
RequestPartsExt, Router,
extract::{FromRequestParts, Path},
http::{StatusCode, request::Parts},
response::{Html, IntoResponse, Response},
routing::get,
};
use std::collections::HashMap;
async fn main_page() -> Html<&'static str> {
Html(
r#"
<a href="/api/v1/status">v1</a><br>
<a href="/api/v2/status">v2</a><br>
<a href="/api/v3/status">v3</a><br>
<a href="/api/v4/status">v4</a><br>
"#,
)
}
#[derive(Debug)]
enum Version {
V1,
V2,
V3,
}
impl<S> FromRequestParts<S> for Version
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let params: Path<HashMap<String, String>> =
parts.extract().await.map_err(IntoResponse::into_response)?;
let version = params
.get("version")
.ok_or_else(|| (StatusCode::NOT_FOUND, "version param missing").into_response())?;
match version.as_str() {
"v1" => Ok(Version::V1),
"v2" => Ok(Version::V2),
"v3" => Ok(Version::V3),
_ => Err((StatusCode::NOT_FOUND, "unknown version").into_response()),
}
}
}
async fn handle_api(version: Version) -> Html<String> {
Html(format!("received request with version {version:?}"))
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/api/{version}/status", get(handle_api))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_v1() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/api/v1/status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "received request with version V1");
}
#[tokio::test]
async fn test_v4() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/api/v4/status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/plain; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "unknown version");
}
#[tokio::test]
async fn test_api() {
let response = create_router()
.oneshot(Request::builder().uri("/api/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// no content-type
assert!(response.headers().get("content-type").is_none());
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "");
}
}
GET parameter with fixed values
TODO!
[package]
name = "versioning"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
mime = "0.3.17"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{
RequestPartsExt, Router,
extract::{FromRequestParts, Path},
http::{StatusCode, request::Parts},
response::{Html, IntoResponse, Response},
routing::get,
};
use std::collections::HashMap;
async fn main_page() -> Html<&'static str> {
Html(
r#"
<a href="/api/v1/status">v1</a><br>
<a href="/api/v2/status">v2</a><br>
<a href="/api/v3/status">v3</a><br>
<a href="/api/v4/status">v4</a><br>
"#,
)
}
#[derive(Debug)]
enum Version {
V1,
V2,
V3,
}
impl<S> FromRequestParts<S> for Version
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let params: Path<HashMap<String, String>> =
parts.extract().await.map_err(IntoResponse::into_response)?;
let version = params
.get("version")
.ok_or_else(|| (StatusCode::NOT_FOUND, "version param missing").into_response())?;
match version.as_str() {
"v1" => Ok(Version::V1),
"v2" => Ok(Version::V2),
"v3" => Ok(Version::V3),
_ => Err((StatusCode::NOT_FOUND, "unknown version").into_response()),
}
}
}
async fn handle_api(version: Version) -> Html<String> {
Html(format!("received request with version {version:?}"))
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/api/{version}/status", get(handle_api))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_v1() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/api/v1/status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "received request with version V1");
}
#[tokio::test]
async fn test_v4() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/api/v4/status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "unknown version");
}
}
Set Content-type
axum provide some tools to set the response Content-type to some of the common values, and provides us ways to set the Content-type to any arbitrary string.
A few of the common Content-type values
See axum responses. The IntoResponse trait.
[package]
name = "set-content-type"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
serde_json = "1.0.149"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{
Json, Router,
http::{
StatusCode,
header::{self, HeaderMap},
},
response::{Html, IntoResponse},
routing::get,
};
use std::time::{SystemTime, UNIX_EPOCH};
async fn handle_main_page() -> Html<&'static str> {
Html(
r#"
<h1>Set Content-Type</h1>
Main page is <b>static text/html</b><br>
<a href="/static-plain-text">static <b>text/plain<b/></a><br>
<a href="/dynamic-plain-text">dynamic <b>text/plain</b></a><br>
<a href="/dynamic-html">dynamic <b>text/html</b></a><br>
<a href="/js">application/javascript</a><br>
<a href="/css">css</a><br>
"#,
)
}
async fn handle_static_plain_text() -> &'static str {
"<h1>static text/plain</h1>"
}
async fn handle_dynamic_plain_text() -> String {
let now = SystemTime::now();
let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards");
format!(
"<h1>dynamic text/plain</h1> Time since epoch: {:?}</h1>",
since_the_epoch
)
}
async fn handle_dynamic_html() -> Html<String> {
let now = SystemTime::now();
let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards");
Html(format!(
"<h1>dynamic text/html</h1> Time since epoch: {:?}</h1>",
since_the_epoch
))
}
async fn handle_json() -> Json<Vec<String>> {
// application/json
let planets = vec![
String::from("Mercury"),
String::from("Venus"),
String::from("Earth"),
];
Json(planets)
}
async fn send_style_css() -> impl IntoResponse {
let css = r#"
h1 {
color: blue;
}
"#;
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "text/css".parse().unwrap());
(headers, css)
}
async fn send_javascript() -> impl IntoResponse {
let js = r#"
alert("hi");
"#;
(
StatusCode::OK, // This status code is optional and OK is the default
[(header::CONTENT_TYPE, "application/javascript")],
js,
)
}
fn create_router() -> Router {
Router::new()
.route("/", get(handle_main_page))
.route("/static-plain-text", get(handle_static_plain_text))
.route("/dynamic-plain-text", get(handle_dynamic_plain_text))
.route("/dynamic-html", get(handle_dynamic_html))
.route("/css", get(send_style_css))
.route("/js", get(send_javascript))
.route("/json", get(handle_json))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>Set Content-Type</h1>"));
assert!(html.contains("Main page is <b>static text/html</b><br>"));
}
#[tokio::test]
async fn test_static_plain_text() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/static-plain-text")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/plain; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "<h1>static text/plain</h1>");
}
#[tokio::test]
async fn test_dynamic_plain_text() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/dynamic-plain-text")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/plain; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>dynamic text/plain</h1> Time since epoch: "));
}
#[tokio::test]
async fn test_dynamic_html() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/dynamic-html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>dynamic text/html</h1> Time since epoch: "));
}
#[tokio::test]
async fn test_css() {
let response = create_router()
.oneshot(Request::builder().uri("/css").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/css");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let content = String::from_utf8(bytes.to_vec()).unwrap();
let expected_css = r#"
h1 {
color: blue;
}
"#;
assert_eq!(content, expected_css);
}
#[tokio::test]
async fn test_js() {
let response = create_router()
.oneshot(Request::builder().uri("/js").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "application/javascript");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let content = String::from_utf8(bytes.to_vec()).unwrap();
let expected_js = r#"
alert("hi");
"#;
assert_eq!(content, expected_js);
}
#[tokio::test]
async fn test_json() {
let response = create_router()
.oneshot(Request::builder().uri("/json").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "application/json");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let content: Vec<String> = serde_json::from_slice(&bytes).unwrap();
let expected = vec![
String::from("Mercury"),
String::from("Venus"),
String::from("Earth"),
];
assert_eq!(content, expected);
}
}
Custom response struct
[package]
name = "custom-respons-struct"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
http-body-util = "0.1.0"
tower = { version = "0.5.2", features = ["util"] }
Code
use axum::{
Router,
http::StatusCode,
http::header,
response::{Html, IntoResponse, Response},
routing::get,
};
struct OurMessage<'a> {
text: String,
lang: &'a str,
}
// Tell axum how to convert `OurMessage` into a response.
impl IntoResponse for OurMessage<'_> {
fn into_response(self) -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
format!("Text: {} in language: {}", self.text, self.lang),
)
.into_response()
}
}
async fn main_page() -> Html<&'static str> {
Html(
r#"<h1>Main page</h1>
<a href="/english">English</a><br>
<a href="/hungarian">Hungarian</a><br>
"#,
)
}
async fn english_page() -> impl IntoResponse {
OurMessage {
text: String::from("Some text comes here"),
lang: "en",
}
}
async fn hungarian_page() -> impl IntoResponse {
OurMessage {
text: String::from("Magyarul is lehet"),
lang: "hu",
}
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/english", get(english_page))
.route("/hungarian", get(hungarian_page))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use super::*;
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::get("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>Main page</h1>"));
}
#[tokio::test]
async fn test_english() {
let response = create_router()
.oneshot(Request::get("/english").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "Text: Some text comes here in language: en");
}
#[tokio::test]
async fn test_hungarian() {
let response = create_router()
.oneshot(Request::get("/hungarian").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "Text: Magyarul is lehet in language: hu");
}
}
Templates
In a web application that return HTML there are at least 5 languages involved.
- The backend language. In our case it is Rust.
- SQL to access the relational database
- HTML
- CSS for styling
- JavaScript for certain client-side elements. E.g. converting the menu into the hamburger on small screens or sorting tables.
Putting all these languages in the same file creates a mess. It is much easier of we can separate them.
HTML can include CSS and JavaScript from external files and a template system can make it easy to move all the HTML out from the Rust code.
Every template system is a mini-language with variables, conditional statements, loops and some similar constructs.
Askama templates
- Askama is a type-safe, compiled Jinja-like template system for Rust. See the documentation of Askama
Askama - Hello World
$ tree
.
├── Cargo.lock
├── Cargo.toml
├── src
│ ├── main.rs
│ └── tests.rs
└── templates
└── main.html
[package]
name = "askama-templates"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use askama::Template;
use axum::{
Router,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "main.html")]
struct MainTemplate {}
async fn main_page() -> impl IntoResponse {
let template = MainTemplate {};
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new().route("/", get(main_page))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>Hello Askama</h1>"));
}
}
<h1>Hello Askama</h1>
Askama - variable
[package]
name = "askama-templates"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use askama::Template;
use axum::{
Router,
extract::Query,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "main.html")]
struct MainTemplate {}
#[derive(Template)]
#[template(path = "echo.html")]
struct EchoTemplate {
text: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Params {
text: String,
}
async fn main_page() -> impl IntoResponse {
let template = MainTemplate {};
HtmlTemplate(template)
}
async fn echo(Query(params): Query<Params>) -> impl IntoResponse {
let template = EchoTemplate { text: params.text };
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/echo", get(echo))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<form method="get" action="/echo">"#));
}
#[tokio::test]
async fn test_echo_with_text() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/echo?text=Hello%20World")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "You wrote: <b>Hello World</b>");
}
#[tokio::test]
async fn test_echo_with_empty_text() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/echo?text=")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "You wrote: <b></b>");
}
#[tokio::test]
async fn test_echo_without_text() {
let response = create_router()
.oneshot(Request::builder().uri("/echo").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/plain; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(
html,
"Failed to deserialize query string: missing field `text`"
);
}
}
<h1>Echo</h1>
<form method="get" action="/echo">
<input type="text" name="text" />
<input type="submit" value="Echo" />
</form>
You wrote: <b>{{ text }}</b>
There are several issues with this solution:
- When we get the response we would probably like to see the form again.
- If the user submits the form without typing in anything we might want to show a different message.
Askama - include
If there template snippets we would like to use in more than one page, we can move those snippets
to a separate template file and then include that file in some of the templates.
I personally like to separate such templates - that are supposed to be included - in a subfolder called incl. That’s what we did in this example.
- We moved the form to a separate file.
- We included the
incl/echo_form.htmlin both the main page and theecho.htmlfile. - We update the tests to verify that the form appears on both pages.
$ tree
.
├── Cargo.lock
├── Cargo.toml
├── src
│ ├── main.rs
│ └── tests.rs
└── templates
├── echo.html
├── incl
│ └── echo_form.html
└── main.html
[package]
name = "askama-templates"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use askama::Template;
use axum::{
Router,
extract::Query,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "main.html")]
struct MainTemplate {}
#[derive(Template)]
#[template(path = "echo.html")]
struct EchoTemplate {
text: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Params {
text: String,
}
async fn main_page() -> impl IntoResponse {
let template = MainTemplate {};
HtmlTemplate(template)
}
async fn echo(Query(params): Query<Params>) -> impl IntoResponse {
let template = EchoTemplate { text: params.text };
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/echo", get(echo))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<form method="get" action="/echo">"#));
}
#[tokio::test]
async fn test_echo_with_text() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/echo?text=Hello%20World")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<form method="get" action="/echo">"#));
assert!(html.contains("You wrote: <b>Hello World</b>"));
}
}
<h1>Echo</h1>
{% include "incl/echo_form.html" %}
{% include "incl/echo_form.html" %}
You wrote: <b>{{ text }}</b>
<form method="get" action="/echo">
<input type="text" name="text" />
<input type="submit" value="Echo" />
</form>
Askama - conditional
[package]
name = "askama-templates"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use askama::Template;
use axum::{
Router,
extract::Query,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "main.html")]
struct MainTemplate {}
#[derive(Template)]
#[template(path = "echo.html")]
struct EchoTemplate {
text: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Params {
text: String,
}
async fn main_page() -> impl IntoResponse {
let template = MainTemplate {};
HtmlTemplate(template)
}
async fn echo(Query(params): Query<Params>) -> impl IntoResponse {
let template = EchoTemplate { text: params.text };
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/echo", get(echo))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<form method="get" action="/echo">"#));
}
#[tokio::test]
async fn test_echo_with_text() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/echo?text=Hello%20World")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("You wrote: <b>Hello World</b>"));
}
#[tokio::test]
async fn test_echo_with_empty_text() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/echo?text=")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("You did not write anything."));
assert!(!html.contains("You wrote: <b>Hello World</b>"));
}
#[tokio::test]
async fn test_echo_without_text_param() {
let response = create_router()
.oneshot(Request::builder().uri("/echo").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/plain; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(
html,
"Failed to deserialize query string: missing field `text`"
);
}
}
<h1>Echo</h1>
<form method="get" action="/echo">
<input type="text" name="text" />
<input type="submit" value="Echo" />
</form>
{% if text.is_empty() %}
You did not write anything.
{% else %}
You wrote: <b>{{ text }}</b>
{% endif %}
Askama - loops
[package]
name = "askama-templates"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use askama::Template;
use axum::{
Router,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "list.html")]
struct ListTemplate {
planets: Vec<String>,
grades: Vec<u8>,
}
async fn main_page() -> impl IntoResponse {
let planets = vec![
String::from("Mercury"),
String::from("Venus"),
String::from("Earth"),
String::from("Mars"),
];
let grades = vec![23, 19, 43, 99, 99];
let template = ListTemplate { planets, grades };
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new().route("/", get(main_page))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_list() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<li>Mercury</li>"));
}
}
<h1>List</h1>
<h2>Planets</h2>
<ul>
{% for planet in planets %}
<li>{{planet}}</li>
{% endfor %}
</ul>
<h2>Grades</h2>
<ul>
{% for grade in grades %}
<li>{{grade}}</li>
{% endfor %}
</ul>
Askama - loop variables (e.g. comma between values)
See the loop variables
[package]
name = "askama-loop-variables"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use askama::Template;
use axum::{
Router,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "list.html")]
struct ListTemplate {
planets: Vec<String>,
}
async fn main_page() -> impl IntoResponse {
let planets = vec![
String::from("Mercury"),
String::from("Venus"),
String::from("Earth"),
String::from("Mars"),
];
let template = ListTemplate { planets };
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new().route("/", get(main_page))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_list() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<span id="plain">Mercury, Venus, Earth, Mars, </span>"#));
assert!(html.contains(r#"<span id="last">Mercury, Venus, Earth, Mars</span>"#));
assert!(html.contains(r#"<span id="and">Mercury, Venus, Earth, and Mars</span>"#));
}
}
<h1>List</h1>
<h2>Planets</h2>
<span id="plain">{% for planet in planets %}{{planet}}, {% endfor %}</span>
<h2>Planets - last</h2>
<span id="last">{% for planet in planets %}{{planet}}{% if ! loop.last %}, {% endif %}{% endfor %}</span>
<h2>Planets - and</h2>
<span id="and">{% for planet in planets %}{{planet}}{% if ! loop.last%}, {% endif %}{% if loop.index == planets.len() - 1 %}and {% endif %}{% endfor %}</span>
Askama - struct
[package]
name = "askama-templates"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use askama::Template;
use axum::{
Router,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
struct Thing {
name: String,
small_number: u8,
}
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "main.html")]
struct MainTemplate {
thing: Thing,
}
async fn main_page() -> impl IntoResponse {
let thing = Thing {
name: String::from("Something"),
small_number: 42,
};
let template = MainTemplate { thing: thing };
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new().route("/", get(main_page))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<h1>Show struct</h1>"#));
assert!(html.contains(r#"name: <b>Something</b>"#));
assert!(html.contains(r#"small_number: <b>42</b>"#));
}
}
<h1>Show struct</h1>
name: <b>{{thing.name}}</b>
<br>
small_number: <b>{{thing.small_number}}</b>
<br>
Askama - vector of structs
[package]
name = "askama-templates"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use askama::Template;
use axum::{
Router,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
struct Planet {
name: String,
distance: f64,
mass: f64,
}
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "main.html")]
struct MainTemplate {
planets: Vec<Planet>,
}
async fn main_page() -> impl IntoResponse {
let planets = vec![
Planet {
name: String::from("Mercury"),
distance: 0.4,
mass: 0.055,
},
Planet {
name: String::from("Venus"),
distance: 0.7,
mass: 0.815,
},
Planet {
name: String::from("Earth"),
distance: 1.0,
mass: 1.0,
},
];
let template = MainTemplate { planets: planets };
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new().route("/", get(main_page))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<h1>Planets</h1>"#));
}
}
<h1>Planets</h1>
<table>
{% for planet in planets %}
<tr><td>{{planet.name}}</td><td>{{planet.distance}}</td><td>{{planet.mass}}</td></tr>
{% endfor %}
</table>
<style>
td {
padding: 10px;
}
</style>
Askama - unified look
In most application we’ll have different types of pages. e.g. a main page, a page listing products, and a separate page for each product.
How can we make sure they will look similar?
For each page-type we’ll have its own template file and we need to ensure that the top and bottom part of the HTML is the same in each file. Instead of copying these structures to each template there are two main methods to handle this problem.
include
In one method we move the top and bottom parts to separate template files called header.html and footer.html and then we include them in every template.
layout
We move the top and bottom part to a single separate file and each template extends the layout template.
Askama - include header footer
$ tree
.
├── Cargo.lock
├── Cargo.toml
├── src
│ ├── main.rs
│ └── tests.rs
└── templates
├── incl
│ ├── footer.html
│ └── header.html
├── main.html
└── page.html
[package]
name = "askama-templates"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use askama::Template;
use axum::{
Router,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "page.html")]
struct PageTemplate {
title: String,
}
async fn main_page() -> impl IntoResponse {
let title = String::from("Welcome to axum");
let template = PageTemplate { title };
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new().route("/", get(main_page))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<title>Welcome to axum</title>"));
assert!(html.contains("<h1>Welcome to axum</h1>"));
assert!(html.contains("Page content"));
}
}
{% include "incl/header.html" %}
<h1>{{title}}</h1>
Page content
{% include "incl/footer.html" %}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{title}}</title>
</head>
<body>
</body>
</html>
Askama - layout
$ tree
.
├── Cargo.lock
├── Cargo.toml
├── src
│ ├── main.rs
│ └── tests.rs
└── templates
├── layouts
│ └── base.html
├── main.html
├── people.html
└── person.html
[package]
name = "askama-templates"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use askama::Template;
use axum::{
Router,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "main.html")]
struct MainTemplate {}
#[derive(Template)]
#[template(path = "person.html")]
struct PersonTemplate {}
#[derive(Template)]
#[template(path = "people.html")]
struct PeopleTemplate {}
async fn main_page() -> impl IntoResponse {
let template = MainTemplate {};
HtmlTemplate(template)
}
async fn person() -> impl IntoResponse {
let template = PersonTemplate {};
HtmlTemplate(template)
}
async fn people() -> impl IntoResponse {
let template = PeopleTemplate {};
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/person", get(person))
.route("/people", get(people))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>Layout</h1>"));
assert!(html.contains(r#"<li><a href="/person">Person</a></li>"#));
}
#[tokio::test]
async fn test_person() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/person")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<title>Default Title</title>"));
assert!(html.contains("Content of the Person page"));
}
#[tokio::test]
async fn test_people() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/people")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<title>List of people</title>"));
assert!(html.contains("Content of the People page"));
}
}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Default Title{% endblock %}</title>
</head>
<body>
<h1>Layout</h1>
{% block content %}{% endblock %}
</body>
</html>
{% extends "layouts/base.html" %}
{% block title %}List of people{% endblock %}
{% block content %}
Content of the People page
{% endblock %}
{% extends "layouts/base.html" %}
{% block content %}
Content of the Person page
{% endblock %}
Askama - filters
Askama supports a number of filters and you can also build your own custom filter.
Filters look like this:
{{ variable | filter }}
and even like this:
{{ variable | filter(param) }}
There are additional Askama filters.
You can also create your own filters.
Askama - safe
If you have an application where users can type in some data that you later display on web pages, especially pages that can be viewed by other then you have to make sure the data does not contain any syntax that would let one person run code in the browser of the other person.
The standard safeguard is to make sure any potential HTML tag submitted by users is escaped when we display them. To faciliate this Askama escapes all HTML contained in variables.
However, if that HTML comes from some trusted source, for example because your input system verified that no dangerous tages are permitted or you have markdown files and you generate HTML from them before passing to the template, then you want to tell Askama to embed the data as it is without escaping.
You can achieve this by using the safe filter.
[package]
name = "askama-templates"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use askama::Template;
use axum::{
Router,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "main.html")]
struct MainTemplate {
content: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Params {
text: String,
}
async fn main_page() -> impl IntoResponse {
let content = String::from("<b>Bold</b>");
let template = MainTemplate { content };
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new().route("/", get(main_page))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<h1>Safe template</h1>"#));
assert!(html.contains(r#"<span id="plain"><b>Bold</b></span>"#));
assert!(html.contains(r#"<span id="safe"><b>Bold</b></span>"#));
}
}
<h1>Safe template</h1>
<span id="plain">{{content}}</span>
<hr>
<span id="safe">{{content | safe}}</span>
Minijinja Templates
The Mini Jinja template system by Armin Ronacher, author of Flask and Jinja.
Minijinja - variable
[package]
name = "use-minijinja"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
minijinja = "2.3.1"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use axum::extract::State;
use axum::http::StatusCode;
use axum::{Router, response::Html, routing::get};
use minijinja::{Environment, context};
use std::sync::Arc;
struct AppState {
env: Environment<'static>,
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
fn create_router() -> Router {
let mut env = Environment::new();
env.add_template("main", include_str!("../templates/main.html"))
.unwrap();
let app_state = Arc::new(AppState { env });
Router::new()
.route("/", get(main_page))
.with_state(app_state)
}
async fn main_page(State(state): State<Arc<AppState>>) -> Result<Html<String>, StatusCode> {
let template = state.env.get_template("main").unwrap();
let rendered = template
.render(context! {
title => "Mini Jinja",
welcome_text => "Hello World!",
})
.unwrap();
Ok(Html(rendered))
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>Mini Jinja</h1>"));
assert!(html.contains("<p>Hello World!</p>"));
}
}
<h1>{{ title }}</h1>
<p>{{ welcome_text }}</p>
Custom 404 page (fallback)
By default axum will return an empty page when the user accesses a path that is not handled by any of the routes.
We can add a special handler called fallback that will be called by axum if no route was match. That function can create any response. It can set any Status Code and it can return any content.
[package]
name = "custom-404-page"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use axum::{
Router,
http::StatusCode,
response::{Html, IntoResponse},
routing::get,
};
async fn main_page() -> Html<&'static str> {
Html("<h1>Hello, World!</h1>")
}
async fn handler_404() -> impl IntoResponse {
(StatusCode::NOT_FOUND, "Custom missing page")
// We could also set the content-type to be text/html
//(StatusCode::NOT_FOUND, Html("Custom missing page"))
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.fallback(handler_404)
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "<h1>Hello, World!</h1>");
}
#[tokio::test]
async fn test_other_page() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/other")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/plain; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "Custom missing page");
}
}
Configuration with state
If we have some static data that we would like to make available in (some of) the routes, the best thing might be to
load it up-front and then pass it on as State.
We can do this by adding the object to the application using the with_state and then in the routes where would like to access this value
we put it in the list of parameters of the function handling the route.
We use Arc as it is a thread-safe reference-counting pointer. So the data will not be copied to each one of the threads handling the requests.
[package]
name = "hello-html-world"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
toml = "0.8"
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
name = "axum maximus"
use axum::{Router, extract::State, response::Html, routing::get};
use serde::Deserialize;
use std::sync::Arc;
#[derive(Deserialize)]
struct Config {
name: String,
}
async fn handle_main_page(State(config): State<Arc<Config>>) -> Html<String> {
Html(format!("<h1>Hello, {}!</h1>", config.name))
}
fn create_router(config: Arc<Config>) -> Router {
Router::new()
.route("/", get(handle_main_page))
.with_state(config)
}
#[tokio::main]
async fn main() {
let config_content =
std::fs::read_to_string("config.toml").expect("Failed to read config.toml");
let config: Config = toml::from_str(&config_content).expect("Failed to parse config.toml");
let config = Arc::new(config);
let app = create_router(config);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
fn test_config() -> Arc<Config> {
Arc::new(Config {
name: String::from("test name"),
})
}
#[tokio::test]
async fn test_main_page() {
let response = create_router(test_config())
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "<h1>Hello, test name!</h1>");
}
#[tokio::test]
async fn test_missing_page() {
let response = create_router(test_config())
.oneshot(
Request::builder()
.uri("/other")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// No content type
assert!(response.headers().get("content-type").is_none());
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "");
}
}
Redirect
We can redirect a request to another page on our side or to a page on another site.
For this we use the Redirect struct that has methods for permanent redirection (308 Permanent Redirect) and temporary redirection (307 Temporary Redirect).
[package]
name = "redirect"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use axum::{
Router,
response::{Html, Redirect},
routing::get,
};
async fn main_page() -> Html<&'static str> {
Html(
r#"
<h1>Redirect</h1>
<a href="/internal-redirect">Internal Redirect</a><br>
<a href="/external-redirect">External Redirect</a><br>
<a href="/target-page">Target page</a>
"#,
)
}
async fn internal_redirect() -> Redirect {
Redirect::temporary("/target-page")
}
async fn external_redirect() -> Redirect {
Redirect::permanent("https://rust.code-maven.com/")
}
async fn target_page() -> Html<&'static str> {
Html("Arrived")
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/internal-redirect", get(internal_redirect))
.route("/external-redirect", get(external_redirect))
.route("/target-page", get(target_page))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>Redirect</h1>"));
}
#[tokio::test]
async fn test_target_page() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/target-page")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "Arrived");
}
#[tokio::test]
async fn test_internal_redirect() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/internal-redirect")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
assert!(response.headers().get("content-type").is_none());
let location = response.headers().get("Location").unwrap();
assert_eq!(location, "/target-page");
}
#[tokio::test]
async fn test_external_redirect() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/external-redirect")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
assert!(response.headers().get("content-type").is_none());
let location = response.headers().get("Location").unwrap();
assert_eq!(location, "https://rust.code-maven.com/");
}
}
Logging - Tracing
axum uses the tracing and tracing-subscriber crates for logging so we need to include both.
[package]
name = "axum-logging"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use axum::{response::Html, routing::get, Router};
use tokio::net::TcpListener;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() {
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.init();
let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap();
tracing::debug!("listening on {}", listener.local_addr().unwrap());
tracing::info!("listening on {}", listener.local_addr().unwrap());
tracing::warn!("listening on {}", listener.local_addr().unwrap());
tracing::error!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
fn create_router() -> Router {
Router::new().route("/", get(handler))
}
async fn handler() -> Html<&'static str> {
tracing::debug!("in handler");
tracing::info!("in handler");
Html("<h1>Hello, World!</h1>")
}
When we start the application with cargo run we’ll see line like this on the terminal:
2026-03-29T15:49:37.789551Z DEBUG axum_logging: listening on 127.0.0.1:3000
2026-03-29T15:49:37.789601Z INFO axum_logging: listening on 127.0.0.1:3000
2026-03-29T15:49:37.789613Z WARN axum_logging: listening on 127.0.0.1:3000
2026-03-29T15:49:37.789623Z ERROR axum_logging: listening on 127.0.0.1:3000
When we access the main page with a browser we’ll see two more lines:
2026-03-29T15:49:39.380861Z TRACE axum::serve: connection 127.0.0.1:44280 accepted
2026-03-29T15:49:39.381339Z DEBUG axum_logging: in handler
2026-03-29T15:49:39.381372Z INFO axum_logging: in handler
Logging to a file
[package]
name = "echo-post"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
mime = "0.3.17"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
chrono = "0.4.42"
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use tracing_subscriber::{
Layer, filter::LevelFilter, layer::SubscriberExt, util::SubscriberInitExt,
};
use axum::{
Form, Router,
response::Html,
routing::{get, post},
};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Params {
text: String,
}
async fn main_page() -> Html<&'static str> {
tracing::info!("main page");
Html(
r#"
<form method="post" action="/echo">
<input type="text" name="text">
<input type="submit" value="Echo">
</form>
"#,
)
}
async fn echo(Form(params): Form<Params>) -> Html<String> {
tracing::info!("params: {:?}", params);
Html(format!(r#"You said: <b>{}</b>"#, params.text))
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/echo", post(echo))
}
#[tokio::main]
async fn main() {
setup_tracing("demo");
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
fn setup_tracing(prefix: &str) {
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
std::fs::create_dir_all("logs").unwrap();
let log_filename = format!("logs/{}-{}.log", prefix, date);
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_filename)
.unwrap(),
)
.with_filter(LevelFilter::DEBUG),
)
.with(tracing_subscriber::fmt::layer())
.init();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{self, Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<form method="post" action="/echo">"#));
}
#[tokio::test]
async fn test_echo_with_data() {
let response = create_router()
.oneshot(
Request::builder()
.method(http::Method::POST)
.uri("/echo")
.header(
http::header::CONTENT_TYPE,
mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(),
)
.body(Body::from("text=Hello+World!"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "You said: <b>Hello World!</b>");
}
#[tokio::test]
async fn test_echo_without_data() {
let response = create_router()
.oneshot(
Request::builder()
.method(http::Method::POST)
.uri("/echo")
.header(
http::header::CONTENT_TYPE,
mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); // 422
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(
html,
"Failed to deserialize form body: missing field `text`"
);
}
#[tokio::test]
async fn test_echo_missing_value() {
let response = create_router()
.oneshot(
Request::builder()
.method(http::Method::POST)
.uri("/echo")
.header(
http::header::CONTENT_TYPE,
mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(),
)
.body(Body::from("text="))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "You said: <b></b>");
}
#[tokio::test]
async fn test_echo_extra_param() {
let response = create_router()
.oneshot(
Request::builder()
.method(http::Method::POST)
.uri("/echo")
.header(
http::header::CONTENT_TYPE,
mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(),
)
.body(Body::from("text=Hello&extra=123"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "You said: <b>Hello</b>");
}
}
Nesting applications
In this examples we have 3 different routes, /events/future, /events/past and /user/ID where ID can be any number. Effectively there are 2 applications the /events/ application and the /user/ application.
Here we implemented separate functions for each one of them, all in the same crate.
[package]
name = "nesting-applications"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use axum::{Router, extract::Path, response::Html, routing::get};
async fn main_page() -> Html<&'static str> {
Html(
r#"
<a href="/events/future">/events/future</a><br>
<a href="/events/past">/events/past</a><br>
<a href="/user/42">/user/42</a><br>
"#,
)
}
async fn future_events() -> Html<String> {
Html(String::from("Future events"))
}
async fn past_events() -> Html<String> {
Html(String::from("Past events"))
}
async fn user_page(Path(id): Path<u32>) -> Html<String> {
Html(format!("User id: {}", id))
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/user/{id}", get(user_page))
.route("/events/future", get(future_events))
.route("/events/past", get(past_events))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let html = get_html("/").await;
assert!(html.contains(r#"<a href="/user/42">/user/42</a>"#));
}
#[tokio::test]
async fn test_user_page() {
let html = get_html("/user/42").await;
assert_eq!(html, "User id: 42");
}
#[tokio::test]
async fn test_future_events() {
let html = get_html("/events/future").await;
assert_eq!(html, "Future events");
}
#[tokio::test]
async fn test_past_events() {
let html = get_html("/events/past").await;
assert_eq!(html, "Past events");
}
async fn get_html(uri: &str) -> String {
let response = create_router()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
}
Nested applications
workspace = { members = ["crates/events"] }
[package]
name = "nested-applications"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
events = { version = "0.1.0", path = "crates/events" }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use axum::{Router, response::Html, routing::get};
mod users;
async fn main_page() -> Html<&'static str> {
Html(
r#"
<a href="/events/future">/events/future</a><br>
<a href="/events/past">/events/past</a><br>
<a href="/user/42">/user/42</a><br>
"#,
)
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.nest("/user", users::create_router())
.nest("/events", events::create_router())
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use axum::{Router, extract::Path, response::Html, routing::get};
async fn user_page(Path(id): Path<u32>) -> Html<String> {
Html(format!("User id: {}", id))
}
pub fn create_router() -> Router {
Router::new().route("/{id}", get(user_page))
}
}
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let html = get_html("/").await;
assert!(html.contains(r#"<a href="/user/42">/user/42</a>"#));
}
#[tokio::test]
async fn test_user_page() {
let html = get_html("/user/42").await;
assert_eq!(html, "User id: 42");
}
#[tokio::test]
async fn test_future_events() {
let html = get_html("/events/future").await;
assert_eq!(html, "Future events");
}
#[tokio::test]
async fn test_past_events() {
let html = get_html("/events/past").await;
assert_eq!(html, "Past events");
}
async fn get_html(uri: &str) -> String {
let response = create_router()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
}
the events crate
[package]
name = "events"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use events::create_router;
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use axum::{Router, response::Html, routing::get};
async fn future_events() -> Html<String> {
Html(String::from("Future events"))
}
async fn past_events() -> Html<String> {
Html(String::from("Past events"))
}
pub fn create_router() -> Router {
Router::new()
.route("/future", get(future_events))
.route("/past", get(past_events))
}
}
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
//#[tokio::test]
//async fn test_main_page() {
// let html = get_html("/").await;
// assert!(html.contains(r#"<a href="/user/42">/user/42</a>"#));
//}
//
#[tokio::test]
async fn test_future_events() {
let html = get_html("/future").await;
assert_eq!(html, "Future events");
}
#[tokio::test]
async fn test_past_events() {
let html = get_html("/past").await;
assert_eq!(html, "Past events");
}
async fn get_html(uri: &str) -> String {
let response = create_router()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
}
Calculator Path
nest
[package]
name = "calculator"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
mime = "0.3.17"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{Router, response::Html, routing::get};
mod v1calc;
mod v2calc;
async fn main_page() -> Html<&'static str> {
Html(
r#"
<h1>Calculator</h1>
<a href="/v1/add/2/3">add 2 + 3</a><br>
"#,
)
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.nest("/v1", v1calc::create_router())
.nest("/v2", v2calc::create_router())
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use axum::{Router, extract::Path, response::Html, routing::get};
async fn handle_add(Path((a, b)): Path<(u32, u32)>) -> Html<String> {
let result = a + b;
Html(format!("{a} + {b} = {result}"))
}
async fn handle_multiply(Path((a, b)): Path<(u32, u32)>) -> Html<String> {
let result = a * b;
Html(format!("{a} * {b} = {result}"))
}
async fn handle_divide(Path((a, b)): Path<(u32, u32)>) -> Html<String> {
let result = a / b;
Html(format!("{a} / {b} = {result}"))
}
async fn handle_subtraction(Path((a, b)): Path<(u32, u32)>) -> Html<String> {
let result = a - b;
Html(format!("{a} - {b} = {result}"))
}
pub fn create_router() -> Router {
Router::new()
.route("/add/{a}/{b}", get(handle_add))
.route("/mul/{a}/{b}", get(handle_multiply))
.route("/div/{a}/{b}", get(handle_divide))
.route("/sub/{a}/{b}", get(handle_subtraction))
}
}
#![allow(unused)]
fn main() {
use axum::{
Router, extract::Path, http::StatusCode, response::Html, response::IntoResponse, routing::get,
};
async fn handle_calc(Path((op, a, b)): Path<(String, u32, u32)>) -> impl IntoResponse {
match op.as_str() {
"add" => {
let result = a + b;
(StatusCode::OK, Html(format!("{a} + {b} = {result}")))
}
"sub" => {
let result = a - b;
(StatusCode::OK, Html(format!("{a} - {b} = {result}")))
}
"mul" => {
let result = a * b;
(StatusCode::OK, Html(format!("{a} * {b} = {result}")))
}
"div" => {
let result = a / b;
(StatusCode::OK, Html(format!("{a} / {b} = {result}")))
}
_ => (
StatusCode::NOT_FOUND,
Html(format!("Unhandled operator: {op}")),
),
}
}
pub fn create_router() -> Router {
Router::new().route("/{op}/{a}/{b}", get(handle_calc))
}
}
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
check_contains("/", "<h1>Calculator</h1>").await;
}
#[tokio::test]
async fn test_v1_add() {
check_equals("/v1/add/2/3", "2 + 3 = 5").await;
check_equals("/v1/add/7/8", "7 + 8 = 15").await;
}
#[tokio::test]
async fn test_v1_subtraction() {
check_equals("/v1/sub/5/3", "5 - 3 = 2").await;
check_equals("/v1/sub/8/7", "8 - 7 = 1").await;
}
#[tokio::test]
async fn test_v1_multiply() {
check_equals("/v1/mul/2/3", "2 * 3 = 6").await;
check_equals("/v1/mul/7/8", "7 * 8 = 56").await;
}
#[tokio::test]
async fn test_v1_divide() {
check_equals("/v1/div/6/3", "6 / 3 = 2").await;
check_equals("/v1/div/120/10", "120 / 10 = 12").await;
}
#[tokio::test]
async fn test_v1_other() {
let uri = "/v1/other/6/3";
let response = create_router()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// No content-type
assert!(response.headers().get("content-type").is_none());
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let content = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(content, "");
}
#[tokio::test]
async fn test_v2_add() {
check_equals("/v2/add/2/3", "2 + 3 = 5").await;
check_equals("/v2/add/7/8", "7 + 8 = 15").await;
}
#[tokio::test]
async fn test_v2_subtraction() {
check_equals("/v2/sub/5/3", "5 - 3 = 2").await;
check_equals("/v2/sub/8/7", "8 - 7 = 1").await;
}
#[tokio::test]
async fn test_v2_multiply() {
check_equals("/v2/mul/2/3", "2 * 3 = 6").await;
check_equals("/v2/mul/7/8", "7 * 8 = 56").await;
}
#[tokio::test]
async fn test_v2_divide() {
check_equals("/v2/div/6/3", "6 / 3 = 2").await;
check_equals("/v2/div/120/10", "120 / 10 = 12").await;
}
#[tokio::test]
async fn test_v2_other() {
let uri = "/v2/other/6/3";
let response = create_router()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let content = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(content, "Unhandled operator: other");
}
async fn check_contains(uri: &str, expected: &str) {
let html = get_page(uri).await;
assert!(html.contains(expected));
}
async fn check_equals(uri: &str, expected: &str) {
let html = get_page(uri).await;
assert_eq!(html, expected);
}
async fn get_page(uri: &str) -> String {
let response = create_router()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
}
Calculator GET
[package]
name = "calculator-get"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use askama::Template;
use axum::{
Router,
extract::Query,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
//#[derive(Debug)]
//enum Operator {
// Add,
// Deduct,
// Multiply,
// Divide,
//}
//
//impl<S> FromRequestParts<S> for Operator
//where
// S: Send + Sync,
//{
// type Rejection = Response;
//
// async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// let params: HashMap<String, String> =
// parts.extract().await.map_err(IntoResponse::into_response)?;
//
// let operation = params
// .get("op")
// .ok_or_else(|| (StatusCode::NOT_FOUND, "version param missing").into_response())?;
//
// match operation.as_str() {
// "add" => Ok(Operator::Add),
// "deduct" => Ok(Operator::Deduct),
// "mul" => Ok(Operator::Multiply),
// "div" => Ok(Operator::Divide),
// _ => Err((StatusCode::NOT_FOUND, "unknown operator").into_response()),
// }
// }
//}
//
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Params {
a: Option<i32>,
b: Option<i32>,
// op: Operator
}
#[derive(Template)]
#[template(path = "main.html")]
struct MainTemplate {
a: i32,
b: i32,
result: i32,
}
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
async fn main_page(Query(params): Query<Params>) -> impl IntoResponse {
let a = params.a.unwrap_or_default();
let b = params.b.unwrap_or_default();
let result = 0;
if params.a.is_none() || params.b.is_none() {
let template = MainTemplate { a, b, result };
return HtmlTemplate(template);
}
let result = a + b;
let template = MainTemplate { a, b, result };
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new().route("/", get(main_page))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<form method="get" action="/">"#));
}
#[tokio::test]
async fn test_echo_with_data() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/?a=4&b=9")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<form method="get" action="/">"#));
}
#[tokio::test]
async fn test_echo_missing_value() {
let response = create_router()
.oneshot(Request::builder().uri("/?a=").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
//assert!(html.contains(r#"<form method="get" action="/">"#));
//
assert_eq!(
html,
"Failed to deserialize query string: a: cannot parse integer from empty string"
);
}
}
Modelling Meetup
In this example we’ll try to implement the routing of Meetuphttps://www.meetup.com/.
[package]
name = "versioning"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
mime = "0.3.17"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{Router, extract::Path, response::Html, routing::get};
// Meetup: https://www.meetup.com/ redirects to Meetup Home: https://www.meetup.com/home/
// About: https://www.meetup.com/code-mavens/
// Events: https://www.meetup.com/code-mavens/events/
// Members: https://www.meetup.com/code-mavens/members/
// Photos: https://www.meetup.com/code-mavens/photos/
// Discussions: https://www.meetup.com/code-mavens/discussions/
//
// Calendar: https://www.meetup.com/code-mavens/events/calendar/
// Upcoming events: https://www.meetup.com/code-mavens/events/?type=upcoming (the same as without
// this type)
// Past events: https://www.meetup.com/code-mavens/events/?type=past
// Event drafts: https://www.meetup.com/code-mavens/events/?type=draft
// this does not show any event: https://www.meetup.com/code-mavens/events/?type=qqrq
// Event: https://www.meetup.com/code-mavens/events/313944233/?eventOrigin=group_events_list
async fn main_page() -> Html<&'static str> {
Html(
r#"
<h1>Meetup</h1>
<a href="/code-mavens/">About</a><br>
<a href="/code-mavens/events/">Events</a><br>
<a href="/code-mavens/members/">Members</a><br>
<a href="/code-mavens/events/1234">Event ID 1234</a><br>
"#,
)
}
//async fn handle_api(version: Version) -> Html<String> {
// Html(format!("received request with version {version:?}"))
//}
//
async fn handle_about(Path(group): Path<String>) -> Html<String> {
Html(format!("<h1>About {group}</h1>"))
}
async fn handle_area(Path((group, area)): Path<(String, String)>) -> Html<String> {
// Limite area to certain values, e.g. events
Html(format!("<h1>{group} {area}</h1>"))
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/{group}/", get(handle_about))
.route("/{group}/{area}", get(handle_area))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
check_page("/", "<h1>Meetup</h1>").await;
}
#[tokio::test]
async fn test_about_page() {
check_page("/code-mavens/", "<h1>About code-mavens</h1>").await;
check_page("/python-mavens/", "<h1>About python-mavens</h1>").await;
check_page("/rust-mavens/", "<h1>About rust-mavens</h1>").await;
}
#[tokio::test]
async fn test_events_page() {
check_page("/code-mavens/events", "<h1>code-mavens events</h1>").await;
}
// #[tokio::test]
// async fn test_events_page() {
// check_page("/code-mavens/events/", "<h1>code-mavens events</h1>").await;
// }
async fn check_page(uri: &str, expected: &str) {
let response = create_router()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(expected));
}
}
Embed Static File
Beside the HTML we might want to server some other static content. For example CSS, JavaScript, or even images. We can embed the content that we would like to serve in the Rust source code and we can create a route setting the Content-Type to the proper value.
[package]
name = "embed-static-file"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{
Router,
http::header::{self, HeaderMap},
response::{Html, IntoResponse},
routing::get,
};
async fn handle_main_page() -> Html<&'static str> {
Html(
r#"
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/static/css/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Embedded static file</title>
</head>
<body>
<h1>Hello, World!</h1>
</body>
</html>
"#,
)
}
async fn send_style_css() -> impl IntoResponse {
let css = r#"
h1 {
color: blue;
}
"#;
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "text/css".parse().unwrap());
(headers, css)
}
fn create_router() -> Router {
Router::new()
.route("/", get(handle_main_page))
.route("/static/css/style.css", get(send_style_css))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>Hello, World!</h1>"));
}
#[tokio::test]
async fn test_css() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/static/css/style.css")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/css");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let content = String::from_utf8(bytes.to_vec()).unwrap();
let expected = r#"
h1 {
color: blue;
}
"#;
assert_eq!(content, expected);
}
}
Embed external Static File
It is probably much better if we keep the CSS file outside of the Rust file
and embed its content during compilation using the include_str! macro.
There is also an include_bytes! macro for embeddig images and other binary files.
[package]
name = "embed-external-static-file"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{
Router,
http::header::{self, HeaderMap},
response::{Html, IntoResponse},
routing::get,
};
async fn handle_main_page() -> Html<&'static str> {
Html(
r#"
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/static/css/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Embedded static file</title>
</head>
<body>
<h1>Hello, World!</h1>
</body>
</html>
"#,
)
}
async fn send_style_css() -> impl IntoResponse {
let css = include_str!("static/style.css");
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "text/css".parse().unwrap());
(headers, css)
}
fn create_router() -> Router {
Router::new()
.route("/", get(handle_main_page))
.route("/static/css/style.css", get(send_style_css))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
CSS file
h1 {
color: green;
}
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>Hello, World!</h1>"));
}
#[tokio::test]
async fn test_css() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/static/css/style.css")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/css");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let content = String::from_utf8(bytes.to_vec()).unwrap();
let expected = r#"
h1 {
color: green;
}
"#;
assert_eq!(content, expected);
}
}
Embed external Static File with cache buster
The problem in both of the previous cases is that the browser will try to cache the content of the CSS file. That means that even if we change the content of the CSS file inside the Rust file or even in the external file, the browser will not download the new version and we won’t see the change.
We could manually clear the cache of the browser, but the end users have the same problem. If we deploy a new version of the CSS file the clients will not see the content until the cache expires.
This is a well known problem in the web development world.
We need a cache buster. One of the solutions is to change the name of the static file every time it changes.However one needs to be careful to also change the reference to the file to match the new filename.
- const-hex
- xxhash-rust used for hashing the content of the CSS file at compile time.
- This idea was taken from rgit and gitore.
[package]
name = "embed-external-static-file"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
const-hex = "1.18.1"
const_format = "0.2.35"
tokio = { version = "1.50.0", features = ["full"] }
xxhash-rust = { version = "0.8.15", features = ["const_xxh3", "xxh3"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{
Router,
http::header::{self, HeaderMap},
response::{Html, IntoResponse},
routing::get,
};
use const_format::formatcp;
use xxhash_rust::const_xxh3;
const STYLE_CSS: &[u8] = include_bytes!("static/style.css");
const STYLE_CSS_HASH: &str = const_hex::Buffer::<16, false>::new()
.const_format(&const_xxh3::xxh3_128(STYLE_CSS).to_be_bytes())
.as_str();
async fn handle_main_page() -> Html<String> {
Html(format!(
r#"
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/static/css/{STYLE_CSS_HASH}-style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Embedded static file</title>
</head>
<body>
<h1>Hello, World!</h1>
</body>
</html>
"#
))
}
async fn send_style_css() -> impl IntoResponse {
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "text/css".parse().unwrap());
(headers, STYLE_CSS)
}
fn create_router() -> Router {
Router::new().route("/", get(handle_main_page)).route(
formatcp!("/static/css/{}-style.css", STYLE_CSS_HASH),
get(send_style_css),
)
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
CSS file
h1 {
color: green;
}
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>Hello, World!</h1>"));
// <link rel="stylesheet" href="/static/css/0d5d21cbd55d2e87fc6dd2713c288a24-style.css">
}
}
Special handling for HEAD requests
For ever GET route axum automatically installs the same HEAD route.
However we can create special handling for HEAD requests.
It rarely needed.
[package]
name = "head-request"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
use axum::response::{Html, IntoResponse, Response};
use axum::{Router, http, routing::get, routing::head};
async fn main_page() -> Response {
let content = String::from(
r#"
<h1>HEAD</h1>
"#,
);
([("x-my-header", "header of main page")], Html(content)).into_response()
}
async fn just_head() -> Response {
([("x-my-header", "header from HEAD of just_head")]).into_response()
}
async fn get_head_handler(method: http::Method) -> Response {
if method == http::Method::HEAD {
return ([("x-my-header", "header for HEAD get_head_handler")]).into_response();
}
println!("do some heavy computing task in GET");
(
[("x-my-header", "header for GET get_head_handler")],
Html("The content"),
)
.into_response()
}
fn create_route() -> Router {
Router::new()
.route("/", get(main_page))
.route("/just-head", head(just_head))
.route("/get-head", get(get_head_handler))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_route()).await.unwrap();
}
#[cfg(test)]
mod tests;
#![allow(unused)]
fn main() {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_get_main_page() {
let app = create_route();
let response = app
.oneshot(Request::get("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
assert_eq!(
response.headers().get("x-my-header").unwrap(),
"header of main page"
);
let bytes = response.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>HEAD</h1>"));
}
#[tokio::test]
async fn test_head_main_page() {
let app = create_route();
let response = app
.oneshot(Request::head("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
assert_eq!(
response.headers().get("x-my-header").unwrap(),
"header of main page"
);
let bytes = response.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "");
}
#[tokio::test]
async fn test_get() {
let app = create_route();
let response = app
.oneshot(Request::get("/get-head").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()["x-my-header"],
"header for GET get_head_handler"
);
let bytes = response.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "The content");
}
#[tokio::test]
async fn test_head() {
let app = create_route();
let response = app
.oneshot(Request::head("/get-head").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()["x-my-header"],
"header for HEAD get_head_handler"
);
let body = response.collect().await.unwrap().to_bytes();
assert!(body.is_empty());
}
#[tokio::test]
async fn test_head_just_head() {
let app = create_route();
let response = app
.oneshot(Request::head("/just-head").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()["x-my-header"],
"header from HEAD of just_head"
);
let body = response.collect().await.unwrap().to_bytes();
assert!(body.is_empty());
}
#[tokio::test]
async fn test_get_just_head() {
let app = create_route();
let response = app
.oneshot(Request::get("/just-head").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
let body = response.collect().await.unwrap().to_bytes();
assert!(body.is_empty());
}
}
axum with Postgres
[package]
name = "sqlx-postgres"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "postgres"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
docker run --rm -d --name pgserver -e POSTGRES_USER=myuser -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=mydb -p 5432:5432 postgres:18
use axum::{
Router,
extract::{FromRef, FromRequestParts, State},
http::{StatusCode, request::Parts},
response::Html,
routing::get,
};
use sqlx::postgres::{PgPool, PgPoolOptions};
use tokio::net::TcpListener;
use std::time::Duration;
async fn main_page() -> Html<&'static str> {
Html(
r#"
<h2>Connection Pool Extractor</h2>
<a href="/v1">Select text</a><br>
<hr>
<a href="/v2">Version 2</a><br>
"#,
)
}
// we can extract the connection pool with `State`
async fn using_connection_pool_extractor(
State(pool): State<PgPool>,
) -> Result<String, (StatusCode, String)> {
sqlx::query_scalar("SELECT 'Hello world from pg'")
.fetch_one(&pool)
.await
.map_err(internal_error)
}
// we can also write a custom extractor that grabs a connection from the pool
// which setup is appropriate depends on your application
struct DatabaseConnection(sqlx::pool::PoolConnection<sqlx::Postgres>);
impl<S> FromRequestParts<S> for DatabaseConnection
where
PgPool: FromRef<S>,
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let pool = PgPool::from_ref(state);
let conn = pool.acquire().await.map_err(internal_error)?;
Ok(Self(conn))
}
}
async fn using_connection_extractor(
DatabaseConnection(mut conn): DatabaseConnection,
) -> Result<String, (StatusCode, String)> {
sqlx::query_scalar("SELECT version();")
.fetch_one(&mut *conn)
.await
.map_err(internal_error)
}
/// Utility function for mapping any error into a `500 Internal Server Error`
/// response.
fn internal_error<E>(err: E) -> (StatusCode, String)
where
E: std::error::Error,
{
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}
async fn set_up_connection_pool() -> PgPool {
let username = "myuser";
let password = "secret";
let db_name = "mydb";
let hostname = "localhost";
let connector = format!(
"postgres://{}:{}@{}/{}",
username, password, hostname, db_name
);
let db_connection_str = std::env::var("DATABASE_URL").unwrap_or_else(|_| connector);
PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(3))
.connect(&db_connection_str)
.await
.expect("can't connect to database")
}
fn create_router(pool: PgPool) -> Router {
Router::new()
.route("/", get(main_page))
.route("/v1", get(using_connection_pool_extractor))
.route("/v2", get(using_connection_extractor))
.with_state(pool)
}
#[tokio::main]
async fn main() {
let pool = set_up_connection_pool().await;
let app = create_router(pool);
let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
Multi-counter with Postgres
TODO
[package]
name = "sqlx-postgres"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "postgres"] }
serde = { version = "1.0.228", features = ["derive"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
docker run --rm -d --name pgserver -e POSTGRES_USER=myuser -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=mydb -p 5432:5432 postgres:18
use serde::Deserialize;
use axum::{
Form,
Router,
extract::{FromRef, FromRequestParts, State},
http::{StatusCode, request::Parts},
response::Html,
routing::{get, post},
};
use sqlx::postgres::{PgPool, PgPoolOptions};
use tokio::net::TcpListener;
use std::time::Duration;
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Params {
counter: String,
}
async fn main_page(State(_pool): State<PgPool>) -> Html<String> {
let mut html = String::from("<h1>Counter example</h1>");
html += r#"<form method="POST" action="/count">
<input name="counter">
<input type="submit" value="Start">
</form>
"#;
Html(html)
}
async fn count(
State(pool): State<PgPool>,
Form(params): Form<Params>,
) -> Result<String, (StatusCode, String)> {
println!("{}", params.counter);
let name = params.counter;
sqlx::query!("INSERT INTO counter ( name) VALUES ( $1 )", name)
.execute(pool)
.await?;
match sqlx::query_scalar("SELECT value FROM counter WHERE name = '%{name}%'")
.fetch_one(&pool)
.await {
Ok(value) => {
}
Err(sqlx::Error::RowNotFound) => 0,
}
sqlx::query_scalar("SELECT * FROM counter WHERE name = '%{name}%'")
.fetch_one(&pool)
.await
.map_err(internal_error)
}
/// Utility function for mapping any error into a `500 Internal Server Error`
/// response.
fn internal_error<E>(err: E) -> (StatusCode, String)
where
E: std::error::Error,
{
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}
async fn set_up_connection_pool() -> PgPool {
let username = "myuser";
let password = "secret";
let db_name = "mydb";
let hostname = "localhost";
let connector = format!(
"postgres://{}:{}@{}/{}",
username, password, hostname, db_name
);
let db_connection_str = std::env::var("DATABASE_URL").unwrap_or_else(|_| connector);
PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(3))
.connect(&db_connection_str)
.await
.expect("can't connect to database")
}
fn create_router(pool: PgPool) -> Router {
Router::new()
.route("/", get(main_page))
.route("/count", post(count))
.with_state(pool)
}
async fn create_database(pool: PgPool) {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS counter (
name TEXT PRIMARY KEY,
value INTEGER NOT NULL DEFAULT 0
);
"#,
)
.execute(&pool)
.await
.expect("failed to create database");
}
#[tokio::main]
async fn main() {
let pool = set_up_connection_pool().await;
create_database(pool.clone()).await;
let app = create_router(pool);
let listener = TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
API
An API will return JSON strings instead of HTML.
It might also accept JSON as input.
API Hello World
Return a simple JSON with a fixed string.
We create a struct to represent the data and use the serde-based Json serializer of axum to serialized the data and to set the content-type to application/json.
[package]
name = "api-calculator"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
serde_json = "1.0"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{Router, response::Json, routing::get};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Message {
text: String,
}
async fn handle_main_page() -> Json<Message> {
let data = Message {
text: String::from("Hello World!"),
};
Json(data)
}
fn create_router() -> Router {
Router::new().route("/", get(handle_main_page))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "application/json");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let message: Message = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
message,
Message {
text: String::from("Hello World!")
}
);
}
}
API Calculator
Send in two numbers as part of the path /2/3 and get back a JSON struct
with the two values and the sum of the numbers.
[package]
name = "api-calculator"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
serde_json = "1.0"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{Router, extract::Path, response::Json, routing::get};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Calc {
a: u32,
b: u32,
sum: u32,
}
async fn handle_main_page(Path((a, b)): Path<(u32, u32)>) -> Json<Calc> {
let data = Calc { a, b, sum: a + b };
Json(data)
}
fn create_router() -> Router {
Router::new().route("/{a}/{b}", get(handle_main_page))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
check("/2/3", Calc { a: 2, b: 3, sum: 5 }).await;
check(
"/7/3",
Calc {
a: 7,
b: 3,
sum: 10,
},
)
.await;
}
async fn check(uri: &str, expected: Calc) {
let response = create_router()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "application/json");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let result: Calc = serde_json::from_slice(&bytes).unwrap();
assert_eq!(result, expected);
}
}
Serving Static files
nest_serivces- tower_http::services::ServeDir;
[package]
name = "serving-static-files"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
tower = "0.5.3"
tower-http = { version = "0.6.8", features = ["fs"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{Router, response::Html, routing::get};
use tower_http::services::ServeDir;
async fn handle_main_page() -> Html<&'static str> {
Html(
r#"<h1>Static</h1>
<a href="/static/css/style.css">style.css</a>
"#,
)
}
fn create_router() -> Router {
Router::new()
.route("/", get(handle_main_page))
.nest_service("/static", ServeDir::new("static"))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
The static CSS file
h1 {
color: blue;
}
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<h1>Static</h1>"));
}
#[tokio::test]
async fn test_static_page() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/static/css/style.css")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/css");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let content = String::from_utf8(bytes.to_vec()).unwrap();
let expected = std::fs::read_to_string("static/css/style.css").unwrap();
assert_eq!(content, expected);
}
}
Session: counter with cookie
[package]
name = "counter-with-cookies"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.8"
tokio = { version = "1.50.0", features = ["full"] }
tower-cookies = "0.11.0"
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{Router, response::Html, routing::get};
use tower_cookies::{Cookie, CookieManagerLayer, Cookies};
const COOKIE_NAME: &str = "counter";
async fn handle_main_page(cookies: Cookies) -> Html<String> {
let mut counter: u32 = match cookies.get(COOKIE_NAME) {
Some(cookie) => cookie.value().parse::<u32>().unwrap(),
None => 0,
};
counter += 1;
cookies.add(Cookie::new(COOKIE_NAME, counter.to_string()));
Html(format!(
r#"<h1>Count {counter}</h1><a href="/delete">delete</a>"#
))
}
async fn delete_cookie(cookies: Cookies) -> Html<String> {
cookies.remove(Cookie::new(COOKIE_NAME, ""));
Html(format!(r#"<a href="/">home</a>"#))
}
fn create_router() -> Router {
Router::new()
.route("/", get(handle_main_page))
.route("/delete", get(delete_cookie))
.layer(CookieManagerLayer::new())
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Test
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_main_page() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let headers = response.headers().clone();
let content_type = headers.get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let cookies = headers.get_all("set-cookie");
let counter_cookie = cookies
.iter()
.find(|c| c.to_str().unwrap().contains("counter="))
.expect("Counter cookie should be set");
assert_eq!(counter_cookie, "counter=1");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "<h1>Count 1</h1><a href=\"/delete\">delete</a>");
// new request, now with cookie
let response = create_router()
.oneshot(
Request::builder()
.uri("/")
.header("Cookie", counter_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "<h1>Count 2</h1><a href=\"/delete\">delete</a>");
}
}
TODO list
- Main page list some todo items
- Form to add todo item
- title: on line
- details: long text
- status: Planned / In Progress / Done
- created: timestamp
- last_updated: timestamp
- deadline: timestamp
- Allow the user to
- edit todo item
- move items between states
[package]
name = "todo"
version = "0.1.0"
edition = "2024"
[dependencies]
askama = "0.15.6"
axum = "0.8.8"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use askama::Template;
use axum::{
Router,
extract::Query,
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
struct Item {
id: String,
title: String,
}
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to render template. Error: {err}"),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "main.html")]
struct MainTemplate {
title: String,
}
#[derive(Template)]
#[template(path = "edit.html")]
struct EditTemplate {
title: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Params {
text: String,
}
async fn main_page() -> impl IntoResponse {
let template = MainTemplate {
title: String::from("TODO"),
};
HtmlTemplate(template)
}
// Query(params): Query<Params>
async fn edit() -> impl IntoResponse {
let template = EditTemplate {
title: String::from("Add new item"),
};
HtmlTemplate(template)
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/edit", get(edit))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, create_router()).await.unwrap();
}
#[cfg(test)]
mod tests;
Test
#![allow(unused)]
fn main() {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_main() {
let response = create_router()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains(r#"<title>TODO</title>"#));
assert!(html.contains(r#"<h1>TODO</h1>"#));
}
#[tokio::test]
async fn test_edit_new() {
let response = create_router()
.oneshot(Request::builder().uri("/edit").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get("content-type").unwrap();
assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(html.contains("<title>Add new item</title>"));
assert!(html.contains("<h1>Add new item</h1>"));
assert!(html.contains(r#"<form method="post" action="/edit">"#));
}
// #[tokio::test]
// async fn test_echo_with_empty_text() {
// let response = create_router()
// .oneshot(
// Request::builder()
// .uri("/echo?text=")
// .body(Body::empty())
// .unwrap(),
// )
// .await
// .unwrap();
// assert_eq!(response.status(), StatusCode::OK);
// let content_type = response.headers().get("content-type").unwrap();
// assert_eq!(content_type.to_str().unwrap(), "text/html; charset=utf-8");
// let body = response.into_body();
// let bytes = body.collect().await.unwrap().to_bytes();
// let html = String::from_utf8(bytes.to_vec()).unwrap();
// assert!(html.contains("You did not write anything."));
// assert!(!html.contains("You wrote: <b>Hello World</b>"));
// }
// #[tokio::test]
// async fn test_echo_without_text_param() {
// let response = create_router()
// .oneshot(Request::builder().uri("/echo").body(Body::empty()).unwrap())
// .await
// .unwrap();
// assert_eq!(response.status(), StatusCode::BAD_REQUEST);
// let content_type = response.headers().get("content-type").unwrap();
// assert_eq!(content_type.to_str().unwrap(), "text/plain; charset=utf-8");
// let body = response.into_body();
// let bytes = body.collect().await.unwrap().to_bytes();
// let html = String::from_utf8(bytes.to_vec()).unwrap();
// assert_eq!(
// html,
// "Failed to deserialize query string: missing field `text`"
// );
// }
}
{% include "incl/header.html" %}
{% include "incl/footer.html" %}
Deployment
There are many ways to deploy an axum-based application. In this section we’ll try to cover a few options.
I personally use two services to run my servers, but that’s just my personal taste.
- Linode now owned by Akamai
- Digital Ocean
I usually run Ubuntu on my servers. Given that I use Ubuntu as my desktop, this makes the transition smoother.
Deploy on Linode running Ubuntu
In the following instructions I used the name demo, axum-demo, and axum-demo.code-maven.com.
You’ll probably have to replace those values.
- Create a Linode:
- Region: does not matter
- Linux Distribution: Ubuntu 24.04 LTS
- Shared CPU: Nanode 1GB ($5 / month)
- Linode Label: axum-demo
- Root Password: Pick something good!
- SSH Keys: I selectec the one I already have on file.
- Public Interface Firewall: No firewall
Click in “Create Linode”
It will take about a minute to create the Linode and you’ll see your IP there.
- ssh root@IP
# apt update
# apt upgrade -y
# apt install -y nginx
- Create user
# adduser demo --gecos ''
# cp -r /root/.ssh/ /home/demo/
# chown -R demo:demo /home/demo/.ssh/
# reboot
ssh demo@IP
As user demo:
$ mkdir app
Release the application
e.g. go to the echo-get example and run
cargo build --release
This will create a binary in the target/release folder. In the case of the echo-get example this binary is called echo-get.
Because for me both my desktop and the server runs Ubuntu, this makes sense, but if you have different Operating Systems or different distributions then you might need to either setup cross-compilation, or use a Docker container to build the executable.
An alternatively could be this command:
docker run --rm -it -v "$PWD:/src" --user ubuntu -w /src szabgab/rust:latest /home/ubuntu/.cargo/bin/cargo build --release
Upload to the server
scp target/release/echo-get demo@IP:app/axum-demo
Verify that the application runs
ssh demo@IP
cd app
./demo
This should run the server
In another terminal ssh demo@IP again and try:
curl http://localhost:3000
That should show the main page
Set it up as a service
In the service configuration file search for the word demo and axum and replace them with the
values appropriate to your setup.
[Unit]
Description=Axum Demo Service
[Service]
Type=simple
User=demo
Group=demo
Restart=always
WorkingDirectory=/home/demo/app/
ExecStart=/home/demo/app/axum-demo
Nice=19
LimitNOFILE=16384
[Install]
WantedBy=multi-user.target
- Upload the
axum-demo.servicefile to/etc/systemd/system/axum-demo.service
$ scp axum-demo.service root@IP:/etc/systemd/system/axum-demo.service
Connect again to the server as user root and set up the service.
$ ssh root@IP
# systemctl daemon-reload
# systemctl enable axum-demo.service
# systemctl start axum-demo.service
Verify that the application runs as a service:
Running curl http://localhost:3000 again (on the server) should return the page again.
nginx
After replacing demo in the file, upload the nginx configuration file:
scp axum-demo.code-maven.com root@IP:/etc/nginx/sites-available/
# cd /etc/nginx/sites-enabled
# ln -s /etc/nginx/sites-available/axum-demo.code-maven.com
# systemctl restart nginx
server {
server_name axum-demo.code-maven.com;
listen [::]:80;
listen 80;
try_files $uri.html $uri $uri/ =404;
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://port3000;
}
access_log /var/log/nginx/axum-demo.log;
error_log /var/log/nginx/axum-demo.error.log;
}
upstream port3000 {
server 127.0.0.1:3000;
}
DNS name resolving
If you have a domain you can map any hostname to this IP. I use the iwantmyname service to register domain names and handle DNS configuration. So I mapped axum-demo.code-maven.com to the IP addresses Linode gave me. (Both IPv4 and IPv6.)
Then waited a minute to allow for their service to be updated and then checked with whatsmydns service if the new name already resolves by a large chunk of the Internet.
HTTPS Certificate
- Install certbot for Let’s Encode certificate following the instructions in that link.
# snap install --classic certbot
# ln -s /snap/bin/certbot /usr/bin/certbot
# certbot --nginx
Domain name to certify: axum-demo.code-maven.com
Done
At this point your service should be up and running. I’d recommend rebooting the server to verify that after a reboot the service runs again.
Upgrade
After a while you will probably make some changes to the application and will want to upgrade it on the server.
- Build a new release
- Create a copy of the old executable so you can easily switch back if necessary.
- Upload the executable to the server replacing the old executable.
- Restart the service:
systemctl restart axum-demo
That’s it.