Zero to Prototype: Trying Out the MASH Stack
Zero to Prototype: Trying Out the MASH Stack
Want to jump right into the code? Check out this tag.
Introduction
As a backend engineer, I spend most of my days thinking about servers, databases, and APIs. Most of the web apps I'm looking at are dashboards to see how my data pipelines are performing. You could say I don't make it to the frontend side of things much, and that's part of why the MASH stack caught my eye. I first learned of this stack when I read this blog post by Evan Schwartz. He talks about organically arriving at this stack and also discovering that Shantanu Mishra had already given it a name and a homepage.
MASH, in short is:
- Maud: a Rust-based templating engine
- Axum: an HTTP server framework
- SQLx: a Rust-based async SQL client without the weight of an ORM
- HTMX: a near-zero JS solution for dynamic frontends
Suffice to say I was intrigued. If multiple other software engineers were successfully making things with this combination of components, there must be something to it. Also, again, I don't do a lot on the frontend - the prospect of simple, fast, and almost fully Rust-based web apps is something I couldn't resist giving a second look. I decided on building the classic todo list app; the requirements are simple enough that I figured it shouldn't take long to stand up a prototype. This is a short writeup of my experiences, thoughts, and some things I want to explore more in the future.
Project Setup
I started off like any Rust project and fired up cargo new
.
I decided right from the start that if I was really going to test-drive the MASH stack, I wanted to treat this like a real application.
That means I want a little more polish than standard "demo" code, so I have a couple of extra dependencies; here's what I ended up with:
[]
= "1.0.98"
= { = "0.8.3", = ["tracing"] }
= { = "4.5.37", = ["derive", "env"] }
= "0.15.7"
= { = "0.27.0", = ["axum"] }
= { = "1.0.219", = ["derive"] }
= { = "0.8.5", = ["runtime-tokio", "sqlite"] }
= { = "1.44.2", = ["full"] }
= { = "0.6.2", = ["fs", "trace"] }
= "0.1.41"
= { = "0.3.19", = ["env-filter"] }
As for the stack components, we have:
axum
and thetracing
feature for request loggingmaud
and theaxum
feature, which I'll talk more about in the next sectionsqlx
with the Tokio runtime andsqlite
feature for a minimalistic database
For everything else:
anyhow
to be a bit hand-wavey about error handling; this is something I want to revisitclap
anddotenvy
for moving config out to the environment like a real app wouldserde
to support form-encoded data the frontend will be sendingtokio
,tower-http
, and thetracing*
crates for general web server "stuff" and communicating to stdout via traces
When all that was out of the way, I stood up the most basic hello world server as a starting point.
The main
method did some basic setup before calling routes::create_router()
, and my routes.rs
file looked like:
use ;
async
Just enough of a starting point that I could call cargo run
and fire off a request via curl
.
Now it was time to actually use MASH.
Serving Maud Markup from Axum Handlers
One word that stuck with me from Evan Schwartz's blog post was "synergy". He does a deep dive on some of the ergonomic benefits of the MASH stack and I recommend giving it a read. I will however echo his sentiment: the components of this stack fit together almost effortlessly.
Let's take a look at a simple example with Maud's Markup
type and the Axum integration feature.
At the risk of oversimplifying, Axum connects routes to handlers which are async functions that return some Response
.
Anything implementing Axum's IntoResponse
trait can also be returned by a handler.
Maud's axum
feature provides an IntoResponse
for Markup
, which is the output of Maud's html!
macro.
In short, it means that this is a valid handler:
use ;
pub async
This makes for a pretty seamless fit between Maud and Axum. Handlers are able to follow the general pattern of:
- optionally extract some data from the request/context
- optionally transform the data in some interesting way
- inject data into the template
- return rendered markup as a response
It's also worth noting that for extremely simple tools or UIs, this is already a ton of functionality with very little fanfare. That being said, we still have half the stack's letters to go over, so let's keep going.
Installing HTMX and Vendoring Dependencies
HTMX is the one part of this project that doesn't have an entry in Cargo.toml
.
That's because it's a lightweight Javascript library, and incidentally, the only <script>
tag actually needed.
HTMX is available via unpkg CDN.
That being said, the HTMX docs link to this blog post by Wesley Aptekar-Cassels, about not using a CDN in production.
Given my general lack of expertise with the Javascript ecosystem, I would need to do more research to get into the details of Wesley's argument.
That being said, there are some compelling points there, and I thought that this would be a good chance to try something I hadn't seen other MASH projects do.
So, I decided to forego the CDN and vendor the HTMX source into my application.
This is where the tower_http
direct dependency in Cargo.toml
comes in.
It provides ServeDir
(docs) to serve requests for files in a directory, which Axum can use directly as middleware.
ServeDir
also comes with some nice features such as handling invalid requests gracefully and returning 404 Not Found
for missing files.
Setting it up was easy:
use ServeDir;
Then, I went ahead and made a public/js
directory in my project root, and downloaded htmx.min.js
as well as its LICENSE file.
One thing I found cool about this pattern is it greatly simplifies compliance with licenses which require distributing them with the code.
Finally, all that was left to do was link htmx.min.js
into my Maud markup:
pub async
NOTE: I use
script ... {}
here (closing brackets at the end) self-closing script tags are generally unsupported by browsers; there's a rabbit hole to go down, if you so desire.
With that done, the application frontend became ✨ dynamic ✨
The only problem is that there was no data to display at this point. It was time to add persistent state and APIs for modifying the data.
SQLx: A not-ORM for SQL in Rust
SQLx says right in the README that it is not an ORM. Having only tried Diesel in the past, this was a new and exciting experience for me. SQLx's approach to queries is a no-DSL, "just write SQL" approach - the minimalism is a big win for me. It also offers really great out-of-the-box features such as migrations and compile-time query verification without mandating their use. I really appreciated this because it let me prototype without the burden of having to form my code around a specific toolchain or DSL.
For example, here's the Todo
struct and the function that fetches all todos from the database:
pub async
That's it. One derive, and a plain-old SQL query. Minimalism at its finest.
Embedding migrations and creating a pool
I decided to forego the compile-time query verification for right now, but I did take advantage of SQLx's migration support.
This does mean using the SQLx CLI tool, so there is one extra cargo install sqlx-cli
step, but I think it was worth it.
I created a reversible migration with the CLI; the "up" portion which creates the todos
table looks like this:
(
-- this is an alias for the row's unique ID (see: https://www.sqlite.org/autoinc.html)
id INTEGER PRIMARY KEY NOT NULL,
description TEXT NOT NULL,
completed_at BIGINT
);
SQLx gives you options when it comes to actually applying migrations. You are able to use the CLI to prepare the database outside of your application code, but you can just as easily embed them in the application code. This is really convenient; with a larger application you might want to run migrations one time as a pre-deploy step, but in a smaller app or one where you're using in-memory SQLite, the same migration files just work.
Embedding migrations happens with the sqlx::migrate!()
macro; here's the code I wrote for handling the database access:
// Embeds all ./migrations into the application binary
static MIGRATOR: Migrator = migrate!;
pub async
Forgive the hardcoded path to the SQLite database file, please.
Aside from that, this code is simple and easy to work with.
I just call create_pool().await?
and I get back a connection pool ready-to-use, always on the latest table schema.
Transactions
SQLx also makes transactions pretty painless.
The only thing to remember is to manually commit the transaction, otherwise when it drop
s, it will roll back.
Here's the function that toggles the completion state of a Todo
:
pub async
Since a read-then-write is necessary here, a transaction is required to ensure that the database is updated atomically. SQLx makes it very easy to do this.
Putting it All Together
Let's take a look at the Todo
completion toggle end-to-end.
It's probably the most complex thing this app does, and it touches every part of the stack, so I think it's a good example of the stack in action.
Let's zoom in on how individual todos are rendered for a moment:
There's a couple of things going on here, so let's break it down:
- This function renders an
<li>
with a unique ID derived from theTodo
- Inside the
<li>
are a<label>
and a checkbox<input>
- The
<label>
wraps the checkbox, making them behave as one unit in terms of clickability - The
<label>
also applies a strikethrough for completedTodo
s only - The
<input>
triggers aPUT /api/v1/todos/{todo.id}/toggle
request when it is toggled (i.e. clicked on) - The results of this request swap the
outerHTML
(the whole element) of the parent<li>
with thePUT
request's response
With that all in mind, here's the actual handler code for toggling a Todo
:
pub async
todos::toggle_todo
is the function we saw earlier which changes the todo state within a transaction.
Finally, the handler is wired up to the router like so:
This makes for a remarkably simple application structure that still provides a good level of interactivity. Simple, fast, and easy. There's a lot to like here.
Closing Thoughts
The MASH stack proved to be an excellent fit for projects with smaller scopes due to its convenience and seamless component integration. This experience has left me feeling generally positive about the stack's potential, owing to its simplicity and flexibility. I recommend giving this stack a try if you're in need of a simple webapp and want to write it in Rust. I'm certainly going to be doing more investigation on my own to explore the potential of this stack.
However, that being said, there are a couple of open questions that I'd like to explore more in the more immediate future.
One big one that's been on my mind throughout the entire project is testability.
Part of this is admittedly my fault; the code I built could use a bit of a clean-up pass and better separation of concerns.
Right now, views
does a ton of heavy lifting, it renders data as markup and calls into the DAO functions of todos
.
A better design might be to add another layer where the data modifications take place, and then views
just handles rendering.
End-to-end testing would still be a concern though, given that the backend is sending out markup with functional meaning for the HTMX frontend.
I've been doing some reading on testing HTMX applications, and I have some ideas I intend to try out.
Stay tuned for more updates as I delve deeper into these areas!