Skip to content

Routes

Routes map incoming HTTP requests to handler functions. FORGE organizes routes into two groups: admin routes for authenticated management endpoints and public routes for client-facing data. Routes are registered using Axum's Router and composed with middleware for authentication and authorization.

Route Organization

/api/
├── auth/
│   ├── POST   login
│   ├── POST   register
│   ├── POST   refresh
│   ├── POST   logout
│   ├── POST   forgot-password
│   ├── POST   reset-password
│   ├── POST   send-otp
│   └── POST   verify-otp
├── profile              GET, PUT (authenticated)
├── contents/{slug}      GET (public)
├── lookups/{type}       GET (public)
├── menus/{location}     GET (public)
└── admin/               (auth + permissions required)
    ├── users/           GET, POST
    │   └── {id}         GET, PUT, DELETE
    │       └── roles    POST
    ├── roles/           GET, POST
    │   └── {id}         GET, PUT, DELETE
    ├── permissions/     GET
    ├── contents/        GET, POST
    │   └── {id}         GET, PUT, DELETE
    ├── lookups/         GET, POST
    │   └── {id}         GET, PUT, DELETE
    ├── menus/           GET, POST
    │   └── {id}         GET, PUT, DELETE
    └── settings/        GET, PUT

Route Registration

Main Router

The top-level router in main.rs composes all route groups:

rust
// src/main.rs
let app = Router::new()
    // Admin routes (auth + RBAC middleware applied inside)
    .nest("/api/admin", routes::admin::routes(pool.clone()))
    // Public API routes
    .nest("/api", routes::public::routes(pool.clone()))
    // Health checks
    .route("/health", get(|| async { "OK" }))
    .route("/ready", get(health_check))
    // Documentation
    .merge(SwaggerUi::new("/docs").url("/api-docs/openapi.json", ApiDoc::openapi()))
    .merge(Redoc::with_url("/redoc", ApiDoc::openapi()))
    // Global middleware
    .layer(CorsLayer::permissive())
    .layer(middleware::from_fn(middleware::request_id::add_request_id));

Admin Routes

Admin routes apply authentication middleware to the entire group and permission middleware to individual routes:

rust
// src/routes/admin.rs
use axum::{Router, routing::{get, post, put, delete}, middleware};
use crate::handlers::admin;
use crate::middleware::{auth::auth_middleware, rbac::require_permission};

pub fn routes(pool: PgPool) -> Router {
    Router::new()
        // User management
        .route(
            "/users",
            get(admin::users::list)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("users.view"),
                ))
        )
        .route(
            "/users",
            post(admin::users::create)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("users.create"),
                ))
        )
        .route(
            "/users/:id",
            get(admin::users::show)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("users.view"),
                ))
        )
        .route(
            "/users/:id",
            put(admin::users::update)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("users.edit"),
                ))
        )
        .route(
            "/users/:id",
            delete(admin::users::delete)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("users.delete"),
                ))
        )
        .route(
            "/users/:id/roles",
            post(admin::users::assign_roles)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("roles.edit"),
                ))
        )
        // Role management
        .route(
            "/roles",
            get(admin::roles::list)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("roles.view"),
                ))
        )
        .route(
            "/roles",
            post(admin::roles::create)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("roles.create"),
                ))
        )
        .route(
            "/roles/:id",
            put(admin::roles::update)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("roles.edit"),
                ))
        )
        .route(
            "/roles/:id",
            delete(admin::roles::delete)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("roles.delete"),
                ))
        )
        // Permissions (read-only)
        .route(
            "/permissions",
            get(admin::permissions::list)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("roles.view"),
                ))
        )
        // Content management
        .route(
            "/contents",
            get(admin::contents::list)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("contents.view"),
                ))
        )
        .route(
            "/contents",
            post(admin::contents::create)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("contents.create"),
                ))
        )
        .route(
            "/contents/:id",
            get(admin::contents::show)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("contents.view"),
                ))
        )
        .route(
            "/contents/:id",
            put(admin::contents::update)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("contents.edit"),
                ))
        )
        .route(
            "/contents/:id",
            delete(admin::contents::delete)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    require_permission("contents.delete"),
                ))
        )
        // Settings
        .route("/settings", get(admin::settings::list))
        .route("/settings", put(admin::settings::update))
        // Apply auth middleware to all admin routes
        .layer(middleware::from_fn_with_state(
            pool.clone(),
            auth_middleware,
        ))
        .with_state(pool)
}

