Overview
REST in Rust is axum (or actix-web) plus serde — the framework routes and extracts, serde owns every JSON boundary, and the ecosystem fills the API-craft gaps (validation, OpenAPI, problem details) with small crates. The distinguishing Rust property is how much of the contract lives in types: a handler's signature is its request/response schema, which is exactly what utoipa exploits to derive OpenAPI documents from code.
Key points
- JSON is serde_json through
#[derive]: struct-driven, with the field attributes doing the API-shaping work —#[serde(rename_all = "camelCase")],#[serde(default)],#[serde(deny_unknown_fields)](strictness is opt-in),#[serde(skip_serializing_if = "Option::is_none")].Option<T>distinguishes absent from null only with effort (double-Optionorserde_with) — the one classic sharp edge. - Validation: the validator crate (derive +
#[validate(email, length(min = 1))]) is the go-to, wired via a small extractor wrapper (e.g. axum-valid); garde is the newer alternative. Types beat validators where possible — aNonZeroU32or a newtype with a checked constructor needs no annotation. - OpenAPI, code-first: utoipa derives schemas (
#[derive(ToSchema)]) and documents paths (#[utoipa::path]), assembling a spec served by Swagger UI/Scalar — the dominant workflow; poem-openapi bakes the same idea into a framework; aide is axum-native spec-from-code. - OpenAPI, spec-first: thinner than Java/Go — progenitor generates reqwest clients from a spec (Oxide's tooling); server-side spec-first codegen has no oapi-codegen-grade standard; teams wanting contract-first in Rust often pick gRPC instead.
- Errors: one
AppErrorenum (thiserror) with anIntoResponseimpl mapping variants to status + RFC 9457 problem+json body — the axum-idiomatic single place where domain errors meet the wire;?in handlers then just works. - Clients: reqwest with explicit timeouts and
rustls-tls; reqwest-middleware for retry/tracing stacks;.json::<T>()deserializes straight into serde types. (ureq for small sync tools.) - Middleware: tower-http's
TraceLayer,CorsLayer, compression,RequestBodyLimitLayer— the cross-cutting stack, shared with gRPC via tower.
Examples
#[derive(Deserialize, Validate, ToSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateUser {
#[validate(length(min = 1, max = 64))]
display_name: String,
#[validate(email)]
email: String,
}
#[utoipa::path(post, path = "/users", request_body = CreateUser,
responses((status = 201, body = User), (status = 422, body = Problem)))]
async fn create_user(
State(st): State<AppState>,
Json(req): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>), AppError> {
req.validate()?;
Ok((StatusCode::CREATED, Json(users::create(&st.pool, req).await?)))
}
Related
- Backend protocols in Rust — the map — parent map.
- Axum deep dive — the framework layer beneath this.
- Web security in Rust apps — the hardening concerns on this surface.
- API design — resource modelling and versioning principles this note implements.
- API documentation in Rust — utoipa and the other doc flavors (AsyncAPI, proto, SDL) mapped per framework.
- REST & HTTP APIs in Java and in Go — the spec-driven and stdlib-driven contrasts.