Sidetracked is, yep you guessed it, a todo application. We are going to write it in Rust (of course) and we are going to draw loosely from Domain-driven Design and Hexagonal Architecture for our project architecture. This will be a fully featured application, including a Backend using Axum, and a Frontend using Htmx.
A Very Loose Roadmap / Feature List
Even I don’t know exactly how this is going to turn out, so lets make a list of what technologies and features we really want to explore (via implementing them). I find that some tutorial style articles usually gloss over the “hard” stuff, so let’s try not to do that.
Technologies
- Backend
- Axum: Web server
- Utoipa: To generate OpenAPI documentation
- Authentication and Authorization using JWTs
- Persistence: Postgres and Sqlx
- Frontend
- Htmx: No/low javascript framework
- DaisyUI (and tailwind): Styling
- ChatGPT: (Design, Colors, Logo)
- CLI
- Clap: Command line parsing
Features
- Todos (obviously)
- Users
- Sharing
- Reminders and notifications
Getting Set Up
Alright, let’s get set up. I’m going to start with a workspace. This may not be necessary as I’m sure a single crate application would be sufficient, but I always find that I, at some point, want to split out another crate. So even if we only end up with a single crate, no harm done. As an aside, I lean towards the enforced isolation that crates provide, especially when I’m trying to figure out the API of something I know I want to keep separate. It stops me just reaching in and changing things, and forces me to plan a little more. This is obviously not required, and is just a personal preference.
First, set up a directory and an empty Cargo.toml
for our workspace. At the time of writing there is no cargo
command to initialise a workspace, but I’m sure their will be soon (I really just expected it to work).
mkdir sidetracked && cd sidetracked
touch Cargo.toml
Then fill in the contents of the Cargo.toml
. This indicates we will have a crate at ./sidetracked
.
# ./Cargo.toml
[workspace]
resolver = "2"
members = ["sidetracked"]
Now to actually initialise the crate with cargo
. This will create a minimal Rust binary crate that is able to be compiled and run.
cargo init --bin sidetracked
Lets check everything is working by running the binary.
❯ cargo run
Compiling sidetracked v0.1.0 (/Users/kglasson/S/github.com/elidhu/sidetracked/sidetracked)
Finished dev [unoptimized + debuginfo] target(s) in 1.02s
Running `target/debug/sidetracked`
Hello, world!
Easy! If this is not working then you will need to retrace your steps and figure out what’s wrong!
A Minimal Web Server
Okay, so we have something working. Let’s expand on our generated “Hello, world!” and get a minimal web server going, for that we are going to use Axum.
Why Axum? Well, I like it.
It is also quite popular within the ecosystem, meaning it is easier to find help with, uses another popular crate (Tower) to provide it’s middleware, and it is maintained by the Tokio GitHub organisation. Tokio provides a-lot of very useful crates in the Rust ecosystem. But I digress, let’s get a server up.
Make your Cargo.toml
look like the following. To be clear, this is the Cargo.toml
for the sidetracked
crate, not the workspace
.
# ./sidetracked/Cargo.toml
[package]
name = "sidetracked"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.82"
axum = { version = "0.7.5", features = ["macros"] }
chrono = "0.4.37"
serde = { version = "1.0.197", features = ["derive"] }
tokio = { version = "1.37.0", features = ["full"] }
tower = "0.4.13"
tower-http = { version = "0.5.2", features = ["trace"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = [
"env-filter",
"json",
"chrono",
] }
As an artifact of how I stitch together my articles, the code snippets will be in their “end-of-article” form. For example, in the above snippet we have all of the dependencies I have added, not necessarily the ones I added in this step.
Now let’s set up a few directories, I did say minimal, but I don’t want to do too much refactoring during the article, we’ll save that for future articles if necessary.
mkdir -p sidetracked/src/web
touch sidetracked/src/web/{mod,application}.rs
Add the following snippets to their respective files to create two modules. The web
module, which will ultimately contain our server code, the code that interacts with the outside world. Followed by the application
module which is going to house the bulk of the router setup. If you need a refresher on how Rust organises modules, take a look at the following section in the Rust book.
// ./sidetracked/src/main.rs
pub mod web;
// ./sidetracked/src/web/mod.rs
pub mod application;
An Application
Now let’s start populating these files with some actual code. A pretty common pattern that I see in the wild that I like is to have an Application
struct. This will encapsulate all of the composing of routes and middleware, plus any relevant helpers that define the server. This is a good way to keep the main
function clean and concise.
// ./sidetracked/src/web/application.rs
pub struct Application;
The above is pretty self-explanatory as currently our Application
doesn’t require any data, so lets get on with the implementation. In Rust, to attach methods to a struct, you need to use an impl
block where we will define the router
method that constructs the Router
for our server.
// ./sidetracked/src/web/application.rs
impl Application {
/// Create the application router
pub fn router(&self) -> Router {
Router::new()
.route("/", get(|| async { "Hello, World!" }))
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
}
}
We will also define an ApplicationConfig
struct to hold some values that we might want to configure in the future.
// ./sidetracked/src/web/application.rs
pub struct ApplicationConfig {
/// The host to listen on
pub host: IpAddr,
/// The port to listen on
pub port: u16,
}
Finally, just one little bit of sugar to make our lives easier. We are going to implement the Default
trait for our ApplicationConfig
struct. This will enable us to easily create a new ApplicationConfig
with some sensible defaults.
// ./sidetracked/src/web/application.rs
impl Default for ApplicationConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".parse().unwrap(),
port: 3000,
}
}
}
And let’s also define a run function to actually run the server using our Application
and ApplicationConfig
.
// ./sidetracked/src/web/application.rs
/// Run the application
pub async fn run(app: Application, config: ApplicationConfig) {
let addr = SocketAddr::new(config.host, config.port);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
let router = app.router();
info!("Listening on {}", listener.local_addr().unwrap());
axum::serve(listener, router.into_make_service())
.await
.expect("Unexpected error during server execution");
}
A note about the above. The
TcpListener
we are using is from thetokio::net
module, not the standard library. This is because we are using Tokio’s runtime to run our async code. I have spent far too much time debugging this particular thing when I was first trying to learn Rust, so I thought I would point it out. Tokio does it’s best to mimic the stdlib structure, and autocomplete sometimes can lead you astray.
So what have we created?
We now have a minimal web server, it has a single route at /
that will respond with Hello, World!
when it receives a GET
request, we have a run
method that will start the server, and we have an Application
struct to define our routes, and an ApplicationConfig
to hold our configuration.
Let’s take a look at our Router
.
// ./sidetracked/src/web/application.rs
Router::new()
.route("/", get(|| async { "Hello, World!" }))
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
We can see that we are adding a Layer
to our Router
. This is how Axum does middleware, by leaning on the Tower ecosystem. What we have here is TraceLayer
from the tower_http
crate. This is a middleware that will log all requests that come into our server. I consider this to be essential, especially during development, so that we can see what is going on!
I highly recommend reading the Axum middleware documentation on ordering as it is very important to understand how the layers are applied, and in what order. This is a common source of confusion in my experience, as the order of middleware execution differs between calling .layer()
on the Router
and calling .layer()
on the ServiceBuilder
. Quoting the documentation:
“ It’s recommended to use tower::ServiceBuilder to apply multiple middleware at once, instead of calling layer (or route_layer) repeatedly “
This is because the “top-to-bottom” ordering provided by ServiceBuilder
is often more natural.
Now, we aren’t actually running the server yet, there is one more thing we should set up first.
Tracing and Logging
If we were to actually run our server, we wouldn’t see anything output to the console. While we have used the TraceLayer
middleware, and the info!
macro, we haven’t configured the logger to actually output anything where we can see it. So let’s do that now.
I use the following snippet in most of my Axum projects. It sets up the logger to either accept the RUST_LOG
environment variable, or fall back to some sensible defaults. Those sensible defaults include DEBUG
level logging for the current crate, DEBUG
logging for tower_http
and TRACE
logging for axum::rejection
(we will get to what this even is in the future). This snippet is straight from the Axum examples with only a tiny tweak so that I can copy-paste it without any changes.
// ./sidetracked/src/main.rs
fn init_logging() {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
// axum logs rejections from built-in extractors with the `axum::rejection`
// target, at `TRACE` level. `axum::rejection=trace` enables showing those events
let crate_name = env!("CARGO_CRATE_NAME");
format!("{crate_name}=debug,tower_http=debug,axum::rejection=trace").into()
}),
)
.with(tracing_subscriber::fmt::layer())
.init();
}
A pretty standard pattern for logging that is also adhered to in Rust is to split it in to two parts. The part that emits the events, in this case that is tracing
, and the part that handles the events, in this case tracing-subscriber
. This is a pretty powerful pattern, as it allows you to change the way you handle logs without changing the way you emit them. For example, you could log to a file, a network socket, a log aggregation service, or all three. The only thing you would need to do is reconfigure the initialisation logic.
Running the Server
With that out of the way, let’s configure the server.
// ./sidetracked/src/main.rs
#[tokio::main]
async fn main() {
init_logging();
let config = ApplicationConfig::default();
let app = Application;
run(app, config).await;
}
For those of you new to Rust (or maybe just new to async Rust), the
tokio::main
macro is a helper macro that sets up the Tokio runtime for you. You can read more about how that works here.
The code is pretty straightforward. We initiliase the logging before anything else. We then set up our ApplicationConfig
using our magical Default
implementation, we create the Application
and then we run the server!
Let’s spin it up. I like to utilise cargo watch
for this, as it will automatically recompile the code when it changes. Most developer workflows will use something like this to “hot reload” changes. If you don’t already have cargo watch
, you can install it with cargo install cargo-watch
.
cargo watch -x run -w sidetracked
You should now see some output in your terminal indicating that the server is listening on 127.0.0.1:3000
. We test our brand new server by hitting it with curl -i http://localhost:3000
from another terminal. You should see something like this:
❯ curl -i http://localhost:3000
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 13
date: Thu, 11 Apr 2024 13:38:07 GMT
Hello, World!
Perfect, as expected we get a 200
status code and a Hello World
in the body. Now from the terminal that is running the server you should see output from the logger. Something like this:
❯ cargo watch -x run -w sidetracked
[Running 'cargo run']
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/sidetracked`
2024-04-11T05:55:42.213495Z INFO sidetracked::web::application: Listening on 127.0.0.1:3000
2024-04-11T05:57:28.517081Z DEBUG request{method=GET uri=/ version=HTTP/1.1}: tower_http::trace::on_request: started processing request
2024-04-11T05:57:28.517193Z DEBUG request{method=GET uri=/ version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=0 ms status=200
Next steps
I think this is probably a logical stopping point otherwise it could, quite literally, go on forever. We have set up a basic project and are ready to start building out some more features. In the next article we will start building out the API.