Middleware
Um uns mit Traits vertraut zu machen, werden wir ein Middleware für eine Webserver-Bibliothek schreiben.
Projektstruktur
Erstelle zuerst ein neues Rust Projekt:
cargo new middleware
cd middleware
Als nächstes fügen wir die Bibliotheken axum und tokio hinzu.
Axum ist für Webserver und tokio is die async runtime, welche axum benötigt.
Die Dokumentation von axum findest du hier: docs.rs/axum
cargo add axum tokio
Hier ist die erste Version von main.rs:
use axum::Router;
use axum::routing::get;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let app = Router::new().route("/", get(greet));
let listener = tokio::net::TcpListener::bind("0.0.0.0:4000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn greet() -> String {
"Hello, world!".into()
}
Diese Programm kann mit cargo run gestartet werden.
Den Webserver ansprechen kann man dann z.B. mit curl localhost:4000.
Die Antwort sollte "Hello, world!" sein.
Der Path Extractor
Oft können einem Webserver Parameter über den Pfad der Anfrage mitgegben werden. Das machen wir mit dem Namen der Person, die gegrüsst werden soll:
Das neue Programm sieht wie folgt aus:
use axum::extract::Path;
async fn main() {
let app = Router::new().route("/{name}", get(greet));
// ...
}
async fn greet(Path(name): Path<String>) -> String {
format!("Hello, {name}!")
}
Ein neuer Import kommt dazu, die Route-Definition hat sich zu "/{name}" geändert und die greet Funktion nimmt den Parameter entgegen.
Testen kannst du das Programm zum Beispiel mit curl localhost:4000/Jeremy.
Wie funktioniert das?
Der Typ Path implementiert den Trait FromRequestParts.
Dieser definiert, wie ein Parameter aus einer HTTP-Anfrage ausgelesen werden kann.
Diesen Trait können wir selber implementieren!
FromRequestParts implementieren
Normalerweise gibt man seinen Username ja nicht über den URL Pfad an.
Schon üblicher wäre da der Authorization Header.
Wir wollen also folgende Anfrage an unseren Server schicken können:
curl --header 'Authorization: Jeremy' localhost:4000
Zunächst setzen wir das Programm auf den Anfangszustand zurück:
async fn main() {
let app = Router::new().route("/", get(greet));
// ...
}
async fn greet() -> String {
"Hello, world!".into()
}
Nun definieren wir unseren eigenen Typ für die Authentifizierung:
struct User(String);
Für diesen Typ müssen wir nun den FromRequestParts Trait implementieren, die Struktur sieht so aus:
use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::request::Parts;
impl FromRequestParts<()> for User {
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
_: &(),
) -> Result<User, StatusCode> {
// TODO
}
}
Ein paar Erklärungen sind angebracht:
- Das
<()>nachFromRequestPartsin der ersten Zeile interessiert uns nicht weiter Da könnte man einen Typparameter angeben, wenn man für seinen Server globalen Zustand bräuchte. type Rejection = StatusCode;gibt den Fehlertyp an, wenn bei der "Extraction" etwas schief geht. Wir geben in diesem Fall einen HTTP Statuscode zurück.- Die Funktion
from_request_partswird kurz vor dem eigentlichen Handlergreetausgeführt, um den User zu ermitteln. parts: &mut Partsist der Parameter, für den wir uns interessieren. Diese "request parts", beinhalten Metadaten über die HTTP Anfrage, inklusive der Header.- Der zweite Parameter
_: &()interessiert uns wiederum nicht, das ist der oben erwähnte globale Zustand. - Und letztlich der Rückgabetyp
Result<Self, StatusCode>. Der sagt, dass im Erfolgsfall einUserzurückgegeben wird ansonsten einStatusCode.
Nun können wir uns an die Implementation wagen:
let Some(user) = parts.headers.get("Authorization") else {
return Err(StatusCode::UNAUTHORIZED);
};
let Ok(user) = user.to_str() else {
return Err(StatusCode::UNAUTHORIZED);
};
Ok(User(user.to_string()))
Grob zusammengefasst:
- Wenn die Anfrage keinen
AuthorizationHeader hat, dann wird direkt mit401 Unauthorizedgeantwortet. (Der eigentlichegreetHandler wird gar nie aufgerufen.) - Wenn der Wert kein valider String ist, dann wird ebenfalls mit
401 Unauthorizedgeantwortet. - Wenn bis jetzt alles gut ging, dann wird ein neuer User mit dem Wert des Headers als Namen konstruiert.
Nun müssen wir nur noch den greet Handler anpassen, damit er nach einem User verlangt:
async fn greet(User(name): User) -> String {
format!("Hello, {name}!")
}
Das war's! Der folgende Befehl sollte zu einer erfolgreichen Antwort führen:
curl --header 'Authorization: Jeremy' localhost:4000
Um den Fehlerfall zu testen: curl -v localhost:4000
In einer realistischen Applikation würde man natürlich nicht den Usernamen direkt auslesen. Sinnvoller wäre es, im "Authorization" header ein JWT Token zu erwarten, das zu lesen und die Signatur zu verifizieren. Die Vorgehensweise ist aber dieselbe.
Fazit
Bibliotheken stellen oft Traits zur Verfügung, um Benutern zu erlauben die Funktionalität der Bibliothek zu erweitern und den eigenen Bedürfnissen anzupassen.