1 Rust + Axum PL
teacher edited this page 2026-04-13 15:13:55 +02:00

Rust + Axum — Kompletny przewodnik po zadaniach

Ten przewodnik omawia wszystkie dziesięć zadań z katalogu rust-assignments/. Rust to kompilowany, niskopoziomowy język programowania zapewniający bezpieczeństwo pamięci bez mechanizmu odśmiecania (garbage collector). Axum to framework webowy dla Rusta zbudowany na Tokio (asynchroniczny runtime) i Tower (biblioteka abstrakcji sieciowych).

Uwaga dla zaczynających: Rust stawia znacznie wyższe wymagania wstępne niż Python czy JavaScript. Zanim zaczniesz te zadania, powinieneś znać podstawowe konstrukcje języka: wiązania zmiennych, funkcje, struktury (struct), wyliczenia (enum) oraz typy Option i Result. System własności (ownership) i borrow checker to koncepcje, które nie mają odpowiednika w innych popularnych językach — jeśli Rust jest dla Ciebie zupełną nowością, najpierw przerabiaj pierwsze kilka rozdziałów The Rust Programming Language. Próba przeskoczenia tego etapu zakończy się frustrującymi błędami kompilatora, których przyczyny będą niejasne bez odpowiedniego fundamentu.


Wymagania wstępne

Instalacja Rusta

Zainstaluj zestaw narzędzi Rusta za pomocą rustup:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

To polecenie instaluje trzy narzędzia:

  • rustc — kompilator Rusta
  • cargo — menedżer pakietów i narzędzie do budowania projektów
  • rustup — menedżer zestawu narzędzi (toolchain manager)

Weryfikacja instalacji:

rustc --version
cargo --version

Cargo

cargo obsługuje wszystkie operacje na projekcie:

  • cargo build — kompiluje projekt
  • cargo run — kompiluje i uruchamia projekt
  • cargo check — sprawdza poprawność kodu bez generowania pliku wykonywalnego (znacznie szybsze niż cargo build)
  • cargo add <crate> — dodaje zależność do Cargo.toml

Cargo.toml

Każde zadanie zawiera plik Cargo.toml z listą zależności (zwanych crates). Uruchomienie cargo run automatycznie pobiera i kompiluje wszystkie zależności — nie jest potrzebny żaden osobny krok instalacji.

Pierwsza kompilacja

Pierwsze wywołanie cargo run kompiluje wszystkie zależności. Może to potrwać kilka minut. Kolejne uruchomienia są znacznie szybsze, ponieważ rekompilowany jest tylko zmieniony kod.

Domyślny port

Wszystkie zadania w Ruście nasłuchują pod adresem http://127.0.0.1:3000.


Zadanie 01: Podstawowe API listy zadań (To-Do)

Lokalizacja: rust-assignments/01-basic-todo-api/

Co się nauczysz

  • Definiowania routera Axum z typowanymi handlerami
  • Współdzielonego mutowalnego stanu z Arc<Mutex<T>>
  • Extractora State
  • Typów żądania i odpowiedzi z biblioteką serde
  • Parametrów trasy z extractorem Path

Kluczowe pojęcia

Extractory Axum to typy, które Axum konstruuje, odczytując przychodzące żądanie. Deklarujesz je jako parametry funkcji handlerów:

