Take a quick glance at the code snippet below. Without thinking too hard, is get
a function or a method? How about post
?
Router::new()
.route(“/one”, get(get_handler).post(post_handler))
.route(“/two”, post(post_handler).get(get_handler))
What did you decide: is get
a function or a method? It appears to be both! And the same with post
!
If you are familiar with API development in Rust, you may recognize this syntax from Axum. This puzzling question piqued my curiosity and led me to build Cairo, where I re-implemented some key Axum concepts in a simpler way as a learning exercise. I’ve long been fascinated by the intermediate + advanced concepts library authors employ to make their interfaces feel frictionless. Rust sits in a brilliant space because of the way it melds high-level expressiveness with low-level control and performance. One of the ways it accomplishes this is using macros.
What is a macro in Rust?
Macros in Rust come in two key forms: declarative and procedural. This post will focus on declarative, but let’s briefly define both.
A declarative macro in Rust uses the function!(..)
syntax, which you may be familiar with from print!(“Hello World!”)
or panic!(“at the disco!”)
. It is a form of metaprogramming which allows developers to reduce boilerplate code. It does this by evaluating the declarative macro at compile time, which produces more Rust code which ultimately ends up in the binary of the program.
If you have wondered why print!(..)
in Rust is a macro, it is due to its dynamic nature. All of these are valid Rust:
println!("Hello");
println!("Hello {}", "World");
println!("Hello {}{}", "World", "!");
In a language with rules strongly-typed language, a traditional function would typically not support a variable number of parameters like this (also called variable-arity). However, Rust’s declarative macros use a $(..),*
syntax to support variable-arity by expanding to different code based on the number of parameters passed. The compiled code may end up calling different function implementations or generating specific code for each unique pattern of parameters.
A procedural macro in Rust uses the #[...]
syntax, which you may have seen in #[tokio:main]
. This macro is applied to a Rust function or struct, allowing the macro to modify the syntax tree of the annotated item at compile time. While it often helps reduce boilerplate code, it does so by transforming the function or struct as a whole rather than directly inserting statements into the function body. For example, #[tokio::main]
wraps the entire main
function in a tokio
async runtime, enabling it to block on the execution of async code in main
.
Code without macros can be repetitive and say the same thing in multiple ways
Axum is one of the most popular web frameworks in Rust. Its compatibility with the Tokio ecosystem and its powerful syntax, among other features, keep it near the front of the pack.
Skipping back to my original bafflement at whether get
is a function or a method, the answer is both and it does this using a declarative macro.
If we look at our snippet of interest, get
must be a function available in the outer scope and a method on whatever type is returned by invoking post
. Similarly, post
must be a function available in the outer scope and a method on whatever type is returned by invoking get
. This symmetry pleases me. Let’s write some code that accomplishes exactly that and nothing more.
use std::collections::HashMap;
/// Define a trait with a single method `handle`.
/// This trait allows different types to implement handler logic, which is useful in API routing
/// systems.
trait Handler {
fn handle(&self);
}
/// Define an enum representing HTTP methods.
/// Deriving `Hash`, `Eq`, and `PartialEq` allows this type to be used as a `HashMap` key.
#[derive(Hash, Eq, PartialEq, Debug)]
enum Method {
Get,
Post,
}
/// Start of a method-chaining function for the `GET` HTTP method.
fn get<H: Handler + 'static>(handler: H) -> InnerType {
InnerType::new().on(Method::Get, handler)
}
/// Start of a method-chaining function for the `POST` HTTP method.
fn post<H: Handler + 'static>(handler: H) -> InnerType {
InnerType::new().on(Method::Post, handler)
}
/// Define a struct that holds a collection of routes. We give it a silly name here to emphasize
/// this is internal to our Axum-like library. Specifically, each `Router` would hold several of
/// these struct instances, each mapped to a specific string-pattern representing a route. This is
/// outside the scope of this example, please see [Cairo](https://github.com/JonesBeach/cairo) if
/// you are interested to learn more.
struct InnerType {
/// `Box<dyn Handler>` enables dynamic dispatch, allowing for different handler types in the
/// `HashMap`.
routes: HashMap<Method, Box<dyn Handler>>,
}
impl InnerType {
/// Initialize our empty instance.
fn new() -> Self {
Self {
routes: HashMap::default(),
}
}
/// Add a `Handler` for a specific HTTP method to the routes map.
fn on<H: Handler + 'static>(mut self, method: Method, handler: H) -> Self {
self.routes.insert(method, Box::new(handler));
self
}
/// Method-chaining function for the `GET` HTTP method.
fn get<H: Handler + 'static>(self, handler: H) -> Self {
self.on(Method::Get, handler)
}
/// Method-chaining function for the `POST` HTTP method.
fn post<H: Handler + 'static>(self, handler: H) -> Self {
self.on(Method::Post, handler)
}
}
At this point, we see some duplication, but the boilerplate isn’t overwhelmingly negative. However, what about when we add support for the remaining HTTP verbs: OPTIONS
, HEAD
, PUT
, DELETE
, PATCH
? We’d find ourselves maintaining 7 functions and 7 methods. Given the choice between maintaining 14 blocks of code versus adding complexity to prove I know a language, I'll choose the latter every single time. Enter declarative macros.
Declarative Macros in Axum
The code below is a mix of Axum and Cairo. We introduce two macros: add_http_function
and add_http_method
.
/// A declarative macro which will define a function `$name` which accepts a `Handler` to be
/// registered to a particular HTTP `Method::$method`.
macro_rules! add_http_function {
(
$name:ident, $method:ident
) => {
fn $name<H: Handler + 'static>(handler: H) -> InnerType {
on(Method::$method, handler)
}
};
}
/// A declarative macro which will define a method `$name` which accepts `self` and a `Handler` to
/// be registered to a particular HTTP `Method::$method`.
macro_rules! add_http_method {
(
$name:ident, $method:ident
) => {
fn $name<H: Handler + 'static>(self, handler: H) -> Self {
self.on(Method::$method, handler)
}
};
}
You'll notice that these both accept two ident
parameters, a $name
and a $method
. The $name
becomes the function name, which will be get
, post
, etc., while the $method
is concatenated to Method::
, meaning we must give a valid Method
enum variant. It's okay if this is confusing at first—metaprogramming is a different way of thinking about programming. Instead of asking "what should my code do?" we are now asking "what code should my code produce?"
You'll also notice that while they both define a "function" with the name $name
, add_http_method
accepts a parameter self
. This means we must invoke our macro (by calling add_http_method!(get, Get)
) in a context which is aware of a self
. For us, this will be inside our impl InnerType
block.
Putting it all together
Putting it all together, here is our new library with minimal boilerplate and support for method chaining for 7 HTTP methods.
use std::collections::HashMap;
/// Define a trait with a single method `handle`.
/// This trait allows different types to implement handler logic, which is useful in API routing
/// systems.
trait Handler {
fn handle(&self);
}
/// Define an enum representing HTTP methods.
/// Deriving `Hash`, `Eq`, and `PartialEq` allows this type to be used as a `HashMap` key.
#[derive(Hash, Eq, PartialEq, Debug)]
enum Method {
Get,
Post,
Options,
Head,
Put,
Delete,
Patch,
}
/// Define a function to be called by our `add_http_function` macro. For a given HTTP method, this
/// function will register a handler and return an type which supports method chaining.
fn on<H: Handler + 'static>(method: Method, handler: H) -> InnerType {
InnerType::new().on(method, handler)
}
/// A declarative macro which will define a function `$name` which accepts a `Handler` to be
/// registered to a particular HTTP `Method::$method`.
macro_rules! add_http_function {
(
$name:ident, $method:ident
) => {
fn $name<H: Handler + 'static>(handler: H) -> InnerType {
on(Method::$method, handler)
}
};
}
// Invoke our declarative macro once for each HTTP `Method`.
add_http_function!(get, Get);
add_http_function!(post, Post);
add_http_function!(delete, Delete);
add_http_function!(head, Head);
add_http_function!(options, Options);
add_http_function!(patch, Patch);
add_http_function!(put, Put);
/// A declarative macro which will define a method `$name` which accepts `self` and a `Handler` to
/// be registered to a particular HTTP `Method::$method`.
macro_rules! add_http_method {
(
$name:ident, $method:ident
) => {
fn $name<H: Handler + 'static>(self, handler: H) -> Self {
self.on(Method::$method, handler)
}
};
}
/// Define a struct that holds a collection of routes. We give it a silly name here to emphasize
/// this is internal to our Axum-like library. Specifically, each `Router` would hold several of
/// these struct instances, each mapped to a specific string-pattern representing a route. This is
/// outside the scope of this example, please see [Cairo](https://github.com/JonesBeach/cairo) if
/// you are interested to learn more.
struct InnerType {
/// `Box<dyn Handler>` enables dynamic dispatch, allowing for different handler types in the
/// `HashMap`.
routes: HashMap<Method, Box<dyn Handler>>,
}
impl InnerType {
/// Initialize our empty instance.
fn new() -> Self {
Self {
routes: HashMap::default(),
}
}
/// Add a `Handler` for a specific HTTP method to the routes map. This will be invoked from our
/// `add_http_method` macro.
fn on<H: Handler + 'static>(mut self, method: Method, handler: H) -> Self {
self.routes.insert(method, Box::new(handler));
self
}
// Invoke our declarative macro once for each HTTP `Method`.
add_http_method!(get, Get);
add_http_method!(post, Post);
add_http_method!(delete, Delete);
add_http_method!(head, Head);
add_http_method!(options, Options);
add_http_method!(patch, Patch);
add_http_method!(put, Put);
}
The End
While we've only scratched the surface of the metapossibilities of Rust's declarative macros, I hope this post has inspired you to explore more of what Rust can do at compile time. Procedural macros are another wonderful beast which I would encourage you to dig into if you are interested in ASTs and language development.
If you are curious about more ways Axum and APIs work under-the-hood, I encourage you to check out my course From Scratch: HTTP Server in Rust. The final module is public as the repo Cairo, which shows the north star you will build towards throughout the course. In addition to the macro usage we described here, you’ll explore the details of HTTP and how to create a Router
which accepts handlers with variable parameters and return types using advanced type erasure.
Now it's your turn! I'd love your perspective in the comments on these two questions:
1. How have you used declarative macros in your own Rust projects to reduce boilerplate code?
2. Are there any Rust crates you have used with an interfaces that makes you go "how did they do that?!"
Top comments (0)