Source Code

Intro Link to heading

For those that know me or read the last blog post, you’ll know Rust has become my new favourite (I’m sorry Go, I still love you). In this post, we’ll be creating a full stack web app in Rust, using a few libraries I’ve come to love, and deploying it super super cheap.

The Stack Link to heading

The Idea Link to heading

Let’s start where every side project starts, the idea. Every good idea for a side project begins with something the developer is passionate about. For me, it was Pokémon, but I’ve had too many side projects based on that idea, so I went with the next best thing, Digimon.

I found a dataset for the Cyber Sleuth games and decided to build a web app around it. The app would allow users to search for Digimon, view their stats. Note, this makes v1 a read only app, but we’ll add more features in the future.

The Server Link to heading

Author note: I’m developing against Pavex 0.1.25 here.

The server is built using Pavex, an opinionated web framework for Rust. The core idea behind Pavex as I understand it is to make it easy to build web apps in Rust. Luca, in his Rust Nation UK talk even mentions

We’ll start with the pavex CLI to create a new project.

# make sure pavex is installed: https://pavex.dev/docs/getting_started/
pavex new cyber-sleuth

This will create a new project with 3 key directories; app, server, server_sdk.

The app directory is where we’ll spend most of our time developing the api. The server directory is where we’ll do our dependency injection and is the main entry point for the server. The server_sdk directory is autogenerated code based on the app.

I would heavily recommend watching Luca’s talk, as it goes into more detail about the architecture of Pavex. After that, read the documentation, it’s really well written. We’re not going to dive into the specifics of Pavex in this post, but we’ll cover how to create a new endpoint and hook it up with Diesel.

Ideally we want 2 endpoints, one to list Digimon (including query parameters to filter them) and one to get a single Digimon by name.

First, let’s create a few endpoints to achieve this.

pub fn register(bp: &mut Blueprint) {
    bp.nest_at("/api", blueprint());
}

pub fn blueprint() -> Blueprint {
    let mut bp = Blueprint::new();
    PathParams::register(&mut bp);
    QueryParams::register(&mut bp);
    bp.request_scoped(f!(self::Digimon::new));
    bp.route(GET, "/digimon", f!(self::Digimon::search))
        .error_handler(f!(self::SearchError::into_response));
    bp.route(GET, "/digimon/:name", f!(self::Digimon::get))
        .error_handler(f!(self::GetError::into_response));
    bp
}

This code registers the endpoints for the Digimon API. It nests them into /api/ and registers the path and query default constructors. The search and get functions are where we’ll do the heavy lifting.

We’ll also define a Digimon struct (this will hold our database connection, and possibly other things later).

pub struct Digimon {}

We’ll also implement the Default trait, a new function to initialise the struct, and the search and get functions.

impl Default for Digimon {
    fn default() -> Self {
        Self::new()
    }
}

impl Digimon {
    pub fn new() -> Self {
        Self {}
    }

    pub fn get(&mut self, params: &PathParams<GetDigimonParams>) -> Result<Response, GetError> {
        Ok(Response::new(StatusCode::OK))
    }

    pub fn search(
        &mut self,
        params: &QueryParams<SearchDigimonParams>,
    ) -> Result<Response, SearchError> {
        Ok(Response::new(StatusCode::OK))
    }
}

For now we’re just returning a 200 status code, but we’ll hook this up with Diesel in a bit. You’ll also notice we’re using the SearchDigimonParams and GetDigimonParams structs, so let’s go ahead and define those.

#[PathParams]
pub struct GetDigimonParams {
    pub name: String,
}

#[derive(serde::Deserialize, Debug)]
pub struct SearchDigimonParams {
    pub stage: Option<String>,
    #[serde(rename = "type")]
    pub type_: Option<String>,
    pub attribute: Option<String>,
    pub memory: Option<i64>,
    pub equip_slots: Option<i64>,
}

The GetDigimonParams struct is used to get the name of the Digimon from the path parameters. For those with a curious mind, try changing the route declaration from earlier from /digimon/:name to /digimon/:digimon_name and see what happens. Pavex does a great job of catching runtime errors earlier on.

The SearchDigimonParams struct is used to get the query parameters from the request.

Next up we need to define some errors for what could go wrong.

