Before this blog, we showed you how to create a CRUD network service with Rust using Warp and how to build a front-end network application with Rust using Yew.

In this tutorial, we’ll put it all together and build a simple full-stack networking application that features a database-supported REST back end and a front-end Wasm based single-page application that calls this back end.

Most importantly, we will create a shared Rust module that will be used by both the front end and back end to demonstrate how code can be shared in such a setup.

We will build a very simple pet owner application that enables users to add owners and their pets. Our application will provide a detailed view of owners and their pet lists, enabling them to remove and add pets as needed.

Here’s what we’re going to cover.

  • Set up a full-stack Rust application
  • Commonly used functions
  • Build the REST back end
  • The front-end implementation
  • Test our Rust full stack application

You don’t need to have read the above post to follow, but since this article includes both concepts, we won’t delve into the basics to the same degree. Feel free to browse through them if you want to dig deeper.

Without further ado, let’s begin!

Set up a full-stack Rust application

To continue learning, all you need is a reasonably up-to-date Rust installation. Docker or some other way to run a Postgres database would also be useful.

In this case, since we’re writing the back end and the front end in Rust, and we’re sharing some code between them, we’re creating a multi-member workspace project with Cargo.

First, create a new Rust project.

cargo new rust-fullstack-example
cd rust-fullstack-example

Copy the code

Then, delete the SRC folder and edit the Cargo. Toml file as shown below.

[workspace]

members = [
    "backend",
    "frontend",

    #Internal
    "common"
]

Copy the code

Now we can create our three separate Rust projects.

cargo new --lib common
cargo new backend
cargo new --lib frontend

Copy the code

Navigate to the common directory, edit the Cargo. Toml file, and add the following dependencies.

[Dependencies] serde = {version = "= ", features = ["derive"]}Copy the code

Next, edit the Cargo. Toml file in Frontend and add these dependencies.

[Yew = "yew" dependencies = "yew" Features = ["derive"]} Anyhow = "1" yew-router = "0.15.0" common = {version = "0.1.0", path = ".. /common" }Copy the code

We used Yew to build a wASM-based front-end. We’ll add some utility libraries for routing, error, and JSON handling, as well as internal dependencies on our common library, which will hold code shared between the front end and the back end.

Finally, edit the Cargo. Toml file in Backend to add these dependencies.

[dependencies]
tokio = { version = "=1.6.1", features = ["macros", "rt-multi-thread"] }
warp = "=0.3.1"
mobc = "=0.7.2"
mobc-postgres = { version = "=0.7.0", features = ["with-chrono-0_4", "with-serde_json-1"] }
serde = {version = "=1.0.126", features = ["derive"] }
serde_json = "=1.0.64"
thiserror = "=1.0.24"
common = { version = "0.1.0", path = "../common" }

Copy the code

We are using the WarpWeb framework to build the back end. Since we use the Postgres database to store data, we will also add Postgres’s MOBC connection pool.

On top of that, since Warp was optimized for Tokio, we needed to include it as our synchronous runtime. We’ll add some utility libraries for error and JSON handling, as well as internal dependencies for our Common project.

That’s the setup. Let’s start writing shared code for Frontend and Backend in our common project.

Common function

We will start by fleshing out the Common module, where we will add the shared data model between Frontend and Backend. In practice, more functionality could be shared — including validation, assistants, utilities, and so on — but in this case, we’ll stick with data structures.

In lib.rs, we will add data models for our Owner and Pet models.

use serde::{Deserialize, Serialize};

#[derive(Deserialize, Clone, PartialEq, Debug)]
pub struct Owner {
    pub id: i32,
    pub name: String,
}

#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct OwnerRequest {
    pub name: String,
}

#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct OwnerResponse {
    pub id: i32,
    pub name: String,
}

impl OwnerResponse {
    pub fn of(owner: Owner) -> OwnerResponse {
        OwnerResponse {
            id: owner.id,
            name: owner.name,
        }
    }
}

#[derive(Deserialize, Clone, PartialEq, Debug)]
pub struct Pet {
    pub id: i32,
    pub name: String,
    pub owner_id: i32,
    pub animal_type: String,
    pub color: Option<String>,
}

#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct PetRequest {
    pub name: String,
    pub animal_type: String,
    pub color: Option<String>,
}

#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub struct PetResponse {
    pub id: i32,
    pub name: String,
    pub animal_type: String,
    pub color: Option<String>,
}

impl PetResponse {
    pub fn of(pet: Pet) -> PetResponse {
        PetResponse {
            id: pet.id,
            name: pet.name,
            animal_type: pet.animal_type,
            color: pet.color,
        }
    }
}

Copy the code

We define database domain structures Owner and Pet, as well as request and response data objects that we will use to communicate between the front end and back end.

Sharing this code is good because a field is being added or removed somewhere in the API, and if we don’t adjust for this change, we’ll get a compilation error on the front end. This may save us time chasing errors when updating the API.