WARNING

The auth_middleware is applied as a layer to the entire admin router. This means every route under /api/admin/* requires a valid JWT token. Individual routes then apply require_permission for fine-grained access control.

Public Routes

Public routes are accessible without authentication, though some may optionally use it:

rust
// src/routes/public.rs
use axum::{Router, routing::{get, post}};
use crate::handlers;

pub fn routes(pool: PgPool) -> Router {
    Router::new()
        // Authentication (no auth required)
        .route("/auth/login", post(handlers::auth::login))
        .route("/auth/register", post(handlers::auth::register))
        .route("/auth/refresh", post(handlers::auth::refresh))
        .route("/auth/logout", post(handlers::auth::logout))
        .route("/auth/forgot-password", post(handlers::auth::forgot_password))
        .route("/auth/reset-password", post(handlers::auth::reset_password))
        .route("/auth/send-otp", post(handlers::auth::send_otp))
        .route("/auth/verify-otp", post(handlers::auth::verify_otp))
        // Profile (auth required)
        .route(
            "/profile",
            get(handlers::profile::show)
                .put(handlers::profile::update)
                .route_layer(middleware::from_fn_with_state(
                    pool.clone(),
                    auth_middleware,
                ))
        )
        // Public content (no auth required)
        .route("/contents/:slug", get(handlers::contents::show))
        .route("/lookups/:type", get(handlers::lookups::by_type))
        .route("/menus/:location", get(handlers::menus::by_location))
        .with_state(pool)
}

Middleware Application

Middleware is applied at different levels depending on scope:

Global Middleware (all routes)
├── CORS
├── Request ID

├── /api/admin/* (admin routes)
│   ├── Auth Middleware (all admin routes)
│   │   ├── /users       → require_permission("users.view")
│   │   ├── /users POST  → require_permission("users.create")
│   │   └── ...
│   │
├── /api/* (public routes)
│   ├── /auth/*           → No middleware
│   ├── /profile          → Auth Middleware (route-level)
│   └── /contents/:slug   → No middleware

TIP

Apply middleware at the narrowest possible scope. Global middleware runs on every request, including health checks and documentation endpoints. Use route_layer for route-specific middleware to keep the overhead minimal.

Route Patterns

Resource CRUD Routes

The standard pattern for a new resource:

rust
// Add to src/routes/admin.rs
.route("/products", get(admin::products::list))
.route("/products", post(admin::products::create))
.route("/products/:id", get(admin::products::show))
.route("/products/:id", put(admin::products::update))
.route("/products/:id", delete(admin::products::delete))

Nested Resource Routes

For resources that belong to a parent:

rust
// Comments belong to a content entry
.route("/contents/:content_id/comments", get(admin::comments::list))
.route("/contents/:content_id/comments", post(admin::comments::create))
.route("/contents/:content_id/comments/:id", put(admin::comments::update))
.route("/contents/:content_id/comments/:id", delete(admin::comments::delete))

Action Routes

For non-CRUD actions on a resource:

rust
// Custom actions
.route("/users/:id/roles", post(admin::users::assign_roles))
.route("/users/:id/activate", post(admin::users::activate))
.route("/users/:id/deactivate", post(admin::users::deactivate))

Path Parameters

Axum extracts path parameters using the Path extractor:

rust
// Single parameter
.route("/users/:id", get(show_user))

async fn show_user(Path(id): Path<Uuid>) -> impl IntoResponse { /* ... */ }

// Multiple parameters
.route("/contents/:content_id/comments/:id", get(show_comment))

async fn show_comment(
    Path((content_id, id)): Path<(Uuid, Uuid)>,
) -> impl IntoResponse { /* ... */ }

See Also

Released under the MIT License.