route_controller 0.2.0

A procedural macro for generating Axum routers from controller-style implementations with support for route prefixing and middleware
Documentation

route_controller

CI Status Crates.io License Rust Version Downloads

Generate Axum routers from controller-style implementations with declarative extractors

Features

  • Clean controller-style API similar to Routing Controller (JS) or Rocket
  • Route prefixing for organizing endpoints
  • Declarative extractor syntax with extract() attribute
  • Built-in extractors:
    • Body extractors: Json, Form, Bytes, Text, Html, Xml, JavaScript
    • URL extractors: Path, Query
    • State extractor: State
  • Optional extractors (with feature flags):
    • HeaderParam - Extract from HTTP headers (requires headers feature)
    • CookieParam - Extract from cookies (requires cookies feature)
    • SessionParam - Extract from session storage (requires sessions feature)
  • Response header support: header() and content_type() attributes
    • Controller-level headers: Apply headers to all routes in a controller
    • Route-level override: Route headers override controller headers with the same name
  • Middleware support at the controller level
  • HTTP method attributes: #[get], #[post], #[put], #[delete], #[patch], #[head], #[options], #[trace]

Installation

[dependencies]
route_controller = "0.2.0"
axum = "0.8"  # Also works with axum 0.7 and earlier versions
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }

Path Parameter Syntax

The path parameter syntax depends on your Axum version:

  • Axum 0.8+: Use curly braces {id} for path parameters
#[get("/{id}", extract(id = Path))]
async fn get_user(id: u32) -> String {
    format!("User {}", id)
}
  • Axum 0.7 and earlier: Use colon syntax :id for path parameters
#[get("/:id", extract(id = Path))]
async fn get_user(id: u32) -> String {
    format!("User {}", id)
}

Optional Dependencies

For additional extractors, enable features and add required dependencies:

[dependencies]
route_controller = { version = "0.2.0", features = ["headers", "cookies", "sessions"] }
axum-extra = { version = "0.12", features = ["cookie"] }  # Required for cookies
tower-sessions = "0.14"  # Required for sessions

Quick Start

use route_controller::{controller, get, post};
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
struct User {
    name: String,
    email: String,
}

struct UserController;

#[controller(path = "/users")]
impl UserController {
    #[get]
    async fn list() -> &'static str {
        "User list"
    }

    #[get("/{id}", extract(id = Path))]
    async fn get_one(id: u32) -> axum::Json<User> {
        let user = User {
            name: format!("User{}", id),
            email: format!("user{}@example.com", id),
        };
        axum::Json(user)
    }

    #[post(extract(user = Json))]
    async fn create(user: User) -> String {
        format!("Created user: {} ({})", user.name, user.email)
    }
}

#[tokio::main]
async fn main() {
    let app = UserController::router();

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();

    println!("🚀 Server running on http://127.0.0.1:3000");
    axum::serve(listener, app).await.unwrap();
}

Controller Types

The extract() Attribute

Use the extract() attribute to specify how each parameter should be extracted from the request. The order of extractors in the attribute can differ from the parameter order:

#[controller(path = "/api/users")]
impl ApiController {
    // Single extractor
    #[post("/", extract(user = Json))]
    async fn create(user: User) -> String {
        format!("Created: {}", user.name)
    }

    // Multiple Path extractors (order independent)
    #[get("/{id}/posts/{post_id}", extract(post_id = Path, id = Path))]
    async fn get_user_post(id: u32, post_id: u32) -> String {
        format!("User {} - Post {}", id, post_id)
    }

    // Mixed extractors: Path + Json
    #[put("/{id}", extract(id = Path, user = Json))]
    async fn update(id: u32, user: User) -> String {
        format!("Updated user {}", id)
    }

    // Path + Query extractors
    #[get("/{id}/search", extract(id = Path, filters = Query))]
    async fn search(id: u32, filters: SearchFilters) -> String {
        format!("Searching for user {}", id)
    }
}

Available Extractors

Request Body Extractors

  • Json - Extract JSON request body: extract(data = Json)

    • Type: Any deserializable struct (T where T: serde::Deserialize)
    • Content-Type: application/json
  • Form - Extract form data (form-data or x-www-form-urlencoded): extract(data = Form)

    • Type: Any deserializable struct (T where T: serde::Deserialize)
    • Content-Type: application/x-www-form-urlencoded or multipart/form-data
  • Bytes - Extract raw binary data: extract(data = Bytes)

    • Type: Vec<u8>
    • Useful for file uploads, binary protocols, etc.
  • Text - Extract plain text: extract(content = Text)

    • Type: String
    • Content-Type: text/plain
  • Html - Extract HTML content: extract(content = Html)

    • Type: String
    • Content-Type: text/html
  • Xml - Extract XML content: extract(content = Xml)

    • Type: String
    • Content-Type: application/xml or text/xml
  • JavaScript - Extract JavaScript content: extract(code = JavaScript)

    • Type: String
    • Content-Type: application/javascript or text/javascript