Owner is very simple, just a name and the ID of the database. The Pet type has a name, an animal_Type, and an optional color.

We also defined helpers to create our data objects for the API from the database domain objects.

That’s all we’re going to put in the Common project.

Let’s continue with the back-end portion of our application.

Build the REST back end

Let’s start with the database definition of the data model.

CREATE TABLE IF NOT EXISTS owner
(
    id SERIAL PRIMARY KEY NOT NULL,
    name VARCHAR(255) NOT NULL
);

CREATE TABLE IF NOT EXISTS pet
(
    id SERIAL PRIMARY KEY NOT NULL,
    owner_id INT NOT NULL,
    name VARCHAR(255) NOT NULL,
    animal_type VARCHAR(255) NOT NULL,
    color VARCHAR(255),

    CONSTRAINT fk_pet_owner_id FOREIGN KEY (owner_id) REFERENCES pet(id)
);

Copy the code

This defines our two data tables and their respective fields.

We will build the back end from the bottom up, starting with the database layer and working our way up to the Web server and routing definitions.

First, we will create a DB module. Here, we’ll start with some database and connection pool initialization code in Mod.rs.

type Result<T> = std::result::Result<T, error::Error>; const DB_POOL_MAX_OPEN: u64 = 32; const DB_POOL_MAX_IDLE: u64 = 8; const DB_POOL_TIMEOUT_SECONDS: u64 = 15; const INIT_SQL: &str = "./db.sql"; pub async fn init_db(db_pool: &DBPool) -> Result<()> { let init_file = fs::read_to_string(INIT_SQL)? ; let con = get_db_con(db_pool).await? ; con.batch_execute(init_file.as_str()) .await .map_err(DBInitError)? ; Ok(()) } pub async fn get_db_con(db_pool: &DBPool) -> Result<DBCon> { db_pool.get().await.map_err(DBPoolError) } pub fn create_pool() -> std::result::Result<DBPool, Mobc ::Error<Error>> {let config = config ::from_str("postgres://[email protected]:7878/postgres")? ; let manager = PgConnectionManager::new(config, NoTls); Ok(Pool::builder() .max_open(DB_POOL_MAX_OPEN) .max_idle(DB_POOL_MAX_IDLE) .get_timeout(Some(Duration::from_secs(DB_POOL_TIMEOUT_SECONDS))) .build(manager)) }Copy the code

In init_DB, we read the db.sql file above and execute it to initialize our table.

The create_pool and get_db_con helpers are used to initialize the database pool and get new connections from the pool.

With these setup details, let’s take a look at our first domain access object, owner.rs.

pub const TABLE: &str = "owner"; const SELECT_FIELDS: &str = "id, name"; pub async fn fetch(db_pool: &DBPool) -> Result<Vec<Owner>> { let con = get_db_con(db_pool).await? ; let query = format! ("SELECT {} FROM {}", SELECT_FIELDS, TABLE); let rows = con.query(query.as_str(), &[]).await.map_err(DBQueryError)? ; Ok(rows.iter().map(|r| row_to_owner(&r)).collect()) } pub async fn fetch_one(db_pool: &DBPool, id: i32) -> Result<Owner> { let con = get_db_con(db_pool).await? ; let query = format! ("SELECT {} FROM {} WHERE id = $1", SELECT_FIELDS, TABLE); let row = con .query_one(query.as_str(), &[&id]) .await .map_err(DBQueryError)? ; Ok(row_to_owner(&row)) } pub async fn create(db_pool: &DBPool, body: OwnerRequest) -> Result<Owner> { let con = get_db_con(db_pool).await? ; let query = format! ("INSERT INTO {} (name) VALUES ($1) RETURNING *", TABLE); let row = con .query_one(query.as_str(), &[&body.name]) .await .map_err(DBQueryError)?; Ok(row_to_owner(&row)) } fn row_to_owner(row: &Row) -> Owner { let id: i32 = row.get(0); let name: String = row.get(1); Owner { id, name } }Copy the code

There are three database operations for the owner.

  1. fetchRead all owners
  2. fetch_oneRetrieves the owner with the given ID
  3. createCreate a new owner

The implementation of these methods is fairly straightforward. First, we get a connection from the pool, then we define the Postgres query to execute and execute it with the given value, propagating any errors.

Finally, we use the Row_to_OWNER helper to transform the returned database row data into an actual Owner structure.

Pet.rs data access objects are similar.

