Cél és előfeltételek

Ez a lecke tiszta gyakorlat: nem tanulunk új fogalmat, hanem összerakjuk, amit az elmúlt leckékben külön-külön megismertél. Különösen az előző, "Middleware és tracing" című leckére épülünk: onnan hozod a tracing/tracing-subscriber inicializálását és az axum::middleware::from_fn-nel írt egyszerű naplózó middleware-t.

Ahhoz, hogy magabiztosan végig tudj menni a leckén, a következőket kell ismerned:

  • Router, route-regisztrálás, handler függvények
  • Path, Query, Json extractorok
  • State és Arc megosztott állapothoz
  • tracing/tracing-subscriber, egyszerű middleware from_fn-nel
  • Result/Option, a ? operátor, thiserror

A feladat: egy egyszerű jegyzet-API ("notes-api"), amely indításkor betölt néhány mintajegyzetet, és ezekre kínál lekérdező végpontokat, plusz egy olyan végpontot, ahol egy beérkező JSON payloadot validálunk és "előnézetként" visszaadunk.

Megjegyzés

A jegyzeteket ebben a leckében egyszerűség kedvéért egy indításkor betöltött, nem módosítható listában tartjuk memóriában. A valódi, tartósan tárolt és módosítható állapotot a következő leckében építjük ki, amikor az sqlx crate-tel és egy Postgres adatbázissal dolgozunk. Így most tisztán az Axum-elemek összerakására koncentrálhatunk, anélkül, hogy egy szinkronizációs primitívvel is meg kellene ismerkedned.

Előkészítés: projekt és függőségek

Hozz létre egy új binárist, és állítsd össze a Cargo.toml-t a következő függőségekkel:

cargo new notes-api
cd notes-api
[package]
name = "notes-api"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = "0.3"

Ha a korábbi leckéből már van egy Axum-projektváltozatod tracinggel, most azt a mintát követve fogjuk kibővíteni – csak most tudatosan, lépésről lépésre.

1. lépés – adatmodell és megosztott állapot

Feladat: definiálj egy Note struktúrát (id: u32, title: String, tag: String), amely Serialize-elhető és Deserialize-elhető, Debug és Clone is legyen rajta. Írj egy függvényt, amely visszaad néhány mintajegyzetet Vec<Note> formában, és csomagold Arc-ba, hogy megosztott állapotként tudd használni.

Megoldás
// részlet – a teljes, összefüggő kód a lecke végén található
use serde::{Deserialize, Serialize};
use std::sync::Arc;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Note {
    id: u32,
    title: String,
    tag: String,
}

fn sample_notes() -> Vec<Note> {
    vec![
        Note { id: 1, title: "Bevásárlólista".to_string(), tag: "otthon".to_string() },
        Note { id: 2, title: "Rust gyakorlás".to_string(), tag: "munka".to_string() },
        Note { id: 3, title: "Könyv: A Rust programozási nyelv".to_string(), tag: "olvasás".to_string() },
    ]
}

type AppState = Arc<Vec<Note>>;

fn build_state() -> AppState {
    Arc::new(sample_notes())
}

2. lépés – listázás és egyetlen jegyzet lekérése

Feladat: írj egy list_notes handlert, amely State<AppState>-ből kiolvassa a jegyzeteket és Json<Vec<Note>>-ként visszaadja. Írj egy get_note handlert is, amely Path<u32>-vel kér egy id-t, és ha megtalálja, visszaadja a jegyzetet, ha nem, egy 404-es státuszkódot ad vissza.

Tipp

A StatusCode az axum::http modulból érkezik (ami maga a jól ismert http crate típusait exportálja újra) – ez a HTTP válaszkódok (200, 404, 201 stb.) típusbiztos megjelenítése. Ha egy handler visszatérési típusa Result<Json<T>, StatusCode>, akkor Axum a StatusCode-ot önmagában is érvényes válasznak tekinti, amit a kliens a megfelelő HTTP-státuszként lát.

Megoldás
// részlet – a teljes, összefüggő kód a lecke végén található
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;

async fn list_notes(State(notes): State<AppState>) -> Json<Vec<Note>> {
    Json((*notes).clone())
}

