Tech Wiki

TOPICSSERIES

[Rust Zero to Production 28] Build an Axum REST API with Explicit Routing, State, and Extractors

An Axum CRUD API needs more than a handful of routes. You also have to decide which extractor consumes the request body, how malformed JSON differs from a valid payload that breaks domain rules, and how much a handler knows about storage. If those boundaries blur, adding SQLite later forces changes through the routing and validation code.

This example pins Rust 2024, Axum 0.8.9, Tower 0.5.2, and Tokio 1.53.1. It implements /status plus endpoint CRUD routes and injects a repository boundary through AppState. Only an in-memory repository is included, leaving Article 29 free to add a SQLite adapter without changing the HTTP contract.

1. Fix the HTTP contract first

The API surface is deliberately small. GET /status returns service status and the number of registered endpoints. Collection operations live at /endpoints; single-resource reads, updates, and deletes use /endpoints/{id}.

Method Path Success Failure
GET /status 200
POST /endpoints 201 400, 415, 422
GET /endpoints 200
GET /endpoints/{id} 200 404
PUT /endpoints/{id} 200 400, 404, 415, 422
DELETE /endpoints/{id} 204 404

A 400 means JSON syntax or deserialization failed. A request without Content-Type: application/json gets 415. A structurally valid payload that fails the name, URL, or interval rules gets 422. This split keeps Axum's default rejection text out of the public API contract.

2. Put a repository behind typed state

State<AppState> connects handlers to application state. Extracting the wrong state type is a compile-time error. Instead of exposing a concrete RwLock<BTreeMap<...>>, AppState owns an Arc<dyn EndpointRepository>.

pub trait EndpointRepository: Send + Sync {
    fn count(&self) -> usize;
    fn list(&self) -> Vec<Endpoint>;
    fn get(&self, id: u64) -> Option<Endpoint>;
    fn create(&self, input: EndpointInput) -> Endpoint;
    fn update(&self, id: u64, input: EndpointInput) -> Option<Endpoint>;
    fn delete(&self, id: u64) -> bool;
}

#[derive(Clone)]
pub struct AppState {
    repository: Arc<dyn EndpointRepository>,
}

impl AppState {
    pub fn new(repository: Arc<dyn EndpointRepository>) -> Self {
        Self { repository }
    }
}

The in-memory implementation keeps a BTreeMap and the next ID inside an RwLock. Handlers do not know about the lock, and no lock guard crosses an await. The trait is not speculative architecture: it is the replacement seam that lets Article 29 introduce a SQLx-backed adapter independently.

A synchronous trait fits this fixture because every repository operation is short and in memory. A database adapter will need asynchronous work, so its trait return types or application-service boundary must be reconsidered. Forcing SQLx through this exact synchronous trait would invite blocking calls or awkward runtime workarounds.

3. Assemble routes and inject state in one place

The Router spells out each path and method combination. All stateful routes are assembled before with_state supplies AppState.

pub fn app(state: AppState) -> Router {
    Router::new()
        .route("/status", get(status))
        .route("/endpoints", get(list_endpoints).post(create_endpoint))
        .route(
            "/endpoints/{id}",
            get(get_endpoint)
                .put(update_endpoint)
                .delete(delete_endpoint),
        )
        .with_state(state)
}

Axum 0.8 uses /{id} for a path capture. Path(id): Path<u64> converts the segment to a u64; State(state): State<AppState> extracts typed router state. A body-consuming Json extractor comes last in a handler because a request body can only be consumed once.

GET /status reads count from the same repository used by CRUD handlers. There is no separate global variable or process-local singleton. Tests inject a fresh repository into each application, so cases remain isolated.

4. Separate JSON rejection from value validation

Successful Json<EndpointInput> extraction says nothing about whether field values make sense. Axum first checks the content type, JSON syntax, and deserialization into the target type. Application validation then checks the name, URL scheme, and interval range.

The handler accepts Result<Json<EndpointInput>, JsonRejection> and translates extractor failures into a stable ApiError. Extractors that do not consume the body, such as State and Path, appear before the JSON payload.