pub const TABLE: &str = "pet"; const SELECT_FIELDS: &str = "id, owner_id, name, animal_type, color"; pub async fn fetch(db_pool: &DBPool, owner_id: i32) -> Result<Vec<Pet>> { let con = get_db_con(db_pool).await? ; let query = format! ( "SELECT {} FROM {} WHERE owner_id = $1", SELECT_FIELDS, TABLE ); let rows = con .query(query.as_str(), &[&owner_id]) .await .map_err(DBQueryError)? ; Ok(rows.iter().map(|r| row_to_pet(&r)).collect()) } pub async fn create(db_pool: &DBPool, owner_id: i32, body: PetRequest) -> Result<Pet> { let con = get_db_con(db_pool).await? ; let query = format! ( "INSERT INTO {} (name, owner_id, animal_type, color) VALUES ($1, $2, $3, $4) RETURNING *", TABLE ); let row = con .query_one( query.as_str(), &[&body.name, &owner_id, &body.animal_type, &body.color], ) .await .map_err(DBQueryError)?; Ok(row_to_pet(&row)) } pub async fn delete(db_pool: &DBPool, owner_id: i32, id: i32) -> Result<u64> { let con = get_db_con(db_pool).await? ; let query = format! ("DELETE FROM {} WHERE id = $1 AND owner_id = $2", TABLE); con.execute(query.as_str(), &[&id, &owner_id]) .await .map_err(DBQueryError) } fn row_to_pet(row: &Row) -> Pet { let id: i32 = row.get(0); let owner_id: i32 = row.get(1); let name: String = row.get(2); let animal_type: String = row.get(3); let color: Option<String> = row.get(4); Pet { id, name, owner_id, animal_type, color, } }Copy the code

Here we have three methods.

  1. fetchGets all pets belonging to a given pet.owner_id
  2. createCreates a new pet for the given petowner_id
  3. deleteDelete the given petidowner_id

In terms of implementation, it follows exactly the same concept as owner.rs above.

That’s the end of the database layer. Let’s go a step further and implement handler.rs in SRC.

pub async fn list_pets_handler(owner_id: i32, db_pool: DBPool) -> Result<impl Reply> { let pets = db::pet::fetch(&db_pool, owner_id) .await .map_err(reject::custom)? ; Ok(json::<Vec<_>>( &pets.into_iter().map(PetResponse::of).collect(), )) } pub async fn create_pet_handler( owner_id: i32, body: PetRequest, db_pool: DBPool, ) -> Result<impl Reply> { Ok(json(&PetResponse::of( db::pet::create(&db_pool, owner_id, body) .await .map_err(reject::custom)?, ))) } pub async fn delete_pet_handler(owner_id: i32, id: i32, db_pool: DBPool) -> Result<impl Reply> { db::pet::delete(&db_pool, owner_id, id) .await .map_err(reject::custom)?; Ok(StatusCode::OK) } pub async fn list_owners_handler(db_pool: DBPool) -> Result<impl Reply> { let owners = db::owner::fetch(&db_pool).await.map_err(reject::custom)? ; Ok(json::<Vec<_>>( &owners.into_iter().map(OwnerResponse::of).collect(), )) } pub async fn fetch_owner_handler(id: i32, db_pool: DBPool) -> Result<impl Reply> { let owner = db::owner::fetch_one(&db_pool, id) .await .map_err(reject::custom)?; Ok(json(&OwnerResponse::of(owner))) } pub async fn create_owner_handler(body: OwnerRequest, db_pool: DBPool) -> Result<impl Reply> { Ok(json(&OwnerResponse::of( db::owner::create(&db_pool, body) .await .map_err(reject::custom)? ,)))}Copy the code

The API-surface consists of six operations.

  1. List the owner
  2. Gets the owner for a given ID
  3. Create a master
  4. Create a pet
  5. Remove the pet
  6. List pets for a given owner

In each case, we simply invoke the appropriate operation in the database layer and convert the returned Owner, or Pet, to OwnerResponse or PetResponse, respectively, and return any errors directly to the caller.

Finally, taking a step up, we implement the actual Web server in main.rs that points to these handlers.

mod db; mod error; mod handler; type Result<T> = std::result::Result<T, Rejection>; type DBCon = Connection<PgConnectionManager<NoTls>>; type DBPool = Pool<PgConnectionManager<NoTls>>; #[tokio::main] async fn main() { let db_pool = db::create_pool().expect("database pool can be created"); db::init_db(&db_pool) .await .expect("database can be initialized"); let pet = warp::path! ("owner" / i32 / "pet"); let pet_param = warp::path! ("owner" / i32 / "pet" / i32); let owner = warp::path("owner"); let pet_routes = pet .and(warp::get()) .and(with_db(db_pool.clone())) .and_then(handler::list_pets_handler) .or(pet .and(warp::post()) .and(warp::body::json()) .and(with_db(db_pool.clone())) .and_then(handler::create_pet_handler)) .or(pet_param .and(warp::delete()) .and(with_db(db_pool.clone())) .and_then(handler::delete_pet_handler)); let owner_routes = owner .and(warp::get()) .and(warp::path::param()) .and(with_db(db_pool.clone())) .and_then(handler::fetch_owner_handler) .or(owner .and(warp::get()) .and(with_db(db_pool.clone())) .and_then(handler::list_owners_handler)) .or(owner .and(warp::post()) .and(warp::body::json()) .and(with_db(db_pool.clone())) .and_then(handler::create_owner_handler)); let routes = pet_routes .or(owner_routes) .recover(error::handle_rejection) .with( warp::cors() .allow_credentials(true)  .allow_methods(&[ Method::OPTIONS, Method::GET, Method::POST, Method::DELETE, Method::PUT, ]) .allow_headers(vec! [header::CONTENT_TYPE, header::ACCEPT]) .expose_headers(vec! [header::LINK]) .max_age(300) .allow_any_origin(), ); warp::serve(routes).run(([127, 0, 0, 1], 8000)).await; } fn with_db(db_pool: DBPool) -> impl Filter<Extract = (DBPool,), Error = Infallible> + Clone { warp::any().map(move || db_pool.clone()) }Copy the code

