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:
We're going to need a ton of dependencies too, so just add these to Cargo.toml now.
argon2 ="0.4.1"# For password hashingaskama ="0.11.1"# For templatingchrono ="0.4.22"# For time stuffform_urlencoded ="1.1.0"# For decoding x-www-form-encodedlog ="0.4.17"# For teh logzserde = { version ="1.0", features = ["derive"] } # For serializing/deserializingserde_urlencoded ="0.7.1"# For url encodingsha2 ="0.10.6"# Because sometimes you gotta hash thingsshuttle-aws-rds = { version ="0.7.2", features = ["postgres"] } # For storing datashuttle-service = { version ="0.7.2", features = ["web-thruster"] } # For deploying easilysqlx = { 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 stufftokio = { version ="1.20.1", features = ["macros"] } # For an async runtimeuuid = { 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 IFNOTEXISTS"uuid-ossp";-- Creating the users tableCREATETABLEIFNOTEXISTS users ( id UUID PRIMARY KEYDEFAULT uuid_generate_v4(),passwordVARCHAR(1024) NOT NULL, username VARCHAR(24) NOT NULLUNIQUE, created_at TIMESTAMPTZDEFAULTnow());-- Creating the sessions table (for when someone logs in)CREATETABLEIFNOTEXISTSsessions ( id UUID PRIMARY KEYDEFAULT uuid_generate_v4(), token VARCHAR(64) NOT NULL, user_id UUID NOT NULL, created_at TIMESTAMPTZDEFAULTnow());-- For storing tweetsCREATETABLEIFNOTEXISTS tweets ( id UUID PRIMARY KEYDEFAULT uuid_generate_v4(), user_id UUID NOT NULL, responding_to UUID, content VARCHAR(280), like_count BIGINTDEFAULT0, retweet_count BIGINTDEFAULT0, reply_count BIGINTDEFAULT0, created_at TIMESTAMPTZDEFAULTnow(), updated_at TIMESTAMPTZDEFAULTnow());-- For relating users to what content they likeCREATETABLEIFNOTEXISTS likes ( tweet_id UUID NOT NULL, user_id UUID NOT NULL, created_at TIMESTAMPTZDEFAULTnow(),PRIMARY KEY(tweet_id, user_id));-- For relating users to other users, but in like a friend wayCREATETABLEIFNOTEXISTS follows ( follower_id UUID NOT NULL, following_id UUID NOT NULL, created_at TIMESTAMPTZDEFAULTnow(),PRIMARY KEY(follower_id, following_id));-- For "retweeting" content rather than duplicating tweetsCREATETABLEIFNOTEXISTS retweets ( tweet_id UUID NOT NULL, user_id UUID NOT NULL, created_at TIMESTAMPTZDEFAULTnow(),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/tweets.rsuse chrono::Duration;use sqlx::{ types::chrono::{DateTime, Utc},FromRow, Pool, Postgres,};use uuid::Uuid;#[derive(Debug, FromRow)]pubstructTweet {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)]pubstructTweetWithUserInfo {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>,}implTweet {pubasyncfncreate_tweet( pool:&Pool<Postgres>, user_id:&Uuid, responding_to:Option<Uuid>, content:String, ) ->Result<Tweet, sqlx::Error> {letmut 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 }pubasyncfnget_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 }pubasyncfnget_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 }pubasyncfnget_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.rsuse sqlx::{ types::chrono::{DateTime, Utc},FromRow, Pool, Postgres,};use uuid::Uuid;#[derive(Debug, FromRow)]pubstructLike {pub tweet_id:Uuid,pub user_id:Uuid,pub created_at:DateTime<Utc>,}implLike {pubasyncfncreate_like( pool:&Pool<Postgres>, tweet_id:&Uuid, user_id:&Uuid, ) ->Result<Like, sqlx::Error> {letmut 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) }pubasyncfndelete_like( pool:&Pool<Postgres>, tweet_id:&Uuid, user_id:&Uuid, ) ->Result<(), sqlx::Error> {letmut 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.rsuse sqlx::{ types::chrono::{DateTime, Utc},FromRow, Pool, Postgres,};use uuid::Uuid;#[derive(Debug, FromRow)]pubstructRetweet {pub tweet_id:Uuid,pub user_id:Uuid,pub created_at:DateTime<Utc>,}implRetweet {pubasyncfncreate_retweet( pool:&Pool<Postgres>, tweet_id:&Uuid, user_id:&Uuid, ) ->Result<Retweet, sqlx::Error> {letmut 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:
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)]pubstructCreatUserReq {pub username:String,pub password:String,}#[middleware_fn]pubasyncfncreate_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.letCreatUserReq { 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 #securelet salt =SaltString::generate(&mutOsRng);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 passwordlet 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 toolet 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)]pubstructSignInReq {pub username:String,pub password:String,}#[middleware_fn]pubasyncfnsign_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?letSignInReq { 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 inlet 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 passwordifArgon2::default().verify_password( password.as_bytes(),&PasswordHash::new(&user.password).unwrap(), ).is_ok() {// Assuming it's okay, we make a sessionlet 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.returnErr(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
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]pubasyncfnfetch_user_from_cookie(mut context:Ctx, next:MiddlewareNext<Ctx>,) ->MiddlewareResult<Ctx> {// We get the Sessino token from the cookies on the requestlet session_token = context.cookies.get("Session");ifletSome(session_token) = session_token {// Then we get the session from the databaselet session =Session::get_session_from_token(&context.extra.pool, &session_token.value).await;ifletOk(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 thatnext(context).await}#[middleware_fn]pubasyncfnauthenticate(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.ifletNone= 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:
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")]pubstructSignUp;// 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]pubasyncfnsignup(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")]pubstructSignIn;#[middleware_fn]pubasyncfnsignin(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> <metaname="viewport"content="width=device-width, initial-scale=1.0" /> <metaname="title"content="Bitter" /> <metaname="description"content="Twitter, but better!" /> <metaname="keywords"content="twitter,social,bitter" /> <metaname="robots"content="index, follow" /> <metahttp-equiv="Content-Type"content="text/html; charset=utf-8" /> <metaname="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; }.tweetul {padding:0; }.tweetul>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/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 %}<sectionclass="content"><!-- look at that, making a POST without javascript, just as nature intended! --> <formaction="/users"method="post"> <inputplaceholder="username"name="username" /> <inputplaceholder="password"name="password"type="password" /> <inputtype="submit"value="Sign Up" /> </form> Or <ahref="/signin">sign in</a></section>{% endblock %}
<!-- templates/signin.html -->{% extends "base.html" %} {% block head %} {% endblock %}<!-- Body -->{% block body %}<sectionclass="content"><!-- WOW! SUCH SIMILAR! --> <formaction="/sessions"method="post"> <inputplaceholder="username"name="username" /> <inputplaceholder="password"name="password"type="password" /> <inputtype="submit"value="Sign In" /> </form> Or <ahref="/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")]pubstructFeed<'a> { user:Option<&'aUser>, feed:Vec<TweetWithUserInfo>,}#[middleware_fn]pubasyncfnhome(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 idlet 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.
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.