Introducing Twurst — a Twirp implementation in Rust

Helsing’s internal infrastructure and software platforms make extensive use of protocol buffers and gRPC to define and implement inter-service APIs. While gRPC’s ease of use and performance have served us very well — both in cloud environments and in embedded systems — we have hit roadblocks when deploying gRPC-based services in brownfield networks that lack support for HTTP/2.

To work around such limitations, we have adopted Twirp, a gRPC alternative designed by Twitch. Twirp uses (a subset of) standard gRPC protobuf definitions, but implements a simpler transport protocol compatible with HTTP 1.1.

We recently open-sourced Twurst , Helsing’s Rust implementation of the Twirp protocol. Twurst focuses on versatility and allows implementing servers for both gRPC and Twirp from the same code base. In this blog post, we first explore why we adopted Twirp and then provide minimal code samples for a Twurst server and client. If you read to the end, you may even learn about the etymology.

Twurst is available on GitHub under the Apache 2 license.

Why Twirp?

gRPC relies on HTTP/2, stream transport, and binary data encoding. In working with our customers’ legacy infrastructure, we frequently encounter networks with constraints that are incompatible with gRPC. Such constraints typically exist due to a combination of system age and security or compliance requirements for defence networks; for example:

Instead of butchering our own, customized version of gRPC to follow all these constraints, we chose to adopt Twirp. Indeed, Twirp:

Twirp already has more than 20 implementations, providing support in a lot of languages, including the well-known protobuf-ts library.

Twurst

Twurst is not the first Rust implementation of Twirp:

This is why we have decided to develop a new Rust Twirp library.

Similar to the other two libraries, Twurst’s design is heavily inspired by Tonic, the most common gRPC implementation in Rust. Like Tonic, Twurst relies on the Prost bindings generator for protobuf. Twurst augments it with the prost-reflect library to add proper JSON support (including for well-known types).

Twurst server

Twurst servers build on Axum, just like Tonic and twirp-rs. Similarly to Tonic, the user first writes a build.rs script to auto-generate Rust code from .proto definitions:

fn main() -> std::io::Result<()> {
    twurst_build::TwirpBuilder::new()
        .with_server()
        .compile_protos(&["proto/service.proto"], &["proto"])
}

We can then wire up a Twirp service by implementing the generated trait and use Twurst to get an axum::Router. This approach allows to embed the Twirp server into a larger HTTP server and make use of Axum and Tower middleware to handle access control, CORS, logging, etc.

The following code sample defines a Twirp service and serves it with CORS enabled under the /twirp path alongside a /healthz path for health checks:

struct ExampleServiceServicer {}

impl ExampleService for ExampleServiceServicer {
    async fn test(
        &self,
        request: TestRequest
    ) -> Result<TestResponse, TwirpError> {
        todo!("My implementation")
    }
}

axum::serve(
   tokio::net::TcpListener::bind("localhost:8080").await?,
   axum::Router::new()
       .nest("/twirp",
           ExampleServiceServicer {}.into_router().fallback(twirp_fallback))
       .route("/healthz", get(|| async {}))
       .layer(tower_http::cors::CorsLayer::new())
).await

Twurst services also support gRPC. We can spawn a gRPC server alongside a Twirp server in order to smoothly migrate from gRPC to Twirp (note the into_grpc_router instead of into_router):

axum::serve(
    tokio::net::TcpListener::bind("localhost:8080").await?,
    ExampleServiceServicer {}.into_grpc_router().fallback(grpc_fallback)
).await

In contrast to Tonic or twirp-rs, Twurst supports Axum extractors. For example, to access request headers, we first augment the build script:

.with_axum_request_extractor("headers", "::axum::http::HeaderMap")

and then access headers (eg, AUTHORIZATION) in the the trait implementation:

impl ExampleService for ExampleServiceServicer {
    async fn test(
        &self,
        request: TestRequest,
        headers: HeaderMap
    ) -> Result<TestResponse, TwirpError> {
        let token = headers.get(AUTHORIZATION);
        todo!("My implementation")
    }
}

The extractor mechanism allows, for example, to integrate with a Tower middleware for request authentication.

Twurst client

Like servers, Twurst clients rely on generated code. The build step generates structs that wrap a TwirpHttpClient and let us call a Twirp service with a few lines of code:

let twirp_client =
    TwirpHttpClient::new_using_reqwest_012("http://example.com/twirp");
// ExampleService is the name of the Twirp service
let client = proto::ExampleServiceClient::new(twirp_client);
// Execute the Twirp request
let response = client.test(&TestRequest {}).await?;

The TwirpHttpClient is a small wrapper over the tower::Service trait, allowing to customize the transport layer. For example, the following code injects credentials into all Twirp requests:

let twirp_client = TwirpHttpClient::new_with_base(
    ServiceBuilder::new()
        .layer(AddAuthorizationLayer::basic("username", "password"))
        .service(Reqwest012Service::from(reqwest::Client::new())),
    "http://example.com/twirp"
);

Because TwirpHttpClient wraps tower::Service and axum::Router implements tower::Service, it is easy to build a TwirpHttpClient from a Twirp service implementation; this can be handy for mocking:

impl ExampleService for ExampleServiceServicer {
    async fn test(
        &self,
        request: TestRequest
    ) -> Result<TestResponse, TwirpError> {
        todo!("My implementation")
    }
}

let my_mock_client = ExampleServiceClient::new(
    TwirpHttpClient::new(ExampleServiceServicer::new().into_router()));

Testing and compatibility

To test our implementation we first wrote a set of unit tests based on the great Twirp wire protocol specification. On top of these tests, we checked interoperability with two other implementations. We tested our Rust client with the Python server Twirpy and the Rust server with the protobuf-ts TypeScript client. In both cases, we tested the happy path, advanced protobuf features (for example, the JSON encoding of well-known types) and proper error support. We also tested our gRPC server implementation with both protobuf-ts and Tonic clients, ensuring it works properly.

Conclusion

Twurst has been instrumental for Twirp adoption at Helsing and in open-sourcing the code base we hope Twurst will be a useful for the broader Rust and Twirp communities. We look forward to your feedback and/or feature requests on GitHub!

The Twurst logo
The Twurst logo

PS: “Twurst” is a freestyle portmanteau of “Twirp” and “Rust”. It is also a play-on-words with a German delicacy, Teewurst.

Author: Thomas PT