There’s a lot to unravel, so let’s take a look.

First, we define modules and some types to save typing time. Then, in the main function (or tokio::main, the asynchronous entry point for our application), we first initialize the database pool and database.

At the bottom, there is a With_DB filter, which is the preferred way of passing data to handlers in WARP — connection pooling in this case.

Then, we define several routing cardinals: PET, which is of the form /owner/$ownerId/pet; Pet_param, which adds a /$petId at the end; Owner, it just contains /owner.

With these foundations, we can define our routes to different handlers.

  • GET /ownerList all owners
  • GET /owner/$ownerIdReturns the owner with the given ID
  • POST /ownerCreate a master
  • GET /owner/$ownerid/petList all pets for a given owner
  • POST /owner/$ownerId/petCreate a pet for the given owner
  • DELETE /owner/$ownerId/pet/$petIdDeletes the pet with the given ID and owner ID.

Then we connected everything with a CORS configuration and ran the server on port 8000.

Backend. You can do this by simply running Cargo Run, as long as you have a Postgres database running on port 7878 (for example, using Docker), and you will have REST APIS at http://localhost:8000.

You can test it with cURL by running a command like this.

curl -X POST http://localhost:8000/owner -d '{"name": "mario"}' -H 'content-type: application/json'

curl -v -X POST http://localhost:8000/owner/1/pet -d '{"name": "minka", "animal_type": "cat", "color": "black-brown-white"}' -H 'content-type: application/json'

Copy the code

Implementation of front desk

Now that we have a fully functional back end, we need a way to interact with it.

In frontend, we’ll start at the top of lib.rs and work our way down through components, because it’s more natural to walk through the component tree step by step.

