Skip to content

Handlers

Handlers are async functions that process incoming HTTP requests. They sit at the top of the application layer, responsible for parsing request data, delegating to services, and building HTTP responses. FORGE organizes handlers into admin and public modules, each following consistent patterns for CRUD operations and error handling.

Handler Organization

src/handlers/
├── mod.rs              # Module exports
├── auth.rs             # Login, register, refresh, logout
├── profile.rs          # Current user profile
├── lookups.rs          # Public lookup endpoints
├── menus.rs            # Public menu endpoints
└── admin/
    ├── mod.rs          # Admin module exports
    ├── users.rs        # User CRUD
    ├── roles.rs        # Role CRUD
    ├── contents.rs     # Content CRUD
    ├── settings.rs     # Settings management
    ├── lookups.rs      # Lookup CRUD
    └── menus.rs        # Menu CRUD

Admin handlers (src/handlers/admin/) require authentication and permissions. They handle full CRUD operations for managing application data.

Public handlers (src/handlers/) may be unauthenticated. They serve data to frontend clients, such as content pages and lookup values.

Handler Anatomy

Every handler follows the same pattern:

1. Extract auth user (if protected)
2. Extract and validate input
3. Call the service layer
4. Return a structured response
rust
pub async fn create_user(
    State(pool): State<PgPool>,                     // 1. Database pool
    Extension(current_user): Extension<AuthUser>,    // 2. Authenticated user
    Json(payload): Json<CreateUserRequest>,           // 3. Request body
) -> Result<impl IntoResponse, AppError> {           // 4. Return type
    // Validate input
    payload.validate()
        .map_err(|e| AppError::ValidationError(format_validation_errors(e)))?;

    // Delegate to service
    let user = UserService::create(&pool, payload).await?;

    // Return response
    Ok((
        StatusCode::CREATED,
        Json(ApiResponse::success(UserResponse::from(user))),
    ))
}

TIP

Handlers should be thin. They extract data from the request, pass it to a service, and format the response. Business logic, validation rules, and database queries belong in the service layer.

CRUD Handler Examples

List (with Pagination)

rust
/// List all users with pagination and optional search
#[utoipa::path(
    get,
    path = "/api/admin/users",
    tag = "Users",
    params(
        ("page" = Option<i64>, Query, description = "Page number"),
        ("per_page" = Option<i64>, Query, description = "Items per page"),
        ("search" = Option<String>, Query, description = "Search by name or email"),
    ),
    responses(
        (status = 200, description = "Paginated user list", body = PaginatedResponse<UserResponse>),
        (status = 401, description = "Unauthorized"),
    ),
    security(("bearer_auth" = []))
)]
pub async fn list(
    State(pool): State<PgPool>,
    Extension(_current_user): Extension<AuthUser>,
    Query(params): Query<PaginatedRequest>,
) -> Result<impl IntoResponse, AppError> {
    let (users, total) = UserService::list(&pool, &params).await?;

    let response = PaginatedResponse::new(
        users.into_iter().map(UserResponse::from).collect(),
        total,
        params.page,
        params.per_page,
    );

    Ok(Json(ApiResponse::success(response)))
}

Show (Single Resource)

rust
/// Get a single user by ID
#[utoipa::path(
    get,
    path = "/api/admin/users/{id}",
    tag = "Users",
    params(
        ("id" = Uuid, Path, description = "User ID"),
    ),
    responses(
        (status = 200, description = "User details", body = ApiResponse<UserResponse>),
        (status = 404, description = "User not found"),
    ),
    security(("bearer_auth" = []))
)]
pub async fn show(
    State(pool): State<PgPool>,
    Extension(_current_user): Extension<AuthUser>,
    Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
    let user = UserService::find_by_id(&pool, id)
        .await?
        .ok_or(AppError::NotFound("User not found".into()))?;

    Ok(Json(ApiResponse::success(UserResponse::from(user))))
}

Create

rust
/// Create a new user
#[utoipa::path(
    post,
    path = "/api/admin/users",
    tag = "Users",
    request_body = CreateUserRequest,
    responses(
        (status = 201, description = "User created", body = ApiResponse<UserResponse>),
        (status = 422, description = "Validation error", body = ErrorResponse),
    ),
    security(("bearer_auth" = []))
)]
pub async fn create(
    State(pool): State<PgPool>,
    Extension(_current_user): Extension<AuthUser>,
    Json(payload): Json<CreateUserRequest>,
) -> Result<impl IntoResponse, AppError> {
    payload.validate()
        .map_err(|e| AppError::ValidationError(format_validation_errors(e)))?;

    let user = UserService::create(&pool, payload).await?;

    Ok((
        StatusCode::CREATED,
        Json(ApiResponse::success(UserResponse::from(user))),
    ))
}