URL Extractors

  • Path - Extract path parameters: extract(id = Path)
  • Query - Extract query parameters: extract(params = Query)

Other Extractors

  • State - Extract application state: extract(state = State)

Feature-Gated Extractors

Enable additional extractors with Cargo features:

[dependencies]
route_controller = { version = "0.2.0", features = ["headers", "cookies", "sessions"] }
axum-extra = { version = "0.12", features = ["cookie"] }  # Required for cookies
tower-sessions = "0.14"  # Required for sessions
  • HeaderParam - Extract from HTTP headers (requires headers feature)

    #[get("/api/data", extract(authorization = HeaderParam))]
    async fn get_data(authorization: String) -> String {
        format!("Auth: {}", authorization)
    }
    
  • CookieParam - Extract from cookies (requires cookies feature + axum-extra)

    #[get("/profile", extract(session_id = CookieParam))]
    async fn get_profile(session_id: String) -> String {
        format!("Session: {}", session_id)
    }
    
  • SessionParam - Extract from session storage (requires sessions feature + tower-sessions)

    #[get("/profile", extract(user_id = SessionParam))]
    async fn get_profile(user_id: String) -> String {
        format!("User ID: {}", user_id)
    }
    

Using State

Extract application state in your handlers using the State extractor:

use std::sync::Arc;
use tokio::sync::RwLock;

#[derive(Clone)]
struct AppState {
    counter: Arc<RwLock<i32>>,
}

struct CounterController;

#[controller(path = "/counter")]
impl CounterController {
    #[get(extract(state = State))]
    async fn get_count(state: AppState) -> axum::Json<i32> {
        let count = *state.counter.read().await;
        axum::Json(count)
    }

    #[post("/increment", extract(state = State))]
    async fn increment(state: AppState) -> axum::Json<i32> {
        let mut counter = state.counter.write().await;
        *counter += 1;
        axum::Json(*counter)
    }
}

#[tokio::main]
async fn main() {
    let app_state = AppState {
        counter: Arc::new(RwLock::new(0)),
    };

    let app = axum::Router::new()
        .merge(CounterController::router())
        .with_state(app_state);

    // Start server...
}

Body Extractor Examples

Form Data

Handle form submissions with the Form extractor:

#[derive(Deserialize)]
struct LoginForm {
    username: String,
    password: String,
}

#[controller(path = "/auth")]
impl AuthController {
    #[post("/login", extract(form = Form))]
    async fn login(form: LoginForm) -> String {
        format!("Logging in user: {}", form.username)
    }
}

Test with:

curl -X POST http://localhost:3000/auth/login \
  -d 'username=john&password=secret123'

Binary Data

Handle file uploads or binary data with the Bytes extractor:

#[controller(path = "/files")]
impl FileController {
    #[post("/upload", extract(data = Bytes))]
    async fn upload(data: Vec<u8>) -> String {
        format!("Received {} bytes", data.len())
    }
}

Text Content Types

Handle various text-based content types:

#[controller(path = "/content")]
impl ContentController {
    // Plain text
    #[post("/text", extract(content = Text))]
    async fn handle_text(content: String) -> String {
        format!("Received text: {}", content)
    }

    // HTML content
    #[post("/html", extract(html = Html))]
    async fn handle_html(html: String) -> String {
        format!("Received {} chars of HTML", html.len())
    }

    // XML content
    #[post("/xml", extract(xml = Xml))]
    async fn handle_xml(xml: String) -> String {
        format!("Received XML: {}", xml)
    }

    // JavaScript code
    #[post("/script", extract(code = JavaScript))]
    async fn handle_script(code: String) -> String {
        format!("Received {} chars of JavaScript", code.len())
    }
}

Response Headers

Add custom headers to your responses using the header() and content_type() attributes at both the controller and route levels.

Controller-Level Headers

Apply headers to all routes in a controller. Route-level headers with the same name will override controller-level headers:

#[controller(
    path = "/api",
    header("x-api-version", "1.0"),
    header("x-powered-by", "route-controller")
)]
impl ApiController {
    // Inherits both controller headers
    #[get("/data")]
    async fn get_data() -> String {
        "Data with controller headers".to_string()
    }

    // Overrides x-api-version, keeps x-powered-by
    #[get("/v2", header("x-api-version", "2.0"))]
    async fn get_data_v2() -> String {
        "Data with overridden version".to_string()
    }

    // Adds route-specific header, keeps controller headers
    #[get("/special", header("x-request-id", "abc-123"))]
    async fn special() -> String {
        "Special endpoint".to_string()
    }
}

Route-Level Headers