We will use yew_router for routing. Otherwise, we will use the same as the Yew official document recommended Settings, use [trunk] (https://github.com/thedodd/trunk) to build and provide network application.

In our application, there are two modules, PET and Owner. However, before we can start writing any Rust code, we need to create our index.html file in the root of our Frontend project, including the styles we will use.

<html>
  <head>
    <title>Rust Fullstack Example</title>
    <style>
        body {
            font-size: 14px;
            font-family: sans-serif;
        }
        a {
            text-decoration: none;
            color: #339;
        }
        a:hover {
            text-decoration: none;
            color: #33f;
        }
        .app {
            background-color: #efefef;
            margin: 100px 25% 25% 25%;
            width: 50%;
            padding: 10px;
        }
        .app .nav {
            text-align: center;
            font-size: 16px;
            font-weight: bold;
        }
        .app .refresh {
            text-align: center;
            margin: 10px 0 10px 0;
        }
        .list-item {
            margin: 2px;
            padding: 5px;
            background-color: #cfc;
        }
        .pet {
            margin-top: 10px;
        }
        .completed {
            text-decoration: line-through;
            background-color: #dedede;
        }
        .detail {
            font-size: 16px;
        }
        .detail h1 {
            font-size: 24px;
        }
        .detail .id {
            color: #999;
        }
        .detail .completed {
            color: #3f3;
        }
        .detail .not-completed {
            color: #f33;
        }
    </style>
  </head>
</html>

Copy the code

This HTML file will be used as a starting point, trunk, and when we build the application, we will add the corresponding fragment to the Dist folder to make our application work with it.

Start at the root

Let’s start at the top, lib.rs.

We start by defining some modules and a structure that contains our root component, as well as some routes.

mod owner;
mod pet;

pub type Anchor = RouterAnchor<AppRoute>;

struct FullStackApp {}

pub enum Msg {}

#[derive(Switch, Clone, Debug)]
pub enum AppRoute {
    #[to = "/app/create-owner"]
    CreateOwner,
    #[to = "/app/create-pet/{id}"]
    CreatePet(i32),
    #[to = "/app/{id}"]
    Detail(i32),
    #[to = "/"]
    Home,
}

Copy the code

Our application has routes for Home (for example, listing owners), for viewing a detailed page of owners, and for creating owners and pets.

We then implemented the Component nature for our FullStackApp so that we could use it as an entry point.

impl Component for FullStackApp { type Message = Msg; type Properties = (); fn create(_: Self::Properties, _link: ComponentLink<Self>) -> Self { Self {} } fn update(&mut self, _msg: Self::Message) -> ShouldRender { true } fn change(&mut self, _props: Self::Properties) -> ShouldRender { true } fn view(&self) -> Html { html! { <div class=classes! ("app")> <div class=classes! ("nav")> <Anchor route=AppRoute::Home>{"Home"}</Anchor> </div> <div class=classes! ("content")> <Router<AppRoute, ()> render = Router::render(move |switch: AppRoute| { match switch { AppRoute::CreateOwner => { html! { <div> <owner::create::CreateForm /> </div>} } AppRoute::CreatePet(owner_id) => { html! { <div> <pet::create::CreateForm owner_id=owner_id/> </div>} } AppRoute::Detail(owner_id) => { html! { <div> <owner::detail::Detail owner_id=owner_id/> </div>} } AppRoute::Home => { html! { <div> <owner::list::List /> <br /> <Anchor route=AppRoute::CreateOwner> { "Create New Owner" } </Anchor> </div> } } } }) /> </div> </div> } } }Copy the code

Our root component doesn’t really do anything; It simply contains a simple menu with a link to Home, which is always visible, and then includes the router, which configudes for each of our routes which components should be displayed and which are just extra markers.

For example, for AppRoute::Home, our default Home route, we display a list of owners and a link to the Create New Owner table.

Finally, we need the following snippet to make WASM-Magic work so that we can get an actual networking application from Trunk.

#[wasm_bindgen(start)]
pub fn run_app() {
    App::<FullStackApp>::new().mount_to_body();
}

Copy the code

The owner list

Let’s start with the owner list displayed on Home, because that’s the simplest component.

In the owner module, we create a mod.rs file, a create.rs, a detail.rs, and a list.rs file.

In mod.rs, we simply export these modules.

pub mod create;
pub mod detail;
pub mod list;

Copy the code

Then, we start implementing list.rs.

Our goal is to get a list of owners from the background and display each owner linked to its details page.

We start by defining the List structure, which is the foundation of our component.

pub struct List {
    fetch_task: Option<FetchTask>,
    owners: Option<Vec<OwnerResponse>>,
    link: ComponentLink<Self>,
}

Copy the code

ComponentLink is Yew’s way of sending messages within components, for example, triggering network requests and other side effects.

Because we are using Yew’s FetchService, we also need to save the fetch_task that we will use to get the owner from the back end.

The owners list starts out as None and is populated as soon as a back-end request (hopefully) returns a list of owners.

We then define our Msg enumeration, which defines the messages to be processed by the component.

pub enum Msg {
    MakeReq,
    Resp(Result<Vec<OwnerResponse>, anyhow::Error>),
}

Copy the code

We simply create an action to make the request and an action to receive the result from the back end.

With this, we can implement the Component, as shown below.

impl Component for List {
    type Properties = ();
    type Message = Msg;

    fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self {
        link.send_message(Msg::MakeReq);
        Self {
            fetch_task: None,
            link,
            owners: None,
        }
    }

    fn view(&self) -> Html {
        html! {
            <div>
                { self.render_list() }
            </div>
        }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::MakeReq => {
                self.owners = None;
                let req = Request::get("http://localhost:8000/owner")
                    .body(Nothing)
                    .expect("can make req to backend");

                let cb = self.link.callback(
                    |response: Response<Json<Result<Vec<OwnerResponse>, anyhow::Error>>>| {
                        let Json(data) = response.into_body();
                        Msg::Resp(data)
                    },
                );

                let task = FetchService::fetch(req, cb).expect("can create task");
                self.fetch_task = Some(task);
                ()
            }
            Msg::Resp(resp) => {
                if let Ok(data) = resp {
                    self.owners = Some(data);
                }
            }
        }
        true
    }

    fn change(&mut self, _props: Self::Properties) -> ShouldRender {
        true
    }
}

Copy the code

When the component is created, we use the component link to trigger MakeReq, which sends a request to the owner back end. We then initialize the component.

