Rust & Axum: A Practical Guide to Understanding Requests & Responses

Building web applications in Rust has become easier with frameworks like Axum. Axum is a minimal and flexible web framework built on top of Tokio that focuses on high performance, making it a great choice for building APIs.

In this article, we'll dive into how the request/response model works in Axum, covering JSON handling, request bodies, and headers. We'll build this understanding step by step, starting with a simple example and gradually adding complexity. By the end, you'll have a hands-on understanding of handling HTTP requests and responses in Rust using Axum. Let's get started!


Step 1: Set up the project

First, we need to create a new Rust project and add Axum as a dependency.

  1. Open your terminal and create a new project:
cargo new axum_demo
cd axum_demo
  1. Now, modify your Cargo.toml file to include the necessary dependencies:
[dependencies]
axum = "0.6"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

These dependencies are:

  • axum for building the web server.
  • tokio for asynchronous operations.
  • serde and serde_json to handle serialization and deserialization of data, especially JSON.

Step 2: Build a basic "Hello, World!" server

Let's start by building a simple Axum server that responds with a basic string message.

Replace the contents of src/main.rs with the following:

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(root));
    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn root() -> &'static str {
    "Hello, World!"
}

Explanation:

  • Router::new().route("/", get(root)): This defines the route for handling GET requests to the root path (/) and links it to the root function.
  • axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()).serve(app.into_make_service()).await: This starts the server and listens on port 3000.
  • root(): This function returns a simple string "Hello, World!" when a request hits the root path.

Run your server using cargo run. Open your browser or use curl to visit http://localhost:3000, and you should see the text "Hello, World!"


Step 3: Work with JSON data

Now that we have a working server, let’s work with JSON. We'll modify the server to return a JSON response instead of a plain string.

Replace the root() function with this:

use axum::{response::Json};
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct Message {
    message: String,
}

async fn root() -> Json<Message> {
    Json(Message {
        message: String::from("Hello, World in JSON!"),
    })
}

Explanation:

  • #[derive(Serialize, Deserialize)]: This macro is used to make the Message struct both serializable and deserializable. We use Serialize to convert the struct to JSON and Deserialize to convert incoming JSON into the struct.
  • Json<Message>: The Json wrapper ensures the return type is automatically serialized to JSON by Axum.
  • Json(Message { message: String::from("Hello, World in JSON!") }): This creates an instance of Message and wraps it in a Json response.

When you visit http://localhost:3000 again, the response will be:

{
  "message": "Hello, World in JSON!"
}

Step 4: Handling POST requests and Request Bodies

Next, we will handle POST requests and work with the request body. In a real application, clients might send data (e.g., user input) to your server. We'll accept a JSON payload in a POST request.

Update the main.rs file to include a new route for POST requests:

use axum::{extract::Json, routing::post};

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(root))
        .route("/send", post(send_message));

    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn send_message(Json(payload): Json<Message>) -> Json<Message> {
    Json(Message {
        message: format!("Received: {}", payload.message),
    })
}

Explanation:

  • post(send_message): We define a route that listens for POST requests on the /send path.
  • Json(payload): Json<Message>: The Json extractor automatically deserializes the incoming request body into a Message struct.
  • format!("Received: {}", payload.message): We modify the message in the response to show what was sent by the client.

Testing POST with curl:

Use the following curl command to test the new POST endpoint:

curl -X POST http://localhost:3000/send -H "Content-Type: application/json" -d '{"message": "Hello!"}'

The response will be:

{
  "message": "Received: Hello!"
}

This demonstrates how you can process JSON data sent by the client and return a modified response.


Concepts and Explanations

Request/Response Cycle in Axum

In web development, the request/response model is fundamental. Here's what happens in Axum:

  • Request: When a client (e.g., a browser or an API client) sends a request, it includes various components:

    • Method: Defines the type of operation (e.g., GET, POST).
    • Headers: Provide additional metadata about the request (e.g., Content-Type, Authorization).
    • Body: Contains data sent with the request (e.g., JSON, form data).
  • Response: The server sends back a response, which includes:

    • Status Code: Indicates whether the request was successful (e.g., 200 OK, 404 Not Found).
    • Headers: Metadata about the response (e.g., Content-Type: application/json).
    • Body: The data being sent back (e.g., JSON, HTML).

In Axum:

  • Json<T> handles JSON serialization and deserialization.
  • extract::Json is used to extract data from the request body.
  • response::Json sends back a JSON response.

Challenges or Questions

Now that you’ve built a basic application, try the following challenges to deepen your understanding:

  1. Add more fields to the Message struct and handle those fields in both requests and responses. How does this affect the JSON response?
  2. Set custom HTTP headers. For example, try to add a custom X-Response-Time header to your responses.
  3. Deserialize nested JSON objects. Create a more complex data structure (e.g., a User struct with a Profile struct) and practice handling nested JSON.

Recap and Conclusion

In this article, we’ve explored the basics of handling HTTP requests and responses using Rust and Axum. Here's what we covered:

  • Setting up a basic server using Axum.
  • Returning and accepting JSON data with the Json type (using both Serialize and Deserialize).
  • Handling POST requests and processing request bodies.
  • Understanding the request/response cycle in web development.

With these foundational concepts in hand, you're ready to start building more complex APIs and web services in Rust using Axum. Keep experimenting and building—Axum’s simplicity and power make it an excellent choice for Rust web development.

Happy coding!