rust
57 lines · 9 steps
Typed error handling in an Axum handler
An Axum route extracts typed params, validates input, queries the database, and maps every failure to an HTTP response through one error enum.
Explained by
highlit
1use axum::{
2 extract::{Path, State},
3 http::StatusCode,
4 response::{IntoResponse, Response},
5 Json,
6};
7use serde::Deserialize;
8use uuid::Uuid;
9
10#[derive(Deserialize)]
11pub struct PostParams {
12 workspace_id: Uuid,
13 post_slug: String,
14}
15
16pub async fn get_post(
17 State(state): State<AppState>,
18 Path(PostParams { workspace_id, post_slug }): Path<PostParams>,
19) -> Result<Json<Post>, ApiError> {
20 if post_slug.len() > 128 || !post_slug.chars().all(|c| c.is_alphanumeric() || c == '-') {
21 return Err(ApiError::InvalidSlug);
22 }
23
24 let post = sqlx::query_as::<_, Post>(
25 "SELECT * FROM posts WHERE workspace_id = $1 AND slug = $2",
26 )
27 .bind(workspace_id)
28 .bind(&post_slug)
29 .fetch_optional(&state.db)
30 .await?
31 .ok_or(ApiError::NotFound)?;
32
33 Ok(Json(post))
34}
35
36pub enum ApiError {
37 InvalidSlug,
38 NotFound,
39 Database(sqlx::Error),
40}
41
42impl From<sqlx::Error> for ApiError {
43 fn from(err: sqlx::Error) -> Self {
44 ApiError::Database(err)
45 }
46}
47
48impl IntoResponse for ApiError {
49 fn into_response(self) -> Response {
50 let (status, message) = match self {
51 ApiError::InvalidSlug => (StatusCode::BAD_REQUEST, "invalid post slug"),
52 ApiError::NotFound => (StatusCode::NOT_FOUND, "post not found"),
53 ApiError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal error"),
54 };
55 (status, Json(serde_json::json!({ "error": message }))).into_response()
56 }
57}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1A single error enum implementing IntoResponse centralizes how every failure becomes an HTTP status and body.
- 2Implementing From lets the `?` operator convert library errors into your domain error automatically.
- 3Destructuring extractors in the function signature pulls typed request data straight into named locals.
Related explainers
rust
use axum::{ extract::ws::{Message, WebSocket, WebSocketUpgrade}, response::IntoResponse, };
How an Axum WebSocket echo server works
websockets
async streams
protocol handling
Intermediate
8 steps
go
package handlers import ( "fmt"
Handling multipart profile uploads in Gin
form binding
file upload
validation
Intermediate
7 steps
typescript
import { ArgumentsHost, Catch, ExceptionFilter,
A catch-all exception filter in NestJS
error-handling
exception-filter
http
Intermediate
8 steps
typescript
import { Module, Injectable } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import * as Joi from 'joi';
Validated, typed config in NestJS
configuration
validation
dependency-injection
Intermediate
6 steps
java
public record ServerConfig(String host, int port, boolean tls, List<String> allowedOrigins) { public ServerConfig { Objects.requireNonNull(host, "host is required");
Validating config with a Java record
records
validation
immutability
Intermediate
9 steps
rust
use std::collections::BinaryHeap; use std::cmp::Reverse; pub fn top_k<T: Ord + Clone>(items: &[T], k: usize) -> Vec<T> {
Top-K selection with a bounded min-heap in Rust
heap
top-k
generics
Intermediate
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/typed-error-handling-in-an-axum-handler-explained-rust-28cf/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.