use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::parse::Parser as _;
use syn::{Expr, ExprLit, Ident, Lit, LitStr, Meta, Token, parse_quote};
#[derive(Default)]
pub struct AuthorizeArgs {
pub action: Option<String>,
pub resource: Option<Ident>,
from: Option<Ident>,
}
fn parse_authorize_args(attr: TokenStream) -> syn::Result<AuthorizeArgs> {
if attr.is_empty() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"#[authorize] requires an action argument: #[authorize(\"update\", resource = Type)]",
));
}
let metas = syn::punctuated::Punctuated::<Meta, Token![,]>::parse_terminated.parse2(attr)?;
let mut args = AuthorizeArgs::default();
for meta in metas {
match meta {
Meta::Path(p) => {
if let Some(ident) = p.get_ident()
&& args.action.is_none()
{
args.action = Some(ident.to_string());
continue;
}
return Err(syn::Error::new_spanned(
p,
"expected `action` literal or `key = value`",
));
}
Meta::NameValue(nv) => {
let key = nv
.path
.get_ident()
.ok_or_else(|| syn::Error::new_spanned(&nv.path, "expected identifier"))?
.to_string();
match key.as_str() {
"resource" => {
let ident = expect_ident(&nv.value, "resource = TypeName")?;
args.resource = Some(ident);
}
"from" => {
let ident = expect_ident(&nv.value, "from = param_name")?;
args.from = Some(ident);
}
other => {
return Err(syn::Error::new_spanned(
&nv.path,
format!("unknown #[authorize] key: {other}"),
));
}
}
}
Meta::List(l) => {
if l.path.is_ident("action") {
let lit: LitStr = syn::parse2(l.tokens)?;
args.action = Some(lit.value());
} else {
return Err(syn::Error::new_spanned(
&l.path,
"unexpected list-style argument",
));
}
}
}
}
if let Some(action) = first_string_literal(args.action.as_ref()) {
args.action = Some(action);
}
Ok(args)
}
fn first_string_literal(action: Option<&String>) -> Option<String> {
action.and_then(|s| {
let trimmed = s.trim();
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
Some(trimmed[1..trimmed.len() - 1].to_owned())
} else {
None
}
})
}
fn expect_ident(expr: &Expr, hint: &str) -> syn::Result<Ident> {
match expr {
Expr::Path(p) if p.path.get_ident().is_some() => Ok(p.path.get_ident().unwrap().clone()),
Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) => Ok(format_ident!("{}", s.value())),
_ => Err(syn::Error::new_spanned(expr, format!("expected `{hint}`"))),
}
}
use crate::idempotency_guard::block_has_replay_guard;
use crate::param_helpers::has_input_named;
fn snake_case(name: &str) -> String {
let mut out = String::new();
for (i, ch) in name.chars().enumerate() {
if ch.is_uppercase() {
if i > 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
}
out
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::needless_pass_by_value)]
pub fn authorize_macro(attr: TokenStream, item: TokenStream) -> TokenStream {
let mut args = match parse_with_leading_literal(attr) {
Ok(a) => a,
Err(err) => return err.to_compile_error(),
};
let Some(action_str) = args.action.take() else {
return syn::Error::new(
proc_macro2::Span::call_site(),
"#[authorize] requires an action: #[authorize(\"update\", resource = Type)]",
)
.to_compile_error();
};
let Some(resource_ident) = args.resource else {
return syn::Error::new(
proc_macro2::Span::call_site(),
"#[authorize] requires `resource = TypeName`",
)
.to_compile_error();
};
let from_ident = args.from.unwrap_or_else(|| {
let name = snake_case(&resource_ident.to_string());
format_ident!("{}", name)
});
let (leading_items, mut input_fn) =
match crate::param_helpers::split_leading_items_and_reject_incompatible(&item, "authorize")
{
Ok(v) => v,
Err(err) => return err,
};
if !has_input_named(&input_fn, "__autumn_state") {
let state_param: syn::FnArg = parse_quote! {
::autumn_web::reexports::axum::extract::State(__autumn_state):
::autumn_web::reexports::axum::extract::State<::autumn_web::AppState>
};
input_fn.sig.inputs.insert(0, state_param);
}
if !has_input_named(&input_fn, "__autumn_session") {
let session_param: syn::FnArg = parse_quote! {
__autumn_session: ::autumn_web::session::Session
};
input_fn.sig.inputs.insert(0, session_param);
}
if !has_input_named(&input_fn, "__autumn_idempotency_replay") {
let idempotency_param: syn::FnArg = parse_quote! {
__autumn_idempotency_replay: ::core::option::Option<
::autumn_web::reexports::axum::extract::Extension<
::autumn_web::idempotency::IdempotencyReplayResponse
>
>
};
input_fn.sig.inputs.insert(0, idempotency_param);
}
if !has_input_named(&input_fn, "__autumn_route_version") {
let route_version_param: syn::FnArg = parse_quote! {
__autumn_route_version: ::core::option::Option<
::autumn_web::reexports::axum::extract::Extension<
::autumn_web::RouteVersionMetadata
>
>
};
input_fn.sig.inputs.insert(0, route_version_param);
}
if !has_input_named(&input_fn, "__autumn_token_scopes") {
let scopes_param: syn::FnArg = parse_quote! {
__autumn_token_scopes: ::core::option::Option<
::autumn_web::reexports::axum::extract::Extension<
::autumn_web::auth::ApiTokenScopes
>
>
};
input_fn.sig.inputs.insert(0, scopes_param);
}
let action_lit = syn::LitStr::new(&action_str, proc_macro2::Span::call_site());
let resource_lit =
syn::LitStr::new(&resource_ident.to_string(), proc_macro2::Span::call_site());
let original_body = &input_fn.block;
let original_response =
crate::param_helpers::build_original_response(original_body, &input_fn.sig.output);
let body_already_has_replay_guard = block_has_replay_guard(original_body);
let replay_stop = if body_already_has_replay_guard {
quote! {}
} else {
quote! {
const __AUTUMN_IDEMPOTENCY_REPLAY_GUARD: () = ();
if let ::core::option::Option::Some(__autumn_response) =
::autumn_web::idempotency::__replay_response(&__autumn_idempotency_replay)
{
return __autumn_response;
}
}
};
input_fn
.attrs
.push(parse_quote!(#[allow(clippy::too_many_arguments)]));
input_fn.sig.output = parse_quote! {
-> ::autumn_web::reexports::axum::response::Response
};
input_fn.block = parse_quote! {
{
const __AUTUMN_AUTHORIZE_BINDINGS: &[(&str, &str)] = &[(#action_lit, #resource_lit)];
if let ::core::result::Result::Err(__autumn_error) = ::autumn_web::authorization::__check_policy_scoped::<#resource_ident>(
&__autumn_state,
&__autumn_session,
__autumn_token_scopes.as_ref().map(|__e| &__e.0),
#action_lit,
&#from_ident,
).await {
if let ::core::option::Option::Some(__autumn_response) =
::autumn_web::idempotency::__replay_finalized_session_response_for_anonymous(
&__autumn_session,
__autumn_state.auth_session_key(),
&__autumn_idempotency_replay,
)
.await
{
return __autumn_response;
}
return ::autumn_web::reexports::axum::response::IntoResponse::into_response(__autumn_error);
}
if let ::core::option::Option::Some(::autumn_web::reexports::axum::extract::Extension(__autumn_meta)) = &__autumn_route_version {
if let ::core::option::Option::Some(__autumn_response) = ::autumn_web::__private::check_sunset(
&__autumn_state,
__autumn_meta,
) {
return __autumn_response;
}
}
#replay_stop
#original_response
}
};
quote! {
#leading_items
#input_fn
}
}
pub fn parse_with_leading_literal(attr: TokenStream) -> syn::Result<AuthorizeArgs> {
use proc_macro2::TokenTree;
let mut iter = attr.into_iter().peekable();
let mut leading_action: Option<String> = None;
if let Some(TokenTree::Literal(lit)) = iter.peek() {
let lit_str = lit.to_string();
if (lit_str.starts_with('"') && lit_str.ends_with('"'))
|| (lit_str.starts_with('\'') && lit_str.ends_with('\''))
{
let s: LitStr = syn::parse2(quote! { #lit })?;
leading_action = Some(s.value());
iter.next();
if let Some(TokenTree::Punct(p)) = iter.peek()
&& p.as_char() == ','
{
iter.next();
}
}
}
let rest: TokenStream = iter.collect();
let mut parsed = if rest.is_empty() {
AuthorizeArgs::default()
} else {
parse_authorize_args(rest)?
};
if let Some(action) = leading_action {
parsed.action = Some(action);
}
Ok(parsed)
}
pub fn attr_is_authorize_shaped(attr: &syn::Attribute, _input_fn: &syn::ItemFn) -> bool {
meta_is_literally_authorize(&attr.meta) && meta_has_authorize_arg_shape(&attr.meta)
}
fn meta_is_literally_authorize(meta: &syn::Meta) -> bool {
meta.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "authorize")
}
fn meta_has_authorize_arg_shape(meta: &syn::Meta) -> bool {
let syn::Meta::List(list) = meta else {
return false;
};
let Ok(args) = parse_with_leading_literal(list.tokens.clone()) else {
return false;
};
args.action.is_some() && args.resource.is_some()
}
fn meta_is_ambiguous_authorize_shape(meta: &syn::Meta) -> bool {
if meta_is_literally_authorize(meta) {
return false;
}
meta_has_authorize_arg_shape(meta)
}
pub fn reject_if_ambiguous_authorize_shape(input_fn: &syn::ItemFn) -> Option<TokenStream> {
const MESSAGE: &str = "this attribute's arguments match #[authorize]'s grammar (\"action\", \
resource = Type[, from = ident]) under a different name, which Autumn \
cannot resolve: a proc-macro attribute never sees `use` aliases, so this \
could be #[authorize] reached through `use ::autumn_web::authorize as ...;`, \
or an unrelated attribute that happens to share its shape -- and guessing \
either way is unsafe for idempotency-replay handling. Spell it \
`#[authorize(...)]` by its real name if that's what this is, or rename the \
other attribute so its argument shape no longer collides.";
for attr in &input_fn.attrs {
if meta_is_ambiguous_authorize_shape(&attr.meta) {
return Some(syn::Error::new_spanned(attr, MESSAGE).to_compile_error());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn authorize_macro_expansion_is_unchanged_by_the_preamble_refactor() {
let generated = authorize_macro(
quote::quote! { "update", resource = Post },
quote::quote! {
async fn handler() -> &'static str { "ok" }
},
)
.to_string();
assert_eq!(
generated,
include_str!("../testdata/authorize_golden.txt").trim_end()
);
}
#[test]
fn authorize_rejects_sync_functions_with_the_attribute_named_in_the_message() {
let generated = authorize_macro(
quote::quote! { "update", resource = Post },
quote::quote! {
fn sync_handler(note: Note) -> &'static str { "ok" }
},
)
.to_string();
assert!(
generated.contains("#[authorize] can only be applied to async functions"),
"should emit the exact async-required message naming #[authorize]:\n{generated}"
);
}
#[test]
fn attr_is_authorize_shaped_requires_name_and_grammar() {
let input_fn: syn::ItemFn = syn::parse_quote! {
async fn h(note: Note) -> &'static str { "ok" }
};
let attr: syn::Attribute = syn::parse_quote! { #[audit("update", resource = Note)] };
assert!(!attr_is_authorize_shaped(&attr, &input_fn));
let aliased: syn::Attribute =
syn::parse_quote! { #[authz_alias("update", resource = Note)] };
assert!(
!attr_is_authorize_shaped(&aliased, &input_fn),
"an aliased #[authorize] is indistinguishable from an unrelated attribute by name \
alone — that's exactly why it must be a compile error, not a guess"
);
let literal: syn::Attribute = syn::parse_quote! { #[authorize("update", resource = Note)] };
assert!(attr_is_authorize_shaped(&literal, &input_fn));
}
#[test]
fn attr_is_authorize_shaped_rejects_a_same_named_attribute_with_a_different_grammar() {
let input_fn: syn::ItemFn = syn::parse_quote! {
async fn h() -> &'static str { "ok" }
};
let wrong_grammar: syn::Attribute = syn::parse_quote! { #[authorize(skip(note))] };
assert!(
!attr_is_authorize_shaped(&wrong_grammar, &input_fn),
"a same-named attribute that doesn't carry #[authorize]'s own argument shape must \
not be treated as Autumn's real guard"
);
let no_args: syn::Attribute = syn::parse_quote! { #[authorize] };
assert!(!attr_is_authorize_shaped(&no_args, &input_fn));
}
#[test]
fn rejects_an_aliased_authorize_shape() {
let input_fn: syn::ItemFn = syn::parse_quote! {
#[authz_alias("update", resource = Note)]
async fn h(note: Note) -> &'static str { "ok" }
};
let generated = reject_if_ambiguous_authorize_shape(&input_fn)
.expect("an aliased #[authorize]-shaped attribute must be refused")
.to_string();
assert!(generated.contains("compile_error"));
}
#[test]
fn rejects_an_unrelated_attribute_sharing_the_shape() {
let input_fn: syn::ItemFn = syn::parse_quote! {
#[audit("update", resource = Note)]
async fn h(note: Note) -> &'static str { "ok" }
};
let generated = reject_if_ambiguous_authorize_shape(&input_fn)
.expect("a shape collision with no real #[authorize] must still be refused")
.to_string();
assert!(generated.contains("compile_error"));
}
#[test]
fn accepts_the_literal_name_without_ambiguity() {
let input_fn: syn::ItemFn = syn::parse_quote! {
#[authorize("update", resource = Note)]
async fn h(note: Note) -> &'static str { "ok" }
};
assert!(
reject_if_ambiguous_authorize_shape(&input_fn).is_none(),
"the literal #[authorize] spelling is never ambiguous"
);
}
#[test]
fn accepts_an_unrelated_attribute_with_a_different_shape() {
let input_fn: syn::ItemFn = syn::parse_quote! {
#[instrument(skip(note))]
async fn h(note: Note) -> &'static str { "ok" }
};
assert!(
reject_if_ambiguous_authorize_shape(&input_fn).is_none(),
"an attribute with a different argument shape is not ambiguous with #[authorize]"
);
}
#[test]
fn authorize_rejects_when_invoked_on_a_static_route_handler_via_an_alias() {
let accepted = crate::static_route::static_get_macro(
quote::quote! { "/private" },
quote::quote! {
#[auth]
async fn update_post(post: Post) -> &'static str { "ok" }
},
);
assert!(
!accepted.to_string().contains("compile_error"),
"static_get_macro cannot recognize an aliased guard attribute by name: {accepted}"
);
let accepted_fn = crate::param_helpers::extract_fn_item(accepted, "update_post");
let generated = authorize_macro(
quote::quote! { "update", resource = Post },
quote::quote! { #accepted_fn },
)
.to_string();
assert!(
generated.contains("compile_error"),
"authorize_macro must reject a handler already marked as a #[static_get] route, \
regardless of what alias attribute name the source used to invoke it: {generated}"
);
}
#[test]
fn parses_action_and_resource() {
let tokens: TokenStream = r#""update", resource = Post"#.parse().unwrap();
let args = parse_with_leading_literal(tokens).unwrap();
assert_eq!(args.action.as_deref(), Some("update"));
assert_eq!(args.resource.unwrap().to_string(), "Post");
}
#[test]
fn parses_with_explicit_from() {
let tokens: TokenStream = r#""delete", resource = Post, from = the_post"#.parse().unwrap();
let args = parse_with_leading_literal(tokens).unwrap();
assert_eq!(args.action.as_deref(), Some("delete"));
assert_eq!(args.from.unwrap().to_string(), "the_post");
}
#[test]
fn rejects_missing_action() {
let tokens: TokenStream = "resource = Post".parse().unwrap();
let args = parse_with_leading_literal(tokens).unwrap();
assert!(args.action.is_none());
}
#[test]
fn snake_case_handles_pascal_case() {
assert_eq!(snake_case("Post"), "post");
assert_eq!(snake_case("BlogPost"), "blog_post");
assert_eq!(snake_case("HTTPRequest"), "h_t_t_p_request");
}
#[test]
fn authorize_string_literal_replay_guard_still_injects_replay_stop() {
let generated = authorize_macro(
quote::quote! { "update", resource = Post },
quote::quote! {
async fn update_post(post: Post) -> &'static str {
let _ = "__AUTUMN_IDEMPOTENCY_REPLAY_GUARD";
"ok"
}
},
)
.to_string();
assert!(
generated.contains("__replay_response"),
"plain handler text must not suppress the generated replay stop: {generated}"
);
}
#[test]
fn authorize_denial_can_replay_finalized_session_response_for_old_cookie() {
let generated = authorize_macro(
quote::quote! { "update", resource = Post },
quote::quote! {
async fn update_post(post: Post) -> &'static str {
"ok"
}
},
)
.to_string();
assert!(
generated.contains("__replay_finalized_session_response_for_anonymous"),
"authorized handlers must let old destroyed-session retries receive cached finalized Set-Cookie responses: {generated}"
);
}
#[test]
fn authorize_injects_token_scopes_and_calls_scoped_policy_check() {
let generated = authorize_macro(
quote::quote! { "update", resource = Post },
quote::quote! {
async fn update_post(post: Post) -> &'static str {
"ok"
}
},
)
.to_string();
assert!(
generated.contains("__check_policy_scoped"),
"#[authorize] must call __check_policy_scoped so token scopes reach the policy: {generated}"
);
assert!(
generated.contains("__autumn_token_scopes"),
"#[authorize] must inject __autumn_token_scopes parameter: {generated}"
);
assert!(
!generated.contains("__check_policy (") && !generated.contains("__check_policy("),
"#[authorize] must not generate the old unscoped __check_policy call: {generated}"
);
}
#[test]
fn authorize_emits_binding_marker_const() {
let generated = authorize_macro(
quote::quote! { "update", resource = Post },
quote::quote! {
async fn update_post(post: Post) -> &'static str {
"ok"
}
},
)
.to_string();
assert!(
generated.contains(
r#"const __AUTUMN_AUTHORIZE_BINDINGS : & [(& str , & str)] = & [("update" , "Post")] ;"#
),
"#[authorize] must leave a binding marker the route macro can read back when it \
expands first: {generated}"
);
}
#[test]
fn authorize_marker_precedes_policy_check() {
let generated = authorize_macro(
quote::quote! { "update", resource = Post },
quote::quote! {
async fn update_post(post: Post) -> &'static str {
"ok"
}
},
)
.to_string();
let marker = generated
.find("__AUTUMN_AUTHORIZE_BINDINGS")
.unwrap_or_else(|| panic!("binding marker must be emitted: {generated}"));
let check = generated
.find("__check_policy_scoped")
.unwrap_or_else(|| panic!("policy check must be emitted: {generated}"));
assert!(
marker < check,
"the binding marker must be the first statement of the guarded body, so extractors \
find it before any generated control flow: {generated}"
);
}
#[test]
fn authorize_handles_nested_impl_trait_return_type() {
let generated = authorize_macro(
quote::quote! { "update", resource = Post },
quote::quote! {
async fn update_post(post: Post) -> Result<impl IntoResponse, String> {
Ok("ok")
}
},
)
.to_string();
assert!(
!generated.contains("__autumn_inner :"),
"should not emit an explicit local annotation for nested impl Trait: {generated}"
);
}
}