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 CRUDAdmin 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 responsepub 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)
/// 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, ¶ms).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)
/// 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
/// 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
/// 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)
/// 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:
| Extractor | Source | Example |
|---|---|---|
State(pool) | Application state | Database pool |
Extension(user) | Request extensions | Authenticated user (set by middleware) |
Json(body) | Request body | JSON payload |
Query(params) | Query string | ?page=1&per_page=15 |
Path(id) | URL path segments | /users/{id} |
TypedHeader(header) | Request headers | Authorization header |
Extractor Ordering
Axum processes extractors in order. Place extractors that consume the request body (Json) last:
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:
/// 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:
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