I've Been Building This Web App For A Month; There Still Isn't Anything Past The Login Page
I'm not going to explain what Numby is because apart from it just being a login screen right now, I don't know if it will ever amount to anything and I don't want you to get disappointed when you find out I dropped it.
But the news is true - I have been working on it for a month, and all I have to show for that time is a login screen.
I've been spending a lot of time thinking about all the things I've hated or been annoyed by in every codebase (but mostly $DAY_JOB) I've had the displeasure of contributing to. And it looks like that has caused me to completely overcompensate and completely overengineer Numby (which again, I'm not going to explain anything about).
So I thought it would be fun if we went through every commit I've made so far and see what led me down this dark path.
- [a6a9d39] first commit
For the past four years I've been doing an annual state of Rust web development survey during new years, because believe it or not, even though Are we web yet? has been saying we've been web for the past five years - we really haven't.
I'm sorry gamers, but web dev in Rust just hasn't been that good until recently. Some of that has been because of the clusterfuck called async and webassembly kinda not being all that right now, but most of it is because people keep thinking to make Rust popular with web devs you need to make a React for Rust.
And let me tell you, the problem with React isn't that it's written in JavaScript - the problem is that it's React and React fucking sucks, and having React (but made in Rust) isn't going to fix any of its numerous problems.
Thankfully our obsession with "web frameworks" is being questioned thoroughly these days, and we have proper alternatives now.
The one I chose for Numby is Datastar, which "brings the functionality provided by libraries like Alpine.js (frontend reactivity) and htmx (backend reactivity) together, into one cohesive solution."
I'll go over it more when we get to the commit where it's actually used, but I recommend you read up on it before because I probably won't explain much about it.
A big annoyance I have with pretty much every project is managing dependencies and environments, not just the language libraries you install, I mean all dependencies - language versions, tooling, everything that you need to run the thing.
Common ways of solving this amount to using Nix or Dev Containers. Let's start with Nix.
Nix is a great idea, but it has the worst fucking UX I have ever seen in my life and that is no exaggeration. I started using Arch Linux at the age of 13 as my first Linux distro and I had an easier time with that then trying to properly learn Nix two years ago (1) (1) Yes, I've looked at it again recently. No, it hasn't gotten any better. as a 23 year old with 5 years of professional software engineering experience. I don't like Nix, and that's before considering all the bullshit going on with its leadership.
And as for Dev Containers - I don't use VS Code, so I can't use it. Seems ok though.
Instead, I'm using mise, which is a very cool piece of software (written in Rust).
miseinstalls and manages dev tools/runtimes like node, python, or terraform both simplifying installing these tools and allowing you to specify which version of these tools to use in different projects.misesupports hundreds of dev tools.
misemanages environment variables letting you specify configuration likeAWS_ACCESS_KEY_IDthat may differ between projects. It can also be used to automatically activate a Python virtualenv when entering projects too.
miseis a task runner that can be used to share common tasks within a project among developers and make things like running tasks on file changes easy.
While in this "first commit," mise wasn't used much, but eventually I started learning a lot more about its capabilities and holy shit, this thing fucking rocks.
If you were to pull the latest commit, all you need installed is mise and podman + podman compose.
(2)
(2)
I feel you shouldn't also need `podman` because mise can install it for you, but I couldn't find a way to also get `podman compose`. Oh yeah, also I use `podman` instead of `docker`. It's better.
Then you run three commands.
mise install
This installs every tool you need to run Numby. It sets the correct Rust version, installs bun (for Datastar and TailwindCSS), and installs cli tools - cargo watch, sqlx-cli, and clorinde (I'll explain this one later). All set to the correct version contained to the project.
mise app:setup
This runs the podman compose command to start up the containers for Postgres and Valkey, then runs migrations on them.
mise app:start
And this is hopefully self-explanatory.
So, three commands. Isn't confusing as shit. Doesn't depend on a dog shit editor trying to be an IDE. (3) (3) But Justin! Dev Containers doesn't depend on VSC, it's an open spec! Any editor can implement it! Oh, yeah? So why can't I use it in Zed, let alone Neovim or Helix. Why is VSC practically the only editor (IntelliJ is not a real editor, I refuse to believe humans can write software that shit) that supports it? Maybe next time think before you speak, fucker. It's fucking sick. It's a tour de force of what Rust is capable of and enables you to do. A MASTERCLASS IN HOW TO WRITE NOT SHIT SOFTWARE.
All right, I don't know why I'm so worked-up and angry, but I really want to shit on something now. Let's go for an old favourite - ORMs.
We all hate ORMs in this house. Remember that time when I wrote an essay in my company Slack explaining why I hate ORMs? Good times, 2020 was one of the best years of my life. (4) (4) This is not a joke. I fucking loved lockdown. Video conferencing wasn't really a thing in my company at the time, so I went two weeks without speaking a word to anyone. Unfortunately my French teacher still wanted to teach me French so that ruined everything, as the Fr*nch often do.
Anyway, some people ask me what they should use for querying the database if not an ORM. And I mean, it's called "writing the query yourself." I'm a big believer in using the Structured Query Language to talk to the service with Structured Query Language in its name.
There's this really cool Rust library called Cornucopia which takes your SQL queries and turns them into Rust types to use in your code. Unfortunately it looks unmaintained.
So I forked it.
This is Clorinde (5) (5) Yes, I named it after the Genshin Impact character, and if you liked that, just wait until find out what a Numby is. No, I still haven't played Genshin Impact, and I never will. which I made to add a bunch of improvements. The most important of which is crate-based code generation which makes the setup much simpler, and improves a bunch of other things you don't need to care about. It's pretty neat, uhh… maybe give it a star on GitHub? I dunno, up to you. But like, it would be pretty pogchamp of you if you did.
The last big thing I wanted was telemetry. Having observability into the platform is very useful for pinpointing issues. I've been using Sentry for pretty much everything for many years, but I've made the switch to OpenTelemetry for this.
While I don't think OpenTelemetry is that great, it's been getting better and is practically the standard, so I think it's a pretty safe bet to use now.
Alright, that was a lot, but it was the first commit. The rest won't be like this.
- [26e8ef0] feat: add auth session
Ok so I hear that you shouldn't roll your own auth a lot. But I've also never seen an auth service that doesn't either create very annoying vendor lock-in, or suck ass. And most of the time they are both.
However, I also don't want to write my own auth because that shit is SO BORING. So I went for a compromise - using an auth library, specifically axum-login.
So far it's been fine, it is (almost) everything I want, and is simple to use.
However, I can't shake the feeling that I'd probably have something better if I wrote it myself. I can see a future where I rewrite all of this.
But we'll burn that bridge when we get to it.
- [7c903f4] feat: add login page
Wait, three commits in and we're already at the login page?
How many commits are there?
14.
Ah, fuck.
Yeah, so this is the one where I add the long awaited login page. And I guess it's time to talk about Datastar.
Datastar is neat because it fulfils my life-long dream of never having to write JavaScript. Here's a very abridged version of the login page.
pub struct Credentials {
pub email: String,
pub password: String,
}
pub async fn get_login() -> maud::Markup {
html! {
form {
div {
div {
label for="email" { "Email Address" }
input data-bind-email #email type="email";
}
div {
label for="password" { "Password" }
input data-bind-password #password type="password" autocomplete="on";
}
}
div #error {}
a data-on-click="@post('/login')" { "Sign In" }
}
}
}
pub async fn post_login(
mut session: AuthSession,
ReadSignals(creds): ReadSignals<Credentials>,
) -> impl IntoResponse {
match session.login(creds).await {
Ok(_) => {
Sse(stream! {
yield ExecuteScript::new("window.location.assign('/')").into()
})
.into_response()
}
Err(e) => {
tracing::error!("{}", e.to_string());
Sse(stream! {
yield MergeFragments::new(html! { p { "Incorrect email or password." } })
.selector("#error")
.merge_mode(datastar::prelude::FragmentMergeMode::Inner).into()
}).into_response()
}
}
}
As I said before, I encourage you to read up on Datastar yourself, specifically sections about Server-Side Events. This is a pretty poor example of what it can really do, but I don't have anything better because of… well you know.
Also, you should have noticed I'm using Maud for HTML templating. Maud is great because it isn't XML syntax. FUCK XML. It's also a lot more composable than other Jinja-like templating libraries, which you can use to make isolated components. Combined with Datastar, it's pretty powerful.
- [25501e3] feat: add tests
TESTS! YIPEE!!!
This is actually the earliest I've ever written tests when starting a project. It normally hasn't actually been that big of a priority for me. So what's changed and caused me to see the light?
If your guess is the experience of constantly having to fix very preventable bugs over the course of my seven year career, your guess is… WRONG. Better luck next time idiot.
The answer is Clorinde. The uhh, library I maintain, not the Genshin Impact character.
The original authors of Cornucopia made a very comprehensive and reliable test suite. And it has been SO HELPFUL. Not only is it a good way of learning about the library, and what it can and can't do. It also gives me an immense amount of confidence when changing things and making sure there are no regressions. (6) (6) To be fair, being a codegen library also makes it inherently easy to spot regressions through a `git diff` of generated code.
It has made me realise the true power of tests (and that everyone else, including me, does not write good test suites), especially when combined with the high amount of confidence you get that the code will work just from it being written in Rust.
Anyway, this commit only includes integration tests, but others will be added later. I also wanted to make sure they are "deterministic." That being making sure a new database is spun up using Testcontainers with test data fixtures applied so that I can be a lot more certain on what data is being used for testing.
At $DAY_JOB, integration tests are run on the database we also use for development. That shit SUCKS. And we often forget to clean up any test data we make, so my dev env is filled with a bunch of bullshit. YUCK.
Right now the worst part about Rust web dev is that this isn't well trodden ground and there aren't many examples on how to do this stuff (most stuff in general actually, not just tests). I had to figure out a lot of this on my own. Thankfully, this isn't a real issue for good programmers like me :)
- [93db8aa] fix: login method for context
This is just a fix to the tests. Nothing much to see. NEXT.
- [970e054] feat: add cache busting
I decided I needed to add something for cache busting assets. I mean, pretty self-explanatory.
Here I'm using a crate called cache-busters, which I found from the Rust on Nails guide.
This isn't an endorsement of Rust on Nails, I actually don't like it that much. It's very close to being something I can agree with and get behind (we've come to a lot of the same conclusions), but it's just not how I would want to build something.
Mostly I don't understand the desire to put everything in separate crates when everything is required to build into the one binary. I don't think isolation makes any sense for that, because it's literally not isolated.
- [0f90e81] chore: add mathesar for admin
A thing I've had to do my entire career is build and maintain an "admin portal" for non-engineers to use and be able to edit a few database tables. Normally, this would be in the app accessible only through certain permissions.
And can I just say. I am fucking SICK AND TIRED of making admin portals. I don't want to do it anymore!! It's literally the most basic CRUD shit you could think of.
I also don't really like having it in the app. I know there's technically nothing wrong with it as long as your access control layer works, and that isn't hard as long as you stay vigilant, but it still doesn't feel great to me.
So I'm trying out Mathesar for this, so far it seems pretty good. I also looked at Budibase, but I don't think I'll need all those features, and Mathesar looks easier to self-host.
- [e7a55a1] chore: move to cacheb
So about that cache busting library, "cache-busters" I just talked about. Yeah, it kinda sucks. The code it generates is in my opinion, not that well thought out and is unformatted (7) (7) I don't like ugly code, codegen'd or not. - in particular it puts all the files in a flat structure to import. So if you had something like this where you have files with the same name but in different directories.
file.js
my-directory/
file.js
It no work good.
So I forked it.
This is cacheb, it's basically the same expect it creates imports according to the directory structure and properly formats the code. There are also tests.
- [51b67a4] chore: license as FSL
Functional Source License (FSL) is a Fair Source license created by Sentry.
Originally Numby's code wasn't supposed to be public, but due to reasons I can't divulge because that would require me to explain Numby, I changed my mind and licensed it under FSL.
Do what you will with that information.
- [bf60f3a] fix: build.rs
This is me fixing the build.rs and doing a small restructure of the static assets.
- [9373253] chore: fix tracing
And this is me fixing the tracing. I don't know why the commit message is chore and not fix.
- [2c17dd5] chore: change test structure
I mentioned previously that the current tests are just integration tests for the Axum API routes. This commit is me making a bunch of changes to support a proper "testing pyramid" - that is, running unit tests, integration tests, and end-to-end (e2e) tests.
This basically just amounts to setting feature flags for it. You can see how they should be run in the mise.toml.
[tasks."test:unit"]
description = "Run unit tests"
run = "cargo test --bin numby"
[tasks."test:integration"]
description = "Run integration tests"
run = "cargo test --features test.integration"
[tasks."test:e2e"]
description = "Run end-to-end tests"
run = "cargo test --features test.e2e"
- [b76351a] feat: add public graphql api
Numby is most likely going to need a public API because of reasons that… well, you should be getting used to this by now.
Many people have said that GraphQL isn't good for public APIs and should only be used for internal ones. But while I use they/them, I am not many people. (8) (8) I'll be here all week folks.
Here's a list of scenarios it would not be a bad idea to use GraphQL for.
- When you have a clear and distinct separation between the service API and the consumer. Both in technology and persons.
That's it.
This applies to organisations with separate front-end and back-end teams, but also equally applies to a public API and everyone else.
GraphQL moves complexity from the front-end to the back-end. For an organisation with separate teams, that's useful because front-end people already have enough shit to deal with (especially if they use React), and having a standard like GraphQL minimises communication errors.
It's harder to design a good REST API than a good GraphQL schema. For a public API this is important because you want the API to be clean and ergonomic so that people will actually use it while not hating you and everything you stand for. (9) (9) I know this happens from experience as a user of several public APIs. It's also much easier to document.
But, for your average "full stack developer" there are not enough pros to GraphQL to justify the added complexity. Having to do a bunch of shit to deal with things that just weren't a problem before (eg. N+1 problem, query caching, access control, etc) is not worth it.
The one thing against a public GraphQL API is that not everyone knows how to use GraphQL. To that I say -
Skill issue.
Don't use my APIs if you're a bad programmer. And that is my genuine, 100% serious opinion.
- [c8f8768] feat: add auth e2e test
This is the last commit we're going to look at. And wow, what a doozy it is.
These are e2e tests using Selenium and Cucumber. I've used Cucumber on a few projects for clients and it's legitimately pretty useful for getting them to properly explain what they want and be able to confirm it works.
It allows for the creation of human-readable test scenarios that describe how a system should behave from a user's perspective. These scenarios are organized into feature files with steps written in a Given-When-Then format, where "Given" sets up preconditions, "When" describes actions, and "Then" verifies outcomes.
Feature: Authenication feature
Scenario: If we log in with correct credentials we will login
Given a webdriver
When we go to /login
When we input correct credentials
Then we are logged in
Then the scenarios are then mapped to executable code through step definitions.
#[given("a webdriver")]
async fn webdriver(world: &mut CommonWorld) {
world.driver = Some(
world
.config
.get_driver()
.await
.expect("Couldn't get webdriver"),
);
}
#[when(regex = r"^we go to /(.+)$")]
async fn navigate_to_path(world: &mut CommonWorld, path: String) {
let driver = &world.driver.as_ref().expect("no webdriver");
let url = format!("{}/{}", world.config.host, path);
driver
.goto(&url)
.await
.expect(&format!("Failed to navigate to /{}", path));
}
#[when("we input correct credentials")]
async fn input_correct_credentials(world: &mut CommonWorld) {
let driver = &world.driver.as_ref().expect("no webdriver");
// ...
}
#[then("we are logged in")]
async fn verify_logged_in(world: &mut CommonWorld) {
let driver = &world.driver.as_ref().expect("no webdriver");
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
let current_url = driver
.current_url()
.await
.expect("Failed to get current URL");
assert!(current_url.as_str().contains("/"));
}
It's a lot more effort to setup than just basic Selenium tests, but once you have a bunch of reusuable steps, it becomes much easier to write than them.
Now that all this setup shit has been done, what's next for Numby? What great features await us?
Well, believe it or not. I'm not done with the setup shit.
I probably need a few helper functions to manage Row Level Security in Postgres. If you don't know what that is, you should probably find out - it's one of Postgres' best features.
I also need to add the OpenTelemetry web-sdk, for telemetry on the browser, as the current telemetry is server side only.
Next I want to figure out what I'm doing with deployment infrastructure. The obvious answer is Kubernetes and Pulumi, but I think it might not be too hard to edit b8s into something that works for Numby. And if that's the case, I may as well properly productionise it and make it more general purpose for other- and oh my Bidoof I'm just thinking about turning it into Kubernetes. Let's not do that.
Or maybe I should?
Epilogue
I've been sick, and I've only had like ten hours of sleep the past the past three days, which might explain why I've been so… aggressive in this post. The Overwatch 2 x LE SSERAFIM collab came out right at the start of this too, so I've been listening to LE SSERAFIM all day and night.
My favourite song is "Eve, Psyche & The Bluebeard’s wife."
Anyway, why don't we talk about you for a change? What have YOU been up to?
…
Uh-huh, silent as always. And I thought I was the one with social anxiety disorder.
You know over the past 7 years this blog has existed, I have given you so many gold nuggets in the form of my wisdom and eternal knowledge.
But what have I ever recieved in return?
Nothing.
You owe me a great debt. I hope you know that. Collection day will come.
Be prepared for that.