From 73672d8c96e000b4aa19c07a8f7f2289368346b9 Mon Sep 17 00:00:00 2001 From: Tristan Druyen Date: Mon, 14 Apr 2025 18:37:43 +0200 Subject: [PATCH] Rewrite for better module handling --- README.md | 3 +- .../advanced/api/files/[...path]/route.rs | 5 + examples/advanced/api/files/route.rs | 9 + examples/advanced/api/route.rs | 9 + examples/advanced/api/users/[id]/route.rs | 5 + examples/advanced/api/users/route.rs | 9 + examples/advanced/main.rs | 28 ++ examples/simple/api/route.rs | 3 +- examples/simple/main.rs | 6 +- rustfmt.toml | 23 ++ src/lib.rs | 328 +++++++++++++----- 11 files changed, 332 insertions(+), 96 deletions(-) create mode 100644 examples/advanced/api/files/[...path]/route.rs create mode 100644 examples/advanced/api/files/route.rs create mode 100644 examples/advanced/api/route.rs create mode 100644 examples/advanced/api/users/[id]/route.rs create mode 100644 examples/advanced/api/users/route.rs create mode 100644 examples/advanced/main.rs create mode 100644 rustfmt.toml diff --git a/README.md b/README.md index d5f40e1..71ca96a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ # 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 diff --git a/examples/advanced/api/files/[...path]/route.rs b/examples/advanced/api/files/[...path]/route.rs new file mode 100644 index 0000000..d88cae6 --- /dev/null +++ b/examples/advanced/api/files/[...path]/route.rs @@ -0,0 +1,5 @@ +use axum::{extract::Path, response::IntoResponse}; + +pub async fn get(Path(path): Path) -> impl IntoResponse { + format!("Requested file path: {}", path) +} diff --git a/examples/advanced/api/files/route.rs b/examples/advanced/api/files/route.rs new file mode 100644 index 0000000..c9e9628 --- /dev/null +++ b/examples/advanced/api/files/route.rs @@ -0,0 +1,9 @@ +use axum::response::{Html, IntoResponse}; + +pub async fn get() -> impl IntoResponse { + Html("

Hello World!

").into_response() +} + +pub async fn post() -> impl IntoResponse { +"Posted successfully".into_response() +} diff --git a/examples/advanced/api/route.rs b/examples/advanced/api/route.rs new file mode 100644 index 0000000..6205103 --- /dev/null +++ b/examples/advanced/api/route.rs @@ -0,0 +1,9 @@ +use axum::response::{Html, IntoResponse}; + +pub async fn get() -> impl IntoResponse { + Html("

Hello World!

").into_response() +} + +pub async fn post() -> impl IntoResponse { + "Posted successfully".into_response() +} diff --git a/examples/advanced/api/users/[id]/route.rs b/examples/advanced/api/users/[id]/route.rs new file mode 100644 index 0000000..1508802 --- /dev/null +++ b/examples/advanced/api/users/[id]/route.rs @@ -0,0 +1,5 @@ +use axum::{extract::Path, response::IntoResponse}; + +pub async fn get(Path(id): Path) -> impl IntoResponse { + format!("User ID: {}", id) +} diff --git a/examples/advanced/api/users/route.rs b/examples/advanced/api/users/route.rs new file mode 100644 index 0000000..6205103 --- /dev/null +++ b/examples/advanced/api/users/route.rs @@ -0,0 +1,9 @@ +use axum::response::{Html, IntoResponse}; + +pub async fn get() -> impl IntoResponse { + Html("

Hello World!

