How Not To Write A Web Service in Rust

Posted on Sun 11 August 2024 in general

When writing a web service in Rust you may be tempted to do careful research based on your use case, to tailor your code to fit the needs of a typed language with strict memory safety, and to take a pragmatic approach to development. Doing things this way might make your project successful, but it isn't as fun.

What's really fun is blocking yourself for months by writing your Rust project in a haphazard manner, complaining about it online, and then writing an article roasting your own skill issues.

I'm here to illuminate your way as you walk the golden path to frustration in Rust for the web.

How did we get here?

I'm writing a second version of Zenkat, a CLI tool for knowledge management. The previous version was in Python, which I'm more familiar with.

The main feature that distinguishes zenkat-rs from zenkat-py is that it makes use of a proper recursive descent parser which emits an abstract syntax tree. Hopefully, this will allow for much more powerful querying and document processing capabilities with less spaghetti code than the original, which used regexps.

One consequence of this is that I should be able to implement an HTTP API for querying and manipulating a large number of notes at once, which lets Zenkat be run on a web server for e.g. handling a household or small business's knowledge management needs.

To do this I need to write a proper RESTful API in Rust, which is a little challenging especially where I also want to use Tokio's asynchronous features. I helped myself fail at the fairly simple task of implementing an HTTP server in Rust by using a few strategies.

In this article I'm going to share the strategies that you can use - like me! - to block and demotivate yourself for months on end.

Use the wrong stack for your use case

Rust networking crates are tricky to understand. There's a ton of different options which are hard to understand for beginners. So why not pick one at random?

For example we should choose gotham, which keeps you guessing about how to use key features by making their documentation a series of examples and sparsely documenting how stuff actually works. They also release pretty slowly and infrequently, which means they must be more stable.

Definitely don't use axum, which provides a simple interface for routes by using derived traits. Their release cycle is way shorter, which makes them more dangerous, and they're part of the same github user as tokio, which means they're shilling their own product.

Make liberal use of dynamic language concepts

Don't use the features of your library which simplify async Rust - those are for beginners only. Avoid the temptation to give each request type its own data type, and definitely don't use fancy lad constructs like traits, type aliases, or Rust enums to provide a common interface for objects. After all, you stopped writing C# for a reason!

Instead, you should try using a generic request type which looks like this:

pub struct Request {
  request_type: RequestType,
  data: Hashmap<String, unknown>
}

This is totally cool and fine in Python, and Rust is basically Python, right?

Using this kind of setup gives you a great opportunity to learn a lot more about how async Rust works and how it interacts with Axum-specific abstractions:

fn make_request_handler(
    parser: &QueryParser,
) -> Box<dyn Fn(Json<Request>) -> (StatusCode, Json<Response>)> {
    Box::new(
        |Json(payload): Json<Request>| -> (StatusCode, Json<Response>) {
            let res = Response::new();

            parser.parse_query(payload);

            println!("{:?}", payload);
            return (StatusCode::OK, Json(res));
        },
    ) 

This is way cleaner and easier to understand than a more static way of handling routes like:

async fn list_trees(State(state): State<AppState>) -> Json<Vec<TreeDetail>> {
    let tree_guard = state.trees.lock().await;
    let mut tree_details = vec![];
    for tree in tree_guard.iter() {
        tree_details.push(TreeDetail {
            path: tree.path.clone(),
            name: tree.name.clone(),
        });
    }
    return Json(tree_details);
}

The second example is even worse because it uses the idiomatic way to write Axum route handlers. Conforming to the norm makes you a square, you should rebel against technical standards as much as possible.

Consider .clone() harmful

One of the core principles of Rust is memory safety, and one of its selling points is that you can do most things with stack-allocated references that require heap allocation in other languages.

It follows that you should listen to programming influencers and memers online who say never to use .clone() unless you have skill issues. Using .clone() on a String might allocate an extra 0.5kb of RAM, and on a personal computer in 2024, that's a lot.

You should also make sure everything is on a CPU register and definitely never leaves the L1 cache. It's very important that you make it fast, then make it work. After all, if your code performs slower than a synthetic benchmark, it means you're a bad programmer and you should feel bad. This is especially the case when you're trying to create an MVP rather than optimising a mature product.

Don't look for help

The internet is full of people ready to laugh at you for not being a Rust virtuoso. Pay attention to the haters and ignore all the people who could help you fix things. If you do post about an issue, take a distant and disinterested attitude so that people can't tell if you're joking or if you really need help with a problem.

Likewise, reading the Rust book is ill-advised. Who reads books in 2024?

Holding data where you don't yet know the type in a hashmap (see above) is definitely the best possible solution. Specialising types from a generic interface sounds like it'd probably conflict with Rust's strict memory safety. There's definitely no construct in Rust which allows multiple unlike data formats to share a location in a struct.

You shouldn't research language features when using a language that you know uses several different paradigms to the ones you're familiar with. Rust is just C++ with quirks like lifetimes, after all.

Seriously, though

I'm not that good at Rust, and attempting a parser project involving concurrency as my first real project in the language was probably a sign of hubris.

But hopefully, I'm slightly better than I was after making all these mistakes.

Hubris is one of the three great virtues of programmers, after all.

Now I've undone some of the questionable choices I made before, I feel a lot better moving forward with zenkat-rs, and managed to implement a tree visualiser this weekend. Feel free to follow along with the project if you find it interesting.