2023-02-03

full stack web development in rust

Full stack development often implies a monorepo with both backend codes and frontend codes sitting together, where the communication between the two often relies on some kind of templating libraries as glue to deliver fully rendered HTML for web requests.

In Rust, there are plenty of options and flexibility throughout the entire stack. Here are the core libraries I have used for a few open source projects.

Technical stack:

Beyong the tech stack and frameworks mentioned above, there are layers such as models, adapters, controllers, etc. that need to be written by the implementer. In other web frameworks, these layers are often abstracted via a code generator or other means of abstractions.

axum favours the more manual approach. That means they will be created manually in the context of this guide.

This guide is not a step by step walkthrough, it assumes some knowledge of Rust from the implementer, especially code/file structure.

backend

Spinning up a web server backend can be done in a few line with axum:

#[tokio::main]
async fn main() -> Result<(), error::AppError> {
    let env = Environment::new();
    let state = AppState::new(env.clone());
    let router = build_router(state);
    let addr =
        SocketAddr::from((parse_to_ipv4(&env.server_url), env.server_port));
    let listener =
        tokio::net::TcpListener::bind(&addr).await.map_err(|err| {
            let msg = format!("Failed to bind IP addr {:?}: {:?}", addr, err);
            AppError::Unexpected(msg)
        })?;
    axum::serve(listener, router.into_make_service())
        .await
        .map_err(|err| {
            let msg = format!("Failed to start server {:?}", err);
            AppError::Unexpected(msg)
        })?;
    Ok(())
}

router

Then a router would be:

fn build_router(state: AppState) -> Router {
    let main_router = Router::new()
        .route("/", get(home))
        .with_state(state);
    main_router
}

And a home route would be:

fn home() -> impl IntoReponse {
    templates::HomeTemplate {
        title: "Home page".to_string(),
        home_title: r#"
        Online marketplace that belongs to the community
        "#
        .to_string(),
        home_subtitle: r#"
        Your search should be filled with what matters to you, not what a
        corporation thinks you should buy.
        "#
        .to_string(),
    }
}
For API only backend
In the case above, the `home` route return an object of type of a trait of `templates::HomeTemplate`. This trait comes from the `askama` templating library.
In the case of a JSON backend, `serde_json` is another library that converts Rust types into JSON before serving data back to `axum` for outbound.
For example:
pub async fn home() -> impl IntoResponse {
    let payload = get_home()
        .await
        .expect("Failed to query home data");
    let json = json!({
        "status_code": StatusCode::OK,
        "payload": payload
    });
    (StatusCode::OK, Json(json)).into_response();
}

frontend

On the browser side, askama handles this via "templates", it translates files that mixed with HTML and some "Rust looking" codes together into pure HTML files. And in some situations, if Javascript is involved, it binds Javascript events to the generated HTML files as well. This will become convenient when handling animations and dynamic theming.

A template from askama can often look something like this:

<!doctype html>
<html lang="en-GB">
    <head>
        <title>{{ title }}</title>
        <link href="/assets/favicon.svg" rel="icon" />
        <link href="/assets/favicon.svg" rel="apple-touch-icon" />
        <link href="/assets/simple.css" rel="stylesheet" />
        <link href="/assets/joinnebula.css" rel="stylesheet" />
        {% block head %}{% endblock %}
    </head>

    <body>
        <header>
            <nav>{% block header_contents %}{% endblock %}</nav>
        </header>

        <main>{% block main_contents %}{% endblock %}</main>

        <footer>{% block footer_contents %}{% endblock %}</footer>
    </body>
</html>