An Christmas-themed, Advent of Code-style event organized by Shuttle where I used their product to build a backend server for Santa.
In the process, I’ve learnt various things about backends and the related Rust ecosystem tooling, which I’m documenting here.
Code Examples
I’ll be referring to code snippets from my participation of the event (see repo).
Axum
This is a staple in the ecosystem for writing performant backend servers. It’s built on top of hyper
by the tokio team, and uses tower:Service
as the middleware. This means the tool is fairly well integrated to the Rust backend ecosystem and is easily extendable with open-source community efforts.
I personally like Axum for the declarative expressiveness of its API—a lot gets done and communicated with a few lines of code.
Take the following example where we want to expose a POST /12/place/:team/:column
endpoint for two teams to play a game of 4x4 tic-tac-toe.
Router::new().route("/12/place/:team/:column", post(day12::place));
Within a single .route
method, we’ve specified
- path parameters, expressed declaratively, as
:team
and:column
as part of the route - the route maps to an async function
day12::place
(see below) - the async function corresponds to a POST method via the
post
method filter
Meanwhile this is what the place
handler looks like:
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
/// Places a piece (🥛/🍪) on the 4x4 tic-tac-toe board
pub async fn place(
State(state): State<AppState>,
Path((team, column)): Path<(String, usize)>,
) -> impl IntoResponse {
let board = &mut state.write().await.board;
if column == 0 || column > board.size() {
return (StatusCode::BAD_REQUEST, "Invalid column".to_string());
};
let Ok(team) = serde_json::from_str(&format!(r#""{team}""#)) else {
return (StatusCode::BAD_REQUEST, "Invalid team".to_string());
};
match (board.set_column(team, column - 1), &board.game_state) {
// Errors, or the game has just ended (Won or Stalemate)
(Err(_), _) => (StatusCode::SERVICE_UNAVAILABLE, board.to_string()),
_ => (StatusCode::OK, board.to_string()),
}
}
There’s quite a few things that are happening even from the function signature.
Extractors
The arguments specified as part of the handler’s input signature will tell the framework the bits and pieces you like the extract from the incoming request.
In this case, we would like to extract the app state (supplied by the framework) and the path parameters :team
and :column
from the request.
One gotcha is that certain extractors have to be supplied in the correct order, as the request body is consumed exactly once by the framework. For example you need to specify the HeaderMap
before the body: String
. See the docs for more detail.
Destructuring
Another obvious syntactical feature is that the input arguments are destructured. Up until this point of writing Rust, it didn’t occur to me that the language’s grammar allows for destructuring in the function signature. While I found it rather hard-to-read, it seems to be a common way of writing Axum handlers.
After spending some time with it, I can see why it may be preferred by some—it allows you to save on some boilerplate, and have the relevant inputs to your handler function readily available for use in the body.
Compare the function above with an equivalent function without destructuring:
pub async fn place_without_destructuring(
state: State<AppState>,
path: Path<(String, usize)>
) -> impl IntoResponse {
let State(state) = state; // `state` is now of type `AppState`
let Path((team, column)) = path;
}
We would need to have additional boilerplate to re-map (a.k.a. shadow) the arguments to the types that we are interested.
Destructuring is optional in handlers, and shifts the boilerplate into the function signature at a cost of readability. The library recommends it, and I’ve decided to stick to it to follow conventions; it also isn’t that hard to read once you get used to it.
Note
This may not be the best example, given that both
State
andPath
from Axum implements theDeref
trait. This allows both of these types to behave like a reference and allows the caller to access the inner value’s methods.The point on boilerplate still holds.
Path parameters
The path parameters are obtained via Path((team, column)): Path<(String, usize)>
.
In this one segment, the framework extract the path parameters from the requests and packages it into a Path
object, which is destructured in the handler into team
and column
input arguments and coerced into the types we’re interested in.
Lots of things in just one line!
Fallible Extractors
If an extractor fails to parse (perhaps malformed) user inputs you can specify the argument type to be a result. See the following handler:
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum BucketUnit {
Liters(f32),
Gallons(f32),
Litres(f32),
Pints(f32),
}
/// Interact with Santa's Cookies and Milk Factory bucket
pub async fn milk(
State(state): State<AppState>
headers: HeaderMap,
payload: Result<Json<BucketUnit>, JsonRejection>
) -> impl IntoResponse { ... }
The payload
is a Result
type. It may:
- succeed in extracting JSON data from the request body and deserialize it into a
BucketUnit
enum - fail to deserialize
BucketUnit
due to malformed or missing JSON data, and we get aJsonRejection
containing the reason
Mutable Shared States
The State<AppState>
extractor allows each request to work with a global app state. This state may be mutable, in which case additional constraints are enforced by the Rust compiler.
The State
struct from Axum wraps over a user-defined class.
type AppState = Arc<RwLock<InnerAppState>>;
struct InnerAppState {
board: Board,
...
}
This allows handlers to access and even mutate shared state for each request. In the case of the place
handler, the shared state of interest is a Board
wrapped with a RwLock
as we wil mutate the state of the board.
Responders
Anything that can be converted IntoResponse
such as String
, &'static str
, Json<T>
, Html<T>
, StatusCode
and tuples are considered Axum responders. See docs.
The IntoResponse
trait is a powerful and flexible feature, allowing various types to be returned. You can return a String
on one line, and a StatusCode
in another segment of the same function! This feels like something you’ll get from a dynamically-typed language.1
In my case, I decided on a (StatusCode, String)
tuple as the return type for simplicity and consistency. The Axum framework will automatically convert this into the appropriate response for the client, filling in the appropriate status code and response body as appropriate.
Shuttle.rs
Of course, an event organized by Shuttle.rs wouldn’t be complete without a tour of its functionalities.
Shuttle has integrations with popular tooling like Axum and sqlx
that makes the backend server easily deployed on their infrastructure with minimal modifications.
As you can see below, almost all of the Shuttle-specific code exists in the following few lines in main
function:
#[shuttle_runtime::main]
async fn main(
#[shuttle_runtime::Secrets] secrets: shuttle_runtime::SecretStore,
#[shuttle_shared_db::Postgres] pool: sqlx::PgPool,
) -> shuttle_axum::ShuttleAxum { ... }
#[shuttle_runtime: :main]
applies special setup that makes themain
entrypoint deployable on their platform#[shutte_runtime: :Secrets]
tells Shuttle that this application requires aSecretStore
#[shuttle_shared_db: :Postgres]
tells Shuttle to spin up a Postgres database and connection pool for the application- The function returns a
shuttle_axum::ShuttleAxum
instance, which the Axum router can be converted.into
The SecretStore
and PgPool
are examples of Framework-Defined Infrastructure in action, where the appropriate infrastructure is spun up as declared by the developer in the code itself! Infrastructure, easily managed directly by the application team in a context they are familiar and comfortable with. Pretty neat if you ask me!
sqlx
I’ve heard of sqlx
multiple times already, but it was certainly refreshing and even avant-garde to see how the crate utilizes macros like sqlx::query!
to verify that the validity of the data model implied by your SQL query at compile time, by checking against a local databases’ schema
ndarray
This is a Rust-analog to Python’s staple numpy
crate for numerical matrix processing.
Overall it feels rather similar to use like numpy
, but a lot more of its API is centered around the iterator pattern. I also find the array types fascinating as it is generic over different underlying datatypes and dimensions.
HTMX
Similarly, I got to try out HTMX that I’ve also heard about for a while. With HTMX I was able to connect my frontend directly with the backend without the need for custom JS for simpler interactions, via the use of new hx-*
attributes.
Footnotes
-
If you’re interested, see Jon Gjengset’s video on how this magic works under the hood. ↩