async fn get_note(
    State(notes): State<AppState>,
    Path(id): Path<u32>,
) -> Result<Json<Note>, StatusCode> {
    notes
        .iter()
        .find(|note| note.id == id)
        .cloned()
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

3. lépés – keresés Query extractorral

Feladat: hozz létre egy SearchParams struktúrát egy opcionális tag: Option<String> mezővel, amely Deserialize-elhető. Írj egy search_notes handlert, amely Query<SearchParams>-szal fogadja a lekérdezést, és ha van tag, csak az annak megfelelő jegyzeteket adja vissza, ha nincs, az összeset.

Megoldás
// részlet – a teljes, összefüggő kód a lecke végén található
use axum::extract::{Query, State};
use axum::Json;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct SearchParams {
    tag: Option<String>,
}

async fn search_notes(
    State(notes): State<AppState>,
    Query(params): Query<SearchParams>,
) -> Json<Vec<Note>> {
    let filtered: Vec<Note> = match params.tag {
        Some(tag) => notes
            .iter()
            .filter(|note| note.tag == tag)
            .cloned()
            .collect(),
        None => (*notes).clone(),
    };
    Json(filtered)
}

Ezt a végpontot például GET /notes/search?tag=munka formában lehet meghívni. Figyeld meg, hogy a Query extractor pontosan úgy viselkedik, mint korábban tanultad: az URL query stringjét deszerializálja a megadott struktúrába, és ha egy mező hiányzik, de Option, akkor egyszerűen None lesz.

4. lépés – előnézet készítése Json extractorral

Feladat: hozz létre egy NewNote struktúrát (title: String, tag: String), amely Deserialize-elhető. Írj egy preview_note handlert, amely Json<NewNote>-ot fogad POST body-ként, ellenőrzi, hogy a title nem üres, és ha rendben van, egy új (fiktív) id-vel ellátott Note-ot ad vissza 201 Created státusszal. Ha a title üres, adjunk vissza 400 Bad Request-et.

Figyelem

Fontos, hogy ez a végpont nem menti el a jegyzetet – csak megmutatja, hogyan nézne ki, ha elmentenénk. A valódi mentést a következő lecke adja hozzá, amikor bevezetjük az adatbázist.

Megoldás
// részlet – a teljes, összefüggő kód a lecke végén található
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct NewNote {
    title: String,
    tag: String,
}

async fn preview_note(
    Json(payload): Json<NewNote>,
) -> Result<(StatusCode, Json<Note>), StatusCode> {
    if payload.title.trim().is_empty() {
        return Err(StatusCode::BAD_REQUEST);
    }

    let preview = Note {
        id: 0, // fiktív azonosító, hiszen még nincs valódi tárolás
        title: payload.title,
        tag: payload.tag,
    };

    Ok((StatusCode::CREATED, Json(preview)))
}

Figyeld meg, hogy a visszatérési típus egy tuple: (StatusCode, Json<Note>). Axum tudja, hogyan alakítsa ezt válasszá: a StatusCode lesz a HTTP-státusz, a Json<Note> pedig a body.

5. lépés – middleware, tracing és a router összeállítása

Feladat: inicializáld a tracing-subscriber-t a main elején, írj egy egyszerű naplózó middleware-t axum::middleware::from_fn-nel (ugyanúgy, mint az előző leckében), amely minden beérkező kérés metódusát és útját kiírja tracing::info!-val, majd építsd fel a Router-t az összes eddigi handlerrel, kösd hozzá a middleware-t és a State-et, és indítsd el a szervert axum::serve-vel.

Megoldás
// részlet – a teljes, összefüggő kód a lecke végén található
use axum::extract::Request;
use axum::middleware::{self, Next};
use axum::response::Response;
use axum::routing::{get, post};
use axum::Router;

async fn log_requests(req: Request, next: Next) -> Response {
    tracing::info!(method = %req.method(), path = %req.uri().path(), "beérkező kérés");
    next.run(req).await
}

fn build_router(state: AppState) -> Router {
    Router::new()
        .route("/notes", get(list_notes))
        .route("/notes/{id}", get(get_note))
        .route("/notes/search", get(search_notes))
        .route("/notes/preview", post(preview_note))
        .layer(middleware::from_fn(log_requests))
        .with_state(state)
}
Jó tudni

Axum 0.8-ban a path-paraméterek szintaxisa {id}, nem a régebbi :id forma – ha korábbi kódot másolsz, ellenőrizd ezt.

Teljes referencia-megoldás

Az alábbi kód az összes lépést egyben tartalmazza, önmagában fordítható:

use axum::extract::{Path, Query, Request, State};
use axum::http::StatusCode;
use axum::middleware::{self, Next};
use axum::response::Response;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Note {
    id: u32,
    title: String,
    tag: String,
}

#[derive(Debug, Deserialize)]
struct SearchParams {
    tag: Option<String>,
}

#[derive(Debug, Deserialize)]
struct NewNote {
    title: String,
    tag: String,
}

type AppState = Arc<Vec<Note>>;

fn sample_notes() -> Vec<Note> {
    vec![
        Note { id: 1, title: "Bevásárlólista".to_string(), tag: "otthon".to_string() },
        Note { id: 2, title: "Rust gyakorlás".to_string(), tag: "munka".to_string() },
        Note { id: 3, title: "Könyv: A Rust programozási nyelv".to_string(), tag: "olvasás".to_string() },
    ]
}

async fn list_notes(State(notes): State<AppState>) -> Json<Vec<Note>> {
    Json((*notes).clone())
}

async fn get_note(
    State(notes): State<AppState>,
    Path(id): Path<u32>,
) -> Result<Json<Note>, StatusCode> {
    notes
        .iter()
        .find(|note| note.id == id)
        .cloned()
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

async fn search_notes(
    State(notes): State<AppState>,
    Query(params): Query<SearchParams>,
) -> Json<Vec<Note>> {
    let filtered: Vec<Note> = match params.tag {
        Some(tag) => notes
            .iter()
            .filter(|note| note.tag == tag)
            .cloned()
            .collect(),
        None => (*notes).clone(),
    };
    Json(filtered)
}

async fn preview_note(
    Json(payload): Json<NewNote>,
) -> Result<(StatusCode, Json<Note>), StatusCode> {
    if payload.title.trim().is_empty() {
        return Err(StatusCode::BAD_REQUEST);
    }

    let preview = Note {
        id: 0,
        title: payload.title,
        tag: payload.tag,
    };

    Ok((StatusCode::CREATED, Json(preview)))
}

async fn log_requests(req: Request, next: Next) -> Response {
    tracing::info!(method = %req.method(), path = %req.uri().path(), "beérkező kérés");
    next.run(req).await
}

fn build_router(state: AppState) -> Router {
    Router::new()
        .route("/notes", get(list_notes))
        .route("/notes/{id}", get(get_note))
        .route("/notes/search", get(search_notes))
        .route("/notes/preview", post(preview_note))
        .layer(middleware::from_fn(log_requests))
        .with_state(state)
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let state: AppState = Arc::new(sample_notes());
    let app = build_router(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .expect("nem sikerült a portra bindelni");

    tracing::info!("a szerver elindult: http://127.0.0.1:3000");

    axum::serve(listener, app)
        .await
        .expect("a szerver hibával leállt");
}

Indítsd el a szervert (cargo run), majd próbáld ki egy másik terminálból:

curl http://127.0.0.1:3000/notes
curl http://127.0.0.1:3000/notes/2
curl "http://127.0.0.1:3000/notes/search?tag=munka"
curl -X POST http://127.0.0.1:3000/notes/preview \
  -H "Content-Type: application/json" \
  -d '{"title": "Új ötlet", "tag": "ötletek"}'

A konzolon közben látnod kell a tracing::info!-val kiírt naplósorokat minden egyes kéréshez – ez pontosan az előző lecke middleware-mintáját használja élesben.

Bónusz-kihívások

Ha szeretnéd tovább gyakorolni az összerakást, próbálkozz meg megoldás nélkül a következőkkel:

  1. Lapozás (pagination): bővítsd a list_notes handlert egy Query-vel fogadott limit és offset paraméterrel, amivel csak a jegyzetek egy részhalmazát adod vissza. Gondolj arra, mi történjen, ha offset nagyobb, mint a lista hossza.
  2. Több tag egyszerre: alakítsd át a search_notes handlert úgy, hogy a tag paraméter helyett vesszővel elválasztott tag-listát fogadjon (?tags=munka,otthon), és minden olyan jegyzetet adjon vissza, amelynek tagja szerepel a listában. Figyelj arra, hogy a Query extractor deszerializálása közben a string felbontását neked kell elvégezned a handler törzsében.

Összefoglalás és mi jön legközelebb

Ezzel a leckével egy valódi, több végpontos JSON API-t raktál össze a semmiből: State-tel megosztott adatot olvastál ki, Path, Query és Json extractorokkal fogadtál bemenetet, StatusCode-dal jelezted a hibákat és sikereket, és middleware-rel naplóztad a forgalmat – mindezt az előző leckében tanult mintára építve.

Az egyetlen hiányzó darab, amit most szándékosan kihagytunk, a tartós tárolás: az AppState-ünk indításkor betöltött, nem módosítható adat volt. A következő leckében pontosan ezt a hiányt töltjük ki: megismerkedünk az sqlx crate-tel és egy valódi Postgres adatbázissal, hogy a jegyzeteket ténylegesen létre lehessen hozni, módosítani és törölni – kapcsolattal, lekérdezésekkel és migrációkkal együtt.