use proc_macro2::{Span, TokenStream};
use quote::{format_ident, quote};
use syn::{FnArg, LitStr, ReturnType, Type};
use crate::api_doc;
use crate::idempotency_guard::block_has_replay_guard;
use crate::parse;
#[allow(clippy::too_many_lines)]
pub fn route_macro(
http_method: &str,
axum_fn: &str,
attr: TokenStream,
item: TokenStream,
) -> TokenStream {
let route_args = match parse::parse_route_attr(attr) {
Ok(a) => a,
Err(err) => return emit_with_attr_parse_error(&item, &err),
};
let path = route_args.path.clone();
let (leading_items, mut input_fn) = match parse::parse_async_handler_with_leading_items(item) {
Ok(v) => v,
Err(err) => return err,
};
if let Some(err) = crate::authorize::reject_if_ambiguous_authorize_shape(&input_fn) {
return err;
}
let interceptors = parse::extract_interceptors(&mut input_fn.attrs);
let api_doc_attr = match api_doc::extract(&mut input_fn.attrs) {
Ok(v) => v,
Err(err) => return err,
};
let edge = crate::edge::detect(&input_fn);
if let Some(marking) = edge
&& let Some(err) = reject_ineligible_edge_route(
http_method,
&input_fn,
!interceptors.is_empty(),
marking.needs_identity,
marking.span,
)
{
return err;
}
let fn_name = &input_fn.sig.ident;
let route_info_name = format_ident!("__autumn_route_info_{}", fn_name);
let vis = &input_fn.vis;
let helper_ident = route_args.helper_ident(fn_name);
let path_helper_name = format_ident!("__autumn_path_{}", helper_ident);
let fn_name_alias = emit_fn_name_alias(
route_args.name_override.as_ref(),
fn_name,
&path_helper_name,
);
let method_const = format_ident!("{}", http_method); let routing_fn = format_ident!("{}", axum_fn);
let has_feature_flag_attr = input_fn.attrs.iter().any(|a| {
a.path()
.segments
.last()
.is_some_and(|s| s.ident == "feature_flag")
});
let has_response_rewriting_guard = has_throttle_guard(&input_fn)
|| has_step_up_guard(&input_fn)
|| has_secured_attr(&input_fn)
|| has_authorize_attr(&input_fn);
let primitive_wrapper = if should_stringify_primitive_output(&input_fn.sig.output)
&& !has_response_rewriting_guard
{
let wrapper_name = format_ident!("__autumn_primitive_handler_{}", fn_name);
let mut wrapper_inputs = Vec::new();
let mut call_args = Vec::new();
if has_feature_flag_attr {
let gate_ident = format_ident!("__AutumnFlagGate_{}", fn_name);
wrapper_inputs.push(quote! { __autumn_gate: #gate_ident });
call_args.push(quote! { __autumn_gate });
}
for (idx, arg) in input_fn.sig.inputs.iter().enumerate() {
match arg {
FnArg::Typed(pat_type) => {
let arg_name = format_ident!("__autumn_arg_{idx}");
let ty = &pat_type.ty;
wrapper_inputs.push(quote! { #arg_name: #ty });
call_args.push(quote! { #arg_name });
}
FnArg::Receiver(receiver) => {
return syn::Error::new_spanned(
receiver,
"Autumn route handlers cannot take a self receiver",
)
.to_compile_error();
}
}
}
Some(quote! {
#[doc(hidden)]
async fn #wrapper_name(#(#wrapper_inputs),*) -> ::std::string::String {
#fn_name(#(#call_args),*).await.to_string()
}
})
} else {
None
};
let handler_name = primitive_wrapper.as_ref().map_or_else(
|| fn_name.clone(),
|_| format_ident!("__autumn_primitive_handler_{}", fn_name),
);
let (native_cfg, edge_companion) = emit_edge_items(edge, fn_name, &handler_name, vis, &path);
let pools_tokens = emit_pool_slice(&derive_pools(&input_fn));
let path_params = api_doc::extract_path_params(&path.value());
let path_params_tokens = api_doc::emit_path_param_slice(&path_params);
let request_body = api_doc::schema_option(api_doc::infer_request_body(&input_fn));
let response_body = api_doc::schema_option(api_doc::infer_response_body(&input_fn));
let query_schema = api_doc::schema_option(api_doc::infer_query_params(&input_fn));
let (secured, required_roles, required_scopes) = api_doc::extract_secured_info(&input_fn);
let is_public = api_doc::is_public(&input_fn);
let has_feature_flag = has_feature_flag_attr || has_expanded_feature_flag_gate(&input_fn);
let body_guarded_replay = secured
|| has_authorize_guard(&input_fn)
|| has_feature_flag
|| has_step_up_guard(&input_fn)
|| has_throttle_guard(&input_fn);
let intercepted_route = !interceptors.is_empty();
let mut handler_expr = build_handler_expr(
&routing_fn,
&handler_name,
&interceptors,
!body_guarded_replay && !intercepted_route,
);
if edge.is_some() {
handler_expr = quote! {
#handler_expr.layer(::autumn_edge::reexports::axum::middleware::map_request(
::autumn_edge::strip_request_credentials,
))
};
}
let route_idempotency = if !route_args.idempotent {
quote! { ::autumn_web::RouteIdempotency::Off }
} else if intercepted_route {
quote! { ::autumn_web::RouteIdempotency::Direct }
} else {
quote! { ::autumn_web::RouteIdempotency::ReplayThroughInner }
};
let api_doc_fields = api_doc_attr.emit_ident_fields(fn_name);
let http_method_lit = LitStr::new(http_method, Span::call_site());
let api_version_expr = route_args.api_version.as_ref().map_or_else(
|| quote! { ::core::option::Option::None },
|lit| quote! { ::core::option::Option::Some(#lit) },
);
let sunset_opt_out_val = route_args.sunset_opt_out;
let route_timeout = match route_args.timeout {
crate::parse::RouteTimeoutAttr::Inherit => {
quote! { ::autumn_web::RouteTimeout::Inherit }
}
crate::parse::RouteTimeoutAttr::Ms(ms) => {
quote! {
::autumn_web::RouteTimeout::Override(
::core::time::Duration::from_millis(#ms)
)
}
}
crate::parse::RouteTimeoutAttr::Disabled => {
quote! { ::autumn_web::RouteTimeout::Disabled }
}
};
let route_criticality = match route_args.criticality {
crate::parse::RouteCriticalityAttr::Critical => {
quote! { ::autumn_web::Criticality::Critical }
}
crate::parse::RouteCriticalityAttr::Default => {
quote! { ::autumn_web::Criticality::Default }
}
crate::parse::RouteCriticalityAttr::Sheddable => {
quote! { ::autumn_web::Criticality::Sheddable }
}
};
let has_policy_val = has_policy_only(&input_fn);
let authorize_bindings =
api_doc::emit_authorize_binding_slice(&api_doc::extract_authorize_bindings(&input_fn));
let agent_authority = if api_doc::extract_agent_authority(&input_fn) {
let authority_static = format_ident!("__AUTUMN_AGENT_AUTHORITY_{}", fn_name);
quote! { ::core::option::Option::Some(&#authority_static) }
} else {
quote! { ::core::option::Option::None }
};
let seo_defaults = route_args.seo.emit();
let graph_descriptor =
crate::graph::emit_route_descriptor(&input_fn, http_method, "e! { #path }, false);
let encoders = if edge.is_some() {
quote! { ::autumn_edge::paths }
} else {
quote! { ::autumn_web::paths }
};
let path_helper = emit_path_helper(&path_helper_name, &path, &path_params, &encoders);
quote! {
#leading_items
#input_fn
#primitive_wrapper
#native_cfg
#[doc(hidden)]
#vis fn #route_info_name() -> ::autumn_web::Route {
::autumn_web::Route {
method: ::autumn_web::reexports::http::Method::#method_const,
path: #path,
handler: #handler_expr,
name: ::core::stringify!(#fn_name),
api_version: #api_version_expr,
sunset_opt_out: #sunset_opt_out_val,
api_doc: ::autumn_web::openapi::ApiDoc {
method: #http_method_lit,
path: #path,
path_params: #path_params_tokens,
request_body: #request_body,
response: #response_body,
query_schema: #query_schema,
secured: #secured,
required_roles: #required_roles,
required_scopes: #required_scopes,
register_schemas: ::core::option::Option::None,
api_version: #api_version_expr,
sunset_opt_out: #sunset_opt_out_val,
has_policy: #has_policy_val,
authorize_bindings: #authorize_bindings,
pools: #pools_tokens,
public: #is_public,
module_path: ::core::module_path!(),
source_file: ::core::file!(),
source_line: ::core::line!(),
agent_authority: #agent_authority,
#api_doc_fields
},
repository: ::core::option::Option::None,
idempotency: #route_idempotency,
timeout: #route_timeout,
criticality: #route_criticality,
seo: #seo_defaults,
}
}
#native_cfg
#graph_descriptor
#path_helper
#fn_name_alias
#edge_companion
}
}
fn emit_with_attr_parse_error(item: &TokenStream, err: &TokenStream) -> TokenStream {
let Ok((leading_items, mut input_fn)) = parse::split_leading_items_and_fn(item) else {
return quote! { #item #err };
};
parse::extract_interceptors(&mut input_fn.attrs);
let _ = api_doc::extract(&mut input_fn.attrs);
let edge = crate::edge::detect(&input_fn);
let vis = &input_fn.vis;
let fn_name = &input_fn.sig.ident;
let route_info_name = format_ident!("__autumn_route_info_{fn_name}");
let native_cfg = edge.map(|_| quote! { #[cfg(not(target_arch = "wasm32"))] });
let edge_stub = edge.map(|_| {
let edge_route_name = format_ident!("__autumn_edge_route_{fn_name}");
quote! {
#[doc(hidden)]
#vis fn #edge_route_name() -> ::autumn_edge::EdgeRoute {
unreachable!()
}
}
});
quote! {
#leading_items
#input_fn
#native_cfg
#[doc(hidden)]
#vis fn #route_info_name() -> ::autumn_web::Route {
unreachable!()
}
#edge_stub
#err
}
}
const EDGE_METHOD_ERROR: &str = "`#[edge]` is only supported on `#[get]` routes; \
the edge lane is read-path only (issue #1790)";
const EDGE_GUARD_ERROR: &str = "`#[edge]` cannot be combined with \
`#[secured]`/`#[authorize]`/`#[step_up]`/`#[throttle]` — the edge \
capsule has no session or auth state; serve this route from the \
origin";
const EDGE_INTERCEPT_ERROR: &str = "`#[edge]` cannot be combined with `#[intercept(...)]` — \
interceptor layers are origin-only tower middleware and are \
not carried into the edge capsule, so the two lanes would \
serve different bytes; serve this route from the origin";
const EDGE_EXTENSION_ERROR: &str = "`#[edge]` handlers cannot take `Extension<...>` — the \
capsule installs no request extensions (the `EdgeCache` seam \
is the one mediated capability), so a missing extension \
would be served as a 500 instead of falling through; use \
`EdgeCache`, or serve this route from the origin";
const EDGE_IDENTITY_UNDECLARED_ERROR: &str = "`#[edge]` handlers that take `EdgeIdentity` must \
declare it with `#[edge(needs(identity))]`, so an unauthenticated request falls through to \
the origin before any handler code runs";
const EDGE_AGENT_OPERABLE_ERROR: &str = "`#[edge]` cannot be combined with `#[agent_operable(...)]` — the edge lane is \
read-only, and the capsule has no audit sink or `AppState` to record an agent \
invocation against, so a governed action would run there unaudited; serve this \
route from the origin.\n\nServe the route from the origin, or drop \
`#[agent_operable]`. See docs/guide/agent-authority.md.";
const GUARD_MARKERS: &[&str] = &[
"__AUTUMN_SECURED_ROLES",
"__AUTUMN_SECURED_SCOPES",
"__AUTUMN_AUTHORIZE_BINDINGS",
"__AUTUMN_AGENT_OPERABLE",
];
fn reject_ineligible_edge_route(
http_method: &str,
input_fn: &syn::ItemFn,
has_interceptors: bool,
needs_identity: bool,
span: Span,
) -> Option<TokenStream> {
if http_method != "GET" {
return Some(syn::Error::new(span, EDGE_METHOD_ERROR).to_compile_error());
}
if crate::api_doc::extract_agent_authority(input_fn) {
return Some(syn::Error::new(span, EDGE_AGENT_OPERABLE_ERROR).to_compile_error());
}
if has_auth_or_rate_guard(input_fn) {
return Some(syn::Error::new(span, EDGE_GUARD_ERROR).to_compile_error());
}
if has_interceptors {
return Some(syn::Error::new(span, EDGE_INTERCEPT_ERROR).to_compile_error());
}
if has_extension_param(input_fn) {
return Some(syn::Error::new(span, EDGE_EXTENSION_ERROR).to_compile_error());
}
if !needs_identity && has_edge_identity_param(input_fn) {
return Some(syn::Error::new(span, EDGE_IDENTITY_UNDECLARED_ERROR).to_compile_error());
}
None
}
fn has_edge_identity_param(input_fn: &syn::ItemFn) -> bool {
input_fn.sig.inputs.iter().any(|arg| {
let syn::FnArg::Typed(pat_type) = arg else {
return false;
};
tokens_contain_ident("e! { #pat_type }, "EdgeIdentity")
})
}
fn has_extension_param(input_fn: &syn::ItemFn) -> bool {
input_fn.sig.inputs.iter().any(|arg| {
let syn::FnArg::Typed(pat_type) = arg else {
return false;
};
tokens_contain_ident("e! { #pat_type }, "Extension")
})
}
const POOL_EXTRACTORS: &[(&str, &str)] = &[
("CrossShard", "db"),
("Db", "db"),
("Events", "events"),
("Mailer", "mail"),
("Notifications", "notifications"),
("Presence", "presence"),
("ShardedDb", "db"),
("ShardedReadDb", "db"),
("Shards", "db"),
];
fn derive_pools(input_fn: &syn::ItemFn) -> Vec<&'static str> {
let mut pools: Vec<&'static str> = Vec::new();
for arg in &input_fn.sig.inputs {
let syn::FnArg::Typed(pat_type) = arg else {
continue;
};
let Some(extractor) = declared_extractor(&pat_type.ty) else {
continue;
};
for (name, pool) in POOL_EXTRACTORS {
if extractor == *name && !pools.contains(pool) {
pools.push(pool);
}
}
}
pools.sort_unstable();
pools
}
fn declared_extractor(ty: &syn::Type) -> Option<String> {
match ty {
syn::Type::Reference(inner) => declared_extractor(&inner.elem),
syn::Type::Paren(inner) => declared_extractor(&inner.elem),
syn::Type::Group(inner) => declared_extractor(&inner.elem),
syn::Type::Path(path) => {
let segment = path.path.segments.last()?;
if segment.ident == "Option"
&& let syn::PathArguments::AngleBracketed(args) = &segment.arguments
&& let Some(syn::GenericArgument::Type(inner)) = args.args.first()
{
return declared_extractor(inner);
}
Some(segment.ident.to_string())
}
_ => None,
}
}
fn emit_pool_slice(pools: &[&'static str]) -> TokenStream {
if pools.is_empty() {
quote! { &[] }
} else {
quote! { &[#(#pools),*] }
}
}
fn tokens_contain_ident(stream: &TokenStream, needle: &str) -> bool {
stream.clone().into_iter().any(|tree| match tree {
proc_macro2::TokenTree::Ident(ident) => ident == needle,
proc_macro2::TokenTree::Group(group) => tokens_contain_ident(&group.stream(), needle),
_ => false,
})
}
fn has_auth_or_rate_guard(input_fn: &syn::ItemFn) -> bool {
has_secured_attr(input_fn)
|| has_authorize_attr(input_fn)
|| has_step_up_guard(input_fn)
|| has_throttle_guard(input_fn)
|| GUARD_MARKERS
.iter()
.any(|marker| crate::edge::stmts_have_marker(&input_fn.block.stmts, marker))
}
fn emit_edge_items(
edge: Option<crate::edge::EdgeMarking>,
fn_name: &proc_macro2::Ident,
handler_name: &proc_macro2::Ident,
vis: &syn::Visibility,
path: &LitStr,
) -> (TokenStream, TokenStream) {
let Some(marking) = edge else {
return (TokenStream::new(), TokenStream::new());
};
let mut capabilities = Vec::new();
if marking.needs_kv {
capabilities.push(quote! { ::autumn_edge::EdgeCapability::Kv });
}
if marking.needs_identity {
capabilities.push(quote! { ::autumn_edge::EdgeCapability::Identity });
}
let needs = quote! { &[#(#capabilities),*] };
let edge_route_name = format_ident!("__autumn_edge_route_{}", fn_name);
(
quote! { #[cfg(not(target_arch = "wasm32"))] },
quote! {
#[doc(hidden)]
#vis fn #edge_route_name() -> ::autumn_edge::EdgeRoute {
::autumn_edge::EdgeRoute {
method: ::autumn_edge::reexports::http::Method::GET,
path: #path,
handler: ::autumn_edge::edge_get(#handler_name),
name: ::core::stringify!(#fn_name),
needs: #needs,
}
}
},
)
}
fn build_handler_expr(
routing_fn: &proc_macro2::Ident,
handler_name: &proc_macro2::Ident,
interceptors: &[syn::Path],
include_replay_layer: bool,
) -> TokenStream {
let mut expr = quote! { ::autumn_web::reexports::axum::routing::#routing_fn(#handler_name) };
if include_replay_layer {
expr = quote! {
::autumn_web::reexports::axum::routing::MethodRouter::<
::autumn_web::AppState, ::core::convert::Infallible
>::layer(#expr, ::autumn_web::idempotency::IdempotencyReplayLayer)
};
}
for interceptor in interceptors.iter().rev() {
expr = quote! {
::autumn_web::reexports::axum::routing::MethodRouter::<
::autumn_web::AppState, ::core::convert::Infallible
>::layer(#expr, #interceptor)
};
}
expr
}
fn has_authorize_guard(input_fn: &syn::ItemFn) -> bool {
input_fn
.attrs
.iter()
.any(|attr| crate::authorize::attr_is_authorize_shaped(attr, input_fn))
|| block_has_replay_guard(&input_fn.block)
|| crate::api_doc::has_policy_check_in_stmts(&input_fn.block.stmts)
}
fn has_step_up_guard(input_fn: &syn::ItemFn) -> bool {
input_fn.attrs.iter().any(|attr| {
attr.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "step_up")
}) || crate::param_helpers::has_guard_gate_param_with_prefix(input_fn, "__AutumnStepUpGate_")
}
fn has_throttle_guard(input_fn: &syn::ItemFn) -> bool {
input_fn.attrs.iter().any(|attr| {
attr.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "throttle")
}) || crate::param_helpers::has_guard_gate_param_with_prefix(input_fn, "__AutumnThrottleGate_")
}
fn has_secured_attr(input_fn: &syn::ItemFn) -> bool {
input_fn.attrs.iter().any(|attr| {
attr.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "secured")
})
}
fn has_authorize_attr(input_fn: &syn::ItemFn) -> bool {
input_fn.attrs.iter().any(|attr| {
attr.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "authorize")
})
}
fn has_policy_only(input_fn: &syn::ItemFn) -> bool {
input_fn.attrs.iter().any(|attr| {
attr.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "authorize")
}) || crate::api_doc::has_policy_check_in_stmts(&input_fn.block.stmts)
}
fn has_expanded_feature_flag_gate(input_fn: &syn::ItemFn) -> bool {
input_fn.sig.inputs.iter().any(|arg| {
let FnArg::Typed(pat_type) = arg else {
return false;
};
let Type::Path(type_path) = pat_type.ty.as_ref() else {
return false;
};
let Some(last_segment) = type_path.path.segments.last() else {
return false;
};
last_segment
.ident
.to_string()
.starts_with("__AutumnFlagGate_")
})
}
fn emit_fn_name_alias(
name_override: Option<&syn::LitStr>,
fn_name: &proc_macro2::Ident,
path_helper_name: &proc_macro2::Ident,
) -> TokenStream {
let fn_path_helper_name = format_ident!("__autumn_path_{}", fn_name);
if name_override.is_some() && fn_path_helper_name != *path_helper_name {
quote! {
#[doc(hidden)]
pub use self::#path_helper_name as #fn_path_helper_name;
}
} else {
quote! {}
}
}
fn emit_path_helper(
helper_name: &proc_macro2::Ident,
path: &LitStr,
params: &[String],
encoders: &TokenStream,
) -> TokenStream {
let param_idents: Vec<proc_macro2::Ident> = params
.iter()
.map(|p| {
let sanitized = p.trim_start_matches('*').replace('-', "_");
proc_macro2::Ident::new_raw(&sanitized, proc_macro2::Span::call_site())
})
.collect();
let format_str = positional_format_string(&path.value());
let format_lit = LitStr::new(&format_str, path.span());
let encoded_params: Vec<TokenStream> = params
.iter()
.zip(param_idents.iter())
.map(|(param, ident)| {
if param.starts_with('*') {
quote! { #encoders::encode_catch_all_param(#ident) }
} else {
quote! { #encoders::encode_path_segment(#ident) }
}
})
.collect();
quote! {
#[doc(hidden)]
pub fn #helper_name(#(#param_idents: impl ::std::fmt::Display),*) -> ::std::string::String {
format!(#format_lit, #(#encoded_params),*)
}
}
}
fn positional_format_string(path: &str) -> String {
let mut result = String::with_capacity(path.len());
let mut chars = path.chars().peekable();
while let Some(c) = chars.next() {
match c {
'{' if chars.peek() == Some(&'{') => {
chars.next();
result.push_str("{{");
}
'{' => {
result.push_str("{}");
let mut depth: u32 = 1;
for inner in chars.by_ref() {
match inner {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
}
}
'}' if chars.peek() == Some(&'}') => {
chars.next();
result.push_str("}}");
}
_ => result.push(c),
}
}
result
}
pub fn should_stringify_primitive_output(output: &ReturnType) -> bool {
let ReturnType::Type(_, ty) = output else {
return false;
};
let Type::Path(path) = ty.as_ref() else {
return false;
};
if path.qself.is_some() || path.path.segments.len() != 1 {
return false;
}
let ident = path.path.segments[0].ident.to_string();
matches!(
ident.as_str(),
"bool"
| "i8"
| "i16"
| "i32"
| "i64"
| "i128"
| "isize"
| "u8"
| "u16"
| "u32"
| "u64"
| "u128"
| "usize"
| "f32"
| "f64"
)
}
#[cfg(test)]
mod tests {
use quote::quote;
use super::{positional_format_string, route_macro};
#[test]
fn positional_plain_params() {
assert_eq!(positional_format_string("/posts/{id}"), "/posts/{}");
}
#[test]
fn positional_regex_constrained_params() {
assert_eq!(positional_format_string("/users/{id:[0-9]+}"), "/users/{}");
}
#[test]
fn positional_multiple_params() {
assert_eq!(
positional_format_string("/posts/{year}/{slug}"),
"/posts/{}/{}"
);
}
#[test]
fn positional_static_path() {
assert_eq!(positional_format_string("/hello"), "/hello");
}
#[test]
fn positional_catch_all_param() {
assert_eq!(positional_format_string("/files/{*path}"), "/files/{}");
}
#[test]
fn positional_hyphenated_param() {
assert_eq!(positional_format_string("/items/{item-id}"), "/items/{}");
}
#[test]
fn positional_regex_with_quantifier_braces() {
assert_eq!(
positional_format_string("/users/{id:[0-9]{1,3}}"),
"/users/{}"
);
}
#[test]
fn positional_keyword_param() {
assert_eq!(positional_format_string("/items/{type}"), "/items/{}");
}
#[test]
fn positional_escaped_braces_pass_through() {
assert_eq!(positional_format_string("/{{hello}}"), "/{{hello}}");
assert_eq!(
positional_format_string("/{{literal}}/{id}"),
"/{{literal}}/{}"
);
}
#[test]
fn route_macro_attr_parse_error_still_emits_handler_and_companion() {
let generated = route_macro(
"GET",
"get",
quote! {},
quote! {
async fn index() -> &'static str { "Hello!" }
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"a bad route attribute must still be a compile error: {generated}"
);
assert!(
generated.contains("fn index"),
"the handler must survive the attribute error, or routes![index] \
cannot find `index`: {generated}"
);
assert!(
generated.contains("fn __autumn_route_info_index"),
"the companion must survive the attribute error, or routes![index] \
adds a second, confusing \"cannot find function\" error: {generated}"
);
}
#[test]
fn route_macro_attr_parse_error_strips_intercept_marker() {
let generated = route_macro(
"GET",
"get",
quote! {},
quote! {
#[intercept(MyLayer)]
async fn index() -> &'static str { "Hello!" }
},
)
.to_string();
assert!(
!generated.contains("intercept"),
"`#[intercept(...)]` must not survive onto the re-emitted handler: {generated}"
);
assert!(
generated.contains("fn index") && generated.contains("fn __autumn_route_info_index"),
"the handler and its companion must still survive: {generated}"
);
}
#[test]
fn route_macro_attr_parse_error_strips_api_doc_marker() {
let generated = route_macro(
"GET",
"get",
quote! {},
quote! {
#[api_doc(summary = "Fetch a user by id")]
async fn index() -> &'static str { "Hello!" }
},
)
.to_string();
assert!(
!generated.contains("api_doc"),
"`#[api_doc(...)]` must not survive onto the re-emitted handler: {generated}"
);
assert!(
generated.contains("fn index") && generated.contains("fn __autumn_route_info_index"),
"the handler and its companion must still survive: {generated}"
);
}
#[test]
fn route_macro_attr_parse_error_gates_the_native_stub_on_edge_routes() {
let generated = route_macro(
"GET",
"get",
quote! {},
quote! {
#[edge]
async fn show() -> &'static str { "post" }
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"a bad route attribute on an edge route must still be a compile error: {generated}"
);
let gate = "# [cfg (not (target_arch = \"wasm32\"))]";
assert!(
generated.contains(&format!(
"{gate} # [doc (hidden)] fn __autumn_route_info_show"
)),
"the route-info stub must be gated off wasm32, same as the successful path: \
{generated}"
);
assert!(
generated.contains("fn __autumn_edge_route_show () -> :: autumn_edge :: EdgeRoute"),
"an unconditional edge-route stub must stand in for edge_routes![show]: {generated}"
);
}
#[test]
fn route_macro_attr_parse_error_survives_a_guard_expanded_above_it() {
let secured = crate::secured::secured_macro(
quote! { "admin" },
quote! {
async fn create() -> &'static str { "ok" }
},
);
let generated = route_macro("POST", "post", quote! {}, secured).to_string();
assert!(
generated.contains("compile_error"),
"a bad route attribute above an expanded guard must still be a compile error: \
{generated}"
);
assert!(
generated.contains("fn create"),
"the handler must survive alongside the guard's gate item: {generated}"
);
assert!(
generated.contains("fn __autumn_route_info_create"),
"the companion must still be built even though `item` carries leading items: \
{generated}"
);
}
#[test]
fn route_macro_string_literal_replay_guard_still_injects_layer() {
let generated = route_macro(
"POST",
"post",
quote! { "/items" },
quote! {
async fn create_item() -> &'static str {
let _ = "__AUTUMN_IDEMPOTENCY_REPLAY_GUARD";
"created"
}
},
)
.to_string();
assert!(
generated.contains("IdempotencyReplayLayer"),
"plain handler text must not be mistaken for a generated replay stop: {generated}"
);
}
#[test]
fn route_macro_interceptor_uses_direct_idempotency() {
let generated = route_macro(
"POST",
"post",
quote! { "/items" },
quote! {
#[intercept(TenantLayer)]
async fn create_item() -> &'static str {
"created"
}
},
)
.to_string();
assert!(
generated.contains("RouteIdempotency :: Direct"),
"intercepted routes must fail closed when replay scope is not explicit: {generated}"
);
assert!(
!generated.contains("IdempotencyReplayLayer"),
"intercepted routes must not advertise an implicit replay stop: {generated}"
);
}
#[test]
fn route_macro_suppresses_replay_layer_when_throttle_attribute_present() {
let generated = route_macro(
"POST",
"post",
quote! { "/items" },
quote! {
#[throttle(limit = 1, per = "60s", key = "ip")]
async fn create_item() -> &'static str { "created" }
},
)
.to_string();
assert!(
!generated.contains("IdempotencyReplayLayer"),
"throttled route (route outermost) must not add the outer replay layer: {generated}"
);
}
#[test]
fn route_macro_suppresses_replay_layer_for_expanded_throttle_prologue() {
let throttled = crate::throttle::throttle_macro(
quote! { limit = 1, per = "60s", key = "ip" },
quote! {
async fn create_item() -> &'static str { "created" }
},
);
let generated = route_macro("POST", "post", quote! { "/items" }, throttled).to_string();
assert!(
!generated.contains("IdempotencyReplayLayer"),
"throttled route (throttle expanded first) must not add the outer replay layer: \
{generated}"
);
}
#[test]
fn route_macro_infers_response_schema_when_throttle_expands_first() {
let throttled = crate::throttle::throttle_macro(
quote! { limit = 5, per = "1m", key = "ip" },
quote! {
async fn create() -> ::autumn_web::reexports::axum::Json<Created> { todo!() }
},
);
let throttled_fn = crate::param_helpers::extract_fn_item(throttled, "create");
let generated = route_macro(
"POST",
"post",
quote! { "/users" },
quote! { #throttled_fn },
)
.to_string();
assert!(
generated.contains("response : :: core :: option :: Option :: Some")
&& generated.contains("\"Created\""),
"a #[throttle]-above-#[post] route must still document its Json<Created> response: \
{generated}"
);
}
#[test]
fn route_macro_infers_response_schema_when_secured_expands_first() {
let secured = crate::secured::secured_macro(
quote! { "admin" },
quote! {
async fn create() -> ::autumn_web::reexports::axum::Json<Created> { todo!() }
},
);
let secured_fn = crate::param_helpers::extract_fn_item(secured, "create");
let generated =
route_macro("POST", "post", quote! { "/users" }, quote! { #secured_fn }).to_string();
assert!(
generated.contains("response : :: core :: option :: Option :: Some")
&& generated.contains("\"Created\""),
"a #[secured]-above-#[post] route must still document its Json<Created> response: \
{generated}"
);
}
#[test]
fn route_macro_infers_response_schema_when_step_up_expands_first() {
let stepped_up = crate::step_up::step_up_macro(
quote! {},
quote! {
async fn create() -> ::autumn_web::reexports::axum::Json<Created> { todo!() }
},
);
let stepped_up_fn = crate::param_helpers::extract_fn_item(stepped_up, "create");
let generated = route_macro(
"POST",
"post",
quote! { "/users" },
quote! { #stepped_up_fn },
)
.to_string();
assert!(
generated.contains("response : :: core :: option :: Option :: Some")
&& generated.contains("\"Created\""),
"a #[step_up]-above-#[post] route must still document its Json<Created> response: \
{generated}"
);
}
#[test]
fn route_macro_infers_response_schema_when_authorize_expands_first() {
let authorized = crate::authorize::authorize_macro(
quote! { "update", resource = Note },
quote! {
async fn update_note(note: Note) -> ::autumn_web::reexports::axum::Json<Created> {
todo!()
}
},
);
let generated =
route_macro("POST", "post", quote! { "/notes/{id}" }, authorized).to_string();
assert!(
generated.contains("response : :: core :: option :: Option :: Some")
&& generated.contains("\"Created\""),
"an #[authorize]-above-#[post] route must still document its Json<Created> response: \
{generated}"
);
}
#[test]
fn route_macro_infers_response_schema_under_stacked_guards_above_route() {
let throttled = crate::throttle::throttle_macro(
quote! { limit = 5, per = "1m", key = "ip" },
quote! {
async fn create() -> ::autumn_web::reexports::axum::Json<Created> { todo!() }
},
);
let throttled_fn = crate::param_helpers::extract_fn_item(throttled, "create");
let secured = crate::secured::secured_macro(quote! { "admin" }, quote! { #throttled_fn });
let secured_fn = crate::param_helpers::extract_fn_item(secured, "create");
let generated =
route_macro("POST", "post", quote! { "/users" }, quote! { #secured_fn }).to_string();
assert!(
generated.contains("response : :: core :: option :: Option :: Some")
&& generated.contains("\"Created\""),
"stacked guards above the route macro must not drop the response schema: {generated}"
);
}
#[test]
fn route_macro_parses_api_version_and_sunset_opt_out() {
let generated = route_macro(
"GET",
"get",
quote! { "/items", api_version = "v1", sunset_opt_out = true },
quote! {
async fn get_items() -> &'static str {
"items"
}
},
)
.to_string();
assert!(
generated.contains("api_version"),
"should generate api_version field: {generated}"
);
assert!(
generated.contains("sunset_opt_out"),
"should generate sunset_opt_out field: {generated}"
);
}
#[test]
fn route_macro_defaults_timeout_to_inherit() {
let generated = route_macro(
"GET",
"get",
quote! { "/items" },
quote! {
async fn get_items() -> &'static str { "items" }
},
)
.to_string();
assert!(
generated.contains("RouteTimeout :: Inherit"),
"routes without a timeout attribute must inherit the global deadline: {generated}"
);
}
#[test]
fn route_macro_parses_timeout_ms_override() {
let generated = route_macro(
"GET",
"get",
quote! { "/export", timeout_ms = 120000 },
quote! {
async fn export() -> &'static str { "report" }
},
)
.to_string();
assert!(
generated.contains("RouteTimeout :: Override"),
"timeout_ms must emit a RouteTimeout::Override: {generated}"
);
assert!(
generated.contains("from_millis") && generated.contains("120000"),
"override must carry the configured millisecond budget: {generated}"
);
}
#[test]
fn route_macro_defaults_public_false() {
let generated = route_macro(
"POST",
"post",
quote! { "/widgets" },
quote! { async fn create_widget() -> &'static str { "ok" } },
)
.to_string();
assert!(
generated.contains("public : false"),
"an unannotated route must record public = false: {generated}"
);
assert!(generated.contains("module_path"));
}
#[test]
fn route_macro_marks_public_when_public_attribute_present() {
let generated = route_macro(
"POST",
"post",
quote! { "/pricing" },
quote! {
#[public]
async fn pricing() -> &'static str { "free" }
},
)
.to_string();
assert!(
generated.contains("public : true"),
"a #[public] handler must record public = true: {generated}"
);
}
#[test]
fn route_macro_marks_public_from_expanded_marker() {
let public_fn = crate::public::public_macro(
quote! {},
quote! { async fn pricing() -> &'static str { "free" } },
);
let generated = route_macro("POST", "post", quote! { "/pricing" }, public_fn).to_string();
assert!(
generated.contains("public : true"),
"a route macro over an expanded #[public] marker must record public = true: {generated}"
);
}
#[test]
fn route_macro_defaults_seo_to_empty() {
let generated = route_macro(
"GET",
"get",
quote! { "/about" },
quote! { async fn about() -> &'static str { "about" } },
)
.to_string();
assert!(
generated.contains("SeoRouteDefaults :: EMPTY"),
"a route without seo(...) must record the empty defaults: {generated}"
);
}
#[test]
fn route_macro_emits_declared_seo_defaults() {
let generated = route_macro(
"GET",
"get",
quote! { "/about", seo(title = "About", description = "Learn about us") },
quote! { async fn about() -> &'static str { "about" } },
)
.to_string();
assert!(
generated.contains("with_title (\"About\")"),
"seo(title = ...) must populate the title default: {generated}"
);
assert!(
generated.contains("with_description (\"Learn about us\")"),
"seo(description = ...) must populate the description default: {generated}"
);
assert!(
generated.contains("SeoRouteDefaults :: EMPTY . with_title"),
"unset seo keys must fall back to the empty defaults: {generated}"
);
}
#[test]
fn route_macro_seo_composes_with_other_keys() {
let generated = route_macro(
"GET",
"get",
quote! { "/about", seo(title = "About"), name = "about_page", timeout_ms = 500 },
quote! { async fn about() -> &'static str { "about" } },
)
.to_string();
assert!(
generated.contains("with_title (\"About\")"),
"seo(...) must parse when it precedes other keys: {generated}"
);
assert!(
generated.contains("RouteTimeout :: Override"),
"keys after seo(...) must still parse: {generated}"
);
assert!(
generated.contains("__autumn_path_about_page"),
"name override after seo(...) must still apply: {generated}"
);
}
#[test]
fn route_macro_never_emits_a_seo_struct_literal() {
let generated = route_macro(
"GET",
"get",
quote! {
"/full",
seo(
title = "t",
description = "d",
canonical = "c",
og_title = "ot",
og_description = "od",
og_image = "oi",
og_type = "oty",
og_url = "ou",
twitter_card = "tc",
twitter_title = "tt",
twitter_description = "td",
twitter_image = "ti",
robots = "noindex"
)
},
quote! { async fn full() -> &'static str { "full" } },
)
.to_string();
assert!(
generated.contains("with_robots (\"noindex\")"),
"every declared key must still be emitted: {generated}"
);
assert!(
!generated.contains("SeoRouteDefaults {"),
"the expansion must not construct SeoRouteDefaults by struct literal: {generated}"
);
let seo_value = generated
.split("seo : ")
.nth(1)
.and_then(|rest| rest.split(',').next())
.expect("expansion should assign the seo field");
assert!(
seo_value.contains("SeoRouteDefaults :: EMPTY"),
"sanity: seo value should build from EMPTY: {seo_value}"
);
assert!(
!seo_value.contains(".."),
"the seo value must not use struct-update syntax: {seo_value}"
);
}
#[test]
fn route_macro_rejects_empty_seo_group() {
let generated = route_macro(
"GET",
"get",
quote! { "/about", seo() },
quote! { async fn about() -> &'static str { "about" } },
)
.to_string();
assert!(
generated.contains("compile_error"),
"an empty seo() must be a compile error, not a silent no-op: {generated}"
);
}
#[test]
fn route_macro_rejects_repeated_seo_argument() {
let generated = route_macro(
"GET",
"get",
quote! { "/about", seo(title = "A"), seo(og_type = "website") },
quote! { async fn about() -> &'static str { "about" } },
)
.to_string();
assert!(
generated.contains("compile_error"),
"a second seo(...) argument must be a compile error: {generated}"
);
}
#[test]
fn route_macro_rejects_seo_keys_without_separating_comma() {
let generated = route_macro(
"GET",
"get",
quote! { "/about", seo(title = "A" og_type = "website") },
quote! { async fn about() -> &'static str { "about" } },
)
.to_string();
assert!(
generated.contains("compile_error"),
"a missing comma must be rejected rather than dropping later keys: {generated}"
);
}
#[test]
fn route_macro_rejects_unknown_seo_key() {
let generated = route_macro(
"GET",
"get",
quote! { "/about", seo(titel = "About") },
quote! { async fn about() -> &'static str { "about" } },
)
.to_string();
assert!(
generated.contains("compile_error"),
"an unknown seo key must be a compile error: {generated}"
);
assert!(
generated.contains("titel"),
"the error must name the offending key: {generated}"
);
}
#[test]
fn route_macro_rejects_duplicate_seo_key() {
let generated = route_macro(
"GET",
"get",
quote! { "/about", seo(title = "A", title = "B") },
quote! { async fn about() -> &'static str { "about" } },
)
.to_string();
assert!(
generated.contains("compile_error"),
"a duplicate seo key must be a compile error: {generated}"
);
}
#[test]
fn route_macro_rejects_non_string_seo_value() {
let generated = route_macro(
"GET",
"get",
quote! { "/about", seo(title = 42) },
quote! { async fn about() -> &'static str { "about" } },
)
.to_string();
assert!(
generated.contains("compile_error"),
"a non-string seo value must be a compile error: {generated}"
);
}
#[test]
fn route_macro_rejects_seo_without_parentheses() {
let generated = route_macro(
"GET",
"get",
quote! { "/about", seo = "title" },
quote! { async fn about() -> &'static str { "about" } },
)
.to_string();
assert!(
generated.contains("compile_error"),
"`seo = ...` must be rejected in favour of `seo(...)`: {generated}"
);
}
#[test]
fn route_macro_parses_timeout_off_disabled() {
let generated = route_macro(
"GET",
"get",
quote! { "/stream", timeout = "off" },
quote! {
async fn stream() -> &'static str { "data" }
},
)
.to_string();
assert!(
generated.contains("RouteTimeout :: Disabled"),
"timeout = \"off\" must emit RouteTimeout::Disabled: {generated}"
);
}
#[test]
fn route_macro_defaults_criticality_to_default() {
let generated = route_macro(
"GET",
"get",
quote! { "/x" },
quote! { async fn x() -> &'static str { "x" } },
)
.to_string();
assert!(
generated.contains("criticality : :: autumn_web :: Criticality :: Default"),
"{generated}"
);
}
#[test]
fn route_macro_parses_each_criticality() {
for (value, variant) in [
("critical", "Critical"),
("default", "Default"),
("sheddable", "Sheddable"),
] {
let generated = route_macro(
"POST",
"post",
quote! { "/x", criticality = #value },
quote! { async fn x() -> &'static str { "x" } },
)
.to_string();
assert!(
generated.contains(&format!("Criticality :: {variant}")),
"criticality = {value:?}: {generated}"
);
}
}
#[test]
fn route_macro_rejects_an_unknown_criticality() {
let generated = route_macro(
"GET",
"get",
quote! { "/x", criticality = "urgent" },
quote! { async fn x() -> &'static str { "x" } },
)
.to_string();
assert!(generated.contains("compile_error"), "{generated}");
assert!(
generated.contains("invalid `criticality` value"),
"{generated}"
);
}
#[test]
fn route_macro_defaults_to_the_idempotency_layer() {
let generated = route_macro(
"POST",
"post",
quote! { "/x" },
quote! { async fn x() -> &'static str { "x" } },
)
.to_string();
assert!(
generated.contains("RouteIdempotency :: ReplayThroughInner"),
"{generated}"
);
}
#[test]
fn route_macro_parses_idempotent_false_as_off() {
for attr in [
quote! { "/x", idempotent = false },
quote! { "/x", criticality = "critical", idempotent = false },
] {
let generated = route_macro(
"POST",
"post",
attr.clone(),
quote! {
#[intercept(TenantLayer)]
async fn x() -> &'static str { "x" }
},
)
.to_string();
assert!(
generated.contains("RouteIdempotency :: Off"),
"{attr}: {generated}"
);
}
}
#[test]
fn route_macro_accepts_idempotent_true_as_the_default() {
let generated = route_macro(
"POST",
"post",
quote! { "/x", idempotent = true },
quote! { async fn x() -> &'static str { "x" } },
)
.to_string();
assert!(
generated.contains("RouteIdempotency :: ReplayThroughInner"),
"{generated}"
);
}
#[test]
fn route_macro_rejects_a_non_bool_idempotent() {
let generated = route_macro(
"POST",
"post",
quote! { "/x", idempotent = "no" },
quote! { async fn x() -> &'static str { "x" } },
)
.to_string();
assert!(generated.contains("compile_error"), "{generated}");
}
#[test]
fn route_macro_emits_empty_authorize_bindings_by_default() {
let generated = route_macro(
"POST",
"post",
quote! { "/widgets" },
quote! { async fn create_widget() -> &'static str { "ok" } },
)
.to_string();
assert!(
generated.contains("authorize_bindings : & []"),
"an unguarded route must record no authorization bindings: {generated}"
);
}
#[test]
fn route_macro_records_authorize_binding_when_attribute_present() {
let generated = route_macro(
"POST",
"post",
quote! { "/notes/{id}" },
quote! {
#[authorize("update", resource = Note)]
async fn update_note(note: Note) -> &'static str { "ok" }
},
)
.to_string();
assert!(
generated.contains(r#"AuthorizeBinding { action : "update" , resource : "Note" }"#),
"an #[authorize] attribute must record its (action, resource) binding: {generated}"
);
}
#[test]
fn route_macro_records_authorize_binding_from_expanded_marker() {
let authorized = crate::authorize::authorize_macro(
quote! { "update", resource = Note },
quote! {
async fn update_note(note: Note) -> &'static str { "ok" }
},
);
let generated =
route_macro("POST", "post", quote! { "/notes/{id}" }, authorized).to_string();
assert!(
generated.contains(r#"AuthorizeBinding { action : "update" , resource : "Note" }"#),
"an already-expanded #[authorize] must still record its binding: {generated}"
);
}
#[test]
fn route_macro_records_authorize_binding_under_secured_wrapper() {
let authorized = crate::authorize::authorize_macro(
quote! { "update", resource = Note },
quote! {
async fn update_note(note: Note) -> &'static str { "ok" }
},
);
let secured = crate::secured::secured_macro(quote! { "admin" }, authorized);
let secured_fn = crate::param_helpers::extract_fn_item(secured, "update_note");
let generated = route_macro(
"POST",
"post",
quote! { "/notes/{id}" },
quote! { #secured_fn },
)
.to_string();
assert!(
generated.contains(r#"AuthorizeBinding { action : "update" , resource : "Note" }"#),
"a binding nested inside a #[secured] wrapper must not be lost: {generated}"
);
}
#[test]
fn route_macro_records_all_bindings_for_stacked_authorize_attributes() {
let generated = route_macro(
"POST",
"post",
quote! { "/notes/{id}" },
quote! {
#[authorize("update", resource = Note)]
#[authorize("publish", resource = Note)]
async fn update_note(note: Note) -> &'static str { "ok" }
},
)
.to_string();
let update = generated
.find(r#"AuthorizeBinding { action : "update" , resource : "Note" }"#)
.unwrap_or_else(|| {
panic!("the first stacked #[authorize] must record a binding: {generated}")
});
let publish = generated
.find(r#"AuthorizeBinding { action : "publish" , resource : "Note" }"#)
.unwrap_or_else(|| {
panic!("the second stacked #[authorize] must record a binding: {generated}")
});
assert!(
update < publish,
"stacked bindings must be recorded in source order: {generated}"
);
}
#[test]
fn route_macro_preserves_secured_roles_when_authorize_wraps_them() {
let secured = crate::secured::secured_macro(
quote! { "admin" },
quote! {
async fn update_note(note: Note) -> &'static str { "ok" }
},
);
let secured_fn = crate::param_helpers::extract_fn_item(secured, "update_note");
let authorized = crate::authorize::authorize_macro(
quote! { "update", resource = Note },
quote! { #secured_fn },
);
let generated =
route_macro("POST", "post", quote! { "/notes/{id}" }, authorized).to_string();
assert!(
generated.contains(r#"required_roles : & ["admin"]"#),
"roles from a #[secured] buried under an #[authorize] wrapper must survive: \
{generated}"
);
assert!(
generated.contains(r#"AuthorizeBinding { action : "update" , resource : "Note" }"#),
"…and the authorize binding must be recorded alongside them: {generated}"
);
}
#[test]
fn route_macro_preserves_secured_roles_when_authorize_is_still_an_attribute() {
let secured = crate::secured::secured_macro(
quote! { "admin", scopes = ["notes:write"] },
quote! {
#[authorize("update", resource = Note)]
async fn update_note(note: Note) -> &'static str { "ok" }
},
);
let secured_fn = crate::param_helpers::extract_fn_item(secured, "update_note");
let generated = route_macro(
"POST",
"post",
quote! { "/notes/{id}" },
quote! { #secured_fn },
)
.to_string();
assert!(
generated.contains(r#"required_roles : & ["admin"]"#),
"roles from an expanded #[secured] must survive a live #[authorize] attribute: \
{generated}"
);
assert!(
generated.contains(r#"required_scopes : & ["notes:write"]"#),
"scopes must survive alongside the roles: {generated}"
);
assert!(
generated.contains(r#"AuthorizeBinding { action : "update" , resource : "Note" }"#),
"…and the authorize binding must be recorded alongside them: {generated}"
);
}
#[test]
fn route_macro_orders_marker_binding_before_live_attribute() {
let authorized = crate::authorize::authorize_macro(
quote! { "update", resource = Note },
quote! {
async fn update_note(note: Note) -> &'static str { "ok" }
},
);
let mixed = quote! {
#[authorize("publish", resource = Note)]
#authorized
};
let generated = route_macro("POST", "post", quote! { "/notes/{id}" }, mixed).to_string();
let update = generated
.find(r#"AuthorizeBinding { action : "update" , resource : "Note" }"#)
.unwrap_or_else(|| panic!("the marker binding must be recorded: {generated}"));
let publish = generated
.find(r#"AuthorizeBinding { action : "publish" , resource : "Note" }"#)
.unwrap_or_else(|| panic!("the live-attribute binding must be recorded: {generated}"));
assert!(
update < publish,
"the marker comes from the attribute above the route macro, so it precedes the \
live attribute below it in source order: {generated}"
);
}
fn edge_companion_fragment(generated: &str) -> &str {
let start = generated
.find("fn __autumn_edge_route_")
.unwrap_or_else(|| panic!("expansion should emit an edge companion: {generated}"));
&generated[start..]
}
#[test]
fn route_macro_edge_get_emits_edge_companion() {
let generated = route_macro(
"GET",
"get",
quote! { "/greet" },
quote! {
#[edge]
async fn greet() -> &'static str { "hi" }
},
)
.to_string();
assert!(
generated.contains("fn __autumn_edge_route_greet () -> :: autumn_edge :: EdgeRoute"),
"an #[edge] GET route must emit the edge companion: {generated}"
);
assert!(
generated.contains(":: autumn_edge :: edge_get (greet)"),
"the edge companion must adapt the handler through edge_get: {generated}"
);
assert!(
generated.contains(":: autumn_edge :: reexports :: http :: Method :: GET"),
"the edge companion must carry the GET method: {generated}"
);
assert!(
generated.contains("path : \"/greet\""),
"the edge companion must carry the route path: {generated}"
);
}
#[test]
fn route_macro_edge_companion_mounts_the_primitive_wrapper() {
let generated = route_macro(
"GET",
"get",
quote! { "/stats" },
quote! {
#[edge]
async fn stats() -> usize { 42 }
},
)
.to_string();
assert!(
generated.contains(":: autumn_edge :: edge_get (__autumn_primitive_handler_stats)"),
"the edge companion must mount the primitive wrapper: {generated}"
);
let wrapper = generated
.find("async fn __autumn_primitive_handler_stats")
.unwrap_or_else(|| panic!("the primitive wrapper must be emitted: {generated}"));
assert!(
!generated[..wrapper].contains("cfg"),
"the primitive wrapper references no autumn_web item, so it is not gated: {generated}"
);
}
#[test]
fn route_macro_edge_cfg_gates_native_companions() {
let generated = route_macro(
"GET",
"get",
quote! { "/posts/{id}" },
quote! {
#[edge]
async fn show(id: Path<String>) -> &'static str { "post" }
},
)
.to_string();
let gate = "# [cfg (not (target_arch = \"wasm32\"))]";
assert!(
generated.contains(&format!(
"{gate} # [doc (hidden)] fn __autumn_route_info_show"
)),
"the route-info companion must be gated off wasm32: {generated}"
);
assert!(
generated.contains(&format!(
"{gate} :: autumn_web :: reexports :: inventory :: submit !"
)),
"the architecture-graph node references ::autumn_web too (#1747): {generated}"
);
assert_eq!(
generated.matches(gate).count(),
2,
"exactly the two native companions are gated: {generated}"
);
assert!(
!edge_companion_fragment(&generated).contains("cfg"),
"the edge companion must not be gated: {generated}"
);
}
#[test]
fn route_macro_edge_path_helper_uses_autumn_edge_and_is_not_gated() {
let generated = route_macro(
"GET",
"get",
quote! { "/files/{id}/{*rest}" },
quote! {
#[edge]
async fn file() -> &'static str { "f" }
},
)
.to_string();
assert!(
generated.contains("pub fn __autumn_path_file"),
"the path helper must be emitted: {generated}"
);
assert!(
!generated.contains(
"# [cfg (not (target_arch = \"wasm32\"))] # [doc (hidden)] pub fn __autumn_path_file"
),
"the edge path helper must not be gated off wasm32: {generated}"
);
assert!(
generated.contains(":: autumn_edge :: paths :: encode_path_segment (r#id)"),
"a segment must use the edge encoder: {generated}"
);
assert!(
generated.contains(":: autumn_edge :: paths :: encode_catch_all_param (r#rest)"),
"a catch-all must use the edge encoder: {generated}"
);
assert!(
!generated.contains(":: autumn_web :: paths"),
"the edge path helper must not name autumn_web: {generated}"
);
}
#[test]
fn route_macro_plain_path_helper_keeps_autumn_web() {
let generated = route_macro(
"GET",
"get",
quote! { "/files/{id}" },
quote! {
async fn file() -> &'static str { "f" }
},
)
.to_string();
assert!(
generated.contains(":: autumn_web :: paths :: encode_path_segment (r#id)"),
"{generated}"
);
assert!(!generated.contains("autumn_edge"), "{generated}");
}
#[test]
fn route_macro_edge_path_helper_alias_is_not_gated() {
let generated = route_macro(
"GET",
"get",
quote! { "/greet", name = "greeting" },
quote! {
#[edge]
async fn greet() -> &'static str { "hi" }
},
)
.to_string();
assert!(
generated.contains(
"} # [doc (hidden)] pub use self :: __autumn_path_greeting as __autumn_path_greet ;"
),
"the alias re-exports an ungated helper, so it is not gated: {generated}"
);
assert_eq!(
generated
.matches("# [cfg (not (target_arch = \"wasm32\"))]")
.count(),
2,
"only route info and the graph node are native-only: {generated}"
);
}
#[test]
fn route_macro_edge_detection_is_identical_for_both_attribute_orders() {
let below = route_macro(
"GET",
"get",
quote! { "/greet" },
quote! {
#[edge]
async fn greet() -> &'static str { "hi" }
},
)
.to_string();
let edged = crate::edge::edge_macro(
quote! {},
quote! { async fn greet() -> &'static str { "hi" } },
);
let above = route_macro("GET", "get", quote! { "/greet" }, edged).to_string();
assert_eq!(
edge_companion_fragment(&below),
edge_companion_fragment(&above),
"both stacking orders must produce the same edge route"
);
}
#[test]
fn route_macro_edge_needs_identity_populates_the_needs_slice() {
let edged = crate::edge::edge_macro(
quote! { needs(kv, identity) },
quote! { async fn me(identity: EdgeIdentity) -> String { identity.user_id().as_str().to_owned() } },
);
let generated = route_macro("GET", "get", quote! { "/me" }, edged).to_string();
assert!(
generated.contains(
"needs : & [:: autumn_edge :: EdgeCapability :: Kv , :: autumn_edge :: EdgeCapability :: Identity]"
),
"needs(identity) must declare the Identity capability on the edge route: {generated}"
);
assert!(!generated.contains("compile_error"), "{generated}");
}
#[test]
fn route_macro_edge_rejects_undeclared_edge_identity() {
let edged = crate::edge::edge_macro(
quote! {},
quote! { async fn me(identity: EdgeIdentity) -> String { identity.user_id().as_str().to_owned() } },
);
let generated = route_macro("GET", "get", quote! { "/me" }, edged).to_string();
assert!(generated.contains("compile_error"), "{generated}");
assert!(
generated.contains("needs(identity)"),
"the error must name the missing declaration: {generated}"
);
}
#[test]
fn route_macro_edge_needs_kv_populates_the_needs_slice() {
let edged = crate::edge::edge_macro(
quote! { needs(kv) },
quote! { async fn note() -> &'static str { "note" } },
);
let generated = route_macro("GET", "get", quote! { "/note" }, edged).to_string();
assert!(
generated.contains("needs : & [:: autumn_edge :: EdgeCapability :: Kv]"),
"needs(kv) must declare the Kv capability on the edge route: {generated}"
);
}
#[test]
fn route_macro_edge_needs_kv_is_read_from_a_live_attribute_too() {
let generated = route_macro(
"GET",
"get",
quote! { "/note" },
quote! {
#[edge(needs(kv))]
async fn note() -> &'static str { "note" }
},
)
.to_string();
assert!(
generated.contains("needs : & [:: autumn_edge :: EdgeCapability :: Kv]"),
"a live #[edge(needs(kv))] attribute must declare the capability: {generated}"
);
}
#[test]
fn route_macro_edge_without_needs_declares_no_capabilities() {
let generated = route_macro(
"GET",
"get",
quote! { "/greet" },
quote! {
#[edge]
async fn greet() -> &'static str { "hi" }
},
)
.to_string();
assert!(
generated.contains("needs : & []"),
"a bare #[edge] route must declare no capabilities: {generated}"
);
}
#[test]
fn route_macro_non_edge_expansion_is_untouched() {
let generated = route_macro(
"GET",
"get",
quote! { "/posts/{id}", name = "post_page" },
quote! {
async fn show(id: Path<String>) -> &'static str { "post" }
},
)
.to_string();
assert!(
!generated.contains("cfg"),
"a non-edge route must not gain a cfg attribute: {generated}"
);
assert!(
!generated.contains("__autumn_edge_route_"),
"a non-edge route must not gain an edge companion: {generated}"
);
assert!(
!generated.contains("autumn_edge"),
"a non-edge route must not reference autumn_edge at all: {generated}"
);
}
#[test]
fn route_macro_edge_on_post_is_a_compile_error() {
let generated = route_macro(
"POST",
"post",
quote! { "/items" },
quote! {
#[edge]
async fn create_item() -> &'static str { "created" }
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"#[edge] on a write-path route must be rejected: {generated}"
);
assert!(
generated.contains("only supported on"),
"the error must say the edge lane is GET-only: {generated}"
);
}
#[test]
fn route_macro_edge_with_secured_attribute_is_a_compile_error() {
let generated = route_macro(
"GET",
"get",
quote! { "/dashboard" },
quote! {
#[edge]
#[secured]
async fn dashboard() -> &'static str { "dash" }
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"#[edge] + #[secured] must be rejected: {generated}"
);
assert!(
generated.contains("no session or auth state"),
"the error must explain why the edge lane cannot authenticate: {generated}"
);
}
#[test]
fn route_macro_edge_with_expanded_secured_marker_is_a_compile_error() {
let secured = crate::secured::secured_macro(
quote! { "admin" },
quote! {
#[edge]
async fn dashboard() -> &'static str { "dash" }
},
);
let generated = route_macro("GET", "get", quote! { "/dashboard" }, secured).to_string();
assert!(
generated.contains("compile_error"),
"an already-expanded #[secured] must still be detected: {generated}"
);
}
#[test]
fn route_macro_edge_marker_buried_under_secured_wrapper_is_a_compile_error() {
let edged = crate::edge::edge_macro(
quote! {},
quote! { async fn dashboard() -> &'static str { "dash" } },
);
let secured = crate::secured::secured_macro(quote! { "admin" }, edged);
let generated = route_macro("GET", "get", quote! { "/dashboard" }, secured).to_string();
assert!(
generated.contains("compile_error"),
"a buried #[edge] marker must still be detected next to #[secured]: {generated}"
);
}
#[test]
fn route_macro_edge_with_intercept_is_a_compile_error() {
let generated = route_macro(
"GET",
"get",
quote! { "/stamped" },
quote! {
#[edge]
#[intercept(StampLayer)]
async fn stamped() -> &'static str { "stamped" }
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"#[edge] + #[intercept] must be rejected: {generated}"
);
assert!(
generated.contains("origin-only tower middleware"),
"the error must explain that interceptors do not cross into the capsule: {generated}"
);
}
#[test]
fn route_macro_edge_marker_with_intercept_is_a_compile_error() {
let edged = crate::edge::edge_macro(
quote! {},
quote! {
#[intercept(StampLayer)]
async fn stamped() -> &'static str { "stamped" }
},
);
let generated = route_macro("GET", "get", quote! { "/stamped" }, edged).to_string();
assert!(
generated.contains("compile_error"),
"an expanded #[edge] marker + live #[intercept] must be rejected: {generated}"
);
}
#[test]
fn route_macro_edge_native_mount_strips_request_credentials() {
let generated = route_macro(
"GET",
"get",
quote! { "/greet/{name}" },
quote! {
#[edge]
async fn greet(Path(name): Path<String>) -> String { name }
},
)
.to_string();
assert!(
generated.contains("strip_request_credentials"),
"the edge route's native mount must carry the credential strip: {generated}"
);
let plain = route_macro(
"GET",
"get",
quote! { "/greet/{name}" },
quote! {
async fn greet(Path(name): Path<String>) -> String { name }
},
)
.to_string();
assert!(
!plain.contains("strip_request_credentials"),
"a non-edge route must not be rewritten: {plain}"
);
}
#[test]
fn route_macro_edge_with_extension_param_is_a_compile_error() {
let generated = route_macro(
"GET",
"get",
quote! { "/tenant" },
quote! {
#[edge]
async fn tenant(ext: Extension<TenantConfig>) -> String { ext.name() }
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"#[edge] + Extension<T> must be rejected: {generated}"
);
assert!(
generated.contains("installs no request extensions"),
"the error must explain the missing-extension 500 hazard: {generated}"
);
}
#[test]
fn route_macro_edge_with_nested_extension_param_is_a_compile_error() {
let generated = route_macro(
"GET",
"get",
quote! { "/tenant" },
quote! {
#[edge]
async fn tenant(ext: Option<axum::Extension<TenantConfig>>) -> String { greet() }
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"a nested Extension extractor must be rejected: {generated}"
);
}
#[test]
fn route_macro_edge_cache_param_is_not_mistaken_for_an_extension() {
let generated = route_macro(
"GET",
"get",
quote! { "/note/{key}" },
quote! {
#[edge(needs(kv))]
async fn note(Path(key): Path<String>, cache: EdgeCache) -> String {
cache.get_string(&key).unwrap_or_default()
}
},
)
.to_string();
assert!(
!generated.contains("compile_error"),
"EdgeCache must stay accepted: {generated}"
);
}
#[test]
fn route_macro_intercept_without_edge_is_unaffected() {
let generated = route_macro(
"GET",
"get",
quote! { "/stamped" },
quote! {
#[intercept(StampLayer)]
async fn stamped() -> &'static str { "stamped" }
},
)
.to_string();
assert!(
!generated.contains("compile_error"),
"#[intercept] without #[edge] must keep compiling: {generated}"
);
assert!(
generated.contains("StampLayer"),
"the interceptor layer must still wrap the native mount: {generated}"
);
}
#[test]
fn route_macro_edge_with_throttle_is_a_compile_error() {
let generated = route_macro(
"GET",
"get",
quote! { "/search" },
quote! {
#[edge]
#[throttle(limit = 1, per = "60s", key = "ip")]
async fn search() -> &'static str { "results" }
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"#[edge] + #[throttle] must be rejected: {generated}"
);
}
#[test]
fn route_macro_edge_with_expanded_throttle_marker_is_a_compile_error() {
let throttled = crate::throttle::throttle_macro(
quote! { limit = 1, per = "60s", key = "ip" },
quote! {
#[edge]
async fn search() -> &'static str { "results" }
},
);
let generated = route_macro("GET", "get", quote! { "/search" }, throttled).to_string();
assert!(
generated.contains("compile_error"),
"an already-expanded #[throttle] must still be detected: {generated}"
);
}
#[test]
fn route_macro_edge_with_step_up_is_a_compile_error() {
let generated = route_macro(
"GET",
"get",
quote! { "/vault" },
quote! {
#[edge]
#[step_up]
async fn vault() -> &'static str { "secrets" }
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"#[edge] + #[step_up] must be rejected: {generated}"
);
}
#[test]
fn route_macro_edge_with_authorize_is_a_compile_error() {
let generated = route_macro(
"GET",
"get",
quote! { "/notes/{id}" },
quote! {
#[edge]
#[authorize("read", resource = Note)]
async fn show_note(note: Note) -> &'static str { "note" }
},
)
.to_string();
assert!(
generated.contains("compile_error"),
"#[edge] + #[authorize] must be rejected: {generated}"
);
}
#[test]
fn route_macro_string_literal_edge_marker_is_not_an_edge_route() {
let generated = route_macro(
"GET",
"get",
quote! { "/greet" },
quote! {
async fn greet() -> &'static str {
let _ = "const __AUTUMN_EDGE: () = ();";
"hi"
}
},
)
.to_string();
assert!(
!generated.contains("__autumn_edge_route_"),
"a string literal must not be mistaken for the edge marker: {generated}"
);
}
#[test]
fn route_macro_string_literal_authorize_marker_is_not_a_binding() {
let generated = route_macro(
"POST",
"post",
quote! { "/items" },
quote! {
async fn create_item() -> &'static str {
let _ = "const __AUTUMN_AUTHORIZE_BINDINGS: &[(&str, &str)] = &[(\"update\", \"Note\")];";
"created"
}
},
)
.to_string();
assert!(
generated.contains("authorize_bindings : & []"),
"a string literal must not be mistaken for a generated binding marker: {generated}"
);
}
const DRAFT_REFUND_AUTHORITY: &str = "agent_authority : :: core :: option :: Option :: Some (& __AUTUMN_AGENT_AUTHORITY_draft_refund)";
#[test]
fn route_macro_sets_agent_authority_from_live_attribute() {
let generated = route_macro(
"POST",
"post",
quote! { "/refunds" },
quote! {
#[agent_operable(grant = RefundDrafter)]
async fn draft_refund() -> &'static str { "drafted" }
},
)
.to_string();
assert!(
generated.contains(DRAFT_REFUND_AUTHORITY),
"a live #[agent_operable] attribute must point ApiDoc at the handler's \
authority static: {generated}"
);
}
#[test]
fn route_macro_sets_agent_authority_from_body_marker() {
let generated = route_macro(
"POST",
"post",
quote! { "/refunds" },
quote! {
async fn draft_refund() -> &'static str {
#[allow(dead_code)]
const __AUTUMN_AGENT_OPERABLE: &str = "RefundDrafter";
"drafted"
}
},
)
.to_string();
assert!(
generated.contains(DRAFT_REFUND_AUTHORITY),
"an already-expanded #[agent_operable] must still point ApiDoc at the \
authority static: {generated}"
);
}
#[test]
fn route_macro_without_agent_operable_emits_none() {
let generated = route_macro(
"POST",
"post",
quote! { "/refunds" },
quote! { async fn draft_refund() -> &'static str { "drafted" } },
)
.to_string();
assert!(
generated.contains("agent_authority : :: core :: option :: Option :: None"),
"a handler with no #[agent_operable] records no authority: {generated}"
);
assert!(
!generated.contains("__AUTUMN_AGENT_AUTHORITY_"),
"…and must not reference an authority static that was never emitted: {generated}"
);
}
#[test]
fn route_macro_string_literal_agent_operable_marker_is_not_an_authority() {
let generated = route_macro(
"POST",
"post",
quote! { "/refunds" },
quote! {
async fn draft_refund() -> &'static str {
let _ = "const __AUTUMN_AGENT_OPERABLE: &str = \"RefundDrafter\";";
"drafted"
}
},
)
.to_string();
assert!(
generated.contains("agent_authority : :: core :: option :: Option :: None"),
"a string literal must not be mistaken for the generated marker: {generated}"
);
}
#[test]
fn edge_rejects_agent_operable() {
let attribute_form = route_macro(
"GET",
"get",
quote! { "/refunds" },
quote! {
#[edge]
#[agent_operable(grant = RefundDrafter)]
async fn draft_refund() -> &'static str { "drafted" }
},
)
.to_string();
assert!(
attribute_form.contains("compile_error"),
"#[edge] + #[agent_operable] must be rejected: {attribute_form}"
);
assert!(
attribute_form.contains("read-only"),
"the error must say the edge lane is read-only: {attribute_form}"
);
let marker_form = route_macro(
"GET",
"get",
quote! { "/refunds" },
quote! {
#[edge]
async fn draft_refund() -> &'static str {
#[allow(dead_code)]
const __AUTUMN_AGENT_OPERABLE: &str = "RefundDrafter";
"drafted"
}
},
)
.to_string();
assert!(
marker_form.contains("compile_error"),
"an already-expanded #[agent_operable] must still be detected next to \
#[edge]: {marker_form}"
);
assert!(
marker_form.contains("read-only"),
"the error must say the edge lane is read-only: {marker_form}"
);
}
#[test]
fn route_macro_records_the_database_pool_from_a_db_extractor() {
let generated = route_macro(
"GET",
"get",
quote! { "/posts" },
quote! {
async fn index(db: Db) -> String {
let _ = db;
String::new()
}
},
)
.to_string();
assert!(
generated.contains(r#"pools : & ["db"]"#),
"a `Db` extractor must prove the database pool: {generated}"
);
}
#[test]
fn route_macro_records_no_pools_for_a_compute_only_handler() {
let generated = route_macro(
"GET",
"get",
quote! { "/about" },
quote! {
async fn about() -> &'static str {
"about"
}
},
)
.to_string();
assert!(
generated.contains("pools : & []"),
"a handler declaring no resource extractor proves no pool: {generated}"
);
}
#[test]
fn route_macro_records_each_distinct_pool_once_and_in_order() {
let generated = route_macro(
"POST",
"post",
quote! { "/posts" },
quote! {
async fn create(db: Db, mailer: Mailer, events: Events) -> String {
let _ = (db, mailer, events);
String::new()
}
},
)
.to_string();
assert!(
generated.contains(r#"pools : & ["db" , "events" , "mail"]"#),
"pools must be sorted and deduplicated for a stable contract diff: {generated}"
);
}
#[test]
fn route_macro_recognizes_sharded_database_extractors() {
for extractor in ["ShardedDb", "ShardedReadDb", "Shards", "CrossShard"] {
let ty = syn::Ident::new(extractor, proc_macro2::Span::call_site());
let generated = route_macro(
"GET",
"get",
quote! { "/posts" },
quote! {
async fn index(db: #ty) -> String {
let _ = db;
String::new()
}
},
)
.to_string();
assert!(
generated.contains(r#"pools : & ["db"]"#),
"`{extractor}` is a database handle: {generated}"
);
}
}
#[test]
fn route_macro_sees_a_pool_extractor_through_a_qualified_path() {
let generated = route_macro(
"GET",
"get",
quote! { "/posts" },
quote! {
async fn index(db: autumn_web::db::Db) -> String {
let _ = db;
String::new()
}
},
)
.to_string();
assert!(
generated.contains(r#"pools : & ["db"]"#),
"a fully qualified extractor path still proves the pool: {generated}"
);
}
#[test]
fn route_macro_does_not_read_a_pool_out_of_a_nested_generic() {
let generated = route_macro(
"GET",
"get",
quote! { "/posts" },
quote! {
async fn index(state: State<AppState<Db>>) -> String {
let _ = state;
String::new()
}
},
)
.to_string();
assert!(
generated.contains("pools : & []"),
"a pool name nested inside another type is not a declared extractor: {generated}"
);
}
#[test]
fn route_macro_still_sees_a_pool_through_option_and_reference_wrappers() {
for spelling in ["Option < Db >", "& Db"] {
let ty: syn::Type = syn::parse_str(spelling).expect("type parses");
let generated = route_macro(
"GET",
"get",
quote! { "/posts" },
quote! {
async fn index(db: #ty) -> String {
let _ = db;
String::new()
}
},
)
.to_string();
assert!(
generated.contains(r#"pools : & ["db"]"#),
"`{spelling}` still declares the database extractor: {generated}"
);
}
}
}