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

adenalhardan1 pts0 comments

Aden Al-Hardan

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

Posts

2026-06-23<br>A Rust macros use case: Tightly-coupled API definitions for the<br>client and server

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

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

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

const METHOD: Method;<br>const PATH: &'static str;

So that I could define paths/endpoints like this:

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

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

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

When I started implementing the API server, which I used the<br>axum crate for, I was having a hard time adding the<br>routing/handling in an elegant way using the protocol I created for the<br>operator. When defining a route in axum, you usually do something like<br>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<br>each endpoint in a different place. I could do something like this, but<br>I don’t really like how I’m specifying the endpoint struct in two<br>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<br>would be a good use case. After some iteration I got this:

macro_rules! into_route {<br>($path:ty, $handler:expr) => {<br>axum::Router::new().route(::PATH, from_method(::METHOD, $handler))<br>};

* from_method just matches the<br>http::Method to the axum handler, e.g.<br>axum::routing::get.

Now I can just define my router like this:

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

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

axum method path server poll macros

Related Articles