use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{ItemFn, LitStr, Token, parse::Parse, parse::ParseStream};
use crate::crate_path;
struct RouteArgs {
path: LitStr,
}
impl Parse for RouteArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let path: LitStr = input.parse()?;
if input.peek(Token![,]) {
let _: Token![,] = input.parse()?;
}
Ok(Self { path })
}
}
fn validate_path(path: &LitStr) -> syn::Result<()> {
let value = path.value();
if value.is_empty() || !value.starts_with('/') {
return Err(syn::Error::new_spanned(
path,
"route path must start with '/'",
));
}
for segment in value.split('/').skip(1) {
if segment.is_empty() {
continue;
}
if segment.starts_with('{') {
if !segment.ends_with('}') {
return Err(syn::Error::new_spanned(
path,
format!("unclosed brace in path segment '{segment}'"),
));
}
let name = &segment[1..segment.len() - 1];
if name.is_empty() {
return Err(syn::Error::new_spanned(
path,
"path parameter name cannot be empty: '{}'",
));
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err(syn::Error::new_spanned(
path,
format!("path parameter name must be alphanumeric or underscore, got '{name}'"),
));
}
} else if let Some(name) = segment.strip_prefix(':') {
if name.is_empty() {
return Err(syn::Error::new_spanned(
path,
"path parameter name cannot be empty after ':'",
));
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err(syn::Error::new_spanned(
path,
format!("path parameter name must be alphanumeric or underscore, got '{name}'"),
));
}
} else if segment.contains('{') || segment.contains('}') {
return Err(syn::Error::new_spanned(
path,
format!("misplaced brace in path segment '{segment}'"),
));
}
}
Ok(())
}
fn to_axum_path(path: &str) -> String {
let segments: Vec<&str> = path.split('/').collect();
let mut result = String::with_capacity(path.len());
for (i, segment) in segments.iter().enumerate() {
if i > 0 {
result.push('/');
}
if let Some(name) = segment.strip_prefix(':') {
result.push('{');
result.push_str(name);
result.push('}');
} else {
result.push_str(segment);
}
}
result
}
pub fn expand_route(method: &str, args: TokenStream, input: TokenStream) -> TokenStream {
let result = expand_route_inner(method, args, input);
match result {
Ok(tokens) => tokens,
Err(err) => err.to_compile_error(),
}
}
fn expand_route_inner(
method: &str,
args: TokenStream,
input: TokenStream,
) -> syn::Result<TokenStream> {
let route_args: RouteArgs = syn::parse2(args)?;
let func: ItemFn = syn::parse2(input)?;
validate_path(&route_args.path)?;
let path_str = &route_args.path;
let fn_name = &func.sig.ident;
let vis = &func.vis;
let const_name = syn::Ident::new(
&format!("__ARO_ROUTE_{}", fn_name.to_string().to_uppercase()),
fn_name.span(),
);
let method_variant = syn::Ident::new(
&format!(
"{}{}",
&method[..1].to_uppercase(),
&method[1..].to_lowercase()
),
proc_macro2::Span::call_site(),
);
let register_fn_name = format_ident!("__aro_register_{}", fn_name);
let axum_method = syn::Ident::new(&method.to_lowercase(), proc_macro2::Span::call_site());
let axum_path = to_axum_path(&path_str.value());
let core_path = crate_path::aro_core_path();
let axum = crate_path::axum_path();
Ok(quote! {
#func
#[doc(hidden)]
#[allow(non_upper_case_globals, reason = "generated by aro route macro")]
#vis const #const_name: #core_path::routing::RouteDef = #core_path::routing::RouteDef {
method: #core_path::routing::Method::#method_variant,
path: #path_str,
};
#[doc(hidden)]
#[allow(non_snake_case, reason = "generated by aro route macro")]
#vis fn #register_fn_name(
router: #axum::Router<#core_path::state::AroState>,
) -> #axum::Router<#core_path::state::AroState> {
router.route(#axum_path, #axum::routing::#axum_method(#fn_name))
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_and_validate(path: &str) -> syn::Result<()> {
let lit: LitStr = syn::parse_str(&format!("\"{path}\""))?;
validate_path(&lit)
}
#[test]
fn valid_paths() {
assert!(parse_and_validate("/").is_ok());
assert!(parse_and_validate("/users").is_ok());
assert!(parse_and_validate("/users/{id}").is_ok());
assert!(parse_and_validate("/users/:id").is_ok());
assert!(parse_and_validate("/users/{id}/posts/{post_id}").is_ok());
assert!(parse_and_validate("/api/v1/items").is_ok());
assert!(parse_and_validate("/trailing/").is_ok());
}
#[test]
fn invalid_empty_path() {
assert!(parse_and_validate("").is_err());
}
#[test]
fn invalid_no_leading_slash() {
assert!(parse_and_validate("users").is_err());
}
#[test]
fn invalid_unclosed_brace() {
assert!(parse_and_validate("/users/{id").is_err());
}
#[test]
fn invalid_empty_brace_param() {
assert!(parse_and_validate("/users/{}").is_err());
}
#[test]
fn invalid_empty_colon_param() {
assert!(parse_and_validate("/users/:").is_err());
}
#[test]
fn invalid_misplaced_brace() {
assert!(parse_and_validate("/users/a{id}").is_err());
}
#[test]
fn invalid_param_name_characters() {
assert!(parse_and_validate("/users/{id-name}").is_err());
assert!(parse_and_validate("/users/:id-name").is_err());
}
#[test]
fn to_axum_path_no_params() {
assert_eq!(to_axum_path("/"), "/");
assert_eq!(to_axum_path("/users"), "/users");
assert_eq!(to_axum_path("/api/v1/items"), "/api/v1/items");
}
#[test]
fn to_axum_path_brace_params_unchanged() {
assert_eq!(to_axum_path("/users/{id}"), "/users/{id}");
assert_eq!(
to_axum_path("/users/{id}/posts/{post_id}"),
"/users/{id}/posts/{post_id}"
);
}
#[test]
fn to_axum_path_colon_params_converted() {
assert_eq!(to_axum_path("/users/:id"), "/users/{id}");
assert_eq!(
to_axum_path("/users/:id/posts/:post_id"),
"/users/{id}/posts/{post_id}"
);
}
}