#[derive(thiserror::Error, Debug)]
pub enum GetError {
    #[error("Digimon not found.")]
    NotFound,
    #[error("Something went wrong. Please retry later.")]
    UnexpectedError(#[source] anyhow::Error),
}

impl GetError {
    pub fn into_response(&self) -> Response {
        match self {
            GetError::NotFound => Response::not_found(),
            GetError::UnexpectedError(_) => Response::internal_server_error(),
        }
        .set_typed_body(format!("{self}"))
    }
}

#[derive(thiserror::Error, Debug)]
pub enum SearchError {
    #[error("Something went wrong. Please retry later.")]
    UnexpectedError(#[source] anyhow::Error),
}

impl SearchError {
    pub fn into_response(&self) -> Response {
        match self {
            SearchError::UnexpectedError(_) => Response::internal_server_error(),
        }
        .set_typed_body(format!("{self}"))
    }
}

I’ll be honest, not a great deal of care has gone into this section, but you can still see just how powerful the error handling is in Pavex. Look at the GetError enum, and see how we’re handling 404 differently from 500 to get an idea.

That whirlwind tour of Pavex is over, let’s move onto Diesel.

The Database Link to heading

Author note: I’m developing against Diesel 2.1.5 here.

Follow the quick start guide for Diesel - I won’t repeat it here, so please go read it.

I hate ORMs (I come from Go, where they’re typically frowned upon). Diesel was pretty fun (provided you’re not using MySql 8.3 on a mac).

First choice is what database we want, I opted (eventually) for something super simple (sqlite) as it can, and in this blog does, run in memory. We’ll need to add the Diesel dependencies to our Cargo.toml.

[dependencies]
diesel = { version = "2.1.5", features = ["sqlite"] }
diesel_migrations = { version = "2.1.0", features = ["sqlite"] }

With that out of the way, we can start defining our migrations.

CREATE TABLE `digimon` (
    `Name` VARCHAR(1024),
    `Stage` VARCHAR(1024),
    `Type` VARCHAR(1024),
    `Attribute` VARCHAR(1024),
    `Memory` BIGINT,
    `Equip Slots` BIGINT,
    -- other columns like HP, SP, ATK, DEF, INT, SPD at various levels go here

    PRIMARY KEY (`Name`)
);

With that all out of the way you’ll be able to generate a schema.rs file.

diesel migration run

Next up we need to create a models.rs file. This will define the queries we want to make, so it may have fields from multiple tables (obviously here we just have the one).

use diesel::prelude::*;

#[derive(serde::Serialize, Queryable, Selectable)]
#[diesel(table_name = crate::schema::digimon)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
#[allow(non_snake_case)]
pub struct Digimon {
    pub Name: String,
    pub Stage: Option<String>,
    pub Type: Option<String>,
    pub Attribute: Option<String>,
    pub Memory: Option<i64>,
    pub Equip_Slots: Option<i64>,
    // other fields like HP, SP, ATK, DEF, INT, SPD at various levels go here too
}

In our lib.rs just make sure the models and schema file are included.

So, back to our Digimon struct in the routes.rs file, we can include the database connection

pub struct Digimon {
    conn: SqliteConnection,
}

And in the new function, we can initialise the connection.

impl Digimon {
    pub fn new() -> Self {
        let mut conn = SqliteConnection::establish(":memory:").unwrap();
        conn.run_pending_migrations(MIGRATIONS).unwrap();
        Self { conn }
    }
}

We can now use this connection in the search and get functions, we’ll leave the search function as an excerise for the reader, but here’s the get function.

impl Digimon {
    pub fn get(&mut self, params: &PathParams<GetDigimonParams>) -> Result<Response, GetError> {
        use super::schema::digimon::dsl::*;

        let res = digimon
            .filter(Name.eq(params.0.name.as_str()))
            .first::<super::models::Digimon>(&mut self.conn)
            .optional()
            .unwrap();

        let res = match res {
            Some(res) => Json::new(res)
                .map_err(Into::into)
                .map_err(GetError::UnexpectedError)?,
            None => return Err(GetError::NotFound),
        };

        Ok(Response::new(StatusCode::OK).set_typed_body(res))
    }
}

Note from earlier the error handling on the match res block, this is where we’re handling the 404 error.

Part 1 Conclusion Link to heading

We’ve covered a lot in this post, but we’ve only just scratched the surface. In the next post, we’ll cover how to deploy this app using Shuttle, and maybe how to add a front end part using Dioxus (though maybe that’s part 3?).

For now, let’s look at how to run this code locally.

cargo px run

In a new terminal, run the following curl command.

curl http://localhost:8080/api/digimon/Agumon
curl "http://localhost:8080/api/digimon?stage=Rookie&type=Vaccine"

Hopefully that works.

Overall, Pavex was a great experience, Diesel was pretty good if I ignore my time with MySql.

I hope you enjoyed this post, and I’ll see you in the next one.