Rewrite for better module handling

This commit is contained in:
Tristan D. 2025-04-14 18:37:43 +02:00
parent b3771ba87a
commit 73672d8c96
Signed by: tristan
SSH key fingerprint: SHA256:9oFM1J63hYWJjCnLG6C0fxBS15rwNcWwdQNMOHYKJ/4
11 changed files with 332 additions and 96 deletions

View file

@ -7,7 +7,8 @@
# axum-folder-router # axum-folder-router
```folder_router``` is a procedural macro for the Axum web framework that automatically generates router boilerplate based on your file structure. It simplifies route organization by using filesystem conventions to define your API routes. ```folder_router``` is a procedural macro for the Axum web framework that automatically generates router boilerplate based on your file structure.
It simplifies route organization by using filesystem conventions to define your API routes.
## Usage ## Usage

View file

@ -0,0 +1,5 @@
use axum::{extract::Path, response::IntoResponse};
pub async fn get(Path(path): Path<String>) -> impl IntoResponse {
format!("Requested file path: {}", path)
}

View file

@ -0,0 +1,9 @@
use axum::response::{Html, IntoResponse};
pub async fn get() -> impl IntoResponse {
Html("<h1>Hello World!</h1>").into_response()
}
pub async fn post() -> impl IntoResponse {
"Posted successfully".into_response()
}

View file

@ -0,0 +1,9 @@
use axum::response::{Html, IntoResponse};
pub async fn get() -> impl IntoResponse {
Html("<h1>Hello World!</h1>").into_response()
}
pub async fn post() -> impl IntoResponse {
"Posted successfully".into_response()
}

View file

@ -0,0 +1,5 @@
use axum::{extract::Path, response::IntoResponse};
pub async fn get(Path(id): Path<String>) -> impl IntoResponse {
format!("User ID: {}", id)
}

View file

@ -0,0 +1,9 @@
use axum::response::{Html, IntoResponse};
pub async fn get() -> impl IntoResponse {
Html("<h1>Hello World!</h1>").into_response()
}
pub async fn post() -> impl IntoResponse {
"Posted successfully".into_response()
}

28
examples/advanced/main.rs Normal file
View file

@ -0,0 +1,28 @@
use axum::Router;
use axum_folder_router::folder_router;
#[derive(Clone, Debug)]
struct AppState {
_foo: String,
}
folder_router!("examples/advanced/api", AppState);
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Create app state
let app_state = AppState {
_foo: "".to_string(),
};
// Generate the router using the macro
let folder_router: Router<AppState> = folder_router();
// Build the router and provide the state
let app: Router<()> = folder_router.with_state(app_state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
println!("Listening on http://{}", listener.local_addr()?);
axum::serve(listener, app).await?;
Ok(())
}

View file

@ -7,4 +7,3 @@ pub async fn get() -> impl IntoResponse {
pub async fn post() -> impl IntoResponse { pub async fn post() -> impl IntoResponse {
"Posted successfully".into_response() "Posted successfully".into_response()
} }

View file

@ -1,13 +1,13 @@
use anyhow;
use axum::Router; use axum::Router;
use axum_folder_router::folder_router; use axum_folder_router::folder_router;
use tokio;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct AppState { struct AppState {
_foo: String, _foo: String,
} }
folder_router!("./examples/simple/api", AppState);
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
// Create app state // Create app state
@ -16,7 +16,7 @@ async fn main() -> anyhow::Result<()> {
}; };
// Generate the router using the macro // Generate the router using the macro
let folder_router: Router<AppState> = folder_router!("./examples/simple/api", AppState); let folder_router: Router<AppState> = folder_router();
// Build the router and provide the state // Build the router and provide the state
let app: Router<()> = folder_router.with_state(app_state); let app: Router<()> = folder_router.with_state(app_state);

23
rustfmt.toml Normal file
View file

