← Back to list

WTF Rust: attribute proc macros

Rust proc macros are confusing. Hopefully this makes them easier to understand.

Brock Smedley · 2022-01-21 20:38 · 19 claps · 3.5 min read
#rust #proc-macro #attr-macr #middleware #auth
Open on Medium ↗

WTF Rust: attribute proc macros

Rust proc macros are confusing. Hopefully this makes them easier to understand.

If you’re reading this I’m assuming that you already know why you need proc (procedural) macros. The specific type of proc macro I’m talking about here is attribute macros.

Proc macros are awesome for implementing things like middleware and default implementations (as in the case of derive macros), but the documentation is kinda shit, and it’s very hard to find practical examples. With this article, I hope to shed some light on this confusing topic.

Here’s the situation I was dealing with: I’m writing an API using actix and I need to define access permissions based on the user’s “user type.” The users in my system have four types: “admin,” “shop,” “customer,” and “manager.” What they mean isn’t really important — the point is that some endpoints are only for admins, and customers shouldn’t be able to access them at all.

To achieve this, I decided to implement a proc attribute macro which would allow me to declare the user types that an endpoint would allow access to.

Like this:

#[auth_route(["admin"])]
pub async fn get_bill_of_lading(...) -> Result<...> {...}

This route should only allow users of the “admin” type to access it.

The signature of my attribute proc macro looks like this:

pub fn auth_route(attr: TokenStream, item: TokenStream) -> TokenStream {...}

attr contains the attributes supplied to the macro invocation (e.g. (["admin"])), and item contains the original function signature and body.

This macro function serves to inject some code that pulls the current user session and runs some checks to see if that user is allowed to access the endpoint. The code it generates is defined in a quote! block, which when compiled, is scoped to the function in which it’s implemented. This means that variables defined in the actual proc macro function (auth_route in my case) are not accessible to the generated code.

So at first, I was trying to do this (WRONG):

// adapted for readability -- don't bother compiling
let auth_check_stmt = quote!({
  if get_auth_types(attr).contains(get_session_user().user_type) {
    // access granted
  } else {
    // access denied
  }
});

attr isn’t defined in the scope that the generated code lives in, so how do we use the information from attr to define whether our user will get access?

Put simply, the answer is match.

Instead of trying to use the attr variable in your injected code, generate new code based on the contents of attr that performs checks against static values.

We need a variable auth_types to store the auth types in the injected code, then we need to push the auth types from attr into it at compile-time. This variable will then be used to check against the user’s type at runtime.

Note: there are two different auth_types variables: the one declared in the proc macro function which stores values from attr at compile-time, and the one declared in the injected code, that is filled with the auth types from the first auth_types variable at runtime (but the code to do so is created at compile-time).

Here’s what it looks like in code.

The first auth_types instance you see is an array containing the auth types pulled from the attr variable. The rest of the auth_types instances you see (inside the build_stmt! calls) are referring to the instance that lives in the scope of the endpoint; where our genereated code will live.

build_stmt! is just a simple macro I wrote to create a Stmt out of arbitrary code.

Notice that instead of using the attr variable in the generated code (which would try to run checks at runtime (and would not compile)), we’re creating static rules at compile-time, which will then be injected into the code and used at runtime. We push these statements into an array (auth_statements), which is then injected into our generated code just before the code that checks the user’s user type against the array of allowed user types.

The lesson is this: because attr can’t be used at runtime, we have to use attr to create code at compile-time to achieve our ends.

Here’s what the injection part of the proc macro definition looks like in the end:

We simply inject our authentication code, which will return a 401 if the user isn’t allowed, or do nothing if they are, at the beginning of the endpoint’s code body. The code is injected in reverse order because we’re using insert(0, ...).

In effect, this code does the following at runtime:

  1. Declare the auth_types variable, which will be used to check against the user’s user type.
  2. Push statically-compiled auth type strings into the auth_types variable.
  3. Check the user’s user type against the auth_types array, return 401 if no match.

Simple, right? 😅

Hopefully this helps some people. I spent days trying to figure this out. I understand that my examples were very specific and missing lots of details, so if you’d like me to provide any clarification, please drop a comment.


메타데이터
post_id
df2c078e7263
slug
wtf-rust-attribute-proc-macros-df2c078e7263
url
https://medium.com/@brocksmedley/wtf-rust-attribute-proc-macros-df2c078e7263
canonical_url
https://medium.com/@brocksmedley/wtf-rust-attribute-proc-macros-df2c078e7263
author_url
https://medium.com/@brocksmedley
status
ok
fetched_at
2026-07-21 23:13:50