#![warn(missing_docs)]
use proc_macro::TokenStream;
use quote::{ToTokens, quote};
use std::collections::BTreeMap; use syn::{
Expr, Ident, ImplItem, ItemImpl, LitStr, Token,
ext::IdentExt,
parse::{Parse, ParseStream},
parse_macro_input,
punctuated::Punctuated,
};
struct AllRoutes {
routes: Vec<RouteDefinition>,
}
struct RouteDefinition {
verb: Option<Verb>,
pattern: LitStr,
handler: Ident,
params: Punctuated<Param, Token![,]>,
}
struct Param {
name: Ident,
ty: syn::Type,
default: Option<Expr>,
}
#[derive(Debug, Clone)]
enum Verb {
GET,
POST,
PUT,
DELETE,
PATCH,
HEAD,
OPTIONS,
}
impl Verb {
fn from_ident(ident: &Ident) -> Option<Self> {
match ident.to_string().as_str() {
"GET" => Some(Verb::GET),
"POST" => Some(Verb::POST),
"PUT" => Some(Verb::PUT),
"DELETE" => Some(Verb::DELETE),
"PATCH" => Some(Verb::PATCH),
"HEAD" => Some(Verb::HEAD),
"OPTIONS" => Some(Verb::OPTIONS),
_ => None,
}
}
fn to_tokens(&self) -> proc_macro2::TokenStream {
match self {
Verb::GET => quote! { ::actus::__internal::Verb::GET },
Verb::POST => quote! { ::actus::__internal::Verb::POST },
Verb::PUT => quote! { ::actus::__internal::Verb::PUT },
Verb::DELETE => quote! { ::actus::__internal::Verb::DELETE },
Verb::PATCH => quote! { ::actus::__internal::Verb::PATCH },
Verb::HEAD => quote! { ::actus::__internal::Verb::HEAD },
Verb::OPTIONS => quote! { ::actus::__internal::Verb::OPTIONS },
}
}
}
#[derive(Debug, Clone, Copy)]
enum ControllerMode {
Strict,
Lax,
}
struct ControllerAttrs {
mode: ControllerMode,
prepare: Option<syn::ExprPath>,
max_body_bytes: Option<syn::Expr>,
rate_limit: Option<syn::Expr>,
}
impl Parse for AllRoutes {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut routes = Vec::new();
while !input.is_empty() {
if input.peek(syn::token::Bracket) {
let bracket_span = input.fork().parse::<proc_macro2::TokenTree>()?.span();
return Err(syn::Error::new(
bracket_span,
"actus no longer ships an `Access` enum or `[Access::*]` section syntax. \
Authorization belongs in your `#[controller(prepare = …)]` hook, where \
you can call into your own policy layer (e.g. `services::policy::*`).",
));
}
let verb = if input.peek2(LitStr) {
if let Ok(ident) = input.parse::<Ident>() {
if let Some(v) = Verb::from_ident(&ident) {
Some(v)
} else {
return Err(syn::Error::new(
ident.span(),
format!(
"Unknown HTTP verb: {}. Expected GET, POST, PUT, DELETE, PATCH, HEAD, or OPTIONS",
ident
),
));
}
} else {
None
}
} else {
None
};
let pattern: LitStr = input.parse()?;
validate_pattern(&pattern)?;
input.parse::<Token![=>]>()?;
let handler: Ident = input.parse()?;
let params_content;
syn::parenthesized!(params_content in input);
let params = Punctuated::parse_terminated(¶ms_content)?;
routes.push(RouteDefinition {
verb,
pattern,
handler,
params,
});
if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
}
}
Ok(AllRoutes { routes })
}
}
impl Parse for Param {
fn parse(input: ParseStream) -> syn::Result<Self> {
let name: Ident = input.parse()?;
input.parse::<Token![:]>()?;
let ty: syn::Type = input.parse()?;
let default = if input.peek(Token![=]) {
input.parse::<Token![=]>()?;
Some(input.parse()?)
} else {
None
};
Ok(Param { name, ty, default })
}
}
impl Parse for ControllerAttrs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut mode = ControllerMode::Strict;
let mut prepare = None;
let mut max_body_bytes = None;
let mut rate_limit = None;
while !input.is_empty() {
let ident: Ident = input.parse()?;
match ident.to_string().as_str() {
"strict" => mode = ControllerMode::Strict,
"lax" => mode = ControllerMode::Lax,
"prepare" => {
input.parse::<Token![=]>()?;
prepare = Some(input.parse()?);
}
"max_body_bytes" => {
input.parse::<Token![=]>()?;
max_body_bytes = Some(input.parse()?);
}
"rate_limit" => {
input.parse::<Token![=]>()?;
rate_limit = Some(input.parse()?);
}
_ => {
return Err(syn::Error::new(
ident.span(),
"Expected 'strict', 'lax', 'prepare = <fn>', 'max_body_bytes = <expr>', \
or 'rate_limit = <expr>'",
));
}
}
if input.peek(Token![,]) {
input.parse::<Token![,]>()?;
}
}
Ok(ControllerAttrs {
mode,
prepare,
max_body_bytes,
rate_limit,
})
}
}
fn type_to_string(ty: &syn::Type) -> String {
quote!(#ty).to_string().replace(" ", "")
}
fn extract_path_params(pattern: &str) -> Vec<String> {
let mut params = Vec::new();
let mut chars = pattern.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '{' {
let mut param = String::new();
for ch in chars.by_ref() {
if ch == '}' {
break;
}
param.push(ch);
}
let name = param.strip_prefix("...").unwrap_or(param.as_str());
if !name.is_empty() {
params.push(name.to_string());
}
}
}
params
}
fn rest_param_name(segment: &str) -> Option<&str> {
segment
.strip_prefix("{...")
.and_then(|s| s.strip_suffix('}'))
.filter(|name| !name.is_empty())
}
fn validate_pattern(pattern: &LitStr) -> syn::Result<()> {
let value = pattern.value();
let segments: Vec<&str> = value.split('/').collect();
for (i, seg) in segments.iter().enumerate() {
let Some(inner) = seg.strip_prefix('{').and_then(|s| s.strip_suffix('}')) else {
continue;
};
if !inner.starts_with('.') {
continue; }
if rest_param_name(seg).is_none() {
return Err(syn::Error::new(
pattern.span(),
format!(
"malformed rest parameter `{{{inner}}}` in route pattern `{value}`; \
write it as `{{...name}}` (three dots, then a non-empty name)"
),
));
}
if i != segments.len() - 1 {
return Err(syn::Error::new(
pattern.span(),
format!(
"rest parameter `{{{inner}}}` must be the last segment of route \
pattern `{value}` (it captures the entire remaining path)"
),
));
}
let earlier_rest = segments[..i]
.iter()
.filter(|s| rest_param_name(s).is_some())
.count();
if earlier_rest > 0 {
return Err(syn::Error::new(
pattern.span(),
format!("route pattern `{value}` has more than one `{{...name}}` rest parameter"),
));
}
}
Ok(())
}
fn collect_method_docs(item_impl: &syn::ItemImpl) -> BTreeMap<String, String> {
use syn::{Attribute, ImplItem, Meta};
fn doc_from_attrs(attrs: &[Attribute]) -> String {
attrs
.iter()
.filter(|a| a.path().is_ident("doc"))
.filter_map(|a| {
match &a.meta {
Meta::NameValue(nv) => {
if let syn::Expr::Lit(expr_lit) = &nv.value
&& let syn::Lit::Str(ls) = &expr_lit.lit
{
return Some(ls.value());
}
None
}
_ => None,
}
})
.collect::<Vec<_>>()
.join("\n")
}
let mut map = BTreeMap::new();
for it in &item_impl.items {
if let ImplItem::Fn(m) = it {
let name = m.sig.ident.to_string();
let doc = doc_from_attrs(&m.attrs);
if !doc.trim().is_empty() {
map.insert(name, doc);
}
}
}
map
}
#[proc_macro_attribute]
pub fn controller(attr: TokenStream, item: TokenStream) -> TokenStream {
let attrs = if attr.is_empty() {
ControllerAttrs {
mode: ControllerMode::Strict,
prepare: None,
max_body_bytes: None,
rate_limit: None,
}
} else {
match syn::parse::<ControllerAttrs>(attr) {
Ok(a) => a,
Err(e) => return e.to_compile_error().into(),
}
};
let item_impl = parse_macro_input!(item as ItemImpl);
let docs_map = collect_method_docs(&item_impl);
let routes_macro = item_impl
.items
.iter()
.find_map(|item| {
if let ImplItem::Macro(m) = item
&& m.mac.path.is_ident("routes") {
return Some(m);
}
None
})
.expect("A `routes!` macro invocation is required inside an `impl` block marked with `#[controller]`");
let all_routes: AllRoutes = match syn::parse2(routes_macro.mac.tokens.clone()) {
Ok(routes) => routes,
Err(e) => return e.to_compile_error().into(),
};
let generated = generate_controller_impl(&item_impl, &all_routes, &attrs, &docs_map);
generated.into()
}
fn generate_controller_impl(
item_impl: &ItemImpl,
all_routes: &AllRoutes,
attrs: &ControllerAttrs,
docs_map: &BTreeMap<String, String>, ) -> proc_macro2::TokenStream {
let self_ty = &item_impl.self_ty;
let mut route_defs = Vec::new();
let mut handler_arms = Vec::new();
for (idx, route) in all_routes.routes.iter().enumerate() {
let pattern = &route.pattern;
let pattern_str = pattern.value();
let handler = &route.handler;
let handler_id = format!("handler_{}", idx);
let path_params = extract_path_params(&pattern_str);
let rest_param: Option<String> = pattern_str
.split('/')
.find_map(|s| rest_param_name(s).map(str::to_string));
let mut param_defs = Vec::new();
let mut param_extractions = Vec::new();
let mut param_names = Vec::new();
for param in &route.params {
let name = ¶m.name;
let name_str = name.unraw().to_string();
let ty_str = type_to_string(¶m.ty);
param_names.push(name.clone());
if ty_str == "&Params" {
param_extractions.push(quote! { ¶ms });
continue;
}
let is_rest = rest_param.as_deref() == Some(name_str.as_str());
if is_rest && ty_str != "String" {
let msg = format!(
"rest parameter `{{...{name_str}}}` must be typed `String` (it holds the \
joined remaining path); found `{ty_str}`"
);
param_defs.push(quote! { compile_error!(#msg) });
param_extractions.push(quote! { compile_error!(#msg) });
continue;
}
let source = if path_params.contains(&name_str) {
quote! { ::actus::__internal::ParamSource::Path }
} else if ty_str == "JsonValue" || ty_str == "Bytes" {
quote! { ::actus::__internal::ParamSource::Body }
} else {
quote! { ::actus::__internal::ParamSource::Query }
};
let (param_type, default_value) =
generate_param_type_and_default(&ty_str, ¶m.default);
param_defs.push(quote! {
::actus::__internal::ParamDef {
name: #name_str,
ty: #param_type,
source: #source,
default: #default_value,
}
});
let extraction = generate_param_extraction(&name_str, &ty_str, ¶m.default);
param_extractions.push(extraction);
}
let verb_expr = match &route.verb {
Some(v) => {
let verb_tokens = v.to_tokens();
quote! { &[#verb_tokens] }
}
None => quote! { ::actus::__internal::DEFAULT_VERBS },
};
let handler_name_str = handler.to_string();
let doc_val = docs_map.get(&handler_name_str).cloned().unwrap_or_default();
let doc_lit = syn::LitStr::new(&doc_val, proc_macro2::Span::call_site());
route_defs.push(quote! {
::actus::__internal::RouteDef {
pattern: #pattern_str,
handler_id: #handler_id,
handler: #handler_name_str,
verb: #verb_expr,
params: &[ #(#param_defs),* ],
doc: if #doc_lit.is_empty() { None } else { Some(#doc_lit) },
}
});
handler_arms.push(quote! {
#handler_id => {
#(let #param_names = #param_extractions;)*
self.#handler(#(#param_names),*).await
}
});
}
let prepare_call = attrs
.prepare
.as_ref()
.map(|prepare_fn| {
quote! {
if let ::core::option::Option::Some(__actus_early_reply) =
#prepare_fn(self, &matched_route, &mut params).await?
{
return ::core::result::Result::Ok(__actus_early_reply);
}
}
})
.unwrap_or_default();
let mode_value = match attrs.mode {
ControllerMode::Strict => quote! { ::actus::__internal::ControllerMode::Strict },
ControllerMode::Lax => quote! { ::actus::__internal::ControllerMode::Lax },
};
let mode_str = match attrs.mode {
ControllerMode::Strict => "strict",
ControllerMode::Lax => "lax",
};
let _ = mode_str;
let params_binding = if attrs.prepare.is_some() {
quote! { mut params: ::actus::__internal::Params }
} else {
quote! { params: ::actus::__internal::Params }
};
let max_body_bytes_impl = attrs.max_body_bytes.as_ref().map(|expr| {
quote! {
fn actus_max_body_bytes(&self) -> ::core::option::Option<usize> {
::core::option::Option::Some(#expr)
}
}
});
let rate_limit_impl = attrs.rate_limit.as_ref().map(|expr| {
quote! {
fn actus_rate_limit(&self) -> ::core::option::Option<&'static str> {
::core::option::Option::Some(#expr)
}
}
});
let controller_impl = quote! {
#[::actus::__internal::async_trait]
impl ::actus::__internal::Controller for #self_ty {
async fn actus_dispatch(&self, action: &str, #params_binding) -> ::actus::__internal::Reply {
static ROUTES: &[::actus::__internal::RouteDef] = &[ #(#route_defs),* ];
let (matched_route, extracted) = ::actus::__internal::routing::resolve(
ROUTES,
action,
¶ms,
#mode_value
)?;
#prepare_call
match matched_route.handler_id {
#(#handler_arms),*
other => ::core::unreachable!(
"dispatch: no handler for route id {:?}", other
),
}
}
fn __name(&self) -> &'static str {
stringify!(#self_ty)
}
fn actus_describe_routes(&self) -> Vec<::actus::__internal::RouteDef> {
static ROUTES: &[::actus::__internal::RouteDef] = &[ #(#route_defs),* ];
ROUTES.to_vec()
}
#max_body_bytes_impl
#rate_limit_impl
}
};
quote! {
#item_impl
#controller_impl
}
}
fn generate_param_type_and_default(
ty_str: &str,
default: &Option<Expr>,
) -> (proc_macro2::TokenStream, proc_macro2::TokenStream) {
match (ty_str, default) {
("String", Some(d)) => (
quote! { ::actus::__internal::ParamType::String },
quote! { Some(::actus::__internal::ParamDefault::String(#d)) },
),
("String", None) => (
quote! { ::actus::__internal::ParamType::String },
quote! { None },
),
("i64", Some(d)) => (
quote! { ::actus::__internal::ParamType::Int },
quote! { Some(::actus::__internal::ParamDefault::Int(#d)) },
),
("i64", None) => (
quote! { ::actus::__internal::ParamType::Int },
quote! { None },
),
("u64", Some(d)) => (
quote! { ::actus::__internal::ParamType::U64 },
quote! { Some(::actus::__internal::ParamDefault::U64(#d)) },
),
("u64", None) => (
quote! { ::actus::__internal::ParamType::U64 },
quote! { None },
),
("u32", Some(d)) => (
quote! { ::actus::__internal::ParamType::U32 },
quote! { Some(::actus::__internal::ParamDefault::U32(#d)) },
),
("u32", None) => (
quote! { ::actus::__internal::ParamType::U32 },
quote! { None },
),
("f64", Some(d)) => (
quote! { ::actus::__internal::ParamType::F64 },
quote! { Some(::actus::__internal::ParamDefault::F64(#d)) },
),
("f64", None) => (
quote! { ::actus::__internal::ParamType::F64 },
quote! { None },
),
("bool", Some(d)) => (
quote! { ::actus::__internal::ParamType::Bool },
quote! { Some(::actus::__internal::ParamDefault::Bool(#d)) },
),
("bool", None) => (
quote! { ::actus::__internal::ParamType::Bool },
quote! { None },
),
("Vec<String>", _) => (
quote! { ::actus::__internal::ParamType::StringArray },
quote! { None },
),
("JsonValue", _) => (
quote! { ::actus::__internal::ParamType::Json },
quote! { None },
),
("Bytes", _) => (
quote! { ::actus::__internal::ParamType::Bytes },
quote! { None },
),
_ => (
quote! { compile_error!(concat!("Unsupported type: ", #ty_str)) },
quote! { None },
),
}
}
fn generate_param_extraction(
name_str: &str,
ty_str: &str,
default: &Option<Expr>,
) -> proc_macro2::TokenStream {
match (ty_str, default) {
("String", Some(d)) => {
quote! {
extracted.get_string(#name_str)
.unwrap_or_else(|_| #d.to_string())
}
}
("String", None) => {
quote! { extracted.get_string(#name_str)? }
}
("i64", Some(d)) => {
quote! {
extracted.get_i64(#name_str).unwrap_or(#d)
}
}
("i64", None) => {
quote! { extracted.get_i64(#name_str)? }
}
("u64", Some(d)) => {
quote! {
extracted.get_u64(#name_str).unwrap_or(#d)
}
}
("u64", None) => {
quote! { extracted.get_u64(#name_str)? }
}
("u32", Some(d)) => {
quote! {
extracted.get_u32(#name_str).unwrap_or(#d)
}
}
("u32", None) => {
quote! { extracted.get_u32(#name_str)? }
}
("f64", Some(d)) => {
quote! {
extracted.get_f64(#name_str).unwrap_or(#d)
}
}
("f64", None) => {
quote! { extracted.get_f64(#name_str)? }
}
("bool", Some(d)) => {
quote! {
extracted.get_bool(#name_str).unwrap_or(#d)
}
}
("bool", None) => {
quote! { extracted.get_bool(#name_str)? }
}
("Vec<String>", _) => {
quote! { extracted.get_string_array(#name_str)? }
}
("JsonValue", _) => {
quote! { extracted.get_json_body()? }
}
("Bytes", _) => {
quote! { extracted.get_body_bytes() }
}
_ => {
quote! { compile_error!(concat!("Unsupported type: ", #ty_str)) }
}
}
}
struct AppRoutesInput {
inputs: Vec<InputParam>,
deps: Vec<DepBinding>,
routes: Vec<RouteBinding>,
}
struct InputParam {
name: Ident,
ty: syn::Type,
}
struct DepBinding {
name: Ident,
value: Expr,
}
struct RouteBinding {
path: LitStr,
construction: Expr,
}
impl Parse for AppRoutesInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut inputs: Vec<InputParam> = Vec::new();
let mut deps: Vec<DepBinding> = Vec::new();
let mut routes: Option<Vec<RouteBinding>> = None;
while !input.is_empty() {
let kw: Ident = input.parse()?;
let kw_str = kw.to_string();
match kw_str.as_str() {
"deps" => {
if input.peek(syn::token::Paren) {
let paren_content;
syn::parenthesized!(paren_content in input);
while !paren_content.is_empty() {
let name: Ident = paren_content.parse()?;
paren_content.parse::<Token![:]>()?;
let ty: syn::Type = paren_content.parse()?;
inputs.push(InputParam { name, ty });
if !paren_content.is_empty() {
paren_content.parse::<Token![,]>()?;
}
}
}
let content;
syn::braced!(content in input);
while !content.is_empty() {
let name: Ident = content.parse()?;
content.parse::<Token![=]>()?;
let value: Expr = content.parse()?;
deps.push(DepBinding { name, value });
if !content.is_empty() {
content.parse::<Token![,]>()?;
}
}
}
"routes" => {
let content;
syn::braced!(content in input);
let mut rs = Vec::new();
while !content.is_empty() {
let path: LitStr = content.parse()?;
content.parse::<Token![=>]>()?;
let construction: Expr = content.parse()?;
rs.push(RouteBinding { path, construction });
if !content.is_empty() {
content.parse::<Token![,]>()?;
}
}
routes = Some(rs);
}
other => {
return Err(syn::Error::new(
kw.span(),
format!("expected 'deps' or 'routes', got '{}'", other),
));
}
}
}
let routes = routes.ok_or_else(|| {
syn::Error::new(
proc_macro2::Span::call_site(),
"app_routes! requires a 'routes { ... }' block",
)
})?;
Ok(Self {
inputs,
deps,
routes,
})
}
}
#[proc_macro]
pub fn app_routes(input: TokenStream) -> TokenStream {
let parsed = parse_macro_input!(input as AppRoutesInput);
generate_app_routes(parsed).into()
}
fn generate_app_routes(parsed: AppRoutesInput) -> proc_macro2::TokenStream {
let init_params = parsed.inputs.iter().map(|p| {
let name = &p.name;
let ty = &p.ty;
quote! { #name: #ty }
});
let dep_lets = parsed.deps.iter().map(|d| {
let name = &d.name;
let value = &d.value;
quote! { let #name = #value; }
});
let route_calls = parsed.routes.iter().map(|r| {
let path = &r.path;
let construction = rewrite_construction(&r.construction);
quote! {
.add_route(#path, ::std::sync::Arc::new(#construction))
}
});
quote! {
pub async fn init(#(#init_params),*) -> ::actus::InitResult<::actus::Router> {
#(#dep_lets)*
let router = ::actus::RouterBuilder::new()
#(#route_calls)*
.build();
::std::result::Result::Ok(router)
}
}
}
fn rewrite_construction(expr: &Expr) -> proc_macro2::TokenStream {
let Expr::Struct(s) = expr else {
return expr.to_token_stream();
};
let path = &s.path;
let mut inner = proc_macro2::TokenStream::new();
let mut wrote_field = false;
for f in s.fields.iter() {
if wrote_field {
inner.extend(quote! { , });
}
wrote_field = true;
let member = &f.member;
if f.colon_token.is_none() {
inner.extend(quote! { #member: #member.clone() });
} else if is_bare_ident(&f.expr) {
let value = &f.expr;
inner.extend(quote! { #member: #value.clone() });
} else {
let value = &f.expr;
inner.extend(quote! { #member: #value });
}
}
if let Some(rest) = &s.rest {
if wrote_field {
inner.extend(quote! { , });
}
if is_bare_ident(rest) {
inner.extend(quote! { ..(#rest).clone() });
} else {
inner.extend(quote! { ..#rest });
}
}
quote! { #path { #inner } }
}
fn is_bare_ident(expr: &Expr) -> bool {
let Expr::Path(p) = expr else { return false };
p.qself.is_none()
&& p.path.leading_colon.is_none()
&& p.path.segments.len() == 1
&& p.path.segments[0].arguments.is_none()
}