Step 6 — Errors, Auth & JSON Contracts
Our handlers work on the happy path but .unwrap() on every query — meaning any database hiccup crashes the request. Let's fix that, then add authentication and lock down the JSON contract between backend and frontend.
Errors as HTTP responses
Recall from Step 2 that errors are values (Result<T, E>) and that IntoResponse is "anything that can become a response." Combine them: define one error type for your API and teach it how to become an HTTP response.
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
// Our API's error type.
enum ApiError {
NotFound,
Database(sqlx::Error),
}
// Teach it to render as an HTTP response.
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
ApiError::Database(e) => {
tracing::error!(?e, "database error"); // log the detail...
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
// ...but don't leak it to the client
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
// Let `?` convert sqlx errors into ApiError automatically.
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
ApiError::Database(e)
}
}
Now handlers return Result<Json<T>, ApiError> and use ? instead of .unwrap():
async fn get_task(
State(pool): State<PgPool>,
Path(id): Path<i64>,
) -> Result<Json<Task>, ApiError> {
let task = sqlx::query_as::<_, Task>("SELECT id, title, done, created_at FROM tasks WHERE id = $1")
.bind(id)
.fetch_optional(&pool) // Option<Task> instead of crashing on 0 rows
.await? // sqlx::Error → ApiError via `From`, bubbled up
.ok_or(ApiError::NotFound)?; // None → 404
Ok(Json(task))
}
Read the tail as a sentence: "fetch optionally; if the DB errored, bubble it up as a 500; if there was no row, return 404; otherwise here's the task." That's the entire error-handling model of a Rust backend — no exceptions, no try/catch, just values flowing through ?.
This is exactly how iKanban's backend does it: a central error type implements
IntoResponse, and domain-specific cases map to the right status code (404, 403, 409, …). Crates likethiserrorreduce the boilerplate of defining such enums.
Authentication with middleware
Auth is a middleware concern — a layer every protected request passes through before reaching a handler. In Axum, middleware can read the request, reject it, or attach data for handlers downstream.
use axum::{
extract::Request,
http::StatusCode,
middleware::{self, Next},
response::Response,
};
async fn require_auth(req: Request, next: Next) -> Result<Response, StatusCode> {
// Read the Authorization header.
let token = req.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
match token {
Some(t) if verify(t) => Ok(next.run(req).await), // valid → continue
_ => Err(StatusCode::UNAUTHORIZED), // invalid → 401
}
}
fn verify(_token: &str) -> bool {
// In production: validate a JWT's signature/issuer/expiry.
true
}
Apply it to the routes that need protecting:
let app = Router::new()
.route("/tasks", get(list_tasks).post(create_task))
.route("/tasks/{id}", get(get_task))
.layer(middleware::from_fn(require_auth)) // wraps the routes above
.with_state(pool);
Layers wrap the router like an onion — the request passes through the outer layers first, then reaches the handler, then the response travels back out. Order matters.
A production gotcha from iKanban: in Axum, an
Extensionlayer that shares per-request data must be the outermost layer, or the request can reach a handler before the data is attached and return 500s. Real JWT validation typically checks a signature against a provider's public keys (iKanban uses AWS Cognito) and inserts aRequestContextdescribing the user, which handlers then extract.
Locking down the JSON contract
Serde gives you precise control over the shape of your request and response bodies. The distinction between required and optional fields is enforced by the type:
use serde::{Serialize, Deserialize};
#[derive(Deserialize)]
struct NewTask {
title: String, // required — request fails to parse if missing
#[serde(default)]
done: bool, // optional — defaults to false if absent
#[serde(alias = "dueDate")]
due_date: Option<String>, // optional AND accepts an old field name (back-compat)
}
| Attribute | Effect |
|---|---|
Option<T> field | Field may be absent; you get None |
#[serde(default)] | Missing field uses the type's default instead of erroring |
#[serde(alias = "x")] | Accept an alternate incoming key (great for renames) |
#[serde(rename = "x")] | Change the key name in the JSON entirely |
Keeping frontend types in sync
A subtle superpower of a Rust backend: tools like ts-rs generate TypeScript type definitions from your Rust structs. Add a derive, and your Task struct emits a matching Task interface for the frontend:
#[derive(Serialize, ts_rs::TS)]
#[ts(export)]
struct Task { id: i64, title: String, done: bool }
Change the Rust struct, regenerate, and the frontend's compiler immediately sees the change. That's a compile-time contract spanning two languages — it's why iKanban's React frontend and Rust backend never drift out of sync on data shapes.
Where we are
Your API returns proper error responses, rejects unauthenticated requests, and has a precise, versioned JSON contract. All that's left is to ship it. Next: Step 7 — Build, Run & Deploy.