rust-web-axum-api-rest

REST API in Rust with Axum: routes, extractors, and JSON

  • 3 min

Axum is ***a web framework for Rust built on Tokio, Hyper, and Tower***.

It serves to create APIs and HTTP services while maintaining what makes Rust special: strong types, explicit errors, and plenty of control without sacrificing a fairly comfortable syntax.

## Creating the project

We create a new project and add the dependencies:

```bash
cargo new mi_api
cd mi_api
cargo add axum
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json
Copied!

axum will be the web framework, tokio the asynchronous runtime, and serde will allow us to convert JSON to Rust structs and back.

Our first Router

In Axum, everything starts with a Router.

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

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

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();

    axum::serve(listener, app)
        .await
        .unwrap();
}

async fn root() -> &'static str {
    "Hello from Axum"
}
Copied!

If we run cargo run and open http://127.0.0.1:3000, we will see the response.

In modern versions of Axum, tokio::net::TcpListener is used together with axum::serve. If you find old examples with axum::Server::bind, they are from previous APIs.

Handlers

A handler is the function that responds to a route.

async fn root() -> &'static str {
    "Hello from Axum"
}
Copied!

It can return many things: text, JSON, HTTP status codes, tuples, custom types that implement IntoResponse

This makes handlers normal Rust functions, without needing to inherit from anything or write a controller class.

Receiving JSON

To receive JSON we use the Json<T> extractor.

use axum::{routing::post, Json, Router};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CreateUser {
    username: String,
    email: String,
}

#[derive(Serialize)]
struct UserResponse {
    id: u64,
    username: String,
    message: String,
}

async fn create_user(
    Json(payload): Json<CreateUser>,
) -> Json<UserResponse> {
    Json(UserResponse {
        id: 1,
        username: payload.username,
        message: String::from("User created"),
    })
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users", post(create_user));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();

    axum::serve(listener, app).await.unwrap();
}
Copied!

If the body is not valid JSON or does not match CreateUser, the extractor rejects the request before calling the handler. Types are part of the structural input validation, although business rules remain our responsibility.

Route parameters

For routes like /users/42, we use Path.

use axum::extract::Path;

async fn get_user(Path(id): Path<u64>) -> String {
    format!("Looking for user {}", id)
}

// In the router:
// .route("/users/{id}", get(get_user))
Copied!

Axum tries to convert the {id} segment to u64. If something that is not a number arrives, the request does not reach the handler.

Shared state

A real API needs configuration, HTTP clients, database pools, or shared services.

For that we use State:

use axum::{extract::State, routing::get, Router};
use std::sync::Arc;

struct AppState {
    name: String,
}

async fn info(State(state): State<Arc<AppState>>) -> String {
    format!("API: {}", state.name)
}

#[tokio::main]
async fn main() {
    let state = Arc::new(AppState {
        name: String::from("Rust Course"),
    });

    let app = Router::new()
        .route("/info", get(info))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();

    axum::serve(listener, app).await.unwrap();
}
Copied!

The Arc allows sharing ownership of the state between requests. If any field needs to mutate, we will still have to choose an appropriate synchronization mechanism.