@ -0,0 +1,23 @@
edition = "2024"
max_width = 100
tab_spaces = 4
# unstable
format_macro_bodies = true
format_macro_matchers = true
format_strings = true
group_imports = "StdExternalCrate"
imports_granularity = "Crate"
imports_layout = "HorizontalVertical"
overflow_delimited_expr = true
reorder_impl_items = true
struct_field_align_threshold = 4
struct_lit_single_line = false
trailing_comma = "Vertical"
unstable_features = true
use_field_init_shorthand = true
use_try_shorthand = true
wrap_comments = true
# format_brace_macros = true

View file

@ -1,6 +1,9 @@
//! # ```axum_folder_router``` Macro Documentation //! # ```axum_folder_router``` Macro Documentation
//! //!
//! [folder_router!] is a procedural macro for the Axum web framework that automatically generates router boilerplate based on your file structure. It simplifies route organization by using filesystem conventions to define your API routes. //! [folder_router!] is a procedural macro for the Axum web framework that
//! automatically generates router boilerplate based on your file structure. It
//! simplifies route organization by using filesystem conventions to define your
//! API routes.
//! //!
//! ## Installation //! ## Installation
//! //!
@ -9,12 +12,13 @@
//! ```toml //! ```toml
//! [dependencies] //! [dependencies]
//! axum_folder_router = "0.1.0" //! axum_folder_router = "0.1.0"
//! axum = "0.7" //! axum = "0.8"
//! ``` //! ```
//! //!
//! ## Basic Usage //! ## Basic Usage
//! //!
//! The macro scans a directory for ```route.rs``` files and automatically creates an Axum router based on the file structure: //! The macro scans a directory for ```route.rs``` files and automatically
//! creates an Axum router based on the file structure:
//! //!
//! ```rust,no_run //! ```rust,no_run
#![doc = include_str!("../examples/simple/main.rs")] #![doc = include_str!("../examples/simple/main.rs")]
@ -23,7 +27,6 @@
//! ## File Structure Convention //! ## File Structure Convention
//! //!
//! The macro converts your file structure into routes: //! The macro converts your file structure into routes:
//!
//! ```text //! ```text
//! src/api/ //! src/api/
//! ├── route.rs -> "/" //! ├── route.rs -> "/"
@ -43,7 +46,6 @@
//! ## Route Handlers //! ## Route Handlers
//! //!
//! Inside each ```route.rs``` file, define async functions named after HTTP methods: //! Inside each ```route.rs``` file, define async functions named after HTTP methods:
//!
//! ```rust //! ```rust
#![doc = include_str!("../examples/simple/api/route.rs")] #![doc = include_str!("../examples/simple/api/route.rs")]
//! ``` //! ```
@ -64,13 +66,11 @@
//! ### Path Parameters //! ### Path Parameters
//! //!
//! Dynamic path segments are defined using brackets: //! Dynamic path segments are defined using brackets:
//!
//! ```text //! ```text
//! src/api/users/[id]/route.rs -> "/users/{id}" //! src/api/users/[id]/route.rs -> "/users/{id}"
//! ``` //! ```
//! //!
//! Inside the route handler: //! Inside the route handler:
//!
//! ```rust //! ```rust
//! use axum::{ //! use axum::{
//! extract::Path, //! extract::Path,
@ -85,11 +85,9 @@
//! ### Catch-all Parameters //! ### Catch-all Parameters
//! //!
//! Use the spread syntax for catch-all segments: //! Use the spread syntax for catch-all segments:
//!
//! ```text //! ```text
//! src/api/files/[...path]/route.rs -> "/files/*path" //! src/api/files/[...path]/route.rs -> "/files/*path"
//! ``` //! ```
//!
//! ```rust //! ```rust
//! use axum::{ //! use axum::{
//! extract::Path, //! extract::Path,
@ -105,7 +103,6 @@
//! //!
//! The state type provided to the macro is available in all route handlers: //! The state type provided to the macro is available in all route handlers:
//! All routes share the same state type, though you can use ```FromRef``` for more granular state extraction. //! All routes share the same state type, though you can use ```FromRef``` for more granular state extraction.
//!
//! ```rust //! ```rust
//! use axum::{ //! use axum::{
//! extract::State, //! extract::State,
@ -123,13 +120,22 @@
//! ## Limitations //! ## Limitations
//! //!
//! - **Compile-time Only**: The routing is determined at compile time, so dynamic route registration isn't supported. //! - **Compile-time Only**: The routing is determined at compile time, so dynamic route registration isn't supported.
//! use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::{format_ident, quote}; use quote::{format_ident, quote};
use std::fs; use syn::{
use std::path::{Path, PathBuf}; Ident,
use syn::{Ident, LitStr, Result, Token, parse::Parse, parse::ParseStream, parse_macro_input}; LitStr,
Result,
Token,
parse::{Parse, ParseStream},
parse_macro_input,
};
struct FolderRouterArgs { struct FolderRouterArgs {
path: String, path: String,
@ -149,25 +155,51 @@ impl Parse for FolderRouterArgs {
} }
} }
/// Creates an Axum router by scanning a directory for `route.rs` files. // A struct representing a directory in the module tree
#[derive(Debug)]
struct ModuleDir {
name: String,
has_route: bool,
children: HashMap<String, ModuleDir>,
}
impl ModuleDir {
fn new(name: &str) -> Self {
ModuleDir {
name: name.to_string(),
has_route: false,
children: HashMap::new(),
}
}
}
/// Creates an Axum router module tree & creation function
/// by scanning a directory for `route.rs` files.
/// ///
/// # Parameters /// # Parameters
/// ///
/// * `path` - A string literal pointing to the API directory, relative to the Cargo manifest directory /// * `path` - A string literal pointing to the API directory, relative to the
/// * `state_type` - The type name of your application state that will be shared across all routes /// Cargo manifest directory
/// * `state_type` - The type name of your application state that will be shared
/// across all routes
/// ///
/// # Example /// # Example
/// ///
/// ```rust /// ```rust
/// # use axum_folder_router::folder_router; /// use axum_folder_router::folder_router;
/// # #[derive(Debug, Clone)] /// # #[derive(Debug, Clone)]
/// # struct AppState (); /// # struct AppState ();
/// # /// #
/// let router = folder_router!("./src/api", AppState); /// folder_router!("./src/api", AppState);
/// #
/// fn main() {
/// let router = folder_router();
/// }
/// ``` /// ```
/// ///
/// This will scan all `route.rs` files in the `./src/api` directory and its subdirectories, /// This will scan all `route.rs` files in the `./src/api` directory and its
/// automatically mapping their path structure to URL routes with the specified state type. /// subdirectories, automatically mapping their path structure to URL routes
/// with the specified state type.
#[proc_macro] #[proc_macro]
pub fn folder_router(input: TokenStream) -> TokenStream { pub fn folder_router(input: TokenStream) -> TokenStream {
let args = parse_macro_input!(input as FolderRouterArgs); let args = parse_macro_input!(input as FolderRouterArgs);
@ -182,26 +214,23 @@ pub fn folder_router(input: TokenStream) -> TokenStream {
let mut routes = Vec::new(); let mut routes = Vec::new();
collect_route_files(&base_dir, &base_dir, &mut routes); collect_route_files(&base_dir, &base_dir, &mut routes);
// Generate module definitions and route registrations // Build module tree
let mut module_defs = Vec::new(); let mut root = ModuleDir::new("__folder_router");
let mut route_registrations = Vec::new(); for (route_path, rel_path) in &routes {
add_to_module_tree(&mut root, rel_path, route_path);
for (route_path, rel_path) in routes {
// Generate module name and axum path
let (axum_path, mod_name) = path_to_route_info(&rel_path);
let mod_ident = format_ident!("{}", mod_name);
// Create module path for include!
let rel_file_path = route_path.strip_prefix(&manifest_dir).unwrap();
let rel_file_str = rel_file_path.to_string_lossy().to_string();
// Add module definition
module_defs.push(quote! {
#[allow(warnings)]
mod #mod_ident {
include!(concat!(env!("CARGO_MANIFEST_DIR"), "/", #rel_file_str));
} }
});
// Generate module tree
let root_mod_ident = format_ident!("{}", root.name);
let base_path_lit = LitStr::new(base_dir.to_str().unwrap(), proc_macro2::Span::call_site());
let mod_hierarchy = generate_module_hierarchy(&root);
// Generate route registrations
let mut route_registrations = Vec::new();
for (route_path, rel_path) in routes {
// Generate module path and axum path
let (axum_path, mod_path) = path_to_module_path(&rel_path);
// Read the file content to find HTTP methods // Read the file content to find HTTP methods
let file_content = fs::read_to_string(&route_path).unwrap_or_default(); let file_content = fs::read_to_string(&route_path).unwrap_or_default();
@ -217,14 +246,15 @@ pub fn folder_router(input: TokenStream) -> TokenStream {
if !method_registrations.is_empty() { if !method_registrations.is_empty() {
let (_first_method, first_method_ident) = &method_registrations[0]; let (_first_method, first_method_ident) = &method_registrations[0];
let mod_path_tokens = generate_mod_path_tokens(&mod_path);
let mut builder = quote! { let mut builder = quote! {
axum::routing::#first_method_ident(#mod_ident::#first_method_ident) axum::routing::#first_method_ident(#root_mod_ident::#mod_path_tokens::#first_method_ident)
}; };
for (_method, method_ident) in &method_registrations[1..] { for (_method, method_ident) in &method_registrations[1..] {
builder = quote! { builder = quote! {
#builder.#method_ident(#mod_ident::#method_ident) #builder.#method_ident(#root_mod_ident::#mod_path_tokens::#method_ident)
}; };
} }
@ -237,9 +267,12 @@ pub fn folder_router(input: TokenStream) -> TokenStream {
// Generate the final code // Generate the final code
let expanded = quote! { let expanded = quote! {
{ #[path = #base_path_lit]
#(#module_defs)* mod #root_mod_ident {
#mod_hierarchy
}
fn folder_router() -> axum::Router::<#state_type> {
let mut router = axum::Router::<#state_type>::new(); let mut router = axum::Router::<#state_type>::new();
#(#route_registrations)* #(#route_registrations)*
router router
@ -249,7 +282,149 @@ pub fn folder_router(input: TokenStream) -> TokenStream {
expanded.into() expanded.into()
} }
// Recursively collect route.rs files // Add a path to the module tree
fn add_to_module_tree(root: &mut ModuleDir, rel_path: &Path, _route_path: &Path) {
let mut current = root;
let components: Vec<_> = rel_path
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect();
// Handle special case for root route.rs
if components.is_empty() {
current.has_route = true;
return;
}
for (i, component) in components.iter().enumerate() {
// For the file itself (route.rs), we just mark the directory as having a route
if i == components.len() - 1 && component == "route.rs" {
current.has_route = true;
break;
}
// For directories, add them to the tree
let dir_name = component.clone();
if !current.children.contains_key(&dir_name) {
current
.children
.insert(dir_name.clone(), ModuleDir::new(&dir_name));
}
current = current.children.get_mut(&dir_name).unwrap();
}
}
// Generate module hierarchy code
fn generate_module_hierarchy(dir: &ModuleDir) -> proc_macro2::TokenStream {
let mut result = proc_macro2::TokenStream::new();
// panic!("{:?}", dir);
// Add route.rs module if this directory has one
if dir.has_route {
let route_mod = quote! {
#[path = "route.rs"]
pub mod route;
};
result.extend(route_mod);
}
// Add subdirectories
for child in dir.children.values() {
let child_name = format_ident!("{}", normalize_module_name(&child.name));
let child_path_lit = LitStr::new(&child.name, proc_macro2::Span::call_site());
let child_content = generate_module_hierarchy(child);
let child_mod = quote! {
#[path = #child_path_lit]
pub mod #child_name {
#child_content
}
};
result.extend(child_mod);
}
result
}
// Generate tokens for a module path
fn generate_mod_path_tokens(mod_path: &[String]) -> proc_macro2::TokenStream {
let mut result = proc_macro2::TokenStream::new();
for (i, segment) in mod_path.iter().enumerate() {
let segment_ident = format_ident!("{}", segment);
if i == 0 {
result = quote! { #segment_ident };
} else {
result = quote! { #result::#segment_ident };
}
}
result
}
// Normalize a path segment for use as a module name
fn normalize_module_name(name: &str) -> String {
if name.starts_with('[') && name.ends_with(']') {
let inner = &name[1..name.len() - 1];
if let Some(stripped) = inner.strip_prefix("...") {
format!("___{}", stripped)
} else {
format!("__{}", inner)
}
} else {
name.replace(['-', '.'], "_")
}
}
// Convert a relative path to module path segments and axum route path
fn path_to_module_path(rel_path: &Path) -> (String, Vec<String>) {
let mut axum_path = String::new();
let mut mod_path = Vec::new();
let components: Vec<_> = rel_path
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect();
// Handle root route
if components.is_empty() {
return ("/".to_string(), vec!["route".to_string()]);
}
for (i, segment) in components.iter().enumerate() {
if i == components.len() - 1 && segment == "route.rs" {
mod_path.push("route".to_string());
} else if segment.starts_with('[') && segment.ends_with(']') {
let inner = &segment[1..segment.len() - 1];
if let Some(param) = inner.strip_prefix("...") {
axum_path.push_str(&format!("/{{*{}}}", param));
mod_path.push(format!("___{}", param));
} else {
axum_path.push_str(&format!("/{{{}}}", inner));
mod_path.push(format!("__{}", inner));
}
} else if segment != "route.rs" {
// Skip the actual route.rs file
axum_path.push('/');
axum_path.push_str(segment);
mod_path.push(normalize_module_name(segment));
} else {
println!("blub");
}
}
if axum_path.is_empty() {
axum_path = "/".to_string();
}
(axum_path, mod_path)
}
// Recursively collect route.rs files (unchanged from your original)
fn collect_route_files(base_dir: &Path, current_dir: &Path, routes: &mut Vec<(PathBuf, PathBuf)>) { fn collect_route_files(base_dir: &Path, current_dir: &Path, routes: &mut Vec<(PathBuf, PathBuf)>) {
if let Ok(entries) = fs::read_dir(current_dir) { if let Ok(entries) = fs::read_dir(current_dir) {
for entry in entries.filter_map(std::result::Result::ok) { for entry in entries.filter_map(std::result::Result::ok) {
@ -258,43 +433,16 @@ fn collect_route_files(base_dir: &Path, current_dir: &Path, routes: &mut Vec<(Pa
if path.is_dir() { if path.is_dir() {
collect_route_files(base_dir, &path, routes); collect_route_files(base_dir, &path, routes);
} else if path.file_name().unwrap_or_default() == "route.rs" { } else if path.file_name().unwrap_or_default() == "route.rs" {
if let Some(parent) = path.parent() { if let Ok(rel_dir) = path.strip_prefix(base_dir) {
if let Ok(rel_dir) = parent.strip_prefix(base_dir) {
routes.push((path.clone(), rel_dir.to_path_buf())); routes.push((path.clone(), rel_dir.to_path_buf()));
} }
}
}
}
}
}
// Convert a relative path to (axum_path, mod_name) // if let Some(parent) = path.parent() {
fn path_to_route_info(rel_path: &Path) -> (String, String) { // if let Ok(rel_dir) = parent.strip_prefix(base_dir) {
if rel_path.components().count() == 0 { // routes.push((path.clone(), rel_dir.to_path_buf()));
return ("/".to_string(), "root".to_string()); // }
} // }
}
let mut axum_path = String::new();
let mut mod_name = String::new();
for segment in rel_path.iter() {
let s = segment.to_str().unwrap_or_default();
if s.starts_with('[') && s.ends_with(']') {
let inner = &s[1..s.len() - 1];
if let Some(param) = inner.strip_prefix("...") {
axum_path.push_str(&format!("/*{}", param));
mod_name.push_str(&format!("__{}", param));
} else {
axum_path.push_str(&format!("/{{{}}}", inner));
mod_name.push_str(&format!("__{}", inner));
}
} else {
axum_path.push('/');
axum_path.push_str(s);
mod_name.push_str("__");
mod_name.push_str(s);
} }
} }
(axum_path, mod_name.trim_start_matches('_').to_string())
} }