Intro
Today we will build a shopping list, that you can edit and share with your friends! So you never have to write one on your own again.
The fullstack workshop will be a guided workshop, where we will implement the things together. If you do not want to copy all this boilerplate code, you can have a look at the finished repo https://github.com/rust-basel/axum-dioxus-shopping-list.
If you need specifically the solution for a chapter, you can have a look at the solutions repository. It is structured after the chapters in this book.
If you see any issues - or errors in this book, or the solutions we are very happy for pull requests to fix these.
Architecture
We strive for a simple architecture with a frontend, backend and a database. For simplicity reasons, we implement a simple in memory database. This means that we will lose our data as soon as we stop the server process, but for the workshop app this is good enough.
Goal
At the end of the workshop you are going to have a fully working small web application, in which you can create and share shopping lists. Those lists can also be edited from several people at the same time.
The app is styled with Tailwind, along with a component library called DaisyUi.
The final result can look like this:
Loading or Creating a list
Editing list
Setup
In order to have our fullstack project, we need some setup first:
Prerequistes
For the setup we need as prerequisites:
Project structure
We will have everything in the same repository. So the backend and the frontend are easier to keep in sync for code updates.
First start with a directory, let's call it fullstack-workshop and hop into this directory (and init git).
mkdir fullstack-workshop
cd fullstack-workshop
git init
After this one, create two new cargo projects within this directory.
cargo new frontend
cargo new backend
After this one, let's create a workspace! Add a top level Cargo.toml (in the fullstack-workshop directory)
[workspace]
resolver = "2"
members = [
"frontend",
"backend"
]
Perfect! Now the core setup stands.
If you run cargo build
both, the frontend and your backend crate should be build.
Install Dioxus CLI
Dioxus comes with a CLI that wraps around cargo, for providing features such as hot reloading and reading configuration
from a Dioxus.toml
file, if one is present.
We can just install it system-wide with cargo:
cargo install dioxus-cli
If you had problems with the setup, you can just copy the code from here
Dioxus Hello World
Prerequisites
If you have not done so already, install the dioxus cli:
cargo install dioxus-cli
This is a wrapper around cargo and installed globally, since it can also be used to init new projects.
First Hello World
Our dioxus part will be the frontend directory. So go ahead an go into the frontend dir
cd frontend
Now add the needed dependencies to your Cargo.toml
cargo add dioxus --features web
and add the following to your main.rs
in src/
.
use dioxus::prelude::*; fn main() { launch(App); } #[allow(non_snake_case)] pub fn App() -> Element { rsx! {"Hello World"} }
Components in Dioxus are plain functions starting with a capital letter (PascalCase).
The rsx!
macro is Dioxus own Domain Specific Language (dsl), which works similar to jsx
in React for example.
Let's run our frontend! For this we now need the prior installed dioxus-cli
.
dx serve --hot-reload
The --hot-reload
flag will recompile your code, when you save your files, that are currently being served.
With default settings, you can now watch your hello world application at localhost:8080
.
Congrats! Your (maybe) first webpage written in Rust!
If you want, you can change the content in your hello world. For example using a variable?
#![allow(unused)] fn main() { pub fn App() -> Element { let rust_basel = "Rust Basel"; rsx! {"Welcome to {rust_basel}"} } }
It's all about style - let's add tailwind and daisyUi
Great. Now that our web hello world is running, let's add some styling to it.
Of course no one likes raw CSS
, which is why we are going to add Tailwindcss, which has
some sane defaults and add a tailwind component library to it. It's called DaisyUi.
You define the style of your DOM components directly as classes in the rsx
macro. No need for an extra css file, you have to maintain.
Tailwind will watch the style classes, you define within your markdown and will automatically generate the correct css for you.
Daisyui is a tailwind component library. As tailwind itself is quite mighty, DaisyUI enhances tailwind and make styling easy having ready to use components like buttons, navbars, etc.
Installing dependencies
First we go ahead and install our dependencies.
We want all configuration files top-level. So go back, where your top-level Cargo.toml
is
and initialize npm
there. Then install tailwindcss
for development and initialize tailwind.
cd ..
npm init
Just say yes to all defaults. This should give a package.json, which looks like this:
package.json:
{
"name": "fullstack-workshop",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
Then go ahead and install tailwindcss as development library and initialize it.
npm install -D tailwindcss
npx tailwindcss init
As last, go ahead and install daisyUi.
npm install -D daisyui@latest
Sorry for installing so many dependencies. And welcome to the npm world! :) However, these will only be development dependencies and will run on your machine only, never on the server or client. Only the CSS resulting from build is served.
Changing the configuration files
Now that we have all dependencies, we need to adapt the configuration to let tailwind know where to look for classes.
Changes to tailwind.config.js
so it will watch the rsx classes in our .rs files:
module.exports = {
mode: "all",
content: [
// include all rust, html and css files in the src directory
"./frontend/src/**/*.{rs,html,css}",
// include all html files in the output (dist) directory
"./frontend/dist/**/*.html",
],
theme: {
extend: {},
},
plugins: [require("daisyui")],
daisyui: {
themes: [
"cupcake",
],
},
}
At very last, add a input.css
to the frontend crate. This is the target file for tailwind to generate the css for you,
and this is the only stylesheet file we will serve to the browser.
first cd into the frontend crate
cd frontend
then add a input.css
with the following contents:
@tailwind base;
@tailwind components;
@tailwind utilities;
that's it. now let's test our new styles.
let's add a stylish button!
go to your main.rs
in the frontend crate, and add a button
#![allow(unused)] fn main() { pub fn App() -> Element { let rust_basel = "Rust Basel"; rsx! { h1{ "Welcome to {rust_basel}" } button{ class: "btn", "My stylish button" } } } }
The btn
class is defined in the daisy ui components https://daisyui.com/components/button/#button. Have a look, if you want to try other classes.
To now let tailwind create the fitting css files, run the tailwind watcher in another terminal. It watches, if any of your watched files (defined in the top-level tailwind.config.js
)
changes and re-generated the css file.
(Note: run from top-level)
npx tailwindcss -i ./frontend/input.css -o ./frontend/public/tailwind.css --watch
Tailwind will regenerate a css file into your frontend/public/
directory, where dioxus will fetch the style from (by default).
Now, let's finally have a look at our stylish first website!
(Note: run from frontend dir)
dx serve --hot-reload
If everything worked our, you should have a page that looks like this:
You still might need some hard refreshes or server restarts with CTRL + C
and dx serve --hot-reload
from time to time,
but we will refine the hot reloading setup later on.
All the commands, you have to run manually now will be put in a nice package that spins everything up with one command.
Dioxus Alternatives
Dioxus is not the only framework of its kind. There is leptos
, which is a close competitor which seems to have a narrower focus and seemingly more production use.
And there is yew
, which has been around for years, and is without a doubt production-ready and battle-tested. Compared with the newer generation, you
have to fight around with lifetimes withing your dependency tree a lot. Dioxus is able to circumevent this with clever types, and deferring a few lifetime checks to runtime.
This leads to a developer experience comparable to react (except for the tools, of course).
Axum Hello World
Let's start building our HTTP backend!
Dependencies
Go into your backend
crate and install the following dependencies:
cargo add axum
cargo add tokio -F full
cargo add serde -F derive
cargo add tower-http -F cors
Axum will be our webservice framework of choice. As it's very extensible with tower-http, where you can easily create middlewares.
Serde is Rusts de facto standard serialization library, so we can easily receive and send json
payloads and map them to Rust models.
Tokio is the most popular async runtime (and required by axum, which runs within tokio)
Add a small webservice
Head to your main.rs
and add the following minimal executable:
use axum::{extract::Path, response::IntoResponse, routing::get, Router}; async fn hello_world() -> impl IntoResponse { "Hello World" } async fn hello_name(Path(name): Path<String>) -> impl IntoResponse { format!("Hello {name}") } #[tokio::main] async fn main() { let app = Router::new() .route("/", get(hello_world)) .route("/:name", get(hello_name)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap(); axum::serve(listener, app).await.unwrap(); }
The Router
lets you define the endpoints of your webservice. You can attach different routes to it, as well as other middlewares.
Middlewares are logical units, that are shared across different routes. One example would be an Auth
middleware.
The most basic function of axum with a router is mapping requests to handlers. A handler is anything that
returns a value that implements IntoResponse
, and the most trivial example of this is a String, as in the line format!("Hello {name}")
.
After adding those few lines run.
cargo run
And wait for your compiler to build and run the app. Your webservice is now running on localhost:3001
. So if you go to your browser, your should see a Hello World
.
If you put localhost:3001/your-name
into your browser, then the path parameter your-name
is extracted by axum, as you see in the Path(name): Path<String>
argument.
Responding with and receiving json payload
If you want to create a resource server side, you usually send a http POST
to your server with a Json payload.
Serialization is hard? Not with Rust - as we have the Serde
crate.
Add a rust struct annotated with serde's Serialize and Deserialize create.
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] struct Workshop { attendees_count: i32, people_like_it: bool, } }
Now this struct Workshop
is automatically de- and serializable. The Procedural Macro implements this for us!
If we now want to receive and respond this with Json, we have to add a fitting handler (function) to it.
Add this to your main.rs
:
#![allow(unused)] fn main() { async fn workshop_echo(Json(workshop): Json<Workshop>) -> impl IntoResponse { Json(workshop) } }
This will just echo the json. But you get the idea. We also need to add this to our Router
.
Add it as a post
(you could also use get
, but idiomatically json payloads are send with either patch
, put
or post
).
#![allow(unused)] fn main() { use axum::routing::post; }
#![allow(unused)] fn main() { let app = Router::new() .route("/", get(hello_world)) .route("/:name", get(hello_name)) .route("/your-route", post(workshop_echo)); }
You can test your json endpoint with the following curl call, to check whether you added it correctly ;).
curl -i \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-X POST -d '{"attendees_count": 20, "people_like_it": true}' \
http://localhost:3001/your-route
Whooray! You got the basic building block ready. The next steps will take care fo letting our frontend backend talk to each other. Let's display a list of items from our backend in the frontend in the next chapter.
Axum alternatives
There are many other Rust server frameworks.
Axum is still rather new, and seemingly the 'bright star' in this universe. It is extremely fast, because of its simplicity and direct coupling to tokio.
Documentation and adaptation in production are still lagging somewhat, but with the current popularity, these things are bound to catch up too.
The most popular framework probably still is actix-web
, which is also rather lightweight and revolves around the actor pattern.
The most full-fledged framework would berocket
, with very robust features and tools. While axum is more like expressjs, rocket is more comparable to Ruby on Rails.
Speaking about rails, there is also loco
, a framework layer built on top of axum, with the goal of being super small and simple, perfect for one-(wo)man teams.
Displaying a list
Now we are going to connect our two components, the dioxus frontend and the axum backend! We are going to display a list in the frontend, which is served by our backend.
Shared Models
As we now connect our front- and backend, it makes sense to share the data models (otherwise we have to define and sync those in both crates). On the backend we need to serialize to json. On the frontend we need to deserialize from json.
So go ahead top-level and create a new crate. As this is a library, we create it with the --lib
tag.
cargo new --lib model
In this crate we will put all of our models, that are shared between our front- and backend. They will correspond to the json data you can see in your network tab.
Go into this new model crate and add serde
to this crate
cargo add serde -F derive
Then go ahead and add our first shared model.
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] pub struct ShoppingListItem { pub title: String, pub posted_by: String, pub uuid: String, } }
Great - now we can use this structure on both sides! Do not forget to add this crate to our workspace (top level Cargo.toml
).
[workspace]
resolver = "2"
members = [
"frontend",
"backend",
"model"
]
Makefile
With more and more moving parts, let's first introduce a Makefile.toml
which will make our life much easier. With cargo-make
.
We can execute our three commands concurrently with one command, which is:
- serving our backend
- serving our frontend
- telling tailwind to watch files, and to recompile the css if needed.
Also we use cargo-watch
, that will watch specific files and recompile our code, once we save one of the watched files.
Go ahead an create Makefile.toml
at the top level.
[tasks.backend-dev]
install_crate = "cargo-watch"
command = "cargo"
args = ["watch", "-w", "backend", "-w", "model", "-x", "run --bin backend"]
[tasks.frontend-dev]
install_crate = "dioxus-cli"
command = "dx"
args = ["serve", "--bin", "frontend", "--hot-reload"]
[tasks.tailwind-dev]
command = "npx"
args = ["tailwindcss", "-i", "./frontend/input.css", "-o", "./frontend/public/tailwind.css", "--watch"]
[tasks.dev]
run_task = { name = ["backend-dev", "tailwind-dev", "frontend-dev"], parallel = true}
This file describes all the processes it will execute, when you type in cargo make
.
But for this to work go ahead and install cargo-watch
cargo-make
cargo install cargo-watch
cargo install cargo-make
Great! Now, if you execute
cargo make --no-workspace dev
Every single command we used before is now executed within one command. With hot-reload!
Backend
Before we can fetch a list in the frontend, let's offer a list in backend first. For this, go to your backend crate and use the struct, we defined in our model crate.
Therefore, add the model
crate as dependency to the backend
Cargo.toml
.
model = { path = "../model" }
Then add a use statement in our current main.rs
:
#![allow(unused)] fn main() { use model::ShoppingListItem; }
Then we need a controller, that sends those items:
#![allow(unused)] fn main() { 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) } }
For now we ignore the uuid generation. Every item has the same - but we'll need it for later. Same for Roland ;o.
Now add this to our router
#![allow(unused)] fn main() { .route("/items", get(get_items)); }
and run cargo make --now-workspace dev
. Opening a webbrowser with localhost:3001/items
should give you those items.
Frontend
Now that you have a backend, which can serve a list of items. Let's build a rudimentary frontend, that can display the list.
Hop to your frontend
crate.
Fetching items
First let's add some logic to fetch data from the backend. We can do this by using the reqwest
crate (a https client library). We also need to add our
model crate
.
cargo add reqwest -F json
cargo add model --path ../model
Let's add a small function, that is fetching items.
#![allow(unused)] fn main() { use model::ShoppingListItem; async fn get_items() -> Result<Vec<ShoppingListItem>, reqwest::Error> { let url = "http://localhost:3001/items"; let list = reqwest::get(url) .await? .json::<Vec<ShoppingListItem>>() .await; list } }
Quite some things are happening here. Let us look at the lines. We fetch something from the backend with reqwest::get(url).await
, that returns
a Result
, containing the payload of that request, or an error. The ?
-operator unwraps this Result - continuing the chain, if the Result was ok.
If it was an error, the ?
-operator stops immediately and returns the error. It also converts the error to the error type from the function, that is reqwest:Error
.
Then the successfully loaded payload is deserialized with .json::<Vec<ShoppingListItem>>
. That also can fail, so this returns a Result.
But instead of checking this Result with another ?
-operator, we just return this Result from the function.
Displaying items
Dioxus
is a component based web framework - it is comparable to e.g. React
, where you nest components into one another.
Also, if one of the properties in a component changes, the components will be re-rendered.
So let's create a component, that displays a single item - and then embed this item component into a list, displaying all components
#![allow(unused)] fn main() { #[component] fn ShoppingListItemComponent(display_name: String, posted_by: String) -> Element { rsx! { div { class: "flex items-center space-x-2", p { class: "grow text-2xl", "{display_name}" } span { "posted by {posted_by}" } } } } }
Anytime one of the properties, either display_name
or posted_by
is changing, the component is going to be re-rendered.
Now we are going to use the above component to display a list. In Another component
#![allow(unused)] fn main() { #[component] fn ShoppingList() -> Element { let items_request = use_resource(move || async move { get_items().await }); match &*items_request.read_unchecked() { Some(Ok(list)) => rsx! { div { class: "grid place-items-center min-h-500", ul { class: "menu bg-base-200 w-200 rounded-box gap-1", for i in list { li { key: "{i.uuid}", ShoppingListItemComponent{ display_name: i.title.clone(), posted_by: i.posted_by.clone() }, } } } } }, Some(Err(err)) => { rsx! { p { "Error: {err}" } } } None => { rsx! { p { "Loading items..." } } } } } }
The use_resource
is one of Dioxus
hooks. Hooks help you to have stateful functionality in your components.
use_resource
especially lets you run async closures and return a result. In this case we match
the result
of the close. On the first render, there will be no data available. So matching will result in a None
. The component will be re-rendered,
once the future is finished and the result will be Some(...)
thing.
If you now add the ShoppingList
component to our App and execute our cargo make
command (if it's not running already)
you will see our list!
#![allow(unused)] fn main() { pub fn App() -> Element { let rust_basel = "Rust Basel"; rsx! { h1{ "Welcome to {rust_basel}" } button{ class: "btn", "My stylish button" } ShoppingList{} } } }
Great! Now you got the basics! Let's head to our backend to create a rudimentary database - so we can later also add or remove items to the list.
CORS
It may happen, that your browser denies fetching data from your backend because of Cross-Origin Resource Sharing (CORS). For security reasons browser restrict cross-origin HTTP requests. Your server has to explicitly tell the browser, which sources, or origins, are allowed to fetch the server's resources. For more info have a read at the MDN Web docs.
In order to tell our server that it is ok to fetch data from it, we can add a cors
layer to our Router
. That layer
is another middleware.
So the cors
logic is applied to all routes we already wrote, and the server sends appropriate CORS headers with each response.
Therefore add the tower-http
crate.
cargo add tower-http -F cors
Afterwards you can add a layer around your existing routes, like so:
#![allow(unused)] fn main() { use tower_http::cors::CorsLayer; }
And in your main function:
#![allow(unused)] fn main() { let app = Router::new() .route("/", get(hello_world)) .route("/:name", get(hello_name)) .route("/your-route", post(workshop_echo)) .route("/items", get(get_items)) .layer(CorsLayer::permissive()); }
Note: Do not use permissive
in production - but choose a cors policy, that fits your setting in production.
Dioxus.toml
Inside our frontend
crate, we now also want something called a Dioxus.toml
. It's a configuration file for dioxus, that is read by the dioxus-cli.
Go ahead and create a Dioxus.toml
with sane defaults.
Dioxus.toml
[application]
# App (Project) Name
name = "workshop-2"
# Dioxus App Default Platform
# desktop, web, mobile, ssr
default_platform = "web"
# `build` & `serve` dist path
out_dir = "dist"
# resource (public) file folder
asset_dir = "public"
[web.app]
# HTML title tag content
title = "Shopping list"
[web.watcher]
# when watcher trigger, regenerate the `index.html`
reload_html = true
# which files or dirs will be watcher monitoring
watch_path = ["src", "public"]
# uncomment line below if using Router
# index_on_404 = true
# include `assets` in web platform
[web.resource]
# CSS style file
style = []
# Javascript code file
script = []
[web.resource.dev]
# serve: [dev-server] only
# CSS style file
style = []
# Javascript code file
script = []
Here you can define other properties, like a custom css file or custom js scripts to integrate. But we do not need this for now. Only that you have seen it :)
CSS hot-reloading
You might have noticed so far that you had to refresh or recompile for tailwind classes to refresh on the screen.
For proper hot reloading, you need manganis
. This is a crate provided by Dioxus to make a e.g. a style file available for css hot-reloading.
cargo add manganis
Add this line below in the main.rs
of your frontend
crate.
const _STYLE: &str = manganis::mg!(file("public/tailwind.css"));
(so far we have worked with implicit defaults, such as the style property).
The style
property has to be set to []
for manganis to work in our Dioxus.toml
.
It is a good indicator of how mature a frontend framework is in 2024, if it provides reliable tailwind hot reloading support out of the box or with a plugin or two. Dioxus is surprisingly far, but the reloading is not fully reliable yet.
Creating a database
For this workshop we will create a small in memory database for simplicity reasons. Basically a tiny wrapper around a HashMap
with items.
For a real adapter, please have a look at sqlx or an Object Relational Mapper (ORM) like
SeaOrm or Diesel. The cool thing about axum is,
it let's you inject our database like the ones referenced. So if you decide later on to have production ready
database implementation - you use it the same way within axum
.
But what exactly should our database be capable of?
- Get/Read an item
- Get all items
- Create an item
- (Update an existing item)
- Delete an item
This translates directly to Create-Read-Update-Delete (CRUD
). Also, for simplicity, we do not use update here.
We only delete, create or get items. Deleting is therefore the same as checking of an item.
Create a database module
Let's create an own module in our backend
crate for this.
Add a new file called database.rs
next to the main.rs
. To make the backend
aware of the new module,
add
#![allow(unused)] fn main() { mod database; }
to the top of your main.rs
.
Create the database
Inside the new database.rs
module create a new struct, called InMemoryDatabase
, which wraps a simple HashMap
#![allow(unused)] fn main() { use std::collections::HashMap; pub struct InMemoryDatabase { inner: HashMap<String, ShoppingItem>, } }
Let's also add some logic to retrieve, add and delete items
#![allow(unused)] fn main() { impl InMemoryDatabase { fn get_item(&self, uuid: &str) -> Option<&ShoppingItem> { self.inner.get(uuid) } fn insert_item(&mut self, uuid: &str, item: ShoppingItem) { self.inner.insert(uuid.to_string(), item); } fn delete_item(&mut self, uuid: &str) { self.inner.remove(uuid); } } }
Let's also add some fixture items, when creating a default database
#![allow(unused)] fn main() { impl Default for InMemoryDatabase { fn default() -> Self { let inner: HashMap<String, ShoppingItem> = [ ( "b8906da9-0c06-45a7-b117-357b784a8612".to_string(), ShoppingItem { title: "Salt".to_string(), creator: "Yasin".to_string(), }, ), ( "ac18131a-c7b8-4bdc-95b5-e1fb6cad4576".to_string(), ShoppingItem { title: "Milk".to_string(), creator: "Tim".to_string(), }, ), ] .into_iter() .collect(); Self { inner } } } }
Adding the database to our axum backend
Adding this database is fairly simple with axums Router
builder.
Go inside the backends
main function, init a default database and use it inside the router.
Before we can use our database, we have to make sure that there is no data race. Or - the compiler checks that for us.
Make a type alias, where we wrap our database in an Arc<RwLock>
. An Arc is an atomic
and reference counted smart pointer in Rust.
The RwLock
makes sure, that reads and writes are mutually exclusive. There is only one writer holding a lock. But several readers can hold onto the same lock.
#![allow(unused)] fn main() { use std::sync::{Arc, RwLock}; use database::InMemoryDatabase; type Database = Arc<RwLock<InMemoryDatabase>>; }
Afterwards you can just initialize this database as default - and inject it with axums with_state(...)
router method.
#[tokio::main] async fn main() { let db = Database::default(); let app = Router::new() .route("/", get(hello_world)) .route("/:name", get(hello_name)) .route("/your-route", post(workshop_echo)) .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(); }
This allows to access the state (db) from each handler function by using it as function parameters.
In the next chapter, we will write controllers
functions for update, read and delete endpoints, where we use the database.
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.
Adding Items
Now we are going to add items from our frontend!
To achieve this, we will add a form that will post
new items to our backend.
After sending the new item, the list will be fetched again, so we have the latest list.
Creating a helper function
Let's first create a client function, that sends the post request to our backend. It's similar to the get_items
function we have already written.
Go to the main.rs
of your frontend and create a post_item
function.
#![allow(unused)] fn main() { async fn post_item(item: PostShopItem) -> Result<ShoppingListItem, reqwest::Error> { let response = reqwest::Client::new() .post("http://localhost:3001/items") .json(&item) .send() .await? .json::<ShoppingListItem>() .await?; Ok(response) } }
The post_item
function will be used in our component we will write next.
Adding a form
We need a form with two fields. Namely the item name
, as well as the person who wants that item, an author
.
Let's begin simple. We need a form that has two input fields - and a button to commit those fields. Let's add those.
#![allow(unused)] fn main() { #[component] fn ItemInput() -> Element { let mut item = use_signal(|| "".to_string()); let mut author = use_signal(|| "".to_string()); // We implement this closure later let onsubmit = move |evt: FormEvent| {}; rsx! { div { class: "w-300 m-4 mt-16 rounded", form { class: "grid grid-cols-3 gap-2", onsubmit: onsubmit, div { input { value: "{item}", class: "input input-bordered input-primary w-full", placeholder: "next item..", r#type: "text", id: "item_name", name: "item_name", oninput: move |e| item.set(e.data.value().clone()) } } div { input { value: "{author}", class: "input input-bordered input-primary w-full", placeholder: "wanted by..", r#type: "text", id: "author", name: "author", oninput: move |e| author.set(e.data.value().clone()) } } button { class: "btn btn-primary w-full", r#type: "submit", "Commit" } } } } } }
The content inside the rsx
might seem long. But in the end you have three elements. Namely two input
fields, as well as a button
to commit the
two input fields. Currently, we do not push anything to our backend
yet. This going to happen in the onsubmit
action, which we will implement later. If you ask yourself what the use_signal
s are doing: They give this component state.
When the component is rendered first, we initialize item
and author
to empty Strings. Every time we input something into the input fields (oninput
actions),
the respective symbols are set, when we enter a character. The plan: If we press the button, then we use the current stored strings, build an PostShopItem
out them and push this to our backend
.
But let's add this component to our existing app first, so you can already examine the UI.
#![allow(unused)] fn main() { pub fn App() -> Element { let rust_basel = "Rust Basel"; rsx! { h1{ "Welcome to {rust_basel}" } button{ class: "btn", "My stylish button" } ShoppingList{} ItemInput{} } } }
If you run our cargo make --no-workspace dev
, then you can already see the finished form.
OnSubmit Action - posting items
Currently, we do nothing on submit - except changing the state of the two hooks
. Let's add a closure, that posts items to our backend.
For this we will use spawn
. This allows us to run async function inside our components, which are fired once.
#![allow(unused)] fn main() { let onsubmit = move |_| { spawn({ async move { let item_name = item.read().to_string(); let author = author.read().to_string(); let _ = post_item(PostShopItem { title: item_name, posted_by: author, }) .await; } }); }; }
As we stored the item as well as the author value inside hooks (our components state), we can just read those values and copy them as strings. For now we just ignore the result of our http call for simplicity.
If you now run everything you are able to create new items. The downside: You do not recognize that you already post items, as the list
is not getting updated. But if you open your browser's developer settings (usually f12
), you will see in your network tab, that you post items.
But let us fix the list in the next step.
Use resource - dependencies
You can configure the use_resource
hook. It is run when the components renders the first time. If you want to re-run
the hook, then you
have to add a dependency. If this dependency changes, the use_resource
is run again. Let's do exactly this.
Let's first introduce something, that signals a change.
#![allow(unused)] fn main() { struct ListChanged; }
In our case a zero-sized
object is sufficient.
The idea:
- When we post an item - and the post request has been successful - then we write to that signal.
- Our list display subscribes this signal. Because we write to it, anything that depends on it (i.e. our
use_resource
hook), will be executed again.
In order that both components ItemInput
and ShoppingList
both have access to that signal, we have to hoist
(lifting the state up) the state.
So let's add a use_signal
hook on top level and add the signal to both components:
New components signature:
#![allow(unused)] fn main() { fn ShoppingList(change_signal: Signal<ListChanged>) -> Element {...} }
#![allow(unused)] fn main() { fn ItemInput(change_signal: Signal<ListChanged>) -> Element {...} }
and add it to your App function:
#![allow(unused)] fn main() { pub fn App() -> Element { let change_signal = use_signal(|| ListChanged); let rust_basel = "Rust Basel"; rsx! { h1{ "Welcome to {rust_basel}" } button{ class: "btn", "My stylish button" } ShoppingList{change_signal} ItemInput{change_signal} } } }
Let's read
the signal on the list fetching side - and write
on the input
side in the next steps.
Writing the signal
In the ItemInput
component, go into your onsubmit
callback. Now we can use the Result
coming from our post_item
http request.
Change it and check the result, if it is ok. If it is ok, we write
the signal (without writing anything to it).
#![allow(unused)] fn main() { let item_name = item.read().to_string(); let author = author.read().to_string(); let response = post_item(PostShopItem { title: item_name, posted_by: author, }) .await; if response.is_ok() { change_signal.write(); } }
But that is sufficient for re-rendering other components subscribed to that signal (automatically when you have a read
).
Or to execute some logic on subscribed components withing hooks.
Reading the signal
Now let's subscribe to that signal by read
ing it inside our ShoppingList
component's use_resource
hook.
#![allow(unused)] fn main() { let items_request = use_resource(move || async move { change_signal.read(); get_items().await }); }
That is all you need to do in order let your components communicate with each other.
If you did it correctly, the list is fetched and updates just after you add a new item (if not - have a look at the solution)
Great! :)
Let's do the same to delete items in our next chapter.
Deleting Items
In this chapter we are going to clean up our frontend crate, as well as creating components, that will delete items from our shopping list.
Cleanup
As you already see, we have #[components]
as well as helper functions in our code base. It would make sense to put all components inside a
components module and helper functions to fetch and post (and later delete) items into a util/controller module.
Go ahead an put all Components and our ListChanged
struct into a components.rs
module, as well as helper functions for getting and posting data to our backend into
a controllers.rs
module.
Do also not forget to put the moved functions into the main.rs
:
#![allow(unused)] fn main() { mod controllers; mod components; }
In order to use symbols across modules (structs and functions), you have to make those pub
(public). In the end the main.rs
function should only be left with our App
function, the main entry point for our app.
Creating a helper function to delete items
Like in the previous examples (e.g. adding an item) we now add another helper function to delete items. Go to your new controllers.rs
module in the frontend crate and create an async function delete_item
, which takes an item_id, a &str
as input.
#![allow(unused)] fn main() { pub async fn delete_item(item_id: &str) -> Result<(), reqwest::Error> { reqwest::Client::new() .delete(format!("http://localhost:3001/items/{}", item_id)) .send() .await?; Ok(()) } }
In this case we are just interested if the deletion was ok. If an error happens, the ?
-Operator returns a reqwest::Error
.
Create the component
Let's add a component to an already existing component! We plan to create a button, which is on top of our item in the list. So if you click the button on the respective item - this item then gets deleted.
Go ahead to your components.rs
module and add a component with a button with a cross on it:
#![allow(unused)] fn main() { #[component] fn ItemDeleteButton() -> Element { // We change this later let onclick = move |_| {}; rsx! { button { onclick: onclick, class: "btn btn-circle", svg { class: "h-6 w-6", view_box: "0 0 24 24", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", fill: "none", path { d: "M6 18L18 6M6 6l12 12" } } } } } }
If you add this component then to our ShoppingListItemComponent
, then you can already see the new button.
#![allow(unused)] fn main() { #[component] fn ShoppingListItemComponent(display_name: String, posted_by: String) -> Element { rsx! { div { class: "flex items-center space-x-2", p { class: "grow text-2xl", "{display_name}" } span { "posted by {posted_by}" } ItemDeleteButton {} } } } }
Delete item onclick
We currrently do nothing. Let's implement the onclick action - so when we click the item, a delete request is sent to our backend.
At the top of your components.rs
module:
#![allow(unused)] fn main() { use crate::controllers::{delete_item, get_items, post_item}; }
And the onclick in your ItemDeleteButton
:
#![allow(unused)] fn main() { let onclick = move |_| { spawn({ let item_id = item_id.clone(); async move { let _ = delete_item(&item_id).await; } }); }; }
Of course, this does not work yet. Somehow you need an item_id
. Let's add it as a component prop
. Go ahead, and change the signature and the use of the following components:
ItemDeleteButton
:
#![allow(unused)] fn main() { #[component] fn ItemDeleteButton(item_id: String) -> Element {...} }
ShoppingLisItemComponent
:
#![allow(unused)] fn main() { #[component] fn ShoppingListItemComponent(display_name: String, posted_by: String, item_id: String) -> Element { rsx! { div { class: "flex items-center space-x-2", p { class: "grow text-2xl", "{display_name}" } span { "posted by {posted_by}" } ItemDeleteButton {item_id} } } } }
And finally the ul
in our ShoppingList
component:
#![allow(unused)] fn main() { ul { class: "menu bg-base-200 w-200 rounded-box gap-1", for i in list { li { key: "{i.uuid}", ShoppingListItemComponent{ display_name: i.title.clone(), posted_by: i.posted_by.clone(), item_id: i.uuid.clone() }, } } } }
With this - you will now see in the developer settings in your browser, that you send delete
requests to the backend.
But again: You do not see the list changing. We have to notify other components, that our list has changed. Let's add this in the next step.
Propagate our ListChanged signal to notify other components
In order to notify others, we introduced a ListChanged
signal before. Let's reuse this, to write
to it - after we successfully deleted an item.
Let's propagate the signal to all involved components:
ItemDeleteButton
:
#![allow(unused)] fn main() { #[component] fn ItemDeleteButton(item_id: String, change_signal: Signal<ListChanged>) -> Element { let onclick = move |_| { spawn({ let item_id = item_id.clone(); async move { let response = delete_item(&item_id).await; if response.is_ok() { change_signal.write(); } } }); }; ... } }
ShoppingListItemComponent
:
#![allow(unused)] fn main() { #[component] fn ShoppingListItemComponent( display_name: String, posted_by: String, item_id: String, change_signal: Signal<ListChanged>, ) -> Element { rsx! { div { class: "flex items-center space-x-2", p { class: "grow text-2xl", "{display_name}" } span { "posted by {posted_by}" } ItemDeleteButton {item_id, change_signal} } } } }
ShoppingList
:
#![allow(unused)] fn main() { #[component] pub fn ShoppingList(change_signal: Signal<ListChanged>) -> Element { let items_request = use_resource(move || async move { change_signal.read(); get_items().await }); match &*items_request.read_unchecked() { Some(Ok(list)) => rsx! { div { class: "grid place-items-center min-h-500", ul { class: "menu bg-base-200 w-200 rounded-box gap-1", for i in list { li { key: "{i.uuid}", ShoppingListItemComponent{ display_name: i.title.clone(), posted_by: i.posted_by.clone(), item_id: i.uuid.clone(), change_signal }, } } } } }, Some(Err(err)) => { rsx! { p { "Error: {err}" } } } None => { rsx! { p { "Loading items..." } } } } } }
If you now run everything - you can now also delete items with the newly added button! As our use_resource
hook will be rerun after we write
to our signal,
the latest list from the backend is fetched - after we deleted an item.
Small Recap
What did we do in this chapter:
- Adding a new components
- Delete items, when we click a button on this item
- Propagate our signal and notify other components about our deletion
Add Routing
important distinction: in a SPA setup, we have two routing systems:
- The browser navigator (what you see in the URL bar)
- the routes for API endpoints
This might be obvious if you are familiar with SPA, but there are also different generations of server side rendered frameworks that just have one routing system, and
where the routes in the URL bars are identical with what you request over network (= normal page load, like it's 1999).
Sometimes, you can find combinations of the two separated by a #
, e.g. your.backendroute.com/admin#users/2
We already saw API endpoint routes in action in the axum chapters, so this chapter is a about navigator routing, and we will use routes without any #
.
Add the router feature to cargo.toml
dioxus = { version = "0.5.1", features = ["web", "router"] }
Create a new mock component (profile)
...and refactor your App component (call it e.g. "Home" instead)
#![allow(unused)] fn main() { pub fn Profile() -> Element { rsx! { div { div { class: "flex flex-col gap-4 w-full", div { class: "flex gap-4 items-center", div { class: "skeleton w-16 h-16 rounded-full shrink-0" } div { class: "flex flex-col hap-4", div { class: "skeleton h-4 w-20" } div { class: "skeleton h-4 w-28" } } } div { class: "skeleton h-32 w-full" } div { class: "skeleton h-32 w-full" } } } } } }
Since we are just getting started with Routing and don't want to implement the profile page yet, we just use daisyui skeletons for a "under construction" site.
define and use a Router enum
#![allow(unused)] fn main() { #[derive(Routable, Clone)] enum Route { #[route("/")] Home {}, #[route("/profile")] Profile {} } }
This enums maps the route "/" to the Home component (what we had so far under App), and "/profile" to the new Profile page.
#![allow(unused)] fn main() { #[allow(non_snake_case)] fn App() -> Element { rsx! { Router::<Route>{} } } }
We can use the router with our new App component, which now renders something different depending on the currently active route.
Try it in the URL bar! with localhost:8080/
and localhost:8080/profile
to see your routes in action.
Add a navigation bar to the base layout
We could add Links to our routes, e.g. with
Link { to: Route::Profile{}, "Profile" }
for the profile page. Instead of repeating these all over as we build more and more pages, let's add a navigation bar to a common layout, which will appear on all pages.
A layout is a component which has an Outlet
. An oulet is like a window through which you can see the router content, in our example everything below the navigation bar.
At the same time, we can take care about some styling aspects for the layout, e.g. putting
everything into a container with auto margins to the left and right.
The layout could look like this:
#![allow(unused)] fn main() { #[component] pub fn Layout() -> Element { rsx! { div { class: "min-h-screen bg-base-300", div { class: "navbar flex", div { Link { class: "p-4", to: Route::Home{}, "Home" } Link { class: "p-4", to: Route::Profile{}, "Profile" } } } div { class: "container mx-auto max-w-[1024px] p-8", Outlet::<Route>{} } } } } }
and we update our route with the layout info:
#![allow(unused)] fn main() { #[derive(Routable, Clone)] pub enum Route { #[layout(Layout)] #[route("/")] Home {}, #[route("/profile")] Profile {} } }
The indentation as suggested by the dioxus docs is not very elegant, but it helps see the structure.
Routes and layouts are the building blocks for complex navigation patterns, they can even be nested e.g. for submenus. And they can contain dynamic placeholders.
Database unique lists
In the end you want share a list right? So we have to extend our database model.
Instead of having only a HashMap
of items, let's have a HashMap
of HashMap
of items (that sounds already bad - but is more than sufficient for our workshop!)
This way we can have unique lists - and a list is identifiable with its list_uuid
.
Wrap a HashMap
with a new struct
Go to your database.rs
module and create a new struct. This is a list in our sense, that maps item uuids
to ShoppingItem
s.
#![allow(unused)] fn main() { struct ShoppingList { list: HashMap<String, ShoppingItem>, } }
Let's also create some sane defaults, so our ShoppingList::default()
already has some items:
#![allow(unused)] fn main() { impl Default for ShoppingList { fn default() -> Self { Self { list: [ ( "6855cfc9-78fd-4b66-8671-f3c90ac2abd8".to_string(), ShoppingItem { title: "Coffee".to_string(), creator: "Roland".to_string(), }, ), ( "3d778d1c-5a4e-400f-885d-10212027382d".to_string(), ShoppingItem { title: "Tomato Seeds".to_string(), creator: "Tania".to_string(), }, ), ] .into(), } } } }
If you never came across the into()
method: It transforms this []
-array into the type of Self
, which is a HashMap<String, ShoppingItem>
.
Change InMemoryDatabase
to wrap HashMap<String, ShoppingList>
In this chapter we create the outer HashMap - which we already have.
Change our existing InMemoryDatabase
.
#![allow(unused)] fn main() { pub struct InMemoryDatabase { inner: HashMap<String, ShoppingList>, } }
Also - change the Default
implementation of InMemoryDatabase
:
#![allow(unused)] fn main() { impl Default for InMemoryDatabase { fn default() -> Self { let mut inner = HashMap::new(); inner.insert( "9e137e61-08ac-469d-be9d-6b3324dd20ad".to_string(), ShoppingList::default(), ); InMemoryDatabase { inner } } } }
We now changed the members of our InMemoryDatabase
. Let's change our API next.
Change the InMemoryDatabase
API and implementation
As we are going to have more than one lists - when we for instance want to receive an item, we also have to know from which list we want to receive that item.
So all our current APIs of the InMemoryDatabase
now also need a list_uuid
to an item_uuid
.
Let's change our implementation and API at once and look at it in detail afterwards:
#![allow(unused)] fn main() { impl InMemoryDatabase { pub fn insert_item(&mut self, list_uuid: &str, item_uuid: &str, shopping_item: ShoppingItem) { self.inner .get_mut(list_uuid) .and_then(|list| list.list.insert(item_uuid.to_string(), shopping_item)); } pub fn delete_item(&mut self, list_uuid: &str, item_uuid: &str) { self.inner .get_mut(list_uuid) .and_then(|list| list.list.remove(item_uuid)); } pub fn create_list(&mut self, list_uuid: &str) { self.inner .insert(list_uuid.to_string(), ShoppingList::default()); } fn get_list(&self, list_uuid: &str) -> Option<&ShoppingList> { self.inner.get(list_uuid) } pub fn as_vec(&self, list_uuid: &str) -> Vec<ShoppingListItem> { let list = self.get_list(list_uuid); match list { Some(list) => list .list .iter() .map(|(key, item)| ShoppingListItem { title: item.title.clone(), posted_by: item.creator.clone(), uuid: key.clone(), }) .collect(), None => Vec::default(), } } } }
If you ask yourself now - what the hell is and_then
? It allows you to chain calls together. For example look at the new delete_item
method. We first try to receive a list, given a list_uuid
.
That can return Some(list)
, or None, if we do not have list with that given uuid. If it is something, then and_then
will continue with whatever is given to it as closure.
If the get_mut
would return None, then the and_then
is a noop, doing nothing.
If you noticed: We also added two new methods - get_list
and create_list
. Of course, when we want to create a new list, we have to enter an entry in our outer HashMap. The get_list
is used only internally in the
as_vec
method.
Making things compile again.
We changed a lot. Our new API needs a list_uuid
. For now, let's hardcode that with a fixed value, wherever the API is used - and make it adaptable later.
Go to your controllers.rs
module in the backend - and hardcode our list_uuid
#![allow(unused)] fn main() { const LIST_UUID: &str = "9e137e61-08ac-469d-be9d-6b3324dd20ad"; }
and use it everywhere, a list_uuid
is needed. We will change it later, when we have a list_uuid
available.
Below the changed controllers:
#![allow(unused)] fn main() { pub async fn get_items(State(state): State<Database>) -> impl IntoResponse { let items: Vec<ShoppingListItem> = state.read().unwrap().as_vec(LIST_UUID); Json(items) } }
#![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 item_uuid = Uuid::new_v4().to_string(); let Ok(mut db) = state.write() else { return (StatusCode::SERVICE_UNAVAILABLE).into_response(); }; db.insert_item(LIST_UUID, &item_uuid, item); ( StatusCode::OK, Json(ShoppingListItem { title: post_request.title, posted_by: post_request.posted_by, uuid: item_uuid, }), ) .into_response() } }
#![allow(unused)] fn main() { 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(LIST_UUID, &uuid.to_string()); StatusCode::OK } }
If you run everything - nothing should have changed. Hardcoding values is not nice. We will fix that issue in the next chapter, when we change our backend
API. We also
add new route
s, such as creating a list.
Backend unique lists
We changed our database - so it's able to handle different lists with different shopping items. But currently we have only one list - as its ID is hardcoded.
Let's change that in this chapter. We expand our REST
api in such a way, so we can create new lists and load existing ones, without needed to hardcode an ID (in the backend).
Changing the routes
We currently had the following route: /items
. But now that we can also have different lists, we should rename our exising routes.
Go to our main.rs
in the backend - and change the routes from:
/items
to/list/:list_uuid/items
/items/:uuid
to/list/:list_uuid/items/:item_uuid
Each item is now parametrized by a list, or a subresource of the list.
#![allow(unused)] fn main() { let app = Router::new() .route("/list/:list_uuid/items", get(get_items).post(add_item)) .route("/list/:list_uuid/items/:item_uuid", delete(delete_item)) .layer(CorsLayer::permissive()) .with_state(db); }
The :list_uuid
will be extracted from the path
and is from now on available in our controllers in the controllers.rs
module.
Perfect! Now let's use the new variable inside our controllers and get rid of the hardcoded LIST_UUID
in the controllers.rs
module.
Using new Path parameters
Now with the new routes, we can extract the extra list_uuid
parameter - and use it in our controllers.
If you have two path parameters, like in delete_item
, you now have to extract path parameters as a tuple.
The names inside the tuple bind to the names given in your route
path.
Let's start with the delete_item
, as it's the hardest (path wise):
#![allow(unused)] fn main() { pub async fn delete_item( State(state): State<Database>, Path((list_uuid, item_uuid)): Path<(Uuid, Uuid)>, ) -> impl IntoResponse { let Ok(mut db) = state.write() else { return StatusCode::SERVICE_UNAVAILABLE; }; db.delete_item(&list_uuid.to_string(), &item_uuid.to_string()); StatusCode::OK } }
Like stated above - your Path
extractor now needs to work with a tuple of type (Uuid,Uuid)
. So if one of those parameters is not parsable as an uuid, we will get a 400 error automatically (invalid input).
Let's change the other existing controllers respectively:
#![allow(unused)] fn main() { pub async fn add_item( Path(list_uuid): Path<Uuid>, 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 item_uuid = Uuid::new_v4().to_string(); let Ok(mut db) = state.write() else { return (StatusCode::SERVICE_UNAVAILABLE).into_response(); }; db.insert_item(&list_uuid.to_string(), &item_uuid, item); ( StatusCode::OK, Json(ShoppingListItem { title: post_request.title, posted_by: post_request.posted_by, uuid: item_uuid, }), ) .into_response() } }
and
#![allow(unused)] fn main() { pub async fn get_items( Path(list_uuid): Path<Uuid>, State(state): State<Database>, ) -> impl IntoResponse { let items: Vec<ShoppingListItem> = state.read().unwrap().as_vec(&list_uuid.to_string()); Json(items) } }
Great! Now we adapted our existing controllers and already use the new list_uuid
. You can now also safely delete the hardcoded LIST_UUID
value ;).
Adding new routes
Now that we adapted the existing routes, let's add a new one to create a fresh list.
As we only have to request a /list
endpoint without to provide any data - we can get away with a GET
endpoint.
Usually, when you want to create new resources server side, you would supply a post
with some data. But for our case we do not need this.
What we need though is a response for this endpoint, as we want to know the list_uuid
of the newly created list.
Go to the model create and add a new serializable
and deserializable
model to the lib.rs
:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize)] pub struct CreateListResponse { pub uuid: String, } }
This model will also be deserialized in the frontend later. So also put a Deserialize from serde
on top of the struct.
Go ahead an create a new async function in the controller.rs
module in our backend
crate.
#![allow(unused)] fn main() { use model::{CreateListResponse, PostShopItem, ShoppingListItem}; // import what is needed. pub async fn create_shopping_list(State(state): State<Database>) -> impl IntoResponse { let uuid = Uuid::new_v4().to_string(); state.write().unwrap().create_list(&uuid); Json(CreateListResponse { uuid }) } }
The last thing remaining is to put the create_shopping_list
into our Router
. You know the drill!
Add it to the router with a /list
route - fetched via a GET
.
#![allow(unused)] fn main() { .route("/list", get(create_shopping_list)) }
We are getting somewhere! :). Let's hop on to change the frontend
also, to get/post and delete with the new routes, we just created.
Frontend: new Endpoints
In this chapter we will do the following:
- Creating a helper to create a new list
- Adapt our controller functions to the new routes, we defined in the backend - so we can build again :)
Creating a helper to create a new list
In order to create a new list, we have to write a new controller function and add it to our controllers.rs
module. As you already know, how to do this one - go ahead and this to your controllers.rs
:
#![allow(unused)] fn main() { async fn create_list() -> Result<CreateListResponse, reqwest::Error> { let response = reqwest::Client::new() .get("http://localhost:3001/list") .send() .await? .json::<CreateListResponse>() .await?; Ok(response) } }
This will make a GET
request to our /list
endpoint - and in return we will receive an uuid for our new list.
Adapt controllers to new endpoints
As you remember - we changed our endpoints in our backend
. Go ahead and change the other controllers.
As an example we look at the existing delete_item
function.
#![allow(unused)] fn main() { pub async fn delete_item(item_id: &str) -> Result<(), reqwest::Error> { reqwest::Client::new() .delete(format!("http://localhost:3001/items/{}", item_id)) .send() .await?; Ok(()) } }
As we now have to know in which list we want to delete the item from, we have to expand the arguments list for a list_id
(the lists uuid).
You can just append the argument to the format!(..)
string. In the end, the function should look like this:
#![allow(unused)] fn main() { pub async fn delete_item(list_id: &str, item_id: &str) -> Result<(), reqwest::Error> { reqwest::Client::new() .delete(format!( "http://localhost:3001/list/{}/items/{}", list_id, item_id )) .send() .await?; Ok(()) } }
As the other functions are kind of straightforward - go ahead and change the other functions that are still tuned to the "old" REST api.
#![allow(unused)] fn main() { pub async fn get_items(list_id: &str) -> Result<Vec<ShoppingListItem>, reqwest::Error> { let url = format!("http://localhost:3001/list/{}/items", list_id); let list = reqwest::get(&url) .await? .json::<Vec<ShoppingListItem>>() .await; list } pub async fn post_item( list_id: &str, item: PostShopItem, ) -> Result<ShoppingListItem, reqwest::Error> { let response = reqwest::Client::new() .post(format!("http://localhost:3001/list/{}/items", list_id)) .json(&item) .send() .await? .json::<ShoppingListItem>() .await?; Ok(response) } }
Propagate the list uuid from top to down
Now we need the list's uuid everywhere, where we fetch data from the backend. So somehow we need a uuid for this. Do you have a guess how?
Go to your Home component
and add a list_uuid
signal there. For now we hardcode the value to be 9e137e61-08ac-469d-be9d-6b3324dd20ad
(the first existing list in our backend - if you remember - also hardcoded ;)).
The idea now: propagate down this list_uuid
to all components, that need a list_uuid
, which are the ShoppingList
and the ItemInput
components (and all the components they use).
#![allow(unused)] fn main() { #[component] pub fn Home() -> Element { let list_uuid = use_signal(|| "9e137e61-08ac-469d-be9d-6b3324dd20ad".to_string()); let change_signal = use_signal(|| ListChanged); rsx! { ShoppingList{list_uuid, change_signal} ItemInput{list_uuid, change_signal} } } }
Of course, those components have to be adjusted in their parameters (or so called: Component props
). Go ahead and change the props of those. Then you can use the list_uuid
in the requests fired by the hooks used in the components. The easiest way to accomplish this is forwarding the signal, and read it inside the component.
To spare you more headaches - use these adjusted components. The only thing that changed, is that they now all use the added list_uuid
.
ShoppingListItemComponent
#![allow(unused)] fn main() { #[component] fn ShoppingListItemComponent( display_name: String, posted_by: String, list_uuid: String, item_id: String, change_signal: Signal<ListChanged>, ) -> Element { rsx! { div { class: "flex items-center space-x-2", p { class: "grow text-2xl", "{display_name}" } span { "posted by {posted_by}" } ItemDeleteButton {list_uuid, item_id, change_signal} } } } }
ShoppingList
#![allow(unused)] fn main() { #[component] pub fn ShoppingList(list_uuid: Signal<String>, change_signal: Signal<ListChanged>) -> Element { let items_request = use_resource(move || async move { change_signal.read(); get_items(list_uuid.read().as_str()).await }); match &*items_request.read_unchecked() { Some(Ok(list)) => rsx! { div { class: "grid place-items-center min-h-500", ul { class: "menu bg-base-200 w-200 rounded-box gap-1", for i in list { li { key: "{i.uuid}", ShoppingListItemComponent{ display_name: i.title.clone(), posted_by: i.posted_by.clone(), list_uuid, item_id: i.uuid.clone(), change_signal }, } } } } }, Some(Err(err)) => { rsx! { p { "Error: {err}" } } } None => { rsx! { p { "Loading items..." } } } } } }
ItemInput
#![allow(unused)] fn main() { #[component] pub fn ItemInput(list_uuid: Signal<String>, change_signal: Signal<ListChanged>) -> Element { let mut item = use_signal(|| "".to_string()); let mut author = use_signal(|| "".to_string()); let onsubmit = move |_| { spawn({ async move { let item_name = item.read().to_string(); let author = author.read().to_string(); let response = post_item( list_uuid.read().as_str(), PostShopItem { title: item_name, posted_by: author, }, ) .await; if response.is_ok() { change_signal.write(); } } }); }; ... } }
ItemDeleteButton
#![allow(unused)] fn main() { #[component] fn ItemDeleteButton( list_uuid: String, item_id: String, change_signal: Signal<ListChanged>, ) -> Element { let onclick = move |_| { spawn({ let list_uuid = list_uuid.clone(); let item_id = item_id.clone(); async move { let response = delete_item(&list_uuid, &item_id).await; if response.is_ok() { change_signal.write(); } } }); }; rsx! { button { onclick: onclick, class: "btn btn-circle", svg { class: "h-6 w-6", view_box: "0 0 24 24", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", fill: "none", path { d: "M6 18L18 6M6 6l12 12" } } } } } }
After you changed these components - your frontend should build again by the way :) You can test it with again with
cargo make --no-workspace dev
In the next chapter we will add a new component, that creates a new list or gets a list uuid by user input.
You almost made it :) hang on!
Frontend: Load or Create - that is the Question
You almost made it. In this chapter we write our component to either load or create a list. The idea:
- We get a uuid by input or we get a uuid by creating a new list in the backend
- We then route to our main
Home
component to with alist_uuid
.
Add a dynamic route
In order to route dynamically to our main Home
component two things have to be done.
- We need a dynamic route, something like
/list/<uuid>
. - This
<uuid
> parameter then has the be given to ourHome
component.
Let's start with our Home
component. Add a list_uuid
component property.
#![allow(unused)] fn main() { #[component] pub fn Home(list_uuid: String) -> Element { let list_uuid = use_signal(|| list_uuid); let change_signal = use_signal(|| ListChanged); rsx! { ShoppingList{list_uuid, change_signal} ItemInput{list_uuid, change_signal} } } }
And you might have noticed - we removed our hardcoded uuid :).
Then we also need to have a dynamic route. Go to our Route
enum (in our frontend).
#![allow(unused)] fn main() { #[derive(Routable, Clone)] pub enum Route { #[layout(Layout)] #[route("/list/:list_uuid")] Home { list_uuid: String }, #[route("/profile")] Profile {}, } }
Great! with this we are now dynamically routable. Let's create a new component that will route to our new Home
with a list_uuid
.
Create a new list
Let's create a new component, that load or create a new list. You also see the use_navigator
hook. It allows is to route to other pages we have!
#![allow(unused)] fn main() { #[component] pub fn LoadOrCreateList() -> Element { let nav = use_navigator(); let on_create_list_click = move |_| { let nav = nav.clone(); spawn({ async move { let response = create_list().await; if let Ok(created_list) = response { nav.push(Route::Home { list_uuid: created_list.uuid, }); } } }); }; rsx! { div{ class: "grid place-content-evently grid-cols-1 md:grid-cols-2 w-full gap-4", div { class: "card glass min-h-500 flex flex-col content-end gap-4 p-4", button{ class: "btn btn-primary", onclick: on_create_list_click, "Create new List" } } } } } }
Add this new component to the Route
as this is now the first page we see, when we open up our url:
#![allow(unused)] fn main() { #[derive(Routable, Clone)] pub enum Route { #[layout(Layout)] #[route("/")] LoadOrCreateList {}, #[route("/list/:list_uuid")] Home { list_uuid: String }, #[route("/profile")] Profile {}, } }
If you then also route correctly in our Layout
then we have a working routing in our frontend :) We can only create new list's though
#![allow(unused)] fn main() { #[component] pub fn Layout() -> Element { rsx! { div { class: "min-h-screen bg-base-300", div { class: "navbar flex", div { Link { class: "p-4", to: Route::LoadOrCreateList{}, "Home" } Link { class: "p-4", to: Route::Profile{}, "Profile" } } } div { class: "container mx-auto max-w-[1024px] p-8", Outlet::<Route>{} } } } } }
If you now run all the code - you can create a new list with a click of a button - and it will route you to the page with the freshly created list.
Load a list from given Uuid
In this chapter, we create a form. Here the user has to input a given uuid. Then we forward with the given uuid.
Let's expand our LoadOrCreateList
our component! As you already have seen how to create a form - we fast forward.
What we've changed:
- We have a input form where we can input a string.
- That string get copied to our
list_uuid
Signal. - On clicking the
Load existing list
button we read the storedlist_uuid
and route to our Home again with this uuid.
#![allow(unused)] fn main() { #[component] pub fn LoadOrCreateList() -> Element { let nav = use_navigator(); let mut list_uuid = use_signal(|| "".to_string()); let onloadsubmit = move |_| { spawn({ async move { let uuid_value = list_uuid.read().clone(); if !uuid_value.is_empty() { nav.push(Route::Home { list_uuid: uuid_value, }); } } }); }; let on_create_list_click = move |_| { let nav = nav.clone(); spawn({ async move { let response = create_list().await; if let Ok(created_list) = response { nav.push(Route::Home { list_uuid: created_list.uuid, }); } } }); }; rsx! { div{ class: "grid place-content-evently grid-cols-1 md:grid-cols-2 w-full gap-4", div { class: "card glass min-h-500 flex flex-col content-end gap-4 p-4", button{ class: "btn btn-primary", onclick: on_create_list_click, "Create new List" } } div { class: "card glass min-h-500", form { onsubmit: onloadsubmit, div { class: "flex flex-col gap-4 p-4", input{ class:"input input-bordered", r#type:"text", placeholder:"Enter UUID here...", id: "uuid", name: "uuid", oninput: move |e| list_uuid.set(e.data.value()) } button{ class: "btn btn-primary", r#type: "submit", "Load existing List" } } } } } } } }
Nice! You've done it! :) If you want - you can stay for some styling.