CRUD Operations

Now that we have a "database" usable in our axum backend. Let's write handling functions, which have access to the database and read and modify it. Head to your backend crate and create again a new module. This time it's called controllers.rs. Why named controllers? There is a famous design pattern called model, view, controller. Controllers are usually those parts, which execute your logic. In our case, you've already seen them. These are the functions, we give the Router as callback in a route.

Do not forget to add new module in the main.rs file.

#![allow(unused)]
fn main() {
mod controllers;
}

Cleanup

Let's remove any other controller we currently have - except the get_items controller. This function you can move to the new controllers.rs module - as this will be our first controller we rewrite to use the database instead of the hardcoded list.

Do not forget to make this one public - by prepending a pub. Otherwise it's not visible outside this new module.

#![allow(unused)]
fn main() {
pub async fn get_items() -> impl IntoResponse {...}
}

Afterwards your main.rs should look like this:

mod controllers;
mod database;

use std::sync::{Arc, RwLock};

use axum::{routing::get, Router};
use controllers::get_items;
use database::InMemoryDatabase;

type Database = Arc<RwLock<InMemoryDatabase>>;

#[tokio::main]
async fn main() {
    let db = Database::default();
    let app = Router::new().route("/items", get(get_items)).with_state(db);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

As well as your controller.rs:

#![allow(unused)]
fn main() {
use axum::{response::IntoResponse, Json};
use model::ShoppingListItem;

pub async fn get_items() -> impl IntoResponse {
    let items = vec!["milk", "eggs", "potatoes", "dogfood"];

    let uuid: &str = "a28e2805-196b-4cdb-ba5c-a1ac18ea264a";
    let result: Vec<ShoppingListItem> = items
        .iter()
        .map(|item| ShoppingListItem {
            title: item.to_string(),
            posted_by: "Roland".to_string(),
            uuid: uuid.to_string(),
        })
        .collect();

    Json(result)
}
}

This should be our starting point.

Reading things

Let's rewrite our get_items controller. Instead of returning a hardcoded list, let's fetch them from the database. Change the signature of your function.

#![allow(unused)]
fn main() {
use axum::extract::State;

pub async fn get_items(State(state): State<Database>) -> impl IntoResponse {...}
}

Now you can access the database, that you've already injected with the with_state(...) method of the Router.

But first, go to your database.rs module and add a new method to your impl InMemoryDatabase to return all items at once.

#![allow(unused)]
fn main() {
  pub fn as_vec(&self) -> Vec<(String, ShoppingItem)> {
      self.inner
          .iter()
          .map(|(uuid, item)| (uuid.clone(), item.clone()))
          .collect()
  }
}

For this, the database item must be Clone, which is Rust lingo for implementing the Clone trait, which we can do automatically for most types by deriving the implementation with a #[derive] attribute. So add this also to the item. Moreover, the struct itself and its members must be public, so one can use it outside the database.rs module.

#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct ShoppingItem {
    pub title: String,
    pub creator: String,
}
}

With those changes, we now can rewrite our get_items function to fetch items from the database.

#![allow(unused)]
fn main() {
pub async fn get_items(State(state): State<Database>) -> impl IntoResponse {
    let items: Vec<ShoppingListItem> = state
        .read()
        .unwrap()
        .as_vec()
        .iter()
        .cloned()
        .map(|(uuid, item)| ShoppingListItem {
            title: item.title,
            posted_by: item.creator,
            uuid,
        })
        .collect();

    Json(items)
}
}

If you now execute all our do-all command cargo make --no-workspace dev, you get the items displayed, which are in the database :). Good Job!

Creating things

In order to add new items to the lists, we have to implement a controller enabling a POST with a json payload. If you remember, you did this one in the beginning.

In order to create a new item, we need two fields. One for the title and one for the creator. Let's create a Data Transfer Object (DTO). As this one is used in both of our crates, the model crate would be a good fit the place it in.

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize)]
pub struct PostShopItem {
    pub title: String,
    pub posted_by: String,
}
}

Now our controller in the backend can deserialize this object, coming from our frontend. But who creates the unique id? Our backend does this. Therefore, let's add a crate that creates uuids.

