Brutalist Twitter

See, Elon? Twitter isn't so hard.

Hey there, how's it going? Good? Probably not, given you had enough interest to start reading an article called "Brutalist Twitter." That's fine though, you might also be wondering "What is this all about?" Well, if you'd stop asking questions I'd tell you;

Brutalist Twitter is about providing the base functionality of Twitter, without all the crap that makes Twitter bad. No, we're not banning parody accounts or adding a subscription model -- we're just cutting out the extra javascript, tracking, css, ads, pretty much everything I can think of so that I can make this as quickly and effectively as possible.

I'll layout how I built it, so hopefully you can build your own, and you can see that it's not so hard. Maybe you'll learn something along the way.

Without further ado, let's make some twitter.

The Setup

We'll be using shuttle.rs as our hosting provider for this, and thruster as our web framework. That makes it easy for us to set up a new project using:

mkdir brutalist-twitter
cargo shuttle init --thruster

We're going to need a ton of dependencies too, so just add these to Cargo.toml now.

argon2 = "0.4.1" # For password hashing
askama = "0.11.1" # For templating
chrono = "0.4.22" # For time stuff
form_urlencoded = "1.1.0" # For decoding x-www-form-encoded
log = "0.4.17" # For teh logz
serde = { version = "1.0", features = ["derive"] } # For serializing/deserializing
serde_urlencoded = "0.7.1" # For url encoding
sha2 = "0.10.6" # Because sometimes you gotta hash things
shuttle-aws-rds = { version = "0.7.2", features = ["postgres"] } # For storing data
shuttle-service = { version = "0.7.2", features = ["web-thruster"] } # For deploying easily
sqlx = { version = "0.6.2", features = ["runtime-tokio-native-tls", "postgres", "chrono", "uuid"] } # For dealing with postgres
thruster = { version = "1.3.0", features = ["hyper_server"] } # For http framework stuff
tokio = { version = "1.20.1", features = ["macros"] } # For an async runtime
uuid = { version = "1.2.1", features = ["v4", "serde"] } # For ids

The Schema

Schemas are simple, the ideas behind twitter are simple, we'll probably never need to make a change to this, so here's the schema; throw it in schema.sql.

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Creating the users table
CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    password VARCHAR(1024) NOT NULL,
    username VARCHAR(24) NOT NULL UNIQUE,
    created_at TIMESTAMPTZ DEFAULT now()
);

-- Creating the sessions table (for when someone logs in)
CREATE TABLE IF NOT EXISTS sessions (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    token VARCHAR(64) NOT NULL,
    user_id UUID NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now()
);

-- For storing tweets
CREATE TABLE IF NOT EXISTS tweets (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL,
    responding_to UUID,
    content VARCHAR(280),
    like_count BIGINT DEFAULT 0,
    retweet_count BIGINT DEFAULT 0,
    reply_count BIGINT DEFAULT 0,
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);

-- For relating users to what content they like
CREATE TABLE IF NOT EXISTS likes (
    tweet_id UUID NOT NULL,
    user_id UUID NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now(),
    PRIMARY KEY(tweet_id, user_id)
);

-- For relating users to other users, but in like a friend way
CREATE TABLE IF NOT EXISTS follows (
    follower_id UUID NOT NULL,
    following_id UUID NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now(),
    PRIMARY KEY(follower_id, following_id)
);

-- For "retweeting" content rather than duplicating tweets
CREATE TABLE IF NOT EXISTS retweets (
    tweet_id UUID NOT NULL,
    user_id UUID NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now(),
    PRIMARY KEY(tweet_id, user_id)
)

Next, let's make some quick models for everything. These models will also have the methods to get and update the content, so we'll throw each of them in their own files, and all those files into a src/models folder. Don't forget to add the files as modules in src/models/mod.rs, that'll get you every time.

I'm going to just list the models in here with the file name at the top because this is mostly straightforward boilerplate. If there are questions about individual parts, feel free to ask a question and I'll get more specific, but come on, this is Brutal Twitter, we're just plowing ahead.

// src/models/users.rs

use sqlx::{
    types::chrono::{DateTime, Utc},
    FromRow, Pool, Postgres,
};
use uuid::Uuid;

#[derive(Clone, Debug, FromRow)]
pub struct User {
    pub id: Uuid,
    pub password: String,
    pub username: String,
    pub created_at: DateTime<Utc>,
}

impl User {
    pub async fn create_user(
        pool: &Pool<Postgres>,
        username: &str,
        password_hash: &str,
    ) -> Result<User, sqlx::Error> {
        sqlx::query_as(
            "
            INSERT INTO users (LOWER(username), password)
            VALUES ($1, $2)
            RETURNING id, username, password, created_at",
        )
        .bind(username)
        .bind(&password_hash)
        .fetch_one(pool)
        .await
    }

    pub async fn get_user_for_id(pool: &Pool<Postgres>, id: &Uuid) -> Result<User, sqlx::Error> {
        sqlx::query_as(
            "
            SELECT id, username, password, created_at FROM users WHERE id = $1",
        )
        .bind(id)
        .fetch_one(pool)
        .await
    }

    pub async fn get_user_for_username(
        pool: &Pool<Postgres>,
        username: &str,
    ) -> Result<User, sqlx::Error> {
        sqlx::query_as(
            "
            SELECT id, username, password, created_at FROM users WHERE username = LOWER($1)",
        )
        .bind(username)
        .fetch_one(pool)
        .await
    }
}
// src/models/sessions.rs

