Chapter 13 · web APIs the Rust way
You built minimal APIs in ASP.NET Core: model binding, [FromBody], exception filters, [Authorize], ClaimsPrincipal, middleware. axum has all of it, with one theme running through — the request's authenticated state lives in the type system, so unauthenticated code won't compile.
Chapter 6 introduced the router. The piece to internalize for APIs is the extractor: a handler's parameters declare what to pull out of the request, and axum binds them — the direct analogue of ASP.NET model binding.
app.MapGet("/users/{id}",
async (long id, Db db) =>
await db.Find(id));
async fn get_user(
State(db): State<Db>, // ≈ DI
Path(id): Path<u64>, // ≈ [FromRoute]
) -> Result<Json<User>, ApiError> {
Ok(Json(db.find(id).await?))
}
Path ≈ [FromRoute], Query<T> ≈ [FromQuery], Json<T> ≈ [FromBody], State<T> ≈ a DI-injected service. If binding fails (bad JSON, wrong type), the extractor short-circuits with a 400 before your handler ever runs — same as failed model binding.
Bodies are plain structs that derive serde::Deserialize (serde is your System.Text.Json). For rules beyond "did it parse," the validator crate plays the role of DataAnnotations / FluentValidation.
use serde::Deserialize;
use validator::Validate;
#[derive(Deserialize, Validate)]
struct CreateUser {
#[validate(email)] // ≈ [EmailAddress]
email: String,
#[validate(length(min = 8))] // ≈ [MinLength(8)]
password: String,
}
async fn create_user(Json(body): Json<CreateUser>)
-> Result<Json<User>, ApiError> {
body.validate()?; // 422 on failure, via the error type below
// ...
}
The idiomatic pattern: define an API error type and impl IntoResponse for it. Then every handler returns Result<T, ApiError>, the ? operator (ch. 4) propagates failures, and they turn into proper status codes and JSON bodies. This is your global exception handler / ProblemDetails, but as a value the compiler forces every handler to account for.
use axum::{response::{IntoResponse, Response}, http::StatusCode, Json};
enum ApiError { NotFound, Unauthorized, Forbidden, Validation, Internal }
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, msg) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "not found"),
ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
ApiError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
ApiError::Validation => (StatusCode::UNPROCESSABLE_ENTITY, "invalid"),
ApiError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "error"),
};
(status, Json(serde_json::json!({ "error": msg }))).into_response()
}
}
From
Add impl From<DbError> for ApiError (and one per domain error), and ? auto-converts as it propagates (ch. 4 / ch. 8). A database miss becomes NotFound, a token failure becomes Unauthorized — without a single try/catch, and with the compiler guaranteeing no error path is forgotten.
Here's the pattern worth stealing. Instead of a [Authorize] attribute plus reaching into HttpContext.User, you write a custom extractor that validates the credential and produces a CurrentUser. Any handler that names CurrentUser in its signature is, by construction, authenticated — the extractor must succeed before the handler body runs.
use axum::{extract::FromRequestParts, http::request::Parts};
use axum_extra::{TypedHeader, headers::{Authorization, authorization::Bearer}};
use jsonwebtoken::{decode, DecodingKey, Validation};
struct CurrentUser { id: u64, roles: Vec<String> }
impl<S: Send + Sync> FromRequestParts<S> for CurrentUser {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _: &S)
-> Result<Self, ApiError> {
let TypedHeader(Authorization(bearer)) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, &())
.await.map_err(|_| ApiError::Unauthorized)?;
let claims = decode::<Claims>(bearer.token(), &key(), &Validation::default())
.map_err(|_| ApiError::Unauthorized)?
.claims;
Ok(CurrentUser { id: claims.sub, roles: claims.roles })
}
}
// this handler CANNOT run unauthenticated — the type guarantees it
async fn me(user: CurrentUser) -> Json<Profile> { /* ... */ }
jsonwebtoken is the JwtBearer-handler equivalent; the extractor folds in what [Authorize] and HttpContext.User did separately. The upgrade over the attribute: forgetting to authenticate isn't a runtime 401 you hope you wired up — a handler simply has no authenticated user value to work with unless it asks for one, and asking for one runs the check.
Role and policy checks build on top. An AdminUser extractor wraps CurrentUser and rejects non-admins with 403. Requiring AdminUser in a handler's signature is the policy — the compile-time version of [Authorize(Roles = "Admin")].
struct AdminUser(CurrentUser);
impl<S: Send + Sync> FromRequestParts<S> for AdminUser {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, s: &S)
-> Result<Self, ApiError> {
let user = CurrentUser::from_request_parts(parts, s).await?;
if user.roles.iter().any(|r| r == "admin") {
Ok(AdminUser(user))
} else {
Err(ApiError::Forbidden)
}
}
}
async fn delete_user(_admin: AdminUser, Path(id): Path<u64>)
-> Result<StatusCode, ApiError> { /* ... */ }
This is chapter 8's "make illegal states unrepresentable" applied to security: "an unauthenticated request reached an admin-only handler" isn't a bug you guard against at runtime — it's a state that can't be expressed, because the handler's signature demands a value only the auth + role checks can produce.
For concerns that span routes — logging, CORS, compression, a blanket auth gate — use tower layers, which are the ASP.NET middleware pipeline. You already saw TraceLayer in this server's main.rs; others slot in the same way, and like the pipeline they apply outside-in.
use tower_http::{trace::TraceLayer, cors::CorsLayer};
let app = Router::new()
.route("/api/users", get(list_users))
.layer(CorsLayer::permissive()) // app.UseCors(...)
.layer(TraceLayer::new_for_http()); // request logging
Two valid styles, same as ASP.NET. The extractor approach (13.4–13.5) is per-handler and fine-grained — auth shows up in the signature where it's needed. A layer (via axum::middleware::from_fn) applies one policy across a whole router subtree — the equivalent of app.UseAuthentication() guarding a route group. Reach for extractors for granular rules, layers for blanket ones.
Validate at the edge (13.2) and let one ApiError own status-code mapping (13.3). Carry authentication in the type — a handler taking CurrentUser/AdminUser can't run without it — rather than trusting every route to remember a guard. Hash passwords with the argon2 or bcrypt crate, never store or log them in the clear. Keep access tokens short-lived and pair them with refresh tokens; set Secure, HttpOnly, and SameSite on session cookies. And keep secrets (signing keys, DB URLs) in configuration/State, not in source.