I'm a software engineer in San Francisco, currently working on a platform to streamline release management for VPCs: Bottlerocket.

Posts

  1. A Rust macros use case: Tightly-coupled API definitions for the client and server

A Rust macros use case: Tightly-coupled API definitions for the client and server

I’m working on a Kubernetes operator and an API server for it to interface with. Both of these are crates in the same Rust workspace. The motivation for this was that I wanted to define all of the types used by the two services in one place, and keep the services tightly-coupled. When writing the operator, I wrote a protocol to define the HTTP requests the operator sends to the server:

pub trait ApiPath {
    type Request: serde::Serialize;
    type Response: serde::de::DeserializeOwned;

    const METHOD: Method;
    const PATH: &'static str;
}

So that I could define paths/endpoints like this:

pub async fn send<P: ApiPath>(&self, body: P::Request) -> Result<P::Response, reqwest::Error> {
        let url = format!("https://{}/{}", self.host, P::PATH);

        self.client
            .request(P::METHOD, url)
            .bearer_auth(&self.token)
            .json(&body)
            .send()
            .await?
            .error_for_status()?
            .json::<P::Response>()
            .await
}

This worked super well when implementing the operator. All I had to do was call this generic send function with a struct that implemented ApiPath, which reduced a lot of boilerplate code, and let me define the method, body type, response type and path all in one place for each endpoint.

When I started implementing the API server, which I used the axum crate for, I was having a hard time adding the routing/handling in an elegant way using the protocol I created for the operator. When defining a route in axum, you usually do something like this:

axum::Router::new().route("/poll", axum::routing::get(poll));

The issue here though is that I’m now defining the path and method for each endpoint in a different place. I could do something like this, but I don’t really like how I’m specifying the endpoint struct in two places:

axum::Router::new().route(Poll::PATH, method_to_handler_func(Poll::METHOD)(handler));

I was only vaguely familiar with macros and thought I’d see if this would be a good use case. After some iteration I got this:

macro_rules! into_route {
    ($path:ty, $handler:expr) => {
        axum::Router::new().route(<$path>::PATH, from_method(<$path>::METHOD, $handler))
    };
}
* from_method just matches the http::Method to the axum handler, e.g. axum::routing::get.

Now I can just define my router like this:

let operator_routes = axum::Router::new()
    .merge(into_route!(Poll, poll))
    .merge(into_route!(ListImages, list_images))

All I have to do is pass in the struct implementing the ApiPath protocol and the handler function! This was my first practical use of macros, and I thought it was interesting enough to share.