cargo add uuid -F serde -F v4

Now let's create our controller:

Add the correct use statements at the top:

#![allow(unused)]
fn main() {
use model::{PostShopItem, ShoppingListItem};
use uuid::Uuid;
use axum::http::StatusCode;
use crate::database::ShoppingItem;
}

Then the controller - using all these:

#![allow(unused)]
fn main() {
pub async fn add_item(
    State(state): State<Database>,
    Json(post_request): Json<PostShopItem>,
) -> impl IntoResponse {
    let item = ShoppingItem {
        title: post_request.title.clone(),
        creator: post_request.posted_by.clone(),
    };
    let uuid = Uuid::new_v4().to_string();

    let Ok(mut db) = state.write() else {
        return (StatusCode::SERVICE_UNAVAILABLE).into_response();
    };

    db.insert_item(&uuid, item);

    (
        StatusCode::OK,
        Json(ShoppingListItem {
            title: post_request.title,
            posted_by: post_request.posted_by,
            uuid,
        }),
    )
        .into_response()
}
}

A lot is happening there. Let's go through it.

The State extractor fetches your database, you injected in you main. We need this to write to the database and insert a new item. Then the Json extractor deserializes your recently created PostShopItem. The cool thing here: If the request is not valid - i.e. the client posts some other json than expected, the controller will automatically return a 400 code for invalid input data it cannot be deserialized into the specified model.

In the body we create the data model we have in our database. For this, we manually map the PostShopItem to a ShoppingItem type. Then we create a universally unique identifier with the Uuid crate (There are different uuid version - we just pick v4).

Then there is rust syntax, the let-else statement, you probably did not see before. It binds the Ok value out of the Result coming from the state.write() statement. That is, if we get the lock to write - then we proceed. Otherwise, the else path, we return early with a service unavailable error code (503).

If we can write, we then insert the new item with the generated uuid and respond with the newly generated item as a json serialized ShoppingListItem.

The last thing we have to do, is now register this controller as POST handler into our axum Router. Go ahead an do that and do not forget the use statement on top:

#![allow(unused)]
fn main() {
use controllers::{add_item, get_items};
}

In your fn main:

#![allow(unused)]
fn main() {
let app = Router::new()
    .route("/items", get(get_items).post(add_item))
    .with_state(db);
}

If you now run everything with our all-do cargo make, then you can already add new items with e.g. curl, or postman.

Here a curl post example

curl -i \
    -H "Accept: application/json" \
    -H "Content-Type: application/json" \
    -X POST -d '{"title": "Pepperoni", "posted_by": "Rustacean"}' \
    http://localhost:3001/items

After adding the item(s), reload the frontend, that is running on localhost:8080 (if you ran the all-do cargo make).

Great - now you know how to add items! Let's fast-forward and add the code to also delete items.

Deleting things

Deletion is similar to creation. We also need writeable access to the database. But we do not have to accept a json payload, but a uuid. The only thing that changes is the extraction of a Path parameter. But you did that already in the axum hello world chapter.

Our deletion controller:

#![allow(unused)]
fn main() {
use axum::extract::Path;

pub async fn delete_item(
    State(state): State<Database>,
    Path(uuid): Path<Uuid>,
) -> impl IntoResponse {
    let Ok(mut db) = state.write() else {
        return StatusCode::SERVICE_UNAVAILABLE;
    };

    db.delete_item(&uuid.to_string());

    StatusCode::OK
}
}

Adding our controller is also straightforward. We only have to add the delete in the Router and add above delete_item controller as callback.

Use statements:

#![allow(unused)]
fn main() {
use axum::routing::delete;
use controllers::delete_item;
}

With the router being expanded with:

#![allow(unused)]
fn main() {
.route("/items/:uuid", delete(delete_item))
}

To delete an item (we do not have that in the UI) you can just use curl again. You only need to have an existing uuid at hand.

curl -X "DELETE" http://localhost:3001/items/<your-uuid>

To get an uuid, just create some items with the curl command from above.

Very good! Now our backend is ready to be consumed by our frontend. Let's head to the next chapter, where we add the needed components.