In the update, we process the request and response information, use FetchService, sends a request to the [http://localhost:8000/owner] (http://localhost:8000/owner), There our back end gives us a list of owners.

We then parse the response in the callback and call Msg::Resp(data) to set the data into our component if no error occurs.

In the render function, we simply call render_list, which we implement in the List itself, as shown below.

impl List { fn render_list(&self) -> Html { if let Some(t) = &self.owners { html! { <div class=classes! ("list")> { t.iter().map(|name| self.view_owner(name)).collect::<Html>() } </div> } } else { html! { <div class=classes! ("loading")>{"loading..." }</div> } } } fn view_owner(&self, owner: &OwnerResponse) -> Html { html! { <div class=classes! ("list-item")> <Anchor route=AppRoute::Detail(owner.id as i32)> { &owner.name } </Anchor> </div> } } }Copy the code

Basically, if we set self.owners, we iterate through the list and render view_owner for everyone. This creates an AppRoute::Detail with the owner ID, which is a link to the Detail page.

If we have no data, we will display a loading… The information.

This is the case for the manifest owner. Let’s continue on the detail.rs detail page.

Create a detailed page for the owner

The owner’s detail page is a bit tricky. Here, we need to make two requests: one is to get the owner with the given owner ID (so we can also refresh the page and use that path directly), and a list of the owner’s pets. In addition, we had to implement the ability to delete pets here.

The general idea is the same.

#[derive(Properties, Clone, PartialEq)]
pub struct Props {
    pub owner_id: i32,
}

pub struct Detail {
    props: Props,
    link: ComponentLink<Self>,
    pets: Option<Vec<PetResponse>>,
    owner: Option<OwnerResponse>,
    fetch_pets_task: Option<FetchTask>,
    fetch_owner_task: Option<FetchTask>,
    delete_pet_task: Option<FetchTask>,
}

pub enum Msg {
    MakePetsReq(i32),
    MakeOwnerReq(i32),
    MakeDeletePetReq(i32, i32),
    RespPets(Result<Vec<PetResponse>, anyhow::Error>),
    RespOwner(Result<OwnerResponse, anyhow::Error>),
    RespDeletePet(Response<Json<Result<(), anyhow::Error>>>, i32),
}

Copy the code

We define the prop for the component being called — in this case, the owner ID in the routing path.

We then define the Detail structure, which holds the data for our component, including the PETS and owner we want to get, as well as the link to the component and the props and FetchTasks for getting the pet, getting the owner, and deleting the pet.

Let’s look at the implementation of the component.

impl Component for Detail { type Properties = Props; type Message = Msg; fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { link.send_message(Msg::MakePetsReq(props.owner_id)); link.send_message(Msg::MakeOwnerReq(props.owner_id)); Self { props, link, owner: None, pets: None, fetch_pets_task: None, fetch_owner_task: None, delete_pet_task: None, } } fn view(&self) -> Html { html! { <div> { self.render_detail(&self.owner, &self.pets)} </div> } } fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { Msg::MakePetsReq(id) => { let req = Request::get(&format! ("http://localhost:8000/owner/{}/pet", id)) .body(Nothing) .expect("can make req to backend"); let cb = self.link.callback( |response: Response<Json<Result<Vec<PetResponse>, anyhow::Error>>>| { let Json(data) = response.into_body(); Msg::RespPets(data) }, ); let task = FetchService::fetch(req, cb).expect("can create task"); self.fetch_pets_task = Some(task); () } Msg::MakeOwnerReq(id) => { let req = Request::get(&format! ("http://localhost:8000/owner/{}", id)) .body(Nothing) .expect("can make req to backend"); let cb = self.link.callback( |response: Response<Json<Result<OwnerResponse, anyhow::Error>>>| { let Json(data) = response.into_body(); Msg::RespOwner(data) }, ); let task = FetchService::fetch(req, cb).expect("can create task"); self.fetch_owner_task = Some(task); () } Msg::MakeDeletePetReq(owner_id, pet_id) => { let req = Request::delete(&format! ( "http://localhost:8000/owner/{}/pet/{}", owner_id, pet_id )) .body(Nothing) .expect("can make req to backend"); let cb = self.link.callback( move |response: Response<Json<Result<(), anyhow::Error>>>| { Msg::RespDeletePet(response, pet_id) }, ); let task = FetchService::fetch(req, cb).expect("can create task"); self.delete_pet_task = Some(task); () } Msg::RespPets(resp) => { if let Ok(data) = resp { self.pets = Some(data); } } Msg::RespOwner(resp) => { if let Ok(data) = resp { self.owner = Some(data); } } Msg::RespDeletePet(resp, id) => { if resp.status().is_success() { self.pets = self .pets .as_ref() .map(|pets| pets.into_iter().filter(|p| p.id ! = id).cloned().collect()); } } } true } fn change(&mut self, props: Self::Properties) -> ShouldRender { self.props = props; true } }Copy the code

The basic principle is the same. Our View calls a render_detail function, which we’ll look at later. At CREATE, we also initialize our component and trigger the pet and owner fetch by sending the corresponding message and the given owner_ID.

In update, we need to implement request and response handlers that get the pet and owner. These are almost identical to those in the List component, with different urls and different return types.

In the MakeDeletePetReq handler, we send the DELETE request with the given owner_id and pet_id. If successful, we fire the Msg::RespDeletePet message.

There, if the request is successful, we simply remove the pet with the given ID from our list of pets. This is good because it means we don’t need to retrieve the entire pet list.

Let’s look at the rendering code for the master details.