async fn create_endpoint(
    State(state): State<AppState>,
    payload: Result<Json<EndpointInput>, JsonRejection>,
) -> Result<(StatusCode, Json<Endpoint>), ApiError> {
    let Json(input) = payload.map_err(ApiError::from_json_rejection)?;
    let input = validate(input)?;
    Ok((StatusCode::CREATED, Json(state.repository.create(input))))
}

Validation trims the name, rejects an empty name or one over 64 characters, requires an absolute http or https URL, and accepts intervals from 5 through 86,400 seconds. It collects all field errors in one response and never passes invalid input to the repository.

fn validate(mut input: EndpointInput) -> Result<EndpointInput, ApiError> {
    let mut errors = Vec::new();
    input.name = input.name.trim().to_owned();
    if input.name.is_empty() {
        errors.push("name must not be blank");
    }
    match url::Url::parse(&input.url) {
        Ok(parsed) if matches!(parsed.scheme(), "http" | "https") => input.url = parsed.to_string(),
        Ok(_) => errors.push("url scheme must be http or https"),
        Err(_) => errors.push("url must be an absolute URL"),
    }
    if !(5..=86_400).contains(&input.interval_seconds) {
        errors.push("interval_seconds must be between 5 and 86400");
    }
    if errors.is_empty() {
        Ok(input)
    } else {
        Err(ApiError::validation(errors.join("; ")))
    }
}

The snippet omits the name-length check to stay focused; the runnable fixture includes it. The fixture defines behavior, and both language editions use byte-identical code blocks.

Extractor rejections use the same external error envelope. A missing content type maps to json_content_type_required; JSON syntax and data errors map to invalid_json. JsonRejection is non-exhaustive, so the implementation does not rely on a match that lists every current variant.

5. Test the Router as a Service without TCP

An Axum Router implements Tower's Service abstraction. Passing a Request<Body> to ServiceExt::oneshot exercises routing, extractors, state, and response bodies without opening a socket. That covers more of the HTTP boundary than calling handlers directly.

#[tokio::test]
async fn json_extractor_rejection_has_a_stable_error_envelope() {
    let response = request("POST", "/endpoints", Body::from(r#"{"name":"broken""#)).await;
    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    assert_eq!(
        json(response).await,
        serde_json::json!({
            "error": {"code":"invalid_json","message":"request body must be valid JSON matching the endpoint schema"}
        })
    );
}

Seven tests fix the relevant contracts: status against an empty repository, create/list/get round trips, no state mutation after validation failure, a stable malformed-JSON envelope, the update/delete lifecycle, 415 for a missing content type, and exact demo-binary output. CRUD tests keep cloning one router, which also proves that requests share the same typed state.

These are service-level tests, not network-stack tests. They are fast and deterministic because they target the route table and application boundary. Listener binding, TLS, and proxy headers belong in deployment integration tests.

6. Run the fixture and check its limits

Run the complete gate from the repository root.

cd examples/article-28-axum-rest-api-routing-state
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet -- --demo

The last command sends three requests through the real Router and exits. Its exact output is:

POST /endpoints -> 201 id=1
GET /status -> 200 endpoint_count=1
DELETE /endpoints/1 -> 204

The fixture's default mode binds the same router to 127.0.0.1:3000 for manual calls. Demo mode is the finite path used by automated verification.

The repository disappears when the process exits, and separate processes cannot share its data. Authentication, pagination, conditional updates, and request-body limits are also outside this article. The useful result here is narrower: routing, typed state, extractor handling, validation, and the repository seam are fixed before persistence arrives. A later adapter should fit behind that boundary without rewriting the HTTP contract.

Full source code

The complete runnable source for this article is available in the Chapter 28 project on GitHub.

Sources


3 responses

  1. […] Previous articleBuild an Axum REST API with Explicit Routing, State, and Extractors […]

  2. […] Next articleBuild an Axum REST API with Explicit Routing, State, and Extractors […]

  3. […] Previous articleBuild an Axum REST API with Explicit Routing, State, and Extractors […]

Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.