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.

on the backend side, there are 4 popular libraries:

for interacting with persistence layer, there are a few libraries, and each are unique for different use cases:

on the frontend side, things are even more flexible. if the use case is to provide a lightweight web client for web admin, then the following templating libraries are perfect:

when web client (or 2nd web client) are intended to serve users (not admins), then it might be smart to look for solutions on the javascript / typescript side:

beyong the tech stack and frameworks mentioned above, there are layers in codes such as models, adapters, controllers, etc. that need to be written by implementers. such layers are often considered to be abstraction layers that falls in the realm of design and architecture.

to form a simple technical stack, i normally pick axum, askama, and sqlx to start a simple application with. this choice should be sufficient to build a service to provide json api, web admin, and basic routing from requests to database interactions.

backend

spinning up a http server backend 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
}

specify 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 (admin portal)

on the browser side, askama handles this via "templates", it translates files that mixed with html and some "rust looking" codes together into functional 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>

optional: more on frontends (various clients)

the range of frontend clients include: cli, mobile apps, browser webapps, desktop apps, hybrid apps (platform agnostic), etc.

cli (or tui) are mostly developed for admins to manage the software itself over ssh or directly from the host environment's tty. its use case is very much similar to admin portal on browser, with significantly less visual clutters.

both desktop and mobile native apps are the most popular type of clients for consumer users. its interactions with backend are done through the backend's api layer.

hybrid mobile clients are stupid ideas due to their many layers of abstractions on top of the destination of client's operating system. they often ends up being slow and looking not as native in terms of ui/ux. they are easy to start, but hard to maintain in the medium to long term.

browser webapps are great idea for showcasing the backend service to a platform agnostic group of users. it's often served as a gateway to other dedicated client apps, as if allowing users to taste test a new software service before committing to the service's native client apps.

a common next step for this sample hello world app is to add a frontend client for users. in my opinion, this part of the development is best to be spawned on a different repository to avoid adding build and deployment complexity to the monorepo. and i'll leave this as a part of exercise for extra credits.