impl Detail { fn render_detail( &self, owner: &Option<OwnerResponse>, pets: &Option<Vec<PetResponse>>, ) -> Html { match owner { Some(o) => { html! { <div class=classes! ("detail")> <h1>{&o.name}{" ("}<span class=classes! ("id")>{o.id}</span>{")"}</h1> { self.view_pet_list(pets) } <br /> <Anchor route=AppRoute::CreatePet(o.id as i32)> { "Create New Pet" } </Anchor> </div> } } None => { html! { <div class=classes! ("loading")>{"loading..." }</div> } } } } fn view_pet_list(&self, pets: &Option<Vec<PetResponse>>) -> Html { match pets { Some(p) => { html! { p.iter().map(|pet| self.view_pet(pet)).collect::<Html>() } } None => { html! { <div class=classes! ("loading")>{"loading..." }</div> } } } } fn view_pet(&self, pet: &PetResponse) -> Html { let id = pet.id; let owner_id = self.props.owner_id; html! { <div class=classes! ("list-item", "pet")> <div><b>{ &pet.name }</b> { " (" } <button onclick=self.link.callback(move |_| Msg::MakeDeletePetReq(owner_id, id))>{"Delete"}</button> {")"}</div> <div>{ &pet.animal_type }</div> <div>{ &pet.color.as_ref().unwrap_or(&String::new()) }</div> </div> } } }Copy the code

Again, if we have data, we render it. Otherwise, we display a loading… . Once we have the owner, we render its name and the ID next to it.

Next, we render the pet list, in view_pet, and actually render the pet. We also created the delete pet button, which has an onClick handler that triggers the MsgMakeDeletePetReq message.

Below the pet list, we display a link to create a pet route.

We’re almost done. Now we just need to look at the components that create the owner and pet. Let’s start with the owner in create.rs.

pub struct CreateForm {
    link: ComponentLink<Self>,
    fetch_task: Option<FetchTask>,
    state_name: String,
}

pub enum Msg {
    MakeReq,
    Resp(Result<OwnerResponse, anyhow::Error>),
    EditName(String),
}

Copy the code

Again, we start with the Component structure and Msg enumeration.

In this case, we need the data infrastructure to make the request to create the host, but we also need a way to create and edit a form.

To do this, we create the state_name field on the component and the EditName(String) in Msg.

Let’s take a look at the Component implementation.

impl Component for CreateForm { type Properties = (); type Message = Msg; fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self { Self { link, state_name: String::new(), fetch_task: None, } } fn view(&self) -> Html { html! { <div> { self.render_form() } </div> } } fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { Msg::MakeReq => { let body = OwnerRequest { name: self.state_name.clone(), }; let req = Request::post("http://localhost:8000/owner") .header("Content-Type", "application/json") .body(Json(&body)) .expect("can make req to backend"); let cb = self.link.callback( |response: Response<Json<Result<OwnerResponse, anyhow::Error>>>| { let Json(data) = response.into_body(); Msg::Resp(data) }, ); let task = FetchService::fetch(req, cb).expect("can create task"); self.fetch_task = Some(task); () } Msg::Resp(resp) => { ConsoleService::info(&format! ("owner created: {:? }", resp)); if let Ok(_) = resp { RouteAgent::dispatcher().send(RouteRequest::ChangeRoute(Route { route: "/".to_string(), state: ()})); } } Msg::EditName(input) => { self.state_name = input; } } true } fn change(&mut self, _props: Self::Properties) -> ShouldRender { true } } impl CreateForm { fn render_form(&self) -> Html { let edit_name = self .link .callback(move |e: InputData| Msg::EditName(e.value)); html! { <div class=classes! ("pet-form")> <div> <input type="text" value={self.state_name.clone()} oninput={edit_name} /> </div> <div> <button onclick=self.link.callback(move |_| Msg::MakeReq)>{"Submit"}</button> </div> </div> } } }Copy the code

As you can see, in the CreateForm implementation within render_Form, we create a simple form input field that takes self.state_name as a value. That means it’s directly connected to our state.

We use the onInput event handler to call the Msg::EditName message with the value of the input field every time someone writes text to the input field.

When you look at the Update function in the Component implementation, the Msg::EditName handler simply sets self.state_name to the given value in the input. This ensures that we always have the values from the form fields in our component.

This is important once we click the Submit button to trigger Msg::MakeReq. There, we create a JSON payload, using self.state_name as the value of name to create an owner.

We then send this payload to the back-end endpoint to create an owner, and if all succeeds, manually change the route back to “/”, our home route, using the Yew_Router ‘sRouteAgent and scheduler.

Pet details page

That’s how simple Yew’s form handling is. Let’s look at the final part of the puzzle and create a PET module with a mod.rs and create.rs.

In mod.rs, we also just export create.

pub mod create;

Copy the code

At Create.rs, we implement the component to add a new pet, which will be very similar to the CreateForm for the owner we just implemented.

#[derive(Properties, Clone, PartialEq)]
pub struct Props {
    pub owner_id: i32,
}

pub struct CreateForm {
    props: Props,
    link: ComponentLink<Self>,
    fetch_task: Option<FetchTask>,
    state_pet_name: String,
    state_animal_type: String,
    state_color: Option<String>,
}

pub enum Msg {
    MakeReq(i32),
    Resp(Result<PetResponse, anyhow::Error>),
    EditName(String),
    EditAnimalType(String),
    EditColor(String),
}

Copy the code