async fn get_todo(
    State(state): State<AppState>,  // współdzielony stan aplikacji
    Path(id): Path<u64>,            // parametr trasy URL :id
    Json(body): Json<TodoCreate>,   // ciało żądania sparsowane jako JSON
) -> impl IntoResponse {

Jeśli extractor zawiedzie — np. parametr trasy nie może być sparsowany jako u64, albo ciało JSON jest niepoprawne — Axum automatycznie zwraca stosowną odpowiedź błędu, nie wywołując Twojego handlera.

serde to framework Rusta do serializacji i deserializacji. Dodanie #[derive(Serialize, Deserialize)] do struktury pozwala Axumowi automatycznie parsować JSON do struktury i serializować strukturę do JSON:

#[derive(Deserialize)]
struct TodoCreate {
    title: String,
    description: Option<String>,
}

#[derive(Serialize)]
struct Todo {
    id: u64,
    title: String,
    description: Option<String>,
    completed: bool,
}

Współdzielony stan i Arc<Mutex<>>: W współbieżnym serwerze HTTP wiele żądań może napływać równocześnie, a wszystkie handlery działają współbieżnie. Jeśli handlery muszą odczytywać i zapisywać współdzielone dane (listę zadań), dostęp musi być synchronizowany.

  • Arc<T> (Atomic Reference Counting — atomowe zliczanie referencji) pozwala, by wartość była jednocześnie własnością wielu części programu. Klonowanie Arc zwiększa licznik; upuszczenie (drop) go — zmniejsza. Dane są zwalniane, gdy licznik osiągnie zero.
  • Mutex<T> (Mutual Exclusion — wzajemne wykluczanie) gwarantuje, że tylko jeden wątek ma dostęp do wewnętrznej wartości w danym momencie. Aby odczytać lub zapisać dane, wywołujesz .lock(), które zwraca strażnika (guard) zwalniającego blokadę po upuszczeniu.
#[derive(Clone)]
struct AppState {
    todos: Arc<Mutex<Vec<Todo>>>,
}

// W handlerze:
let mut todos = state.todos.lock().unwrap();
todos.push(new_todo);

Uruchamianie serwera

cargo run

Pliki projektu

src/main.rs

Zawiera wszystkie definicje tras i funkcje handlerów. Router budowany jest z użyciem routingu opartego na metodach HTTP:

let app = Router::new()
    .route("/todos", get(list_todos).post(create_todo))
    .route("/todos/:id", get(get_todo).put(update_todo).delete(delete_todo))
    .with_state(state);

axum::serve(listener, app).await uruchamia serwer.

Testowanie krok po kroku

# Utwórz zadanie
curl -X POST http://127.0.0.1:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy groceries"}'

# Pobierz listę
curl http://127.0.0.1:3000/todos

# Zaktualizuj (zastąp id zwróconym identyfikatorem)
curl -X PUT http://127.0.0.1:3000/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy groceries and milk", "completed": true}'

# Usuń
curl -X DELETE http://127.0.0.1:3000/todos/1

Częste błędy kompilacji

  • E0507: cannot move out of ... which is behind a shared reference — reguły własności Rusta uniemożliwiają przeniesienie wartości z referencji. Użyj .clone() lub przeprojektuj kod, by unikać przeniesienia.
  • E0308: mismatched types — funkcja oczekuje jednego typu, a otrzymuje inny. Sprawdź typ zwracany przez handler.

Zadanie 02: Blog osobisty

Lokalizacja: rust-assignments/02-personal-blog/

Co się nauczysz

  • Parametrów trasy URL z typem string
  • Używania HashMap jako magazynu in-memory
  • Wykrywania konfliktów (zduplikowane sligi)
  • Zwracania różnych kodów statusu z handlerów

Kluczowe pojęcia

HashMap<K, V> przechowuje pary klucz-wartość ze średnią złożonością wyszukiwania O(1). W blogu sligami postów są klucze:

HashMap<String, Post>

Wyszukiwanie posta: posts.get(&slug) zwraca Option<&Post>. Wartość None oznacza, że slug nie istnieje.

StatusCode w Axum: Aby zwrócić konkretny kod statusu HTTP wraz z ciałem JSON, użyj krotki jako odpowiedzi:

return (StatusCode::CONFLICT, Json(json!({"error": "Slug already exists"}))).into_response();

Trait IntoResponse w Axum pozwala zwracać zwykłe Json(...), krotki (StatusCode, Json(...)) lub Html(...).

Path<String> vs Path<u64>: Sligi to ciągi znaków, nie liczby całkowite. Path(slug): Path<String> wyciąga parametr URL jako String.

Uruchamianie serwera

cargo run

Testowanie krok po kroku

curl -X POST http://127.0.0.1:3000/posts \
  -H "Content-Type: application/json" \
  -d '{"title": "First Post", "slug": "first-post", "content_markdown": "Hello world"}'

curl http://127.0.0.1:3000/posts/first-post

# Zduplikowany slug — oczekiwany kod 409
curl -X POST http://127.0.0.1:3000/posts \
  -H "Content-Type: application/json" \
  -d '{"title": "Another Post", "slug": "first-post", "content_markdown": "..."}'

Zadanie 03: Profile użytkowników i przesyłanie plików

Lokalizacja: rust-assignments/03-user-profile-uploads/

Co się nauczysz

  • Extractora Multipart do przesyłania plików
  • Zapisu plików z użyciem tokio::fs
  • Serwowania plików statycznych za pomocą ServeDir z tower-http
  • Strumieniowania danych pliku z żądania

Kluczowe pojęcia

Multipart to extractor Axum dla żądań multipart/form-data. Udostępnia asynchroniczny strumień obiektów Field. Każde pole ma metodę name() (nazwę pola formularza) oraz metody do odczytu danych: bytes() i chunk().

async fn upload_image(mut multipart: Multipart) -> impl IntoResponse {
    while let Some(field) = multipart.next_field().await.unwrap() {
        if field.name() == Some("file") {
            let data = field.bytes().await.unwrap();
            tokio::fs::write("static/uploads/image.jpg", &data).await.unwrap();
        }
    }
}

tokio::fs zapewnia asynchroniczne operacje I/O na plikach. tokio::fs::write(path, data) zapisuje bajty do pliku bez blokowania asynchronicznego runtime.

ServeDir z tower-http udostępnia lokalny katalog jako pliki statyczne:

use tower_http::services::ServeDir;

let app = Router::new()
    // ...trasy...
    .nest_service("/static", ServeDir::new("static"));

Pliki w katalogu static/ stają się dostępne pod adresem /static/nazwa_pliku.

Uruchamianie serwera

cargo run

Testowanie krok po kroku

# Pobierz profil
curl http://127.0.0.1:3000/profiles/john_doe

# Prześlij zdjęcie
curl -X POST \
  -F "file=@./photo.jpg" \
  http://127.0.0.1:3000/profiles/john_doe/upload-image

# Pobierz przesłany obraz
curl http://127.0.0.1:3000/static/uploads/john_doe.jpg --output received.jpg

Zadanie 04: Menedżer zadań z zadaniami w tle

Lokalizacja: rust-assignments/04-task-manager-bg-tasks/

Co się nauczysz

  • Odłączonych zadań asynchronicznych z tokio::spawn
  • Jak asynchroniczny runtime Tokio różni się od wykonania synchronicznego
  • Różnicy między wykonaniem współbieżnym a sekwencyjnym

Kluczowe pojęcia

Tokio to asynchroniczny runtime dla Rusta. Zapewnia:

  • Pulę wątków wykonującą zadania asynchroniczne
  • Asynchroniczne wersje operacji I/O (pliki, sieć, timery)
  • tokio::spawn — uruchamia nowe zadanie asynchroniczne działające współbieżnie

tokio::spawn tworzy nowe zadanie i przekazuje je do zarządzania przez runtime Tokio. Wywołująca funkcja kontynuuje działanie natychmiast, nie czekając na zakończenie uruchomionego zadania. Jest to rustowy odpowiednik BackgroundTasks z FastAPI oraz wzorca „fire-and-forget" z JavaScriptu:

tokio::spawn(async move {
    tokio::time::sleep(Duration::from_secs(5)).await;
    println!("Notification sent to {}", email);
});
// Kod poniżej działa natychmiast — zanim minie 5-sekundowe opóźnienie

Słowo kluczowe move przenosi własność przechwyconych zmiennych do bloku asynchronicznego. Jest to konieczne, ponieważ uruchomione zadanie może przeżyć funkcję, która je stworzyła.

tokio::time::sleep to asynchroniczny odpowiednik thread::sleep. Zawiesza bieżące zadanie bez blokowania wątku, pozwalając innym zadaniom działać w czasie oczekiwania.

Uruchamianie serwera

cargo run

Testowanie krok po kroku

  1. Uruchom serwer z widocznym terminalem.
  2. Utwórz zadanie: curl -X POST http://127.0.0.1:3000/tasks -H "Content-Type: application/json" -d '{"title": "Deploy", "user_email": "dev@example.com"}'
  3. Oznacz jako ukończone: curl -X POST http://127.0.0.1:3000/tasks/1/complete
  4. Odpowiedź HTTP dociera natychmiast. Po 5 sekundach terminal wyświetla log powiadomienia.

Zadanie 05: Panel pogodowy

Lokalizacja: rust-assignments/05-weather-dashboard/

Co się nauczysz

  • Extractora Query do parametrów zapytania
  • Wykonywania żądań HTTP z użyciem reqwest
  • Przechowywania reqwest::Client we współdzielonym stanie w celu ponownego użycia połączeń
  • Budowania in-memory cache z TTL w Ruście

Kluczowe pojęcia

Extractor Query<T>: Axum deserializuje parametry ciągu zapytania URL do struktury opatrzonej atrybutem #[derive(Deserialize)]:

#[derive(Deserialize)]
struct WeatherQuery {
    city_name: Option<String>,
    lat: f64,
    lon: f64,
}

async fn get_weather(Query(params): Query<WeatherQuery>, ...) -> impl IntoResponse {

reqwest to asynchroniczny klient HTTP dla Rusta. W przeciwieństwie do synchronicznego requests z Pythona, reqwest integruje się z asynchronicznym runtime Tokio:

let response = reqwest::Client::new()
    .get("https://api.open-meteo.com/v1/forecast")
    .query(&[("latitude", lat), ("longitude", lon)])
    .send()
    .await?
    .json::<serde_json::Value>()
    .await?;

Ponowne użycie klienta: Tworzenie nowego reqwest::Client dla każdego żądania jest marnotrawstwem — każdy klient tworzy nową pulę połączeń. Idiomatyczne podejście to stworzenie jednego klienta i przechowywanie go w AppState:

struct AppState {
    http_client: reqwest::Client,
    cache: Arc<Mutex<HashMap<String, CacheEntry>>>,
}

Uruchamianie serwera

cargo run

Wymagane połączenie z internetem.

Testowanie krok po kroku

# Pierwsze wywołanie — dane z API
curl "http://127.0.0.1:3000/weather?city_name=London&lat=51.5&lon=-0.12"

# Drugie wywołanie — dane z cache
curl "http://127.0.0.1:3000/weather?city_name=London&lat=51.5&lon=-0.12"

Zadanie 06: Czat w czasie rzeczywistym

Lokalizacja: rust-assignments/06-real-time-chat/

Co się nauczysz

  • Procesu upgrade'u połączenia do WebSocket w Axum
  • Kanałów broadcast Tokio do rozsyłania wiadomości do wielu odbiorców
  • tokio::select! do współbieżnych operacji
  • Obsługi jednoczesnego wysyłania i odbierania przez WebSocket

Kluczowe pojęcia

WebSocketUpgrade to extractor Axum obsługujący upgrade protokołu HTTP na WebSocket. Twój handler go otrzymuje i wywołuje .on_upgrade(handler), by przejść do trybu WebSocket:

async fn ws_handler(ws: WebSocketUpgrade, ...) -> impl IntoResponse {
    ws.on_upgrade(handle_socket)
}

async fn handle_socket(mut socket: WebSocket) {
    // socket.send(...), socket.recv(), ...
}

Kanał broadcast Tokio: broadcast::Sender<String> obsługuje dowolną liczbę nadawców i odbiorców. Każdy nowy klient tworzy nowy Receiver wywołując sender.subscribe(). Gdy wiadomość jest wysyłana przez sender.send(msg), każdy aktywny odbiornik otrzymuje jej kopię:

let (tx, _rx) = broadcast::channel::<String>(100);
// Przechowuj tx w AppState

// W handlerze:
let mut rx = state.tx.subscribe();
tx.send("Hello everyone".to_string()).unwrap();

tokio::select! czeka współbieżnie na wiele operacji asynchronicznych i wykonuje gałąź, która zakończy się pierwsza:

loop {
    tokio::select! {
        // Wiadomość od tego klienta
        Some(Ok(msg)) = socket.recv() => {
            state.tx.send(format!("Client: {}", msg.to_text().unwrap())).unwrap();
        }
        // Wiadomość od dowolnego innego klienta (przez broadcast)
        Ok(msg) = rx.recv() => {
            socket.send(Message::Text(msg)).await.unwrap();
        }
        else => break,  // połączenie zamknięte
    }
}

Eliminuje to potrzebę osobnych wątków: jedna pętla asynchroniczna obsługuje zarówno wysyłanie, jak i odbieranie.

Uruchamianie serwera

cargo run

Testowanie krok po kroku

  1. Uruchom serwer.
  2. Otwórz http://127.0.0.1:3000/ w dwóch zakładkach przeglądarki.
  3. Wpisz wiadomość w jednej zakładce i kliknij „Send". Wiadomość pojawia się w drugiej zakładce.
  4. Zamknij jedną zakładkę. W drugiej pojawia się powiadomienie o rozłączeniu.

Zadanie 07: Obsługa formularzy i szablony

Lokalizacja: rust-assignments/07-forms-templates/

Co się nauczysz

  • Extractora Form do danych formularzy HTML
  • Zwracania odpowiedzi HTML z Html<T>
  • Generowania HTML bezpośrednio w kodzie Rusta
  • Formatu application/x-www-form-urlencoded

Kluczowe pojęcia

Extractor Form<T> parsuje ciała żądań w formacie application/x-www-form-urlencoded do typowanej struktury. Formularze HTML bez enctype="multipart/form-data" domyślnie używają tego kodowania:

#[derive(Deserialize)]
struct SubscriptionForm {
    name: String,
    email: String,
}

async fn subscribe(Form(data): Form<SubscriptionForm>) -> impl IntoResponse {
    // data.name, data.email
}

Html<T>: Opakowanie Html w Axum sygnalizuje, że ciało odpowiedzi jest HTML-em:

return Html("<h1>Success</h1>").into_response();
return Html(format!("<p>Hello, {}!</p>", name)).into_response();

Zwracanie z kodem statusu:

return (StatusCode::UNPROCESSABLE_ENTITY, Html(error_page)).into_response();

Uruchamianie serwera

cargo run

Testowanie krok po kroku

  1. Otwórz http://127.0.0.1:3000/.
  2. Wyślij poprawnie wypełniony formularz. Sprawdź stronę potwierdzenia.
  3. Wyślij formularz z imieniem składającym się z jednego znaku. Sprawdź komunikat błędu.
  4. GET http://127.0.0.1:3000/admin/subscriptions — lista wszystkich zgłoszeń w formacie JSON.

Zadanie 08: Odbiornik webhooków

Lokalizacja: rust-assignments/08-webhook-receiver/

Co się nauczysz

  • Extractora Bytes do dostępu do surowego ciała żądania
  • Wyciągania headerów z TypedHeader lub HeaderMap
  • Weryfikacji HMAC przy użyciu crates hmac i sha2
  • Porównywania w stałym czasie (constant-time comparison)

Kluczowe pojęcia

Koncepcyjne wyjaśnienie webhooków, HMAC i ataków czasowych (timing attacks) znajdziesz w zadaniu 08 przewodnika FastAPI.

Extractor Bytes: Aby uzyskać dostęp do surowego ciała żądania bez żadnego parsowania:

async fn receive_webhook(
    headers: HeaderMap,
    body: Bytes,
) -> impl IntoResponse {
    let raw_body: &[u8] = &body;
    // oblicz HMAC nad raw_body
}

HMAC w Ruście używa crates hmac i sha2:

use hmac::{Hmac, Mac};
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

fn verify_signature(payload: &[u8], secret: &[u8], signature: &str) -> bool {
    let mut mac = HmacSha256::new_from_slice(secret).unwrap();
    mac.update(payload);
    let expected = hex::encode(mac.finalize().into_bytes());
    // Porównanie w stałym czasie
    expected.as_bytes().ct_eq(signature.as_bytes()).into()
}

Crate subtle dostarcza ConstantTimeEq (.ct_eq()), który wykonuje porównanie bajtów w stałym czasie, eliminując podatność na ataki czasowe.

Wyciąganie headerów:

let signature = headers
    .get("x-webhook-signature")
    .and_then(|v| v.to_str().ok())
    .unwrap_or("");

Uruchamianie serwera

cargo run

Testowanie krok po kroku

# Użyj skryptu testowego Pythona z zadania webhook FastAPI (oryginalnie celuje w port 5000).
# Zmień URL na http://127.0.0.1:3000/webhook i dostosuj port.

# Podejrzyj zalogowane zdarzenia
curl http://127.0.0.1:3000/admin/events

Zadanie 09: Zarządzanie inwentarzem

Lokalizacja: rust-assignments/09-inventory-management/

Co się nauczysz

  • Zagnieżdżania routerów w Axum do wersjonowania API
  • Struktur parametrów zapytania do paginacji
  • Filtrowania i wycinania wektorów

Kluczowe pojęcia

Router::nest("/prefix", sub_router) montuje sub-router pod prefiksem URL. Trasy zdefiniowane w sub_router są dostępne pod adresem /prefix/trasa:

let v1_routes = Router::new()
    .route("/items", get(list_items_v1));

let v2_routes = Router::new()
    .route("/items", get(list_items_v2));

let app = Router::new()
    .nest("/v1", v1_routes)
    .nest("/v2", v2_routes)
    .with_state(state);

Struktura parametrów paginacji:

#[derive(Deserialize)]
struct PaginationParams {
    offset: Option<usize>,
    limit: Option<usize>,
    category: Option<String>,
}

Wycinanie wektora w Ruście:

let offset = params.offset.unwrap_or(0);
let limit = params.limit.unwrap_or(20);
let page: Vec<&Item> = items.iter()
    .skip(offset)
    .take(limit)
    .collect();

Uruchamianie serwera

cargo run

Testowanie krok po kroku

curl "http://127.0.0.1:3000/v1/items?offset=0&limit=5"
curl "http://127.0.0.1:3000/v2/items?category=Electronics&offset=0&limit=10"

Zadanie 10: Bezpieczny sejf dokumentów

Lokalizacja: rust-assignments/10-secure-document-vault/

Co się nauczysz

  • Uwierzytelniania JWT w Ruście z użyciem crate jsonwebtoken
  • Haszowania haseł z argon2
  • Middleware Axum z from_fn_with_state
  • Przekazywania claims przez rozszerzenia żądania

Kluczowe pojęcia

Koncepcyjne tło znajdziesz w zadaniu 10 przewodnika FastAPI.

Argon2 to algorytm haszowania haseł wymagający dużej ilości pamięci (memory-hard) — konfigurowalny poziom zużycia pamięci sprawia, że równoległe ataki brute-force są kosztowne obliczeniowo. Wygrał Password Hashing Competition 2015 i jest rekomendowany dla nowych aplikacji:

use argon2::{Argon2, PasswordHash, PasswordVerifier, password_hash::SaltString};
use argon2::PasswordHasher;

// Haszowanie
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?.to_string();

// Weryfikacja
let parsed_hash = PasswordHash::new(&stored_hash)?;
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()

Crate jsonwebtoken do obsługi JWT:

use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};

#[derive(Serialize, Deserialize)]
struct Claims {
    sub: String,
    scopes: Vec<String>,
    exp: usize,  // znacznik czasu wygaśnięcia
}

// Kodowanie
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET))?;

