0Pricing
Learn Rust Coding · Lesson

Endpoints and Models

Routes and data.

Endpoints and Models is a free Learn Rust Coding lesson on CoddyKit — lesson 2 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Learn Rust Coding learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Routes and Data Models

An API is defined by its endpoints (URLs plus HTTP methods) and the models (data shapes) flowing through them. In this lesson you will define request and response models with serde and wire up CRUD-style routes in Axum.

Defining a Model

A model is a plain Rust struct. Derive Serialize so it can become JSON in responses and Deserialize so it can be parsed from request bodies. The field names map directly to JSON keys.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Clone)]
struct Todo {
    id: u32,
    title: String,
    done: bool,
}

Separate Request and Response Types

Clients should not send the server-assigned id when creating a resource. Use a separate input struct for the request body and the full model for responses. This keeps the contract clear and prevents clients from setting fields they should not.

use serde::Deserialize;

#[derive(Deserialize)]
struct CreateTodo {
    title: String,
}
// The handler assigns the id and sets done = false.

GET: Listing Resources

A GET /todos handler returns the whole collection as a JSON array. It reads the shared state, clones the data out of the lock quickly, and wraps it in Json.

use axum::{extract::State, Json};
use std::sync::{Arc, Mutex};

type Store = Arc<Mutex<Vec<Todo>>>;
#[derive(Clone, serde::Serialize)]
struct Todo { id: u32, title: String, done: bool }

async fn list_todos(State(store): State<Store>) -> Json<Vec<Todo>> {
    let todos = store.lock().unwrap().clone();
    Json(todos)
}

POST: Creating a Resource

A POST /todos handler reads the JSON body with the Json extractor, assigns a new id, stores the item, and returns it with status 201 Created. The tuple (StatusCode, Json<T>) lets you set both status and body.

use axum::{extract::State, Json, http::StatusCode};

async fn create_todo(
    State(store): State<Store>,
    Json(input): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
    let mut todos = store.lock().unwrap();
    let id = todos.len() as u32 + 1;
    let todo = Todo { id, title: input.title, done: false };
    todos.push(todo.clone());
    (StatusCode::CREATED, Json(todo))
}

Path Parameters

To fetch one resource, capture part of the URL with a path parameter. Declare it in the route as /todos/{id} and extract it with the Path extractor. The type you ask for (here u32) is parsed automatically.

use axum::extract::{Path, State};
use axum::{Json, http::StatusCode};

async fn get_todo(
    State(store): State<Store>,
    Path(id): Path<u32>,
) -> Result<Json<Todo>, StatusCode> {
    let todos = store.lock().unwrap();
    match todos.iter().find(|t| t.id == id) {
        Some(t) => Ok(Json(t.clone())),
        None => Err(StatusCode::NOT_FOUND),
    }
}

Query Parameters

Filtering and pagination use the query string, like /todos?done=true. Capture it with the Query extractor into a Deserialize struct. Optional fields use Option so missing params are fine.

use axum::extract::{Query, State};
use axum::Json;
use serde::Deserialize;

#[derive(Deserialize)]
struct Filter { done: Option<bool> }

async fn filtered(
    State(store): State<Store>,
    Query(f): Query<Filter>,
) -> Json<Vec<Todo>> {
    let todos = store.lock().unwrap();
    let out = todos.iter()
        .filter(|t| f.done.map_or(true, |d| t.done == d))
        .cloned().collect();
    Json(out)
}

PUT and DELETE

Updating uses PUT /todos/{id} with a body; deleting uses DELETE /todos/{id}. Both look up the item by id and return 404 if it is missing. Delete typically returns 204 No Content on success.

use axum::extract::{Path, State};
use axum::http::StatusCode;

async fn delete_todo(
    State(store): State<Store>,
    Path(id): Path<u32>,
) -> StatusCode {
    let mut todos = store.lock().unwrap();
    let before = todos.len();
    todos.retain(|t| t.id != id);
    if todos.len() < before { StatusCode::NO_CONTENT }
    else { StatusCode::NOT_FOUND }
}

Wiring the Routes Together

Register every handler on the router. Group methods on shared paths: /todos handles list and create, while /todos/{id} handles fetch, update, and delete. Attach the shared store with .with_state.

use axum::{routing::get, Router};

fn build(store: Store) -> Router {
    Router::new()
        .route("/todos", get(list_todos).post(create_todo))
        .route("/todos/{id}",
            get(get_todo).delete(delete_todo))
        .with_state(store)
}

Validating Input

Never trust client data. Check it inside the handler and return 400 Bad Request when it is invalid. Here we reject an empty title before storing anything, keeping bad data out of the system.

use axum::{Json, extract::State, http::StatusCode};

async fn create_validated(
    State(store): State<Store>,
    Json(input): Json<CreateTodo>,
) -> Result<(StatusCode, Json<Todo>), StatusCode> {
    if input.title.trim().is_empty() {
        return Err(StatusCode::BAD_REQUEST);
    }
    let mut todos = store.lock().unwrap();
    let id = todos.len() as u32 + 1;
    let todo = Todo { id, title: input.title, done: false };
    todos.push(todo.clone());
    Ok((StatusCode::CREATED, Json(todo)))
}

Consistent Error Responses

Returning bare status codes works, but production APIs return a JSON error body too. A common approach is a custom error enum implementing IntoResponse, mapping each variant to a status and message. This gives clients predictable, machine-readable errors.

use axum::response::{IntoResponse, Response};
use axum::http::StatusCode;
use axum::Json;
use serde_json::json;

enum ApiError { NotFound, BadRequest(String) }

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, msg) = match self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
            ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
        };
        (status, Json(json!({ "error": msg }))).into_response()
    }
}

Quick Check

Test your understanding of endpoints and models.

Recap

You defined endpoints and models:

  • Models are structs deriving Serialize/Deserialize; use separate input types.
  • Json, Path, Query, and State extract request data.
  • Map CRUD to GET/POST/PUT/DELETE with appropriate status codes.
  • Validate input and return 400 on bad data.
  • A custom error type implementing IntoResponse gives consistent JSON errors.

Frequently asked questions

Is the “Endpoints and Models” lesson free?

Yes — the full text of “Endpoints and Models” is free to read here on the web, and the Learn Rust Coding course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Learn Rust Coding course, upgrade to CoddyKit PRO.

What will I learn in “Endpoints and Models”?

Routes and data. You practise Learn Rust Coding with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Learn Rust Coding?

No prior experience is required. Learn Rust Coding on CoddyKit is structured for beginners through advanced learners; this is — lesson 2 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Endpoints and Models” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Learn Rust Coding lesson?

Yes. Every Learn Rust Coding lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Project Setup
  2. Endpoints and Models
  3. Database Integration
  4. Testing the API
← Back to Learn Rust Coding