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.