#[controller(path = "/api")]
impl ApiController {
    #[get("/data", header("x-api-version", "1.0"))]
    async fn get_data() -> String {
        "Data with custom header".to_string()
    }
}

Multiple Headers

#[controller(path = "/api")]
impl ApiController {
    #[get(
        "/info",
        header("x-api-version", "2.0"),
        header("x-request-id", "abc-123")
    )]
    async fn get_info() -> String {
        "Info with multiple headers".to_string()
    }
}

Content-Type Header

Set content-type at controller or route level:

// Controller-level content-type applies to all routes
#[controller(path = "/api", content_type("application/json"))]
impl ApiController {
    // Inherits application/json content-type
    #[get("/data")]
    async fn get_data() -> String {
        r#"{"status":"ok"}"#.to_string()
    }

    // Route overrides to XML
    #[get("/xml", content_type("application/xml"))]
    async fn get_xml() -> String {
        r#"<?xml version="1.0"?>
<response>
    <message>Hello XML</message>
</response>"#.to_string()
    }

    // Route overrides to plain text
    #[get("/text", content_type("text/plain; charset=utf-8"))]
    async fn get_text() -> String {
        "Plain text response".to_string()
    }
}

Combining Controller and Route Headers

Controller headers provide a base set of headers, and routes can override or extend them:

// Controller provides base headers and content-type
#[controller(
    path = "/api",
    content_type("application/json"),
    header("x-api-version", "1.0"),
    header("x-service", "my-api")
)]
impl ApiController {
    // Inherits all controller headers
    #[get("/info")]
    async fn get_info() -> axum::Json<Response> {
        axum::Json(Response { status: "ok".to_string() })
    }

    // Override version and content-type, keep x-service
    #[post(
        "/data",
        content_type("application/json; charset=utf-8"),
        header("x-api-version", "2.0"),
        header("x-rate-limit", "100")
    )]
    async fn post_data() -> axum::Json<Response> {
        axum::Json(Response { status: "ok".to_string() })
    }
}

Test with:

# Check inherited headers
curl -v http://localhost:3000/api/info
# Output: x-api-version: 1.0, x-service: my-api, content-type: application/json

# Check overridden headers
curl -v http://localhost:3000/api/data
# Output: x-api-version: 2.0, x-service: my-api, x-rate-limit: 100

Examples

The crate includes 15 comprehensive examples demonstrating different features:

# 1. Basic routing with different HTTP methods (GET, POST, PUT, DELETE)
cargo run --example 01_basic_routing

# 2. Path parameter extraction
cargo run --example 02_path_params

# 3. Query parameter extraction
cargo run --example 03_query_params

# 4. JSON body extraction
cargo run --example 04_json_body

# 5. Form data handling (form-data and x-www-form-urlencoded)
cargo run --example 05_form_data

# 6. Text body extraction
cargo run --example 06_text_body

# 7. Binary data (bytes) handling
cargo run --example 07_bytes

# 8. Header extraction (requires 'headers' feature)
cargo run --example 08_headers --features headers

# 9. Cookie handling (requires 'cookies' feature)
cargo run --example 09_cookies --features cookies

# 10. Session management (requires 'sessions' feature)
cargo run --example 10_sessions --features sessions

# 11. Application state management
cargo run --example 11_state

# 12. Response headers and content types
cargo run --example 12_response_headers

# 13. Middleware application
cargo run --example 13_middleware

# 14. Mixed extractors (Path + Query + Json)
cargo run --example 14_mixed_extractors

# 15. Multiple controllers with merged routers
cargo run --example 15_multiple_controllers

Each example includes:

  • Clear comments explaining the feature
  • Test commands using curl
  • Working code that can be run immediately

With Middleware

Apply middleware at the controller level:

use axum::{
    middleware::Next,
    extract::Request,
    response::Response,
};

async fn log_middleware(request: Request, next: Next) -> Response {
    println!("Request: {} {}", request.method(), request.uri());
    next.run(request).await
}

#[controller(path = "/api", middleware = log_middleware)]
impl ApiController {
    #[get("/data")]
    async fn get_data() -> String {
        "Protected data".to_string()
    }
}

You can also apply multiple middlewares:

#[controller(
    path = "/api",
    middleware = middleware_a,
    middleware = middleware_b
)]
impl MultiMiddlewareController {
    #[get("/test")]
    async fn test() -> &'static str {
        "ok"
    }
}

See examples/13_middleware.rs for a complete example.

Verbose Logging

Enable verbose logging during compilation by setting the ROUTE_CONTROLLER_VERBOSE environment variable:

ROUTE_CONTROLLER_VERBOSE=1 cargo build
ROUTE_CONTROLLER_VERBOSE=1 cargo run --example basic

This shows detailed information about route registration during compilation.

License

MIT