Pytania rekrutacyjne dla programisty backendowego Rust
15 wybranych pytań rekrutacyjnych dla programistów backendowych Rust, pogrupowanych według poziomu doświadczenia. Wykorzystaj je do powtórzenia podstaw, rozważenia praktycznych kompromisów i wnioskowania na poziomie seniora w kontekście produkcyjnym.
1Wyjaśnij zasady własności Rust i jak kształtują one praktyczne wybory w tworzeniu backendu, takie jak klonowanie, pożyczanie i współdzielenie stanu aplikacji.
Rust przypisuje każdej wartości jednego właściciela. Przeniesienie wartości niebędącej typu `Copy` przenosi własność, a wartość jest usuwana (drop) po wyjściu jej właściciela poza zakres. Kod może pożyczać dane niemutowalnie poprzez współdzielone referencje lub mutowalnie poprzez wyłączną mutowalną referencję. W kodzie backendowym wpływa to na to, czy funkcja przejmuje własność, pożycza tymczasowo, klonuje, aby uzyskać niezależną wartość, której jest właścicielem, czy też współdzieli długożyjący stan za pomocą uchwytów, takich jak `Arc`, z synchronizacją lub innymi prymitywami współbieżności, gdy potrzebna jest współdzielona mutacja.
use std::sync::Arc;
struct AppState {
service_name: String,
}
async fn handler(state: Arc<AppState>, request_id: String) -> String {
// Borrow: no ownership transfer of the String inside state.
let name: &str = &state.service_name;
// Clone only if an owned independent value is needed.
let owned_name = name.to_owned();
format!("{owned_name}:{request_id}")
}
fn configure(state: Arc<AppState>) {
// Cloning Arc increments the reference count; it does not clone AppState.
let state_for_route = Arc::clone(&state);
let _ = state_for_route;
}
2Jaka jest różnica między pożyczaniem za pomocą `&T` i `&mut T`, i jak te zasady wpływają na interfejsy programistyczne (API) i sygnatury funkcji obsługujących (handler signatures)?
`&T` to współdzielone odniesienie (shared reference): umożliwia dostęp tylko do odczytu, a wiele współdzielonych odniesień do tej samej wartości może być aktywnych jednocześnie. `&mut T` to wyłączne, mutowalne odniesienie (exclusive mutable reference): pozwala na modyfikację, ale gdy jest aktywne, żadne inne współdzielone ani mutowalne odniesienia do tej samej wartości nie mogą być używane. Te zasady kształtują interfejsy programistyczne (API), sprawiając, że funkcje tylko do odczytu akceptują `&T` lub węższe typy pożyczone, takie jak `&str`, podczas gdy funkcje modyfikujące akceptują `&mut T`, przejmują własność (ownership) lub używają wewnętrznej mutowalności (interior mutability)/synchronizacji. Funkcje obsługujące (handlery) w warstwie backendowej zazwyczaj unikają prostego dostępu `&mut` do współdzielonego stanu aplikacji (shared application state) w przypadku równoczesnych żądań, a zamiast tego używają współdzielonych uchwytów (shared handles) wraz z synchronizacją lub innymi wzorcami własności.
struct User {
name: String,
login_count: u64,
}
fn display_name(user: &User) -> &str {
&user.name
}
fn record_login(user: &mut User) {
user.login_count += 1;
}
fn main() {
let mut user = User { name: "Ada".into(), login_count: 0 };
let name = display_name(&user); // shared borrow for reading
println!("{name}");
record_login(&mut user); // exclusive mutable borrow for mutation
}
3Jak działa `Box` i jakie problemy rozwiązuje alokacja na stercie za pomocą `Box`?
`Box<T>` to wskaźnik inteligentny (owning smart pointer) posiadający własność, którego `T` jest przechowywane na stercie, podczas gdy uchwyt `Box` jest wartością stałego rozmiaru przypominającą wskaźnik. Posiada pojedyncze prawo własności i zwalnia alokację na stercie, gdy wychodzi poza zakres. `Box` zapewnia indyrekcję, co pomaga w przypadku dużych wartości, typów rekurencyjnych, które wymagają znanego rozmiaru, wartości o dynamicznym rozmiarze oraz obiektów cech (trait objects), takich jak `Box<dyn Trait>`. Przeniesienie `Box` przenosi prawo własności alokacji bez przenoszenia ani kopiowania samej wartości przechowywanej na stercie.
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
4Opisz `Result` i `Option` oraz główne idiomatyczne sposoby pracy z brakiem wartości i operacjami, które mogą zakończyć się niepowodzeniem.
`Option<T>` reprezentuje `Some(T)` lub `None` i jest używany do oczekiwanego braku wartości. `Result<T, E>` reprezentuje `Ok(T)` lub `Err(E)` i jest używany dla operacji, które mogą zakończyć się niepowodzeniem, gdzie informacja o błędzie jest istotna. Idiomatyczne sposoby obsługi obejmują `match`, `if let`/`let else`, kombinatory takie jak `map`, `and_then`, `ok_or`, `unwrap_or` i `unwrap_or_else`, a także operator `?` do wczesnego propagowania z funkcji zwracających kompatybilne typy `Option` lub `Result`. Metody `unwrap` i `expect` najlepiej rezerwować dla niezmienników, testów, prototypów lub sytuacji, z których nie da się odzyskać, a nie dla normalnych, możliwych do odzyskania błędów w systemach backendowych.
5Jak działa operator `?` i co umożliwia konwersję między różnymi typami błędów?
Operator `?` jest skrótem do propagacji błędu. Dla typu `Result`, `expr?` zwraca wartość `Ok` i kontynuuje działanie, lub wcześnie zwraca `Err` z bieżącej funkcji; dla typu `Option`, zwraca wartość `Some` lub `None`. Funkcja nadrzędna musi zwracać kompatybilny typ. W przypadku `Result`, różne typy błędów mogą się komponować, ponieważ błąd źródłowy jest konwertowany na typ błędu funkcji za pomocą konwersji w stylu `From`/`Into`, często implementowanej ręcznie lub wyprowadzonej za pomocą pomocniczych bibliotek (crates), takich jak `thiserror`.
use std::{fs, io, num::ParseIntError};
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
}
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}
fn read_number(path: &str) -> Result<i32, AppError> {
let s = fs::read_to_string(path)?; // io::Error -> AppError
let n = s.trim().parse::<i32>()?; // ParseIntError -> AppError
Ok(n)
}
6Wyjaśnij dopasowywanie wzorców (pattern matching) na typach wyliczeniowych (enumach) i jak wyczerpujące dopasowywanie (exhaustive matching) poprawia poprawność dziedziny i API.
Typy wyliczeniowe (enumy) modelują wartość, która może być jednym z ustalonego zestawu wariantów, a warianty mogą przenosić dane. Instrukcja `match` rozgałęzia się według wariantu i może destrukturyzować wewnętrzne dane. Rust sprawdza, czy dopasowania enumów są wyczerpujące, chyba że użyto wzorca wieloznacznego lub „catch-all”. Poprawia to poprawność dziedziny i API, ponieważ gdy dodany zostanie nowy stan lub wariant odpowiedzi, kompilator może wymusić na kodzie, który go dopasowuje, podjęcie decyzji, jak obsłużyć nową sytuację, zamiast po cichu ją ignorować.
7Czym są opakowania newtype (newtype wrappers) i jak poprawiają bezpieczeństwo typów (type safety) dla identyfikatorów, pieniędzy, tokenów i poufnych danych (secrets)?
Opakowanie newtype (newtype wrapper) to odrębny typ w Rust, często struktura krotkowa z jednym polem, opakowująca istniejącą reprezentację, taką jak `struct UserId(Uuid);` lub `struct AccessToken(String);`. Poprawia bezpieczeństwo typów (type safety), ponieważ kompilator nie pomyli semantycznie różnych wartości, które współdzielą ten sam bazowy typ, takich jak `UserId` w porównaniu z `OrderId`, centy w porównaniu z dolarami, lub surowe ciągi znaków w porównaniu ze zweryfikowanymi tokenami. Newtypes mogą również centralizować reguły konstrukcji i walidacji oraz kontrolować cechy (traits), takie jak `Display`, `Debug`, `Serialize`, lub zachowanie redakcji (redaction behavior) dla poufnych danych (secrets).
use uuid::Uuid;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct UserId(Uuid);
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct OrderId(Uuid);
struct SecretToken(String);
impl std::fmt::Debug for SecretToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretToken(**redacted**)")
}
}
fn load_user(id: UserId) {
// query by user id
}
fn example(user_id: UserId, order_id: OrderId) {
load_user(user_id);
// load_user(order_id); // does not compile: expected UserId, found OrderId
}
8Jak działa cecha `Drop`, uwzględniając kolejność zwalniania, czyszczenie RAII (Resource Acquisition Is Initialization) i ograniczenia dotyczące asynchronicznego czyszczenia?
`Drop` to deterministyczny hak czyszczący w Rust. Kiedy posiadana wartość zostaje zniszczona, na przykład gdy wychodzi poza zakres, Rust automatycznie wywołuje jej metodę `drop(&mut self)`, jeśli implementuje ona cechę `Drop`, a następnie zwalnia jej pola. To wspiera RAII: zasoby takie jak pliki, gniazda, blokady, transakcje, pozwolenia lub bufory są powiązane z wartościami/strażnikami posiadającymi i są zwalniane, gdy te wartości zostają usunięte. Zmienne lokalne są zwalniane w odwrotnej kolejności tworzenia; dla struktury z `Drop`, metoda `drop` struktury uruchamia się, zanim jej pola zostaną zwolnione, a pola są zwalniane w kolejności deklaracji. `Drop::drop` jest synchroniczne i nie może być `async` ani `await`owane, więc eleganckie asynchroniczne czyszczenie zazwyczaj wymaga jawnej asynchronicznej metody zamknięcia/wyłączenia/opróżnienia lub innego projektu; `Drop` może wykonywać tylko synchroniczne lub na miarę możliwości czyszczenie.
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("drop {}", self.0);
}
}
struct Pair {
first: Guard,
second: Guard,
}
impl Drop for Pair {
fn drop(&mut self) {
println!("drop Pair");
}
}
fn main() {
let _a = Guard("a");
let _pair = Pair {
first: Guard("first"),
second: Guard("second"),
};
let _b = Guard("b");
}
9Wyjaśnij, czym są czasy życia (lifetimes) i dlaczego jawne adnotacje czasu życia są czasami wymagane w API w Rust.
Czas życia (lifetime) opisuje, jak długo referencja jest ważna, co pozwala kompilatorowi odrzucać zwisające referencje. Adnotacje czasu życia to ograniczenia czasu kompilacji, które wyrażają relacje między referencjami; nie wydłużają one czasu życia bazowych danych ani nie dodają zachowania w czasie wykonania. Wiele prostych sygnatur jest obsługiwanych przez elisionowanie czasów życia, ale jawne adnotacje są potrzebne, gdy kompilator nie jest w stanie wydedukować, jak referencje wejściowe i wyjściowe są ze sobą powiązane, gdy typy przechowują referencje, lub gdy API musi wyrażać generyczne ograniczenia pożyczania (borrowing constraints).
fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
if left.len() >= right.len() { left } else { right }
}
struct UserView<'a> {
name: &'a str,
}
fn main() {
let a = String::from("short");
let b = String::from("longer");
let result = longest(&a, &b);
let view = UserView { name: result };
println!("{}", view.name);
}
10Co oznacza czas życia `'static` i jak współdziała z tworzonymi zadaniami (spawned tasks) oraz projektowaniem API (Application Programming Interface) zaplecza (backendu)?
`'static` na referencji oznacza, że dane, do których się odnosi, są ważne przez cały czas trwania programu, tak jak w przypadku literałów ciągu znaków (string literals) lub elementów statycznych (`static` items). Ograniczenie typu `T: 'static` oznacza, że wartości typu `T` nie zawierają niestatycznych pożyczonych referencji (non-`'static` borrowed references), więc mogą być przechowywane przez dowolny czas życia; nie oznacza to, że sama wartość musi żyć wiecznie lub nie może zostać usunięta. Tworzone zadania (spawned tasks) często wymagają `futures` z `'static` ze względu na to, że egzekutor (executor) może kontynuować ich uruchamianie po zakończeniu ramki stosu (stack frame) tworzącej zadanie. W kodzie zaplecza (backend code) oznacza to zazwyczaj przenoszenie danych na własność (owned data) do zadania za pomocą `async move`, używanie typów na własność (owned types) lub klonowanie współdzielonych uchwytów (shared handles), takich jak `Arc`, zamiast pożyczania zmiennych lokalnych. Interfejsy API (Application Programming Interface) powinny używać ograniczeń `'static` dla przechowywanych wywołań zwrotnych (callbacks), zadań w tle (background jobs) i długotrwałego stanu (long-lived state), ale unikać niepotrzebnych ograniczeń `'static` dla krótkotrwałych operacji pożyczania (borrowed operations).
use std::sync::Arc;
struct AppState {
name: String,
}
fn spawn_background(state: Arc<AppState>, user_id: String) {
tokio::spawn(async move {
// The task owns an Arc handle and a String, so it does not borrow this function's stack.
println!("{}:{user_id}", state.name);
});
}
11Opisz, w jaki sposób Rust używa cech (traits) do polimorfizmu, i porównaj `impl Trait`, generyki oraz `dyn Trait`.
Rust używa cech (traits) do wyrażania współdzielonego zachowania. Ograniczenia generyczne, takie jak `fn f<T: Trait>(x: T)`, zazwyczaj wykorzystują statyczne wysyłanie (static dispatch) poprzez monomorfizację. `impl Trait` w pozycji argumentu jest głównie skrótem dla anonimowego parametru generycznego. `impl Trait` w pozycji zwracanej ukrywa jeden konkretny typ zwracany, wybrany przez funkcję. `dyn Trait` to obiekt cechy (trait object) używany za wskaźnikiem, np. `&dyn Trait` lub `Box<dyn Trait)`; wykorzystuje dynamiczne wysyłanie (dynamic dispatch) poprzez vtable i obsługuje wymazywanie typów (type erasure) w czasie wykonania / wartości heterogeniczne, z zastrzeżeniem ograniczeń bezpieczeństwa obiektów (object-safety restrictions).
12Czym są typy skojarzone (associated types) w cechach (traits) i kiedy są preferowane w stosunku do generycznych parametrów typów?
Typy skojarzone to nazwane symbole zastępcze (placeholders) typów, deklarowane wewnątrz cechy i sprecyzowane przez każdą implementację, na przykład `Iterator::Item`. Są one preferowane, gdy typ jest częścią kontraktu cechy i każda implementacja ma dla niego jeden naturalny wybór. Generyczne parametry typów są preferowane, gdy wywołujący powinien wybrać typ lub gdy ta sama implementacja może potrzebować wielu implementacji dla różnych wyborów typów.
Aby zaimplementować wzorzec skrzynki nadawczej transakcji (Transactional Outbox pattern) w usłudze Rust, należy atomowo zapisać aktualizacje danych domenowych i ładunki wychodzących zdarzeń w ramach jednej transakcji bazodanowej (np. używając `sqlx::Transaction`). Tabela `outbox` przechowuje temat docelowy, ładunek oraz status lub znacznik czasu utworzenia. Asynchroniczny proces roboczy działający w tle (lub proces Change Data Capture, taki jak Debezium) następnie odpytuje lub strumieniuje oczekujące zdarzenia i publikuje je do brokera wiadomości (takiego jak Kafka, RabbitMQ lub NATS). W przypadku użycia procesu roboczego odpytującego w Rust (np. w pętli `tokio::spawn`), użycie `SELECT ... FOR UPDATE SKIP LOCKED` pozwala wielu instancjom usługi pobierać i wysyłać wiersze `outbox` współbieżnie bez rywalizacji o blokady. Gdy dostarczenie przez brokera zostanie potwierdzone, proces roboczy aktualizuje status rekordu `outbox` lub usuwa wiersz. Ponieważ ponowne próby sieciowe mogą skutkować dostarczeniem co najmniej raz (at-least-once delivery), konsumenci końcowi muszą być zaprojektowani tak, aby obsługiwać wiadomości idempotentnie.
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
pub async fn create_user_with_outbox(
mut tx: Transaction<'_, Postgres>,
username: &str,
email: &str,
) -> Result<(), sqlx::Error> {
let user_id = Uuid::new_v4();
sqlx::query!(
"INSERT INTO users (id, username, email) VALUES ($1, $2, $3)",
user_id, username, email
)
.execute(&mut *tx)
.await?;
let payload = serde_json::json!({"event": "UserCreated", "user_id": user_id, "email": email});
sqlx::query!(
"INSERT INTO outbox_events (id, topic, payload, status) VALUES ($1, $2, $3, 'PENDING')",
Uuid::new_v4(),
"user_events",
payload
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn run_outbox_relay(pool: PgPool) {
loop {
let mut tx = match pool.begin().await { Ok(t) => t, Err(_) => continue };
let rows = sqlx::query!(
"SELECT id, topic, payload FROM outbox_events WHERE status = 'PENDING' LIMIT 50 FOR UPDATE SKIP LOCKED"
)
.fetch_all(&mut *tx)
.await;
if let Ok(events) = rows {
for event in events {
// publish_to_broker(&event.topic, &event.payload).await;
let _ = sqlx::query!("UPDATE outbox_events SET status = 'PROCESSED' WHERE id = $1", event.id)
.execute(&mut *tx)
.await;
}
let _ = tx.commit().await;
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
}
14Jak zaprojektować wieloetapową operację, która aktualizuje bazę danych i wywołuje zewnętrzne API (Application Programming Interface)?
Projektowanie wieloetapowej operacji, która obejmuje zapis do bazy danych i wywołanie zewnętrznego API, wymaga zarządzania częściowymi awariami i partycjami sieciowymi bez polegania na zatwierdzaniu dwufazowym (2PC - Two-Phase Commit). 1. **Najpierw utrwal stan / zamiar**: Zapisz stan początkowy (taki jak `PENDING` lub `INITIATED`) i wymagany ładunek w ramach lokalnej transakcji bazodanowej. Zatwierdź tę transakcję przed zainicjowaniem zewnętrznego wywołania sieciowego, aby uniknąć blokowania bazy danych i slotów puli połączeń podczas wolnych operacji I/O (Input/Output) sieciowych. 2. **Idempotentne żądanie zewnętrzne**: Wywołaj zewnętrzne API, używając deterministycznego klucza idempotencji (np. wywodzącego się z UUID (Universally Unique Identifier) operacji). Zapewnia to, że powtarzające się próby nie spowodują zduplikowanych efektów ubocznych w świecie rzeczywistym (takich jak podwójne obciążenie). 3. **Zmiana stanu i obsługa limitów czasu**: Jeśli wywołanie API zwróci pomyślną odpowiedź, zaktualizuj rekord w bazie danych na `COMPLETED`. Jeśli zakończy się trwale niepowodzeniem, oznacz go jako `FAILED` i wykonaj akcje kompensujące. Jeśli wywołanie przekroczy limit czasu lub zwróci błąd sieci, oznacz rekord jako `INDETERMINATE` / `REQUIRES_RECONCILIATION` i pozwól procesowi roboczemu w tle uzgodnić stan, odpytując punkt końcowy statusu dostawcy lub ponawiając próbę z tym samym kluczem idempotencji.
use uuid::Uuid;
pub async fn process_external_charge(pool: &sqlx::PgPool, user_id: Uuid, amount: i64) -> Result<(), String> {
let payment_id = Uuid::new_v4();
let idempotency_key = format!("pay_{}", payment_id);
// 1. Commit intent locally before making external network calls
sqlx::query!(
"INSERT INTO payments (id, user_id, amount, status, idempotency_key) VALUES ($1, $2, $3, 'PENDING', $4)",
payment_id, user_id, amount, idempotency_key
)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
// 2. Call external API with timeout and idempotency key
let client = reqwest::Client::new();
let api_result = client.post("https://api.payment.com/v1/charges")
.header("Idempotency-Key", &idempotency_key)
.json(&serde_json::json!({ "amount": amount }))
.timeout(std::time::Duration::from_secs(5))
.send()
.await;
// 3. Update state based on outcome; handle timeouts via reconciliation
match api_result {
Ok(resp) if resp.status().is_success() => {
sqlx::query!("UPDATE payments SET status = 'SUCCESS' WHERE id = $1", payment_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
}
Ok(_) => {
sqlx::query!("UPDATE payments SET status = 'FAILED' WHERE id = $1", payment_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
}
Err(_) => {
sqlx::query!("UPDATE payments SET status = 'REQUIRES_RECONCILIATION' WHERE id = $1", payment_id)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
15Jak zaprojektowałbyś wielodostępny backend w Rust ze ścisłą izolacją danych i dynamicznym routowaniem?
Projektowanie wielodostępnego backendu w Rust obejmuje trzy kluczowe obszary: identyfikację dzierżawcy, strategię izolacji danych i dynamiczne routowanie. 1. **Identyfikacja dzierżawcy i propagacja kontekstu**: Oprogramowanie pośredniczące HTTP (middleware HTTP) (np. w Axum lub Actix-web) rozstrzyga tożsamość dzierżawcy z roszczeń JWT (JSON Web Token), subdomen lub nagłówków. Konstruuje silnie typowany `TenantContext` przechowywany w rozszerzeniach żądania. Handlery używają ekstraktorów typów Rust, aby wymusić, aby logika biznesowa nie mogła być wykonana bez prawidłowego kontekstu dzierżawcy. 2. **Strategia izolacji danych**: - *Wspólna baza danych z zabezpieczeniami na poziomie kolumn / wierszy (RLS - Row-Level Security)*: Wszyscy dzierżawcy współdzielą tabele z kolumną `tenant_id`. Izolacja jest wymuszana za pomocą RLS w PostgreSQL przy użyciu zmiennych sesji na poziomie połączenia (np. `SET LOCAL app.tenant_id = $1`) lub opakowań konstruktora zapytań. - *Schemat na dzierżawcę*: Dzierżawcy współdzielą instancję bazy danych, ale mają izolowane schematy; routowanie dostosowuje ścieżkę wyszukiwania dla każdego połączenia. - *Baza danych na dzierżawcę*: Dzierżawcy mają oddzielne bazy danych. Usługa utrzymuje rejestr pul (np. `Arc<DashMap<TenantId, PgPool>>` lub pamięć podręczną puli LRU (Least Recently Used)) do dynamicznego rozwiązywania pul połączeń. 3. **Zapobieganie wyciekom**: Aby uniknąć zanieczyszczenia między dzierżawcami w asynchronicznych aplikacjach Tokio, kontekst dzierżawcy musi być przekazywany jawnie przez granice zadań, a nie przechowywany w stanie globalnym lub pamięci lokalnej wątku (`thread_local!`), co powoduje problemy podczas migracji między wątkami roboczymi asynchronicznymi.
use axum::{extract::{FromRequestParts, State}, http::request::Parts, async_trait};
use std::sync::Arc;
use dashmap::DashMap;
use sqlx::PgPool;
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct TenantId(pub String);
#[derive(Clone)]
pub struct AppState {
pub pool_registry: Arc<DashMap<TenantId, PgPool>>,
}
#[derive(Clone)]
pub struct TenantContext {
pub tenant_id: TenantId,
pub pool: PgPool,
}
#[async_trait]
impl FromRequestParts<AppState> for TenantContext {
type Rejection = (axum::http::StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let tenant_header = parts.headers.get("X-Tenant-ID")
.and_then(|v| v.to_str().ok())
.ok_or((axum::http::StatusCode::BAD_REQUEST, "Missing tenant ID"))?;
let tenant_id = TenantId(tenant_header.to_string());
let pool = match state.pool_registry.get(&tenant_id) {
Some(p) => p.value().clone(),
None => return Err((axum::http::StatusCode::NOT_FOUND, "Tenant database pool not found")),
};
Ok(TenantContext { tenant_id, pool })
}
}