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ényekPath,Query,JsonextractorokStateésArcmegosztott állapothoztracing/tracing-subscriber, egyszerű middlewarefrom_fn-nelResult/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.
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.
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.
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)
}
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:
- Lapozás (pagination): bővítsd a
list_noteshandlert egyQuery-vel fogadottlimitésoffsetparaméterrel, amivel csak a jegyzetek egy részhalmazát adod vissza. Gondolj arra, mi történjen, haoffsetnagyobb, mint a lista hossza. - Több tag egyszerre: alakítsd át a
search_noteshandlert úgy, hogy atagparamé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 aQueryextractor 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.