rgoussu@goussu: ~/library/rust/protocols
~/library/rust/protocols cat rest.md

REST & HTTP APIs in Rust

# Building REST services on axum/actix — serde realities, validation, utoipa and the OpenAPI workflows, error shapes as IntoResponse, and reqwest on the client side.

Conceptsaved 2026-08-09 #rust#protocols#rest#api-design#serde

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-Option or serde_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 — a NonZeroU32 or 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 AppError enum (thiserror) with an IntoResponse impl 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