").into_response() +} + +pub async fn post() -> impl IntoResponse { + "Posted successfully".into_response() +} diff --git a/examples/advanced/main.rs b/examples/advanced/main.rs new file mode 100644 index 0000000..67b7e3c --- /dev/null +++ b/examples/advanced/main.rs @@ -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 = 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(()) +} diff --git a/examples/simple/api/route.rs b/examples/simple/api/route.rs index 78034c2..6205103 100644 --- a/examples/simple/api/route.rs +++ b/examples/simple/api/route.rs @@ -5,6 +5,5 @@ pub async fn get() -> impl IntoResponse { } pub async fn post() -> impl IntoResponse { -"Posted successfully".into_response() + "Posted successfully".into_response() } - diff --git a/examples/simple/main.rs b/examples/simple/main.rs index fc42c6c..6a22e04 100644 --- a/examples/simple/main.rs +++ b/examples/simple/main.rs @@ -1,13 +1,13 @@ -use anyhow; use axum::Router; use axum_folder_router::folder_router; -use tokio; #[derive(Clone, Debug)] struct AppState { _foo: String, } +folder_router!("./examples/simple/api", AppState); + #[tokio::main] async fn main() -> anyhow::Result<()> { // Create app state @@ -16,7 +16,7 @@ async fn main() -> anyhow::Result<()> { }; // Generate the router using the macro - let folder_router: Router = folder_router!("./examples/simple/api", AppState); + let folder_router: Router = folder_router(); // Build the router and provide the state let app: Router<()> = folder_router.with_state(app_state); diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..cb06898 --- /dev/null +++ b/rustfmt.toml @@ -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 diff --git a/src/lib.rs b/src/lib.rs index 2ef7016..aa9dd06 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,9 @@ //! # ```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 //! @@ -9,21 +12,21 @@ //! ```toml //! [dependencies] //! axum_folder_router = "0.1.0" -//! axum = "0.7" +//! axum = "0.8" //! ``` //! //! ## 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 #![doc = include_str!("../examples/simple/main.rs")] //! ``` -//! +//! //! ## File Structure Convention //! //! The macro converts your file structure into routes: -//! //! ```text //! src/api/ //! ├── route.rs -> "/" @@ -37,17 +40,16 @@ //! └── [...path]/ //! └── route.rs -> "/files/*path" //! ``` -//! +//! //! Each ```route.rs``` file can contain HTTP method handlers that are automatically mapped to the corresponding route. //! //! ## Route Handlers //! //! Inside each ```route.rs``` file, define async functions named after HTTP methods: -//! //! ```rust #![doc = include_str!("../examples/simple/api/route.rs")] //! ``` -//! +//! //! ## Supported Features //! //! ### HTTP Methods @@ -64,13 +66,11 @@ //! ### Path Parameters //! //! Dynamic path segments are defined using brackets: -//! //! ```text //! src/api/users/[id]/route.rs -> "/users/{id}" //! ``` -//! +//! //! Inside the route handler: -//! //! ```rust //! use axum::{ //! extract::Path, @@ -81,15 +81,13 @@ //! format!("User ID: {}", id) //! } //! ``` -//! +//! //! ### Catch-all Parameters //! //! Use the spread syntax for catch-all segments: -//! //! ```text //! src/api/files/[...path]/route.rs -> "/files/*path" //! ``` -//! //! ```rust //! use axum::{ //! extract::Path, @@ -100,12 +98,11 @@ //! format!("Requested file path: {}", path) //! } //! ``` -//! +//! //! ### State Extraction //! //! 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. -//! //! ```rust //! use axum::{ //! extract::State, @@ -119,17 +116,26 @@ //! format!("State: {:?}", state) //! } //! ``` -//! +//! //! ## Limitations //! //! - **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 quote::{format_ident, quote}; -use std::fs; -use std::path::{Path, PathBuf}; -use syn::{Ident, LitStr, Result, Token, parse::Parse, parse::ParseStream, parse_macro_input}; +use syn::{ + Ident, + LitStr, + Result, + Token, + parse::{Parse, ParseStream}, + parse_macro_input, +}; struct FolderRouterArgs { 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, +} + +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 /// -/// * `path` - A string literal pointing to the API directory, relative to the Cargo manifest directory -/// * `state_type` - The type name of your application state that will be shared across all routes +/// * `path` - A string literal pointing to the API directory, relative to the +/// Cargo manifest directory +/// * `state_type` - The type name of your application state that will be shared +/// across all routes /// /// # Example /// /// ```rust -/// # use axum_folder_router::folder_router; +/// use axum_folder_router::folder_router; /// # #[derive(Debug, Clone)] /// # 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, -/// automatically mapping their path structure to URL routes with the specified state type. +/// This will scan all `route.rs` files in the `./src/api` directory and its +/// subdirectories, automatically mapping their path structure to URL routes +/// with the specified state type. #[proc_macro] pub fn folder_router(input: TokenStream) -> TokenStream { let args = parse_macro_input!(input as FolderRouterArgs); @@ -182,26 +214,23 @@ pub fn folder_router(input: TokenStream) -> TokenStream { let mut routes = Vec::new(); collect_route_files(&base_dir, &base_dir, &mut routes); - // Generate module definitions and route registrations - let mut module_defs = Vec::new(); + // Build module tree + let mut root = ModuleDir::new("__folder_router"); + for (route_path, rel_path) in &routes { + add_to_module_tree(&mut root, rel_path, route_path); + } + + // 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 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 path and axum path + let (axum_path, mod_path) = path_to_module_path(&rel_path); // Read the file content to find HTTP methods 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() { let (_first_method, first_method_ident) = &method_registrations[0]; + let mod_path_tokens = generate_mod_path_tokens(&mod_path); 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..] { builder = quote! { - #builder.#method_ident(#mod_ident::#method_ident) + #builder.#method_ident(#root_mod_ident::#mod_path_tokens::#method_ident) }; } @@ -237,19 +267,164 @@ pub fn folder_router(input: TokenStream) -> TokenStream { // Generate the final code let expanded = quote! { - { - #(#module_defs)* + #[path = #base_path_lit] + mod #root_mod_ident { + #mod_hierarchy + } - let mut router = axum::Router::<#state_type>::new(); - #(#route_registrations)* - router - } + fn folder_router() -> axum::Router::<#state_type> { + let mut router = axum::Router::<#state_type>::new(); + #(#route_registrations)* + router + } }; 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) { + 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)>) { if let Ok(entries) = fs::read_dir(current_dir) { 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() { collect_route_files(base_dir, &path, routes); } else if path.file_name().unwrap_or_default() == "route.rs" { - if let Some(parent) = path.parent() { - if let Ok(rel_dir) = parent.strip_prefix(base_dir) { - routes.push((path.clone(), rel_dir.to_path_buf())); - } + if let Ok(rel_dir) = path.strip_prefix(base_dir) { + routes.push((path.clone(), rel_dir.to_path_buf())); } + + // if let Some(parent) = path.parent() { + // if let Ok(rel_dir) = parent.strip_prefix(base_dir) { + // routes.push((path.clone(), rel_dir.to_path_buf())); + // } + // } } } } } - -// Convert a relative path to (axum_path, mod_name) -fn path_to_route_info(rel_path: &Path) -> (String, String) { - if rel_path.components().count() == 0 { - 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()) -}