The Web Server and Routing

We looked at Actix Web, Tokio Axum and Rocket. Axum was chosen as it's very actively maintained and has the fastest incremental build times.

Most rust web server projects operate in a similar way. That is you configure a route and a function that will respond to that route.

The functions that respond to routes can have parameters. These parameters which might be structs, database pools or form data are passed to the function by the framework.

Handling Configuration

We'll separate our configuration into it's own file. create crates/web-server/src/config.rs

#[derive(Clone, Debug)]
pub struct Config {
    pub database_url: String,
}

impl Config {
    pub fn new() -> Config {
        let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL not set");

        Config {
            database_url,
        }
    }
}

Handling Errors

Now is a good time to think about how we will handle errors so we don't have to unwrap all the time.

Create a file called crates/web-server/src/errors.rs and add the following code.

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
};
use std::fmt;
use db::{TokioPostgresError, PoolError};

#[derive(Debug)]
pub enum CustomError {
    FaultySetup(String),
    Database(String),
}

// Allow the use of "{}" format specifier
impl fmt::Display for CustomError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            CustomError::FaultySetup(ref cause) => write!(f, "Setup Error: {}", cause),
            //CustomError::Unauthorized(ref cause) => write!(f, "Setup Error: {}", cause),
            CustomError::Database(ref cause) => {
                write!(f, "Database Error: {}", cause)
            }
        }
    }
}

// So that errors get printed to the browser?
impl IntoResponse for CustomError {
    fn into_response(self) -> Response {
        let (status, error_message) = match self {
            CustomError::Database(message) => (StatusCode::UNPROCESSABLE_ENTITY, message),
            CustomError::FaultySetup(message) => (StatusCode::UNPROCESSABLE_ENTITY, message),
        };

        format!("status = {}, message = {}", status, error_message).into_response()
    }
}

impl From<axum::http::uri::InvalidUri> for CustomError {
    fn from(err: axum::http::uri::InvalidUri) -> CustomError {
        CustomError::FaultySetup(err.to_string())
    }
}

impl From<TokioPostgresError> for CustomError {
    fn from(err: TokioPostgresError) -> CustomError {
        CustomError::Database(err.to_string())
    }
}

impl From<PoolError> for CustomError {
    fn from(err: PoolError) -> CustomError {
        CustomError::Database(err.to_string())
    }
}

Install Axum

Make sure you're in the crates/web-server folder and add Axum to your Cargo.toml using the following command.

cargo add [email protected] --no-default-features -F json,http1,tokio
cargo add [email protected] --F form
cargo add tokio@1 --no-default-features -F macros,rt-multi-thread
cargo add [email protected] --no-default-features
cargo add [email protected]
cargo add serde@1 -F "derive"
cargo add --path ../db

And replace your crates/web-server/src/main.rs with the following

mod config;
mod errors;
mod root;

use std::net::SocketAddr;

use axum::{routing::get, Extension, Router};
use tower_livereload::LiveReloadLayer;

#[tokio::main]
async fn main() {
    let config = config::Config::new();

    let pool = db::create_pool(&config.database_url);

    // build our application with a route
    let app = Router::new()
        .route("/", get(root::loader))
        .layer(LiveReloadLayer::new())
        .layer(Extension(config))
        .layer(Extension(pool.clone()));

    // run it
    let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
    println!("listening on {}", addr);
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
    axum::serve(listener, app.into_make_service())
        .await
        .unwrap();
}

Loaders

create a file called root.rs. This is where we will have the logic that will load data from the database and pass it to the pages so they can render.

In this example we will just return JSON for now.

use crate::errors::CustomError;
use axum::{Extension, Json};
use db::User;

pub async fn loader(Extension(pool): Extension<db::Pool>) -> Result<Json<Vec<User>>, CustomError> {
    let client = pool.get().await?;

    let users = db::queries::users::get_users().bind(&client).all().await?;

    Ok(Json(users))
}

Watching for changes and Hot Reload

We could use cargo run to start our server but ideally we'd like our server to re-start when we make changes and for the browser to reload itself.

We've installed Just which is a command runner.

Issue the following command to create a justfile with an entry to run our server.

echo -e 'watch:\n    mold -run cargo watch --workdir /workspace/ -w crates/web-server -w crates/db --no-gitignore -x "run --bin web-server"' > Justfile

You should get a Justfilelike the following.

watch:
    mold -run cargo watch --workdir /workspace/ -w crates/web-server -w crates/db --no-gitignore -x "run --bin web-server"

Run the server

just watch

The server will run up. It's setup for hot reload so when you change the code, the browser will automatically update.

It also uses a very fast linker called Mold to speed up our incremental build times.

And you should be able to point your browser at http://localhost:3000 and see the web server deliver a plain text list of users.

Users