The CreatePet form takes as a prop the owner_ID of the owner for whom we want to create a pet.

We then define state_PEt_name, state_animal_type, and state_color to keep our three form fields in the same state as we did for the host.

The same is true for MSGS: we need to provide handlers for each form field, as well as request and process the response for create PET.

Let’s look at the Component implementation and render logic.

impl Component for CreateForm { type Properties = Props; type Message = Msg; fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { Self { props, link, state_pet_name: String::new(), state_animal_type: String::from("cat"), state_color: Some(String::from("black")), fetch_task: None, } } fn view(&self) -> Html { html! { <div> { self.render_form(self.props.owner_id) } </div> } } fn update(&mut self, msg: Self::Message) -> ShouldRender { match msg { Msg::MakeReq(id) => { let body = PetRequest { name: self.state_pet_name.clone(), animal_type: self.state_animal_type.clone(), color: self.state_color.clone(), }; let req = Request::post(&format! ("http://localhost:8000/owner/{}/pet", id)) .header("Content-Type", "application/json") .body(Json(&body)) .expect("can make req to backend"); let cb = self.link.callback( |response: Response<Json<Result<PetResponse, anyhow::Error>>>| { let Json(data) = response.into_body(); Msg::Resp(data) }, ); let task = FetchService::fetch(req, cb).expect("can create task"); self.fetch_task = Some(task); () } Msg::Resp(resp) => { ConsoleService::info(&format! ("pet created: {:? }", resp)); if let Ok(_) = resp { RouteAgent::dispatcher().send(RouteRequest::ChangeRoute(Route { route: format! ("/app/{}", self.props.owner_id), state: (), })); } } Msg::EditName(input) => { self.state_pet_name = input; } Msg::EditAnimalType(input) => { ConsoleService::info(&format! ("input: {:? }", input)); self.state_animal_type = input; } Msg::EditColor(input) => { self.state_color = Some(input); } } true } fn change(&mut self, props: Self::Properties) -> ShouldRender { self.props = props; true } } impl CreateForm { fn render_form(&self, owner_id: i32) -> Html { let edit_name = self .link .callback(move |e: InputData| Msg::EditName(e.value)); let edit_animal_type = self.link.callback(move |e: ChangeData| match e { ChangeData::Select(elem) => Msg::EditAnimalType(elem.value()), _ => unreachable! ("only used on select field"), }); let edit_color = self .link .callback(move |e: InputData| Msg::EditColor(e.value)); html! { <div class=classes! ("pet-form")> <div> <input type="text" value={self.state_pet_name.clone()} oninput={edit_name} /> </div> <div> <select onchange={edit_animal_type}> <option value="cat" selected=true>{ "Cat" }</option> <option value="dog">{ "Dog" }</option>  </select> </div> <div> <input type="text" value={self.state_color.clone()} oninput={edit_color} /> </div> <div> <button  onclick=self.link.callback(move |_| Msg::MakeReq(owner_id))>{"Submit"}</button> </div> </div> } } }Copy the code

Let’s start with the render_form function in CreateForm. Here again, we create input fields for all fields of the pet. This time, however, there is a twist: we used a SELECT field for animal type because we wanted to limit it to cats and dogs.

This means that for the edit_animal_type callback handler, we get a ChangeData instead of an InputData. In it, we need to match the type of change. We just want to set up ChangeData::Select(ELEm) and get the value of that element and send it to our component state.

For the other two fields, the procedure is the same as in our Create Owner component.

There’s nothing new here either in terms of the Component implementation. We implemented a handler that calls the CREATE PET endpoint on the back end, as well as a handler that passes the value of the form input field to our state, so we can create a payload for this endpoint.

With this last component completed, our implementation of the Rust full-stack networking application is complete and all that remains is to test whether it actually works.

Test our Rust full stack application

Frontend and Backend can be run using the Postgres database running on port 7878 (navigate to http://localhost:8080).

There, we were greeted by an empty Home screen. We can click “Create a new owner”, which shows us this table.

Commit will create the owner, and we’ll see the Home in the list.

Next, let’s click on the new owner to see the owner’s details page.

Now we can start adding some pets using Create New Pet.

Once we were done, we were redirected to the owner details page, which showed a list of our newly added pets.

Finally, we can try to delete a pet by clicking the delete button next to it.

Great, it worked! All of this is written in Rust.

You can find the full code for this example on GitHub.

conclusion

In this tutorial, we demonstrated how to build a simple full-stack networking application entirely with Rust. We showed you how to use Cargo to create a multi-module workspace and how to share code between front-end and back-end parts of your application.

So Rust’s web ecosystem is still maturing, so it’s impressive that you can already build modern full-stack web applications with too much fuzziness.

I’m excited to see how Wasm’s journey continues, and I look forward to seeing the Rust asynchronous network ecosystem evolve further with improved stability, compatibility, and library richness.

However, the future of web development in Rust looks promising!

The Postfull-stack Rust: Complete Tutorial with Examples first appeared on The LogRocket blog.