Update

rust
/// Update an existing user
#[utoipa::path(
    put,
    path = "/api/admin/users/{id}",
    tag = "Users",
    params(("id" = Uuid, Path, description = "User ID")),
    request_body = UpdateUserRequest,
    responses(
        (status = 200, description = "User updated", body = ApiResponse<UserResponse>),
        (status = 404, description = "User not found"),
        (status = 422, description = "Validation error", body = ErrorResponse),
    ),
    security(("bearer_auth" = []))
)]
pub async fn update(
    State(pool): State<PgPool>,
    Extension(_current_user): Extension<AuthUser>,
    Path(id): Path<Uuid>,
    Json(payload): Json<UpdateUserRequest>,
) -> Result<impl IntoResponse, AppError> {
    payload.validate()
        .map_err(|e| AppError::ValidationError(format_validation_errors(e)))?;

    let user = UserService::update(&pool, id, payload)
        .await?
        .ok_or(AppError::NotFound("User not found".into()))?;

    Ok(Json(ApiResponse::success(UserResponse::from(user))))
}

Delete (Soft Delete)

rust
/// Delete a user (soft delete)
#[utoipa::path(
    delete,
    path = "/api/admin/users/{id}",
    tag = "Users",
    params(("id" = Uuid, Path, description = "User ID")),
    responses(
        (status = 200, description = "User deleted"),
        (status = 404, description = "User not found"),
    ),
    security(("bearer_auth" = []))
)]
pub async fn delete(
    State(pool): State<PgPool>,
    Extension(current_user): Extension<AuthUser>,
    Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
    // Prevent self-deletion
    if current_user.id == id {
        return Err(AppError::Forbidden(
            "You cannot delete your own account".into(),
        ));
    }

    UserService::delete(&pool, id)
        .await?
        .ok_or(AppError::NotFound("User not found".into()))?;

    Ok(Json(ApiResponse::success("User deleted successfully")))
}

WARNING

Delete operations use soft deletion by setting deleted_at rather than removing the row. This preserves data integrity and allows recovery. The service layer handles the soft-delete logic.

Extractors

Axum provides extractors to parse different parts of the HTTP request:

ExtractorSourceExample
State(pool)Application stateDatabase pool
Extension(user)Request extensionsAuthenticated user (set by middleware)
Json(body)Request bodyJSON payload
Query(params)Query string?page=1&per_page=15
Path(id)URL path segments/users/{id}
TypedHeader(header)Request headersAuthorization header

Extractor Ordering

Axum processes extractors in order. Place extractors that consume the request body (Json) last:

rust
pub async fn update(
    State(pool): State<PgPool>,         // First: shared state
    Extension(user): Extension<AuthUser>, // Second: extensions
    Path(id): Path<Uuid>,               // Third: URL params
    Json(payload): Json<UpdateRequest>,   // Last: request body (consumes it)
) -> Result<impl IntoResponse, AppError> {
    // ...
}

TIP

If you get a compile error about extractors, check the ordering. The Json extractor must always be the last parameter because it consumes the request body.

Public Handlers

Public handlers serve data without requiring authentication:

rust
/// Get published content by slug
pub async fn get_content(
    State(pool): State<PgPool>,
    Path(slug): Path<String>,
    Query(params): Query<LocaleQuery>,
) -> Result<impl IntoResponse, AppError> {
    let content = ContentService::find_by_slug(&pool, &slug)
        .await?
        .ok_or(AppError::NotFound("Content not found".into()))?;

    if !content.is_active {
        return Err(AppError::NotFound("Content not found".into()));
    }

    Ok(Json(ApiResponse::success(ContentResponse::from(content))))
}

Error Handling in Handlers

All handlers return Result<impl IntoResponse, AppError>. Errors propagate using the ? operator:

rust
pub async fn show(
    State(pool): State<PgPool>,
    Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
    // Database errors propagate via ?
    let user = UserService::find_by_id(&pool, id)
        .await?  // sqlx::Error -> AppError automatically
        .ok_or(AppError::NotFound("User not found".into()))?;

    Ok(Json(ApiResponse::success(UserResponse::from(user))))
}

See Error Handling for the full AppError type and how it maps to HTTP status codes.

See Also

  • Routes — How handlers are registered to URL paths
  • Services — Business logic layer that handlers delegate to
  • DTOs — Request and response types used in handlers
  • Middleware — Auth and permission checks before handlers run
  • API Documentation — Utoipa annotations on handlers

Released under the MIT License.