Compare commits

...

3 Commits

Author SHA1 Message Date
Alex 5dc358ccb8 style: fix formatting
Tests / test (push) Has been cancelled
2026-05-31 17:44:42 +03:00
Alex de24db2d2c chore: bump version to 0.1.3
Tests / test (push) Has been cancelled
2026-05-31 17:42:37 +03:00
Alex 61b1514b13 feat: auto-discover routes via inventory, remove need for impl_routes!
- #[controller] on impl blocks now auto-discovers #[get]/#[post] methods
- Routes registered globally via inventory, collected by TypeId
- ControllerRoutes trait provides default get_router() implementation
- Supports multiple impl blocks across files for the same controller
- impl_routes! kept for backward compatibility (deprecated)
- Updated README with new API examples
2026-05-31 17:41:29 +03:00
9 changed files with 297 additions and 39 deletions
+29 -5
View File
@@ -67,13 +67,14 @@ inject_services!(manager, "MyFunc", {
### Controller — macros for Axum routes ### Controller — macros for Axum routes
```rust ```rust
use desert_framework::{controller, get, post, impl_routes}; use desert_framework::controller;
#[controller(path = "/api/users")] #[controller(path = "/api/users")]
struct UserController { struct UserController {
user_service: Arc<UserService>, user_service: Arc<UserService>,
} }
#[controller]
impl UserController { impl UserController {
#[get("/")] #[get("/")]
async fn list(&self) -> Json<Vec<User>> { async fn list(&self) -> Json<Vec<User>> {
@@ -91,8 +92,6 @@ impl UserController {
} }
} }
impl_routes!(UserController, [list, get, create]);
// Usage in application // Usage in application
let controller = UserController { user_service }; let controller = UserController { user_service };
let router = controller.get_router(); let router = controller.get_router();
@@ -102,17 +101,42 @@ let app = Router::new()
.merge(post_controller.get_router()); .merge(post_controller.get_router());
``` ```
#### Multiple impl blocks
Routes are discovered automatically via `inventory`. You can split methods across multiple `impl` blocks and even multiple files:
```rust
// file: user_controller.rs
#[controller(path = "/api/users")]
struct UserController { ... }
#[controller]
impl UserController {
#[get("/")]
async fn list(&self) -> Json<Vec<User>> { ... }
}
// file: user_create.rs
#[controller]
impl UserController {
#[post("/")]
async fn create(&self, Json(body): Json<CreateUser>) -> Json<User> { ... }
}
// Both routes are automatically included in get_router()
```
## Macros ## Macros
| Macro | Description | | Macro | Description |
|-------|-------------| |-------|-------------|
| `#[controller(path = "/prefix")]` | Defines controller with base path | | `#[controller(path = "/prefix")]` | Defines controller with base path (on struct) |
| `#[controller]` | Discovers route methods in impl block (on impl) |
| `#[get("/path")]` | GET route | | `#[get("/path")]` | GET route |
| `#[post("/path")]` | POST route | | `#[post("/path")]` | POST route |
| `#[put("/path")]` | PUT route | | `#[put("/path")]` | PUT route |
| `#[delete("/path")]` | DELETE route | | `#[delete("/path")]` | DELETE route |
| `#[patch("/path")]` | PATCH route | | `#[patch("/path")]` | PATCH route |
| `impl_routes!(Type, [methods])` | Generates `get_router()` for controller |
| `inject_services!` | Quick service injection | | `inject_services!` | Quick service injection |
## Route Parameters ## Route Parameters
+29 -5
View File
@@ -67,13 +67,14 @@ inject_services!(manager, "MyFunc", {
### Controller — макросы для Axum маршрутов ### Controller — макросы для Axum маршрутов
```rust ```rust
use desert_framework::{controller, get, post, impl_routes}; use desert_framework::controller;
#[controller(path = "/api/users")] #[controller(path = "/api/users")]
struct UserController { struct UserController {
user_service: Arc<UserService>, user_service: Arc<UserService>,
} }
#[controller]
impl UserController { impl UserController {
#[get("/")] #[get("/")]
async fn list(&self) -> Json<Vec<User>> { async fn list(&self) -> Json<Vec<User>> {
@@ -91,8 +92,6 @@ impl UserController {
} }
} }
impl_routes!(UserController, [list, get, create]);
// Использование в приложении // Использование в приложении
let controller = UserController { user_service }; let controller = UserController { user_service };
let router = controller.get_router(); let router = controller.get_router();
@@ -102,17 +101,42 @@ let app = Router::new()
.merge(post_controller.get_router()); .merge(post_controller.get_router());
``` ```
#### Несколько impl блоков
Маршруты обнаруживаются автоматически через `inventory`. Можно разбить методы на несколько `impl` блоков и даже по разным файлам:
```rust
// файл: user_controller.rs
#[controller(path = "/api/users")]
struct UserController { ... }
#[controller]
impl UserController {
#[get("/")]
async fn list(&self) -> Json<Vec<User>> { ... }
}
// файл: user_create.rs
#[controller]
impl UserController {
#[post("/")]
async fn create(&self, Json(body): Json<CreateUser>) -> Json<User> { ... }
}
// Оба маршрута автоматически попадут в get_router()
```
## Макросы ## Макросы
| Макрос | Назначение | | Макрос | Назначение |
|--------|-----------| |--------|-----------|
| `#[controller(path = "/prefix")]` | Определяет контроллер с базовым путём | | `#[controller(path = "/prefix")]` | Определяет контроллер с базовым путём (на struct) |
| `#[controller]` | Обнаруживает route-методы в impl блоке (на impl) |
| `#[get("/path")]` | GET маршрут | | `#[get("/path")]` | GET маршрут |
| `#[post("/path")]` | POST маршрут | | `#[post("/path")]` | POST маршрут |
| `#[put("/path")]` | PUT маршрут | | `#[put("/path")]` | PUT маршрут |
| `#[delete("/path")]` | DELETE маршрут | | `#[delete("/path")]` | DELETE маршрут |
| `#[patch("/path")]` | PATCH маршрут | | `#[patch("/path")]` | PATCH маршрут |
| `impl_routes!(Type, [methods])` | Генерирует `get_router()` для контроллера |
| `inject_services!` | Быстрая инъекция сервисов | | `inject_services!` | Быстрая инъекция сервисов |
## Параметры маршрутов ## Параметры маршрутов
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "desert-framework-macros" name = "desert-framework-macros"
version = "0.1.2" version = "0.1.3"
edition = "2021" edition = "2021"
description = "Procedural macros for desert-framework" description = "Procedural macros for desert-framework"
license = "MIT" license = "MIT"
+189 -18
View File
@@ -3,7 +3,8 @@ use proc_macro2::TokenStream as TokenStream2;
use quote::quote; use quote::quote;
use syn::{ use syn::{
parse::{Parse, ParseStream}, parse::{Parse, ParseStream},
parse_macro_input, FnArg, ImplItemFn, ItemStruct, Meta, Pat, PatType, Token, Type, parse_macro_input, FnArg, ImplItem, ImplItemFn, ItemImpl, ItemStruct, Meta, Pat, PatType,
Token, Type,
}; };
fn parse_controller_path(attr: TokenStream) -> String { fn parse_controller_path(attr: TokenStream) -> String {
@@ -39,21 +40,201 @@ fn method_code(http: &str) -> u8 {
} }
} }
fn code_to_ident(code: u8) -> TokenStream2 {
match code {
0 => quote! { ::axum::routing::get },
1 => quote! { ::axum::routing::post },
2 => quote! { ::axum::routing::put },
3 => quote! { ::axum::routing::delete },
4 => quote! { ::axum::routing::patch },
_ => unreachable!(),
}
}
fn extract_route_info(attr: &syn::Attribute) -> (String, String) {
let method_name = attr.path().segments.last().unwrap().ident.to_string();
let path = match &attr.meta {
Meta::List(meta_list) => {
let lit: syn::LitStr =
syn::parse2(meta_list.tokens.clone()).expect("expected path string");
lit.value()
}
_ => panic!("expected #[method(\"path\")]"),
};
(method_name, path)
}
fn is_route_attr(attr: &syn::Attribute) -> bool {
let ident = attr.path().segments.last().unwrap().ident.to_string();
matches!(ident.as_str(), "get" | "post" | "put" | "delete" | "patch")
}
/// #[controller(path = "/api")] on struct /// #[controller(path = "/api")] on struct
#[proc_macro_attribute] fn controller_on_struct(path: String, s: ItemStruct) -> TokenStream {
pub fn controller(attr: TokenStream, item: TokenStream) -> TokenStream {
let path = parse_controller_path(attr);
let s = parse_macro_input!(item as ItemStruct);
let name = &s.ident; let name = &s.ident;
quote! { quote! {
#s #s
impl #name { pub const __CONTROLLER_PATH: &str = #path; } impl #name { pub const __CONTROLLER_PATH: &str = #path; }
impl ::desert_framework::ControllerRoutes for #name {
const CONTROLLER_PATH: &'static str = #path;
}
} }
.into() .into()
} }
/// Generate cleaned method + metadata consts + handler factory /// #[controller] on impl block — discovers route methods automatically
fn controller_on_impl(impl_block: ItemImpl) -> TokenStream {
if impl_block.trait_.is_some() {
panic!("#[controller] on impl block is only supported for bare impls (not trait impls)");
}
let self_type = &impl_block.self_ty;
let type_name = match self_type.as_ref() {
Type::Path(type_path) => type_path.path.segments.last().unwrap().ident.clone(),
_ => panic!("#[controller] on impl block requires a named type"),
};
let mut cleaned_methods: Vec<TokenStream2> = Vec::new();
let mut factory_fns: Vec<TokenStream2> = Vec::new();
let mut inventory_submits: Vec<TokenStream2> = Vec::new();
for item in &impl_block.items {
if let ImplItem::Fn(method) = item {
let route_attr = method.attrs.iter().find(|a| is_route_attr(a));
if let Some(attr) = route_attr {
let (http_method, route_path) = extract_route_info(attr);
let code = method_code(&http_method);
let name = &method.sig.ident;
let is_async = method.sig.asyncness.is_some();
let router_fn = code_to_ident(code);
let extra: Vec<&FnArg> = method
.sig
.inputs
.iter()
.filter(|a| !matches!(a, FnArg::Receiver(_)))
.collect();
let pats: Vec<&Pat> = extra
.iter()
.map(|a| match a {
FnArg::Typed(PatType { pat, .. }) => pat.as_ref(),
_ => unreachable!(),
})
.collect();
let tys: Vec<&Type> = extra
.iter()
.map(|a| match a {
FnArg::Typed(PatType { ty, .. }) => ty.as_ref(),
_ => unreachable!(),
})
.collect();
let closure = if extra.is_empty() {
if is_async {
quote! { move || async move { state.#name().await } }
} else {
quote! { move || { state.#name() } }
}
} else if is_async {
quote! {
move |#(#pats: #tys),*| async move {
state.#name(#(#pats),*).await
}
}
} else {
quote! {
move |#(#pats: #tys),*| {
state.#name(#(#pats),*)
}
}
};
let factory_name = syn::Ident::new(&format!("__make_route_{}", name), name.span());
// Cleaned method (without route attribute)
let non_route_attrs: Vec<_> =
method.attrs.iter().filter(|a| !is_route_attr(a)).collect();
let vis = &method.vis;
let sig = &method.sig;
let block = &method.block;
cleaned_methods.push(quote! {
#(#non_route_attrs)*
#vis #sig #block
});
// Factory function (type-erased)
factory_fns.push(quote! {
fn #factory_name(
state: ::std::sync::Arc<dyn ::std::any::Any + Send + Sync>,
) -> ::axum::routing::MethodRouter<()> {
let state = state.downcast::<#type_name>().unwrap();
#router_fn(#closure)
}
});
// inventory::submit!
inventory_submits.push(quote! {
::desert_framework::inventory::submit! {
::desert_framework::RouteEntry {
controller_type_id: ::std::any::TypeId::of::<#type_name>(),
path: #route_path,
method: #code,
make_route: #factory_name,
}
}
});
} else {
cleaned_methods.push(quote! { #method });
}
} else {
cleaned_methods.push(quote! { #item });
}
}
let defaultness = &impl_block.defaultness;
let generics = &impl_block.generics;
let self_ty = &impl_block.self_ty;
let where_clause = &generics.where_clause;
quote! {
#defaultness impl #generics #self_ty #where_clause {
#(#cleaned_methods)*
}
#(#factory_fns)*
#(#inventory_submits)*
}
.into()
}
// ─── #[controller] dispatch ───
#[proc_macro_attribute]
pub fn controller(attr: TokenStream, item: TokenStream) -> TokenStream {
let input = item.clone();
if let Ok(s) = syn::parse::<ItemStruct>(input) {
let path = parse_controller_path(attr);
return controller_on_struct(path, s);
}
let input = item.clone();
if let Ok(impl_block) = syn::parse::<ItemImpl>(input) {
return controller_on_impl(impl_block);
}
panic!("#[controller] can only be applied to structs or impl blocks");
}
// ─── Standalone route attributes (for backward compat) ───
fn process_route_method(http: &str, attr: TokenStream, item: TokenStream) -> TokenStream { fn process_route_method(http: &str, attr: TokenStream, item: TokenStream) -> TokenStream {
let method = parse_macro_input!(item as ImplItemFn); let method = parse_macro_input!(item as ImplItemFn);
let name = &method.sig.ident; let name = &method.sig.ident;
@@ -125,17 +306,6 @@ fn process_route_method(http: &str, attr: TokenStream, item: TokenStream) -> Tok
.into() .into()
} }
fn code_to_ident(code: u8) -> TokenStream2 {
match code {
0 => quote! { ::axum::routing::get },
1 => quote! { ::axum::routing::post },
2 => quote! { ::axum::routing::put },
3 => quote! { ::axum::routing::delete },
4 => quote! { ::axum::routing::patch },
_ => unreachable!(),
}
}
#[proc_macro_attribute] #[proc_macro_attribute]
pub fn get(attr: TokenStream, item: TokenStream) -> TokenStream { pub fn get(attr: TokenStream, item: TokenStream) -> TokenStream {
process_route_method("get", attr, item) process_route_method("get", attr, item)
@@ -161,6 +331,8 @@ pub fn patch(attr: TokenStream, item: TokenStream) -> TokenStream {
process_route_method("patch", attr, item) process_route_method("patch", attr, item)
} }
// ─── impl_routes! (backward compat) ───
struct ImplRoutesInput { struct ImplRoutesInput {
type_: syn::Path, type_: syn::Path,
methods: Vec<syn::Ident>, methods: Vec<syn::Ident>,
@@ -180,7 +352,6 @@ impl Parse for ImplRoutesInput {
} }
} }
/// impl_routes!(MyCtrl, [hello, login])
#[proc_macro] #[proc_macro]
pub fn impl_routes(input: TokenStream) -> TokenStream { pub fn impl_routes(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as ImplRoutesInput); let input = parse_macro_input!(input as ImplRoutesInput);
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "desert_framework" name = "desert_framework"
version = "0.1.2" version = "0.1.3"
edition = "2021" edition = "2021"
description = "Micro-framework for building backend applications in Rust with Axum" description = "Micro-framework for building backend applications in Rust with Axum"
license = "MIT" license = "MIT"
@@ -10,6 +10,7 @@ repository = "https://github.com/Desert-Ecosystem/framework"
tokio = { version = "1.47.1", features = ["sync"] } tokio = { version = "1.47.1", features = ["sync"] }
log = "0.4.28" log = "0.4.28"
axum = "0.8" axum = "0.8"
inventory = "0.3"
desert-framework-macros = { path = "../desert-framework-macros" } desert-framework-macros = { path = "../desert-framework-macros" }
[dev-dependencies] [dev-dependencies]
+22 -3
View File
@@ -1,4 +1,23 @@
pub trait Controller { use std::any::{Any, TypeId};
type State: Send + Sync + 'static; use std::sync::Arc;
fn register_routes(self) -> axum::Router<Self::State>;
use crate::route::RouteEntry;
pub trait ControllerRoutes: Sized + Send + Sync + 'static {
const CONTROLLER_PATH: &'static str;
fn get_router(self) -> axum::Router {
let state: Arc<dyn Any + Send + Sync> = Arc::new(self);
let mut router = axum::Router::new();
for entry in inventory::iter::<RouteEntry> {
if entry.controller_type_id == TypeId::of::<Self>() {
let full_path = format!("{}{}", Self::CONTROLLER_PATH, entry.path);
let mr = (entry.make_route)(state.clone());
router = router.route(&full_path, mr);
}
}
router
}
} }
+6
View File
@@ -1,8 +1,14 @@
extern crate self as desert_framework;
pub mod controller; pub mod controller;
pub mod dependency; pub mod dependency;
pub mod macros; pub mod macros;
pub mod manager; pub mod manager;
pub mod route;
pub mod service; pub mod service;
pub mod test; pub mod test;
pub use controller::ControllerRoutes;
pub use desert_framework_macros::*; pub use desert_framework_macros::*;
pub use inventory;
pub use route::RouteEntry;
+13
View File
@@ -0,0 +1,13 @@
use std::any::{Any, TypeId};
use std::sync::Arc;
use axum::routing::MethodRouter;
pub struct RouteEntry {
pub controller_type_id: TypeId,
pub path: &'static str,
pub method: u8,
pub make_route: fn(Arc<dyn Any + Send + Sync>) -> MethodRouter,
}
inventory::collect!(RouteEntry);
+6 -6
View File
@@ -9,7 +9,7 @@ mod tests {
use crate::dependency::{dep, Deps}; use crate::dependency::{dep, Deps};
use crate::manager::DependencyManager; use crate::manager::DependencyManager;
use crate::service::Service; use crate::service::Service;
use crate::{controller, get, impl_routes, post}; use crate::{controller, ControllerRoutes};
struct TestService1; struct TestService1;
@@ -95,11 +95,12 @@ mod tests {
assert_eq!(s1.get_hello(), "hello from service 1"); assert_eq!(s1.get_hello(), "hello from service 1");
} }
// === Controller Tests === // === Controller Tests (inventory-based) ===
#[controller(path = "/api")] #[controller(path = "/api")]
struct TestController; struct TestController;
#[controller]
impl TestController { impl TestController {
#[get("/hello")] #[get("/hello")]
async fn hello(&self) -> &'static str { async fn hello(&self) -> &'static str {
@@ -117,8 +118,6 @@ mod tests {
} }
} }
impl_routes!(TestController, [hello, get_item, add_item]);
#[tokio::test] #[tokio::test]
async fn controller_get_hello() { async fn controller_get_hello() {
let controller = TestController; let controller = TestController;
@@ -208,9 +207,12 @@ mod tests {
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
} }
// === Multiple impl blocks ===
#[controller(path = "/api/other")] #[controller(path = "/api/other")]
struct AnotherController; struct AnotherController;
#[controller]
impl AnotherController { impl AnotherController {
#[get("/test")] #[get("/test")]
async fn test(&self) -> &'static str { async fn test(&self) -> &'static str {
@@ -218,8 +220,6 @@ mod tests {
} }
} }
impl_routes!(AnotherController, [test]);
#[tokio::test] #[tokio::test]
async fn merge_different_controllers() { async fn merge_different_controllers() {
let c1 = TestController; let c1 = TestController;