../sidetracked-introducing-the-project-and-getting-setup

Sidetracked: Introducing the Project and Getting Setup

Take me straight to the code

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 the tokio::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.

Tags: sidetracked rust