Ferris Talk #13: Rust-Web-APIs und Mocking mit Axum

Das jüngste Release von Axum verbessert das State-Handling und vermeidet auf diese Weise Laufzeitfehler.

In Pocket speichern vorlesen Druckansicht 7 Kommentare lesen
Lesezeit: 9 Min.
Von
  • Rainer Stropek
Inhaltsverzeichnis

Komponenten aus dem Tokio-Stack waren bereits häufiger Thema im Ferris Talk. Zu ihnen gehört Axum, ein Framework für die Entwicklung von Web-APIs. Im November ist Axum in Version 0.6 erschienen. Eine wichtige Änderung ist die Verbesserung der Typsicherheit bezüglich des Shared State. Bisher galt die Empfehlung, die Extension-Middleware und den Extension-Extraktor zu verwenden, um den State zu teilen.

Ferris Talks – die Kolumne für Rustaceans

Diese Komponenten stellten jedoch die Konsistenz der Typen nicht zur Übersetzungszeit sicher. Hatte jemand die Middleware vergessen oder die falschen Typen bereitgestellt, kam es beim Extraktor zu einem Laufzeitfehler. In Axum 0.6 wurde der State-Extraktor eingeführt, der diese Schwäche beseitigt.

Diese Folge des Ferris Talk zeigt, wie das verbesserte State-Handling in Axum aussieht und zwar über triviale Anwendungsfälle hinaus. Die Beispiele demonstrieren, wie sich mit Rust-Traits und dem Mocking-Framework mockall Unittests für die Axum-API umsetzen lassen, bei denen Mock-Objekte zugrundeliegende Schichten wie Datenbankzugriff während des Tests ersetzen.

Das folgende Beispiel illustriert zunächst die Axum-Neuerungen und zeigt anschließend die Entkopplung der Schichten mit Rust-Traits Schritt für Schritt. Die im Text eingebetteten Codeausschnitte gehen nur auf die Codeteile ein, die für das Thema relevant sind. Der gesamte, lauffähige Code findet sich auf GitHub.

Der Beispielcode stellt den Prototyp einer einfachen Web-API zum Verwalten von Superhelden mit Rust und Axum dar. Er soll zunächst die Suche nach Helden anhand ihrer Namen umsetzen. Weitere API-Funktionen würden demselben Schema folgen. Eine Einschränkung des Prototyps besteht darin, dass er nicht tatsächlich auf eine Datenbank zugreift. Dennoch soll der der Code alle Vorkehrungen enthalten, um den asynchronen Datenbankzugriff beispielsweise mit der sqlx-Crate hinzuzufügen.

Der Code der Web-API und der für den Datenbankzugriff sollten unbedingt voneinander getrennt sein. Das ist im Beispiel für den Einsatz von Mock-Objekten und Unittests unerlässlich, wäre aber auch für eine reguläre Anwendung sinnvoll. Der folgende Code zeigt die Datenzugriffsschicht des Prototyps. Details zur Implementierung finden sich in den Kommentaren.

/// The model for our API. We are maintaining heroes.
#[derive(Serialize)]
pub struct Hero {
  pub id: &'static str,
  pub name: &'static str,
}

/// Represents an error that happened during data access.
enum DataAccessError {
  NotFound,
  #[allow(dead_code)]
  TechnicalError,
  #[allow(dead_code)]
  OtherError,
}

/// Dummy implementation for our repository
/// 
/// In real life, this repository would access a 
/// database with persisted heroes. However, for
/// the sake of simplicity, we just simulate 
/// database access by using an in-memory
/// collection of heroes.
struct HeroesRepository();

impl HeroesRepository {
  async fn get_by_name(&self, name: &str) 
    -> Result<Vec<Hero>, DataAccessError> {
    // We don't use a database in this example,
    // but simulate one.
    // Our data is just a const array of heroes:
    const HEROES: [Hero; 2] = [
      Hero {
        id: "1",
        name: "Wonder Woman",
      },
      Hero {
        id: "2",
        name: "Deadpool",
      },
    ];

    // Simulate database access by doing 
    // an async wait operation.
    time::sleep(Duration::from_millis(100)).await;

    // As we do not have a real database, we just 
    // filter the heroes array in memory. Note
    // that we simulate the DB behaviour of `LIKE`
    // and the % wildcard at the end of the name 
    // filter by using the `starts_with` method.
    let found_heroes = HEROES
      .into_iter()
      .filter(|hero| {
        if let Some(stripped_name) = 
          name.strip_suffix('%') {
          // We have a % at the end of the name 
          // filter. This means that we have to use
          // the `starts_with` method to simulate
          // the `LIKE` behaviour of the DB.
          hero.name.starts_with(stripped_name)
        } 
        else {
          // No % at the end, so we can use 
          // the `==` operator.
          hero.name == name
        }
      })
      .collect::<Vec<Hero>>();

    if found_heroes.is_empty() {
      // We did not find any heroes. This means 
      // that we have to return a "not found" error.
      Err(DataAccessError::NotFound)
    } 
    else {
      // We found some heroes. Return them.
      Ok(found_heroes)
    }
  }
}

Besonders zu beachten ist, dass die Methode get_by_name als Rückgabetyp Result<vec, DataAccessError> verwendet, also Fehlerzustände an den Aufrufer der Methode zurückmelden kann. Es wird Aufgabe der API-Handler-Funktion sein, diese Fehlerzustände in passende HTTP Response Messages umzuwandeln. Das spielt für das automatisierte Testen später eine wichtige Rolle.