// Dekodowanie
let data = decode::<Claims>(&token, &DecodingKey::from_secret(SECRET), &Validation::default())?;
let claims = data.claims;

Middleware Axum z from_fn_with_state: Middleware to funkcja opakowująca handler. Może uzyskiwać dostęp do współdzielonego stanu oraz modyfikować lub odrzucać żądania przed dotarciem do handlera:

async fn auth_middleware(
    State(state): State<AppState>,
    mut req: Request,
    next: Next,
) -> impl IntoResponse {
    // Wyciągnij i zweryfikuj Bearer token
    // Zdekoduj JWT, pobierz Claims
    // req.extensions_mut().insert(claims)  — dołącz do żądania
    next.run(req).await
}

Handlery pobierają claims przez Extension<Claims>:

async fn get_documents(
    Extension(claims): Extension<Claims>,
    State(state): State<AppState>,
) -> impl IntoResponse {
    // claims.sub, claims.scopes
}

Uruchamianie serwera

cargo run

Testowanie krok po kroku

# Uwierzytelnienie
curl -X POST http://127.0.0.1:3000/token \
  -H "Content-Type: application/json" \
  -d '{"username": "alice", "password": "alice123"}'

TOKEN="<wklej_token>"

# Odczyt dokumentów
curl http://127.0.0.1:3000/documents \
  -H "Authorization: Bearer $TOKEN"

# Utwórz dokument powyżej poziomu uprawnień
curl -X POST http://127.0.0.1:3000/documents \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title": "Top Secret", "content": "...", "secret_level": 4}'
# Oczekiwany kod 403

# Endpoint administratora (alice nie ma zakresu admin)
curl http://127.0.0.1:3000/admin/users \
  -H "Authorization: Bearer $TOKEN"
# Oczekiwany kod 403

Uwierzytelnij się jako admin / admin123 i powtórz żądanie do endpointu administratora, by zweryfikować pełny dostęp.