use sha2::{Digest, Sha256};
use sqlx::{
    types::chrono::{DateTime, Utc},
    FromRow, Pool, Postgres,
};
use uuid::Uuid;

#[derive(Debug, FromRow)]
pub struct Session {
    pub id: Uuid,
    pub token: String,
    pub user_id: Uuid,
    pub created_at: DateTime<Utc>,
}

impl Session {
    pub async fn create_session(
        pool: &Pool<Postgres>,
        user_id: &Uuid,
    ) -> Result<Session, sqlx::Error> {
        sqlx::query_as(
            "
            INSERT INTO sessions (token, user_id)
            VALUES ($1, $2)
            RETURNING id, token, user_id, created_at",
        )
        .bind(format!(
            "{:x}",
            Sha256::new()
                .chain_update(Uuid::new_v4().to_bytes_le())
                .finalize()
        ))
        .bind(user_id)
        .fetch_one(pool)
        .await
    }

    pub async fn get_session_from_token(
        pool: &Pool<Postgres>,
        token: &str,
    ) -> Result<Session, sqlx::Error> {
        sqlx::query_as(
            "
            SELECT id, token, user_id, created_at FROM sessions WHERE token = $1",
        )
        .bind(token)
        .fetch_one(pool)
        .await
    }
}
// src/models/tweets.rs

use chrono::Duration;
use sqlx::{
    types::chrono::{DateTime, Utc},
    FromRow, Pool, Postgres,
};
use uuid::Uuid;

#[derive(Debug, FromRow)]
pub struct Tweet {
    pub id: Uuid,
    pub user_id: Uuid,
    pub responding_to: Option<Uuid>,
    pub content: String,
    pub like_count: i64,
    pub retweet_count: i64,
    pub reply_count: i64,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, FromRow)]
pub struct TweetWithUserInfo {
    pub id: Uuid,
    pub user_id: Uuid,
    pub responding_to: Option<Uuid>,
    pub content: String,
    pub username: String,
    pub user_has_retweeted: bool,
    pub user_has_liked: bool,
    pub like_count: i64,
    pub retweet_count: i64,
    pub reply_count: i64,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

impl Tweet {
    pub async fn create_tweet(
        pool: &Pool<Postgres>,
        user_id: &Uuid,
        responding_to: Option<Uuid>,
        content: String,
    ) -> Result<Tweet, sqlx::Error> {
        let mut transaction = pool.begin().await?;

        let tweet = sqlx::query_as(
            "
            INSERT INTO tweets (user_id, responding_to, content)
            VALUES ($1, $2, $3)
            RETURNING id, user_id, responding_to, content, created_at, updated_at, like_count, retweet_count, reply_count",
        )
        .bind(user_id)
        .bind(responding_to)
        .bind(content)
        .fetch_one(&mut transaction)
        .await?;

        if let Some(tweet_id) = responding_to {
            sqlx::query(
                "
            UPDATE tweets
            SET reply_count = reply_count + 1
            WHERE id = $1",
            )
            .bind(tweet_id)
            .execute(&mut transaction)
            .await?;
        }

        transaction.commit().await?;

        Ok(tweet)
    }

    pub async fn get_tweet_for_id(pool: &Pool<Postgres>, id: &Uuid) -> Result<Tweet, sqlx::Error> {
        sqlx::query_as(
            "
            SELECT id, user_id, responding_to, content, created_at, updated_at, like_count, retweet_count, reply_count FROM tweets WHERE id = $1",
        )
        .bind(id)
        .fetch_one(pool)
        .await
    }

    pub async fn get_tweet_with_user_info(
        pool: &Pool<Postgres>,
        id: &Uuid,
        user_id: Option<&Uuid>,
    ) -> Result<TweetWithUserInfo, sqlx::Error> {
        sqlx::query_as(
            "
            SELECT
                t.id, t.user_id, t.responding_to, t.content,
                t.created_at, t.updated_at, u.username, t.like_count,
                t.retweet_count, t.reply_count,
                l.user_id IS NOT NULL as user_has_liked,
                r.user_id IS NOT NULL as user_has_retweeted
            FROM
                tweets as t
            JOIN
                users as u ON t.user_id = u.id
            LEFT JOIN
                likes as l ON l.tweet_id = t.id AND l.user_id = $2
            LEFT JOIN
                retweets as r ON r.tweet_id = t.id AND r.user_id = $2
            WHERE
                t.id = $1",
        )
        .bind(id)
        .bind(user_id)
        .fetch_one(pool)
        .await
    }

    pub async fn get_recent_tweets_with_user_info(
        pool: &Pool<Postgres>,
        user_id: Option<&Uuid>,
        offset: Option<DateTime<Utc>>,
    ) -> Result<Vec<TweetWithUserInfo>, sqlx::Error> {
        sqlx::query_as(
            "
            SELECT
                t.id, t.user_id, t.responding_to, t.content,
                t.created_at, t.updated_at, u.username, t.like_count,
                t.retweet_count, t.reply_count,
                l.user_id IS NOT NULL as user_has_liked,
                r.user_id IS NOT NULL as user_has_retweeted
            FROM
                tweets as t
            JOIN
                users as u ON t.user_id = u.id
            LEFT JOIN
                likes as l ON l.tweet_id = t.id AND l.user_id = $2
            LEFT JOIN
                retweets as r ON r.tweet_id = t.id AND r.user_id = $2
            WHERE
                    t.created_at < $1
                AND
                    t.responding_to IS NULL
        ORDER BY
                t.created_at DESC
            LIMIT 20",
        )
        .bind(offset.unwrap_or_else(|| Utc::now() + Duration::days(1)))
        .bind(user_id)
        .fetch_all(pool)
        .await
    }

    pub async fn get_tweet_replies_with_user_info(
        pool: &Pool<Postgres>,
        tweet_id: &Uuid,
        user_id: Option<&Uuid>,
        offset: Option<DateTime<Utc>>,
    ) -> Result<Vec<TweetWithUserInfo>, sqlx::Error> {
        sqlx::query_as(
            "
            SELECT
                t.id, t.user_id, t.responding_to, t.content,
                t.created_at, t.updated_at, u.username, t.like_count,
                t.retweet_count, t.reply_count,
                l.user_id IS NOT NULL as user_has_liked,
                r.user_id IS NOT NULL as user_has_retweeted
            FROM
                tweets as t
            JOIN
                users as u ON t.user_id = u.id
            LEFT JOIN
                likes as l ON l.tweet_id = t.id AND l.user_id = $3
            LEFT JOIN
                retweets as r ON r.tweet_id = t.id AND r.user_id = $3
            WHERE
                    t.created_at < $2
                AND
                    t.responding_to = $1
            ORDER BY
                t.created_at DESC
            LIMIT 20",
        )
        .bind(tweet_id)
        .bind(offset.unwrap_or_else(|| Utc::now() + Duration::days(1)))
        .bind(user_id)
        .fetch_all(pool)
        .await
    }
}
// src/models/likes.rs

use sqlx::{
    types::chrono::{DateTime, Utc},
    FromRow, Pool, Postgres,
};
use uuid::Uuid;

#[derive(Debug, FromRow)]
pub struct Like {
    pub tweet_id: Uuid,
    pub user_id: Uuid,
    pub created_at: DateTime<Utc>,
}

impl Like {
    pub async fn create_like(
        pool: &Pool<Postgres>,
        tweet_id: &Uuid,
        user_id: &Uuid,
    ) -> Result<Like, sqlx::Error> {
        let mut transaction = pool.begin().await?;

        let like = sqlx::query_as(
            "
            INSERT INTO likes (tweet_id, user_id)
            VALUES ($1, $2)
            RETURNING tweet_id, user_id, created_at",
        )
        .bind(tweet_id)
        .bind(user_id)
        .fetch_one(&mut transaction)
        .await?;

        sqlx::query(
            "
        UPDATE tweets
        SET like_count = like_count + 1
        WHERE id = $1",
        )
        .bind(tweet_id)
        .execute(&mut transaction)
        .await?;
        transaction.commit().await?;

        Ok(like)
    }

    pub async fn delete_like(
        pool: &Pool<Postgres>,
        tweet_id: &Uuid,
        user_id: &Uuid,
    ) -> Result<(), sqlx::Error> {
        let mut transaction = pool.begin().await?;

        sqlx::query(
            "
            DELETE FROM likes WHERE tweet_id = $1 AND user_id = $2",
        )
        .bind(tweet_id)
        .bind(user_id)
        .execute(&mut transaction)
        .await?;

        sqlx::query(
            "
        UPDATE tweets
        SET like_count = like_count - 1
        WHERE id = $1",
        )
        .bind(tweet_id)
        .execute(&mut transaction)
        .await?;

        Ok(())
    }
}
// src/models/retweets.rs

use sqlx::{
    types::chrono::{DateTime, Utc},
    FromRow, Pool, Postgres,
};
use uuid::Uuid;

#[derive(Debug, FromRow)]
pub struct Retweet {
    pub tweet_id: Uuid,
    pub user_id: Uuid,
    pub created_at: DateTime<Utc>,
}

impl Retweet {
    pub async fn create_retweet(
        pool: &Pool<Postgres>,
        tweet_id: &Uuid,
        user_id: &Uuid,
    ) -> Result<Retweet, sqlx::Error> {
        let mut transaction = pool.begin().await?;

        let like = sqlx::query_as(
            "
            INSERT INTO retweets (tweet_id, user_id)
            VALUES ($1, $2)
            RETURNING tweet_id, user_id, created_at",
        )
        .bind(tweet_id)
        .bind(user_id)
        .fetch_one(&mut transaction)
        .await?;

        sqlx::query(
            "
        UPDATE tweets
        SET retweet_count = retweet_count + 1
        WHERE id = $1",
        )
        .bind(tweet_id)
        .execute(&mut transaction)
        .await?;
        transaction.commit().await?;

        Ok(like)
    }
}

Great. That's a lot of random sql queries and boilerplate for our models. None of it is really terribly important -- if we were a bit fancier maybe we'd have something like prisma that could provide all this functionality for us without the need for this boilerplate, but this is Brutal Twitter -- we're going ugly.

The Controllers

Now, we're not trying to build some "mobile first" API bullshit here, we're building a website. You want to use it on your phone? Open up your web browser.

In light of that, our controllers are going to serve up some good old fashioned HTML justice. To do that, we're using askama to staple together a bunch of templates so we can reuse code. For all you React based web devs, it's like using "components" but it's "not shit."

The relevant pages we'll need here are:

  • Sign up

  • Sign in

  • Home

  • View a tweet

Starting with the users, we make a src/controllers/users.rs and throw the imports we need to the top of the file:

use argon2::{
    password_hash::{rand_core::OsRng, SaltString},
    Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
};
use log::error;
use serde::Deserialize;
use thruster::{
    errors::{ErrorSet, ThrusterError},
    middleware::cookies::CookieOptions,
    middleware_fn, MiddlewareNext, MiddlewareResult,
};

use crate::{
    app::Ctx,
    models::{sessions::Session, users::User},
};

It'll be much clearer why we need these things as we fill in the middleware functions, so just trust me for now.

For starters, let's make the sign up function. Basically this will get called as a POST whenever someone submits the signup form.

Note: assume all of these functions are still in the users controller until I say otherwise.

#[derive(Deserialize)]
pub struct CreatUserReq {
    pub username: String,
    pub password: String,
}

#[middleware_fn]
pub async fn create_user(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    // First we parse the request content as an x-www-form-urlencoded body. That's
    // what the CreateUserReq is used for above.
    let CreatUserReq { username, password } =
        serde_urlencoded::from_str(&context.body_string().await.map_err(|_e| {
            error!("_e: {:#?}", _e);
            
            // If there's an error, map it to a thruster error
            ThrusterError::parsing_error(
                Ctx::new_without_request(context.extra.clone()),
                "Bad request",
            )
        })?)
        .map_err(|_e| {
            error!("_e: {:#?}", _e);
            ThrusterError::parsing_error(
                Ctx::new_without_request(context.extra.clone()),
                "Bad request",
            )
        })?;

    // Now we need to take the password, generate a salt, hash the password,
    // and store the hash, because we're #secure
    let salt = SaltString::generate(&mut OsRng);
    let password_hash = Argon2::default()
        .hash_password(password.as_bytes(), salt.as_ref())
        .map(|h| h.to_string())
        .map_err(|_e| {
            error!("_e: {:#?}", _e);

            ThrusterError::parsing_error(
                Ctx::new_without_request(context.extra.clone()),
                "Bad request",
            )
        })?
        .to_string();

    // Assuming we can hash the password, we create a new user with that hashed password
    let user = User::create_user(&context.extra.pool, &username, &password_hash)
        .await
        .map_err(|_e| {
            error!("_e: {:#?}", _e);

            ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
        })?;

    // Now that we have a user, let's be nice and make them a session too
    let session = Session::create_session(&context.extra.pool, &user.id)
        .await
        .map_err(|_e| {
            error!("_e: {:#?}", _e);

            ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
        })?;

    // We're going to redirect the user back to the home screen
    context.redirect("/");
    
    // And set a session cookie for them so their requests can be authenticated
    context.cookie(
        "Session",
        &session.token,
        &CookieOptions {
            http_only: true,
            ..CookieOptions::default()
        },
    );

    // In thruster, you always have to return a context so that any middleware
    // that was run before it can pick up where it left off.
    Ok(context)
}

Now, creating a user is great, but we need to let users sign in as well.

#[derive(Deserialize)]
pub struct SignInReq {
    pub username: String,
    pub password: String,
}

#[middleware_fn]
pub async fn sign_in_user(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    // Similar to before, we gotta parse that ish -- would be nice if we just made this
    // into a trait, huh?
    let SignInReq { username, password } =
        serde_urlencoded::from_str(&context.body_string().await.map_err(|_e| {
            error!("_e: {:#?}", _e);
            ThrusterError::parsing_error(
                Ctx::new_without_request(context.extra.clone()),
                "Bad request",
            )
        })?)
        .map_err(|_e| {
            error!("_e: {:#?}", _e);
            ThrusterError::parsing_error(
                Ctx::new_without_request(context.extra.clone()),
                "Bad request",
            )
        })?;

    // We fetch the user that is trying to sign in
    let user = User::get_user_for_username(&context.extra.pool, &username)
        .await
        .map_err(|_e| {
            error!("_e: {:#?}", _e);

            ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
        })?;

    // Now we have to test their password
    if Argon2::default()
        .verify_password(
            password.as_bytes(),
            &PasswordHash::new(&user.password).unwrap(),
        )
        .is_ok()
    {
        // Assuming it's okay, we make a session
        let session = Session::create_session(&context.extra.pool, &user.id)
            .await
            .map_err(|_e| {
                error!("_e: {:#?}", _e);

                ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
            })?;

        // Just like the create user, we redirect and make an auth cookie
        context.redirect("/");
        context.cookie(
            "Session",
            &session.token,
            &CookieOptions {
                http_only: true,
                ..CookieOptions::default()
            },
        );
    } else {
        // Otherwise someone forgot their password or is trying to hack us,
        // so throw them back an error.
        return Err(ThrusterError::unauthorized_error(context));
    }

    Ok(context)
}

These next two bits are specific to how thruster is structured, so I'll describe a bit before the code this time. Thruster works by chaining together functions, but not as much in a linear fashion as much as a nested fashion. So if there are three pieces of middleware, a, b, and c, where c is the "last" in the chain, i.e. it doesn't call next, then the execution would look something like

a(context, |context| {
  b(context, |context| {
    c(context, |context| context)
  }
}

This lets us do some neat things around execution and timing. It also means that the same context object gets continuously passed to the different middleware functions.

So, with that, here's some code to take the session cookie and get the user from it, and then another one to stop execution and return an error if there's no valid user on the context.

// Same as all the other middleware. That's the neat part, thruster is homogenous
// in that all the middleware, endpoints, basically anything that touches or handles
// a request looks exactly the same. It's all middleware.
#[middleware_fn]
pub async fn fetch_user_from_cookie(
    mut context: Ctx,
    next: MiddlewareNext<Ctx>,
) -> MiddlewareResult<Ctx> {
    // We get the Sessino token from the cookies on the request
    let session_token = context.cookies.get("Session");

    if let Some(session_token) = session_token {
        // Then we get the session from the database
        let session =
            Session::get_session_from_token(&context.extra.pool, &session_token.value).await;

        if let Ok(session) = session {
            // Then we get the user from the session and we attach it to the context.
            //
            // context.extra is a generic field on the provided `TypedHyperContext`.
            // This is a long way of saying, there's a `user` on the `extra` field
            // that we have yet to define (but will soon.)
            context.extra.user = User::get_user_for_id(&context.extra.pool, &session.user_id)
                .await
                .ok();
        } else {
            // If there isn't a valid session, we want to make sure we clear out the
            // bad cookies.
            context.cookie(
                "Session",
                "",
                &CookieOptions {
                    http_only: true,
                    expires: 1,
                    ..CookieOptions::default()
                },
            );
        }
    }
    
    // We're returning the result of `next` here, meaning we just call the next
    // middleware in the chain and return the result of that
    next(context).await
}

#[middleware_fn]
pub async fn authenticate(context: Ctx, next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    // Remember that generic type earlier? Well we're using it. Not all
    // routes need a user, but some do, and some change depending on if
    // they do have a user.
    if let None = context.extra.user.as_ref() {
        Err(ThrusterError::unauthorized_error(context))
    } else {
        next(context).await
    }
}

Before we jump into the endpoints for creating tweets and retweets, let's make the static pages first. Now we make another controller, src/controllers/pages.rs, and throw in these imports:

use std::str::FromStr;

use askama::Template;
use log::{error, info};
use thruster::{
    context::context_ext::ContextExt,
    errors::{ErrorSet, ThrusterError},
    middleware_fn, Context, MiddlewareNext, MiddlewareResult,
};
use uuid::Uuid;

use crate::{
    app::Ctx,
    models::{
        tweets::{Tweet, TweetWithUserInfo},
        users::User,
    },
};

We'll go through the same process as the form endpoints: sign up, sign in.

// This is a template in Askama, basically when there are no variables in the template
// then we can use a type struct. The template will live in `templates/signup.html`.
#[derive(Template)]
#[template(path = "signup.html")]
pub struct SignUp;

// LOOK HOW SIMPLE THIS SHIT IS! Everything should seem familiar from before, but since
// we know the render will succeed (since there are no variables,) we can just unwrap
// the result. That's right, we know better than the compiler.
#[middleware_fn]
pub async fn signup(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    context.set("Content-Type", "text/html");
    context.body(&SignUp.render().unwrap());

    Ok(context)
}

// Sign in, surprise, looks basically the same
#[derive(Template)]
#[template(path = "signin.html")]
pub struct SignIn;

#[middleware_fn]
pub async fn signin(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    context.set("Content-Type", "text/html");
    context.body(&SignIn.render().unwrap());

    Ok(context)
}

Now it's time to add our first templates. I made:

  • A base template that has common headers, footers, styles

  • A navbar template

  • A sign up template

  • A sign in template

This is all html/askama, I'll add a few comments but it's pretty self expanatory.

<!-- templates/base.html -->
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="title" content="Bitter" />
    <meta name="description" content="Twitter, but better!" />
    <meta name="keywords" content="twitter,social,bitter" />
    <meta name="robots" content="index, follow" />
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta name="language" content="en-us" />
    <!-- 
      I know, there are styles here, forgive me father for I have sinned and made
      a slightly less brutal version of twitter.
    -->
    <style>
      body {
        padding: 20px;
      }

      section {
        margin: 0 auto;
        display: block;
        max-width: 600px;
      }

      .feed {
        padding: 0;
        list-style: none;
      }

      .feed > li {
        border-bottom: 1px solid gray;
      }

      .tweet .clickable {
        text-decoration: none;
        color: inherit;
      }

      .tweet ul {
        padding: 0;
      }

      .tweet ul > li {
        display: inline-block;
        margin-left: 0;
        padding-right: 20px;
      }

      textarea {
        width: 100%;
      }
    </style>
    <!-- Include the head for any extra header stuff -->
    {% block head %} {% endblock %}
  </head>
  <body>
    <!-- Include the body for any extra body stuff -->
    {% block body %} {% endblock %}
  </body>
</html>
<!-- templates/navbar.html -->
<nav>
  {% if user.is_some() %} Hey there, {{ user.as_ref().unwrap().username }}! {%
  else %}
  <a href="/signin">Sign In</a>
  {% endif %}
</nav>
<!-- templates/signup.html -->

<!-- extends is exactly what you think it is, and we're passing an empty block head -->
{% extends "base.html" %} {% block head %} {% endblock %}

<!-- Body -->
{% block body %}
<section class="content">
  <!-- look at that, making a POST without javascript, just as nature intended! -->
  <form action="/users" method="post">
    <input placeholder="username" name="username" />
    <input placeholder="password" name="password" type="password" />
    <input type="submit" value="Sign Up" />
  </form>
  Or <a href="/signin">sign in</a>
</section>
{% endblock %}
<!-- templates/signin.html -->

{% extends "base.html" %} {% block head %} {% endblock %}

<!-- Body -->
{% block body %}
<section class="content">
  <!-- WOW! SUCH SIMILAR! -->
  <form action="/sessions" method="post">
    <input placeholder="username" name="username" />
    <input placeholder="password" name="password" type="password" />
    <input type="submit" value="Sign In" />
  </form>
  Or <a href="/signup">sign up</a>
</section>
{% endblock %}

Great, but where are the tweets?? Yes, the tweets. Let's build the home page. Starting with the pages controller

// Now we actually have dynamic data that we want in the page, so we have that
// as fields in the struct. The home page won't _always_ have a user, so it's an 
// Option type.
#[derive(Template)]
#[template(path = "home.html")]
pub struct Feed<'a> {
    user: Option<&'a User>,
    feed: Vec<TweetWithUserInfo>,
}

#[middleware_fn]
pub async fn home(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    // Like in the auth middleware, we take the user off the extra.
    let user = context.extra.user.clone();
    // If we're authenticated, then map it to an id
    let user_id = context.extra.user.clone().map(|v| v.id);

    context.set("Content-Type", "text/html");
    context.body(
        // Render the template
        &Feed {
            user: user.as_ref(),
            // Fetch them tweets! We include the user info to efficiently
            // fetch whether or not the signed in user has retweeted or
            // liked the particular tweet.
            feed: Tweet::get_recent_tweets_with_user_info(
                &context.extra.pool,
                user_id.as_ref(),
                None,
            )
            .await
            .map_err(|_e| {
                error!("_e: {:#?}", _e);

                ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
            })?,
        }
        .render()
        .unwrap(),
    );

    Ok(context)
}

Now, we need to build the home page itself. Ideally we'd have a form to create a new tweet embedded as well. Let's throw that in a template since we'll use it more than once.

<!-- templates/create_tweet.html -->

<form action="{{ create_tweet_route }}" method="post">
  <textarea placeholder="Your best 280 characters" name="content"></textarea>
  <input type="submit" value="Tweet" />
</form>

Notice that it calls a dynamic route? That's so we can set a special route if it's a reply. Later. Foresight. Okay, now the home template.

<!-- templates/home.html -->

{% extends "base.html" %} {% block head %} {% endblock %}

<!-- Body -->
{% block body %}
<section>{% include "navbar.html" %}</section>

<section>
  {% if user.is_some() %} {% let create_tweet_route = "/tweets" %} {% include
  "create_tweet.html" %} {% endif %}
</section>

<section class="content">{% include "feed.html" %}</section>
{% endblock %}

Also a reusable feed (again, foresight.)

<!-- templates/feed.html -->

<ul class="feed">
  {% for tweet in feed %}
  <li>{% include "tweet.html" %}</li>
  {% endfor %}
</ul>

Aaand the reusable tweet template

<!-- templates/tweet.html -->

<div class="tweet">
  <a href="/tweets/{{ tweet.id }}" class="clickable">
    <p class="author"><b>{{ tweet.username }}</b></p>
    <p class="content">{{ tweet.content }}</p>
    <p class="timestamp"><i>{{ tweet.created_at }}</i></p>
  </a>
  <ul>
    <li>
      <form action="/tweets/{{ tweet.id }}/likes" method="POST">
        {{ tweet.like_count }} {% if tweet.user_has_liked %} ❤️ {% else %}
        <input type="submit" value="❤️" />
        {% endif %}
      </form>
    </li>
    <li>
      <form action="/tweets/{{ tweet.id }}/retweets" method="POST">
        {{ tweet.retweet_count }} {% if tweet.user_has_retweeted %} ♺ {% else %}
        <input type="submit" value="♺" />
        {% endif %}
      </form>
    </li>
    <li>
      <form action="/tweets/{{ tweet.id }}/replies" method="GET">
        {{ tweet.reply_count }} <input type="submit" value="💬" />
      </form>
    </li>
  </ul>
</div>

Cool cool cool. So that tweet template has a few things we haven't figured out yet. It has replies, likes, and retweets. We need the actual controllers for those endpoints, so we make a new rust file for tweets.

// src/controllers/tweets.rs

use std::str::FromStr;

use log::error;
use serde::Deserialize;
use thruster::{
    context::context_ext::ContextExt,
    errors::{ErrorSet, ThrusterError},
    middleware::cookies::HasCookies,
    middleware_fn, MiddlewareNext, MiddlewareResult,
};
use uuid::Uuid;

use crate::{
    app::Ctx,
    models::{likes::Like, retweets::Retweet, tweets::Tweet},
};

// We've seen this pattern before, and will three more times
#[derive(Deserialize)]
pub struct CreateTweetReq {
    pub content: String,
}

#[middleware_fn]
pub async fn create_tweet(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    let CreateTweetReq { content } =
        serde_urlencoded::from_str(&context.body_string().await.map_err(|_e| {
            ThrusterError::parsing_error(
                Ctx::new_without_request(context.extra.clone()),
                "Bad request",
            )
        })?)
        .map_err(|_e| {
            ThrusterError::parsing_error(
                Ctx::new_without_request(context.extra.clone()),
                "Bad request",
            )
        })?;

    // Create the new tweet, the None part is the tweet this is replying to. Since
    // this is a new tweet, it's None.
    Tweet::create_tweet(
        &context.extra.pool,
        &context.extra.user.as_ref().unwrap().id,
        None,
        content,
    )
    .await
    .map_err(|_e| ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone())))?;

    // Send the user back to the home page after the tweet is created
    context.redirect("/");

    Ok(context)
}

#[derive(Deserialize)]
pub struct ReplyReq {
    pub content: String,
}

#[middleware_fn]
pub async fn reply(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    // For replying, we actually want the id it's replying to from the url.
    // So, we grab it from the route using context.params() and make sure it 
    // successfully parses.
    let responding_to = context
        .params()
        .get("id")
        .and_then(|id_string| Uuid::from_str(&id_string.param).ok())
        .ok_or(ThrusterError::generic_error(Ctx::new_without_request(
            context.extra.clone(),
        )))?;

    let ReplyReq { content } =
        serde_urlencoded::from_str(&context.body_string().await.map_err(|_e| {
            error!("_e: {:#?}", _e);

            ThrusterError::parsing_error(
                Ctx::new_without_request(context.extra.clone()),
                "Bad request",
            )
        })?)
        .map_err(|_e| {
            error!("_e: {:#?}", _e);

            ThrusterError::parsing_error(
                Ctx::new_without_request(context.extra.clone()),
                "Bad request",
            )
        })?;

    // See? Told you this would coome up again.
    Tweet::create_tweet(
        &context.extra.pool,
        &context.extra.user.as_ref().unwrap().id,
        Some(responding_to),
        content,
    )
    .await
    .map_err(|_e| {
        error!("_e: {:#?}", _e);
        ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
    })?;

    context.redirect("/");

    Ok(context)
}

#[middleware_fn]
pub async fn like_tweet(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    // For liking, we need to grab the id again.
    let tweet_id = context
        .params()
        .get("id")
        .and_then(|id_string| Uuid::from_str(&id_string.param).ok())
        .ok_or(ThrusterError::generic_error(Ctx::new_without_request(
            context.extra.clone(),
        )))?;

    // Create the like
    Like::create_like(
        &context.extra.pool,
        &tweet_id,
        &context.extra.user.as_ref().unwrap().id,
    )
    .await
    .map_err(|_e| {
        error!("_e: {:#?}", _e);
        ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
    })?;

    // This bit's different. If you're looking at a tweet, you don't expect to get 
    // redirected home after we "like" a tweet, you expect to stay what you're
    // looking at. That means that we should grab the referer header and just 
    // redirect back to that page. If there isn't a referer header, then we default
    // back to the root.
    let location = context
        .get_header("Referer")
        .pop()
        .unwrap_or_else(|| "/".to_string())
        .to_owned();

    context.redirect(&location);

    Ok(context)
}

// Oh look! This is basically the same as like.
#[middleware_fn]
pub async fn retweet(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    let tweet_id = context
        .params()
        .get("id")
        .and_then(|id_string| Uuid::from_str(&id_string.param).ok())
        .ok_or(ThrusterError::generic_error(Ctx::new_without_request(
            context.extra.clone(),
        )))?;

    Retweet::create_retweet(
        &context.extra.pool,
        &tweet_id,
        &context.extra.user.as_ref().unwrap().id,
    )
    .await
    .map_err(|_e| {
        error!("_e: {:#?}", _e);
        ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
    })?;

    let location = context
        .get_header("Referer")
        .pop()
        .unwrap_or_else(|| "/".to_string())
        .to_owned();

    context.redirect(&location);

    Ok(context)
}

Okay, so what happens when a user clicks on some individual tweet action? In original twitter, "fancy" twitter, "more refined" twitter, you go to the tweet and see all its replies. We can do that too, pretty simple too -- add another template.

<!-- templates/single_tweet.html -->

{% extends "base.html" %} {% block head %} {% endblock %}

<!-- Body -->
{% block body %}
<section>{% include "navbar.html" %}</section>

<section>{% include "tweet.html" %}</section>

<section>
  <ul class="replies">
    {% for tweet in replies %}
    <li>{% include "tweet.html" %}</li>
    {% endfor %}
  </ul>
</section>
{% endblock %}

And one more function in the pages controller

#[derive(Template)]
#[template(path = "single_tweet.html")]
pub struct SingleTweet<'a> {
    user: Option<&'a User>,
    tweet: TweetWithUserInfo,
    replies: Vec<TweetWithUserInfo>,
}

#[middleware_fn]
pub async fn single_tweet(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    let user_id = context.extra.user.as_ref().map(|v| v.id.clone());

    // We need to know what the tweet we're showing is after all!
    let tweet_id = context
        .params()
        .get("id")
        .and_then(|id_string| Uuid::from_str(&id_string.param).ok())
        .ok_or(ThrusterError::generic_error(Ctx::new_without_request(
            context.extra.clone(),
        )))?;
        
    // Grab the tweet
    let tweet = Tweet::get_tweet_with_user_info(&context.extra.pool, &tweet_id, user_id.as_ref())
        .await
        .map_err(|_e| {
            error!("_e: {:#?}", _e);
            ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
        })?;
        
    // Grab the replies
    let replies = Tweet::get_tweet_replies_with_user_info(
        &context.extra.pool,
        &tweet_id,
        user_id.as_ref(),
        None,
    )
    .await
    .map_err(|_e| {
        error!("_e: {:#?}", _e);
        ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone()))
    })?;

    // Same as before, render and set the body!
    context.set("Content-Type", "text/html");
    context.body(
        &SingleTweet {
            user: context.extra.user.as_ref(),
            tweet,
            replies,
        }
        .render()
        .unwrap(),
    );

    Ok(context)
}

Alright. At this point, we have models, we have controllers, we have templates, but we have no routes. We have to tell thruster what routes lead to what. That means we need to make a new file. src/app.rs. This holds all of our routes and special middleware (like logging!)

use log::info;
use sqlx::{Pool, Postgres};
use thruster::{
    context::typed_hyper_context::TypedHyperContext, m, middleware::cookies::cookies,
    middleware_fn, App, HyperRequest, MiddlewareNext, MiddlewareResult,
};
use tokio::time::Instant;

use crate::{
    controllers::{
        pages::{home, reply as reply_page, signin, signup, single_tweet},
        tweets::{create_tweet, like_tweet, reply, retweet},
        users::{authenticate, create_user, fetch_user_from_cookie, sign_in_user},
    },
    models::users::User,
};

// Wayyyyyyy back when, we mentioned that Ctx is a an alias of TypedHyperContext, well
// here we go. Each context will have RequestConfig as an exra. This will contain not
// only the user (if logged in) but a reference to our database so that the middleware
// can use the postgres pool.
pub type Ctx = TypedHyperContext<RequestConfig>;

// The server config is a singleton that is owned by the main instance of the server.
// Usually server configs contain database references, and any atomic objects that
// need to be shared with middleware.
pub struct ServerConfig {
    pub pool: Pool<Postgres>,
}

#[derive(Clone)]
pub struct RequestConfig {
    pub pool: Pool<Postgres>,
    pub user: Option<User>,
}

// Generate context is called every time a new request is made. It's important that
// this be quick.
fn generate_context(request: HyperRequest, state: &ServerConfig, _path: &str) -> Ctx {
    Ctx::new(
        request,
        RequestConfig {
            pool: state.pool.clone(),
            user: None,
        },
    )
}

// I like having ping on my servers, so sue me.
#[middleware_fn]
async fn ping(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    context.body("pong");

    Ok(context)
}

// This is a neat part about middeware in thruster.
#[middleware_fn]
async fn profiling(mut context: Ctx, next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    // Note the time when the request comes in along with the method and path
    let start_time = Instant::now();

    let method = context
        .hyper_request
        .as_ref()
        .unwrap()
        .request
        .method()
        .clone();
    let path_and_query = context
        .hyper_request
        .as_ref()
        .unwrap()
        .request
        .uri()
        .path_and_query()
        .unwrap()
        .clone();

    // Now run the rest of the middleware (like rendering the page and fetching data)
    context = match next(context).await {
        Ok(context) => context,
        Err(e) => e.context,
    };

    // At this point, context is the results of all the other middleware being run
    // so we check the time again and print out the elapsed time.
    let elapsed_time = start_time.elapsed();
    info!(
        "{}μs\t\t{}\t{}\t{}",
        elapsed_time.as_micros(),
        method,
        context.status,
        path_and_query,
    );

    Ok(context)
}

// Now we make the app so it can be used elsewhere
pub async fn app(
    pool: Pool<Postgres>,
) -> Result<App<HyperRequest, Ctx, ServerConfig>, Box<dyn std::error::Error>> {
    Ok(
        // We chain all the routes together
        App::<HyperRequest, Ctx, ServerConfig>::create(generate_context, ServerConfig { pool })
            // `middleware` is applied to every route that matches (even partially)
            // the route string
            .middleware("/", m![profiling])
            // Normal http verb methods on the other hand need exact matches
            .get("/ping", m![ping])
            // Middleware looks like an array of functions. In this case the call
            // order is
            // 1. cookies
            // 2. fetch_user_from_cookie
            // 3. home
            .get("/", m![cookies, fetch_user_from_cookie, home])
            .get("/signup", m![signup])
            .get("/signin", m![signin])
            .post("/users", m![create_user])
            .post("/sessions", m![sign_in_user])
            // Hey look! It's our authenticate middleware. Neat!
            .post(
                "/tweets",
                m![cookies, fetch_user_from_cookie, authenticate, create_tweet],
            )
            .get(
                "/tweets/:id",
                m![cookies, fetch_user_from_cookie, single_tweet],
            )
            .post(
                "/tweets/:id/likes",
                m![cookies, fetch_user_from_cookie, authenticate, like_tweet],
            )
            .post(
                "/tweets/:id/retweets",
                m![cookies, fetch_user_from_cookie, authenticate, retweet],
            )
            .get(
                "/tweets/:id/replies",
                m![cookies, fetch_user_from_cookie, authenticate, reply_page],
            )
            .post(
                "/tweets/:id/replies",
                m![cookies, fetch_user_from_cookie, authenticate, reply],
            ),
    )
}

Okay, we're almost done. Now update your lib.rs file to actually make the app.

use app::{Ctx, ServerConfig};
use log::info;
use shuttle_service::error::CustomError;
use sqlx::{Executor, PgPool};
use thruster::{HyperServer, ThrusterServer};

pub mod app;
pub mod controllers;
pub mod models;

#[shuttle_service::main]
async fn shuttle(
    #[shuttle_aws_rds::Postgres] pool: PgPool,
) -> shuttle_service::ShuttleThruster<HyperServer<Ctx, ServerConfig>> {
    info!("Starting server...");

    pool.execute(include_str!("../schema.sql"))
        .await
        .map_err(|e| CustomError::new(e))?;

    let app = app::app(pool)
        .await
        .map_err(|_e| CustomError::msg("Starting thruster server failed"))?;

    info!("Server started...");

    Ok(HyperServer::new(app))
}

If only there was an easy, brutally efficient way to deploy this masterpiece. RIGHT -- we made this based on shuttle! All you have to do is run

cargo shuttle deploy --allow-dirty

This will provision a new postgres instance as well as create an edge server to run the code. When it's done, it'll print out a url that you can visit your brand spankin' new site.

Wrap Up

See, Twitter isn't so complicated. Enjoy this ad-free, javascript-free, tracking-free version of twitter. Also we didn't have to fire a bunch of people, then attempt to rehire them, then say they all have to work in the office, so I guess net win!

Last updated