Evaluating Rust's http/websocket frameworks

I spent this day of learning on evaluating the three popular high-level Rust frameworks for HTTP/websocket servers. At some point we want/need to rewrite Cockpit’s web server, and Rust feels like a natural choice for this (besides Python).

My goal was to write a little webserver which can do the following:

  • /hello: simple http GET, with optional User-Agent: header inspection: curl http://localhost:3030/hello/myname replies with “Hello myname from from curl/7.85.0”.

  • /dir: serve files from static directory: This has some uncompressed and gzip-compressed text files. If the framework supports it, this should deliver the pre-compressed optzip.txt.gz with “Content-Encoding: gzip”:

    curl -v http://localhost:3030/dir/plain.txt
    curl --compressed -v http://localhost:3030/dir/dir1/optzip.txt
    
  • /file: serve static files, with dynamically computed on-disk file paths; this can be used to implement delivering compressed files, or look them up in different directories (which is necessary in cockpit’s webserver). On the outside it should look pretty much the same, but this then must deliver the pre-compressed optzip.txt.gz:

    curl --compressed -v http://localhost:3030/file/dir1/optzip.txt
    
  • /ws-echo: a websocket route which just echos back its text or binary messages, and handles pings:

    ❱❱❱ websocat -E --ping-interval=5 --ping-timeout=10 ws://127.0.0.1:3030/ws-echo
    hello
    hello
    world
    world
    
  • /ws-rev: a websocket route which reverses its input (the empty line is because the line break is part of the reversal):

    ❱❱❱ websocat -b ws://127.0.0.1:3030/ws-rev
    hello
     
    olleh
    
  • unit tests for all the above routes

warp

I started with warp, as this seemed to be the most “modern” framework. I liked its idea about building everything from filters, as it is functional and flexible. You can see the implementation in the warp-server directory of my “learn rust” project. It implements /hello, /dir (without compression support, as warp::fs::dir does not support that), and the two websocket routes.

I did not implement /file as even after an hour of experimenting, I was not able to actually get this working. My most recent attempt looked like this:

let static_file = warp::path!("file" / String)
    .and_then(|path: String| async {
        let path = PathBuf::from(path);
        log::info!("requesting file path {:?}", path);
        if !path.is_file() {
            return Ok(warp::reply::with_status("not found", warp::http::StatusCode::NOT_FOUND))
        }

        // Return a File with the specified path
        Ok(warp::fs::file(path))
    });

This doesn’t compile. There I got lost, and I gave up eventually.

Advantages:

  • Most coherent API, it does not need API from other crates
  • Warp has the best websocket support of the three contenders
  • It’s warp::test API for writing unit test is a real pleasure to use, including testing websockets

Disadvantages:

  • I find the documentation hard to understand. It is missing some explanation of how all the classes and structures fit together. E.g. the fs::file filter only shows how to make this a static route, but that does not help me to figure out how to make the above code work when computing the file path dynamically. I stared a lot on the official examples and its source code, and rummaged in Stackoverflow and Google to make progress there.

  • Flexible handling of file returns, i.e. going from something like warp::path!("prefix" / String) to warp::fs::file(path), is hard. I did not find a solution.

axum

The axum implementation is in the axum-server directory. For me this was the hardest framework to understand, mostly because it’s not a single API, but glues together several others. It implements the same features as the warp implemenation above, except that the /dir route also supports gzip encoding – tower’s ServeDir supports this natively, which is fairly nice.

However, just like for warp I spent a lot of time in the documentation, examples, source, Google, and even ChatGPT to figure out how to serve a dynamically computed file path, and eventually gave up. I didn’t get further than this:

async fn static_file(uri: Uri) -> Result<Response<body::BoxBody>, (StatusCode, String)> {
    tracing::info!("static_file get path {?:}", uri);
    tower_http::services::ServeFile::new("../static/plain.txt")
}

and later on, add this to the Router::new() call list with .nest("/file", get(static_file)).

Likewise, there are no unit tests for the websocket routes, after another hour of searching I gave up. I didn’t bother to implement the /ws-rev route. It’s probably straightforward, but it wouldn’t have added anything new to my evaluation.

Advantages:

  • Nice builtin transparent gzip/deflate handling in ServeDir
  • Nice unit tests for http requests

Disadvantages:

  • I find the documentation even harder to use and understand than warp’s. Classes are spread out over at least four crates (axum, tower-http, hyper, http-body), and a lot of them don’t even exist in the docs at all (like axum::http::Request). I often got lost in a maze of types when I didn’t know how to create or use them, or how they fit into the whole structure.

  • Again, connecting the dots for a flexible way how to serve files (more flexible than ServeDir) is hard, did not find a solution.

  • No support for testing websockets (or it is too hard to discover).

actix-web

The actix implementation is the only one which implements all of the above goals. As long as I stayed inside the actix-web crate, I had very little problems, I find the API nice to use. Between all three frameworks, its concept of a handler API that can simply return impl Responder, which can then take text, statuses, async results, or even file objects makes it very consistent.

My troubles began when I added websocket support. That isn’t supported by actix-web itself, but uses the older actix API for that (see actix-web’s What is? page for details). It took me two hours to solve the puzzle of how to write an unit test for a websocket route, but I eventually found it, and the resulting code is actually quite pleasant. A simplified version:

#[get("/ws-echo")]
async fn ws_echo(req: HttpRequest, stream: web::Payload) -> Result<HttpResponse, Error> { ... }

#[actix_web::test]
async fn test_ws_echo() {
    let mut srv = actix_test::start(|| App::new().service(ws_echo));
    let mut client = srv.ws_at("/ws-echo").await.unwrap();

    client.send(ws::Message::Text("hello".into())).await.unwrap();
    let received = client.next().await.unwrap().unwrap();
    assert_eq!(received, ws::Frame::Text("hello".into()));
}

Advantages:

  • Much simpler and more consistent handler API than the other two
  • Good and clear documentation with working examples
  • Only framework where serving dynamically computed file paths/types is straightforward and documented
  • Writing HTTP unit tests is pleasant and well documented

Disadvantages:

  • Factorizing the App::New().service().… is harder than with axum and warp, as the App class is a template soup. This is important for using the exact same App between main and the unit tests. This requires a macro, but then is just about as good.
  • Writing websocket handlers is more work than with axum or warp: more boilerplate, no automatic ping handling. But docs and examples are good, it is still easy to do.
  • Websockets have to deal with messy types; e.g. web::Bytes and bytestring::ByteString which is even a separate crate. There is not much coherence between these, actix-web, and the Rust standard library.
  • Figuring out how to test a websocket handler was super hard. There was zero help from the documentation, examples, Google, or Stackoverflow. Finding the actix-test crate, its TestServer.ws_at() method, and figuring out the type soup for ws::Message::Text on send vs. ws::Frame::Text on receive was a bit frustrating. However, the resulting code is small and fairly readable.

Conclusion

So undoubtedly, if we are going to rewrite Cockpit’s web server in Rust, then actix-web will be it. The above proof of concept shows all the basic ingredients that we need, and after getting a feel for the API structure and concepts, it is actually quite pleasant to develop real things with it.

This comparison also provides a nice guideline for implementing other kinds of web services – not everything requires serving files in a complicated way or websockets. E.g. for a simple REST service I’d choose warp.