Automatische Generierung und Validierung von OpenAPI-Dokumenten in Rust: Ein schlanker Ansatz mit Utoipa und Schemathesis

Lernen Sie während der Entwicklung automatisch generierte und validierte OpenAPI-Dokumente zu benutzen um die Qualität ihrer APIs zu verbessern. Entdecken sie an einem Beispiel wie leistungsstarke Tools wie Utoipa und Schemathesis in Ihre Rust-Projekte und CI-Pipelines eingebaut werden können.

Lesezeit:
10 min
https://www.freepik.com/free-vector/application-programming-interface-concept-illustration_25625375.htm

Die OpenAPI-Spezifikation hat sich zum Industriestandard für die Definition von APIs entwickelt. Es ist unbestritten, dass die Sicherstellung einer genauen und aktuellen API-Dokumentation sowohl für Benutzer als auch für Entwickler wichtig ist. Die manuelle Erstellung und Aktualisierung der Dokumentation für eine sich ständig weiterentwickelnde API kann jedoch mühsam und fehleranfällig sein. In diesem Artikel werden wir untersuchen, wie Utoipa und Schemathesis verwendet werden können, um eine API-Definition direkt aus Rust-Code zu generieren und zu validieren und so diese Herausforderungen effektiv zu bewältigen.

Zur Veranschaulichung der Fähigkeiten dieser Werkzeuge, und wie sie nahtlos in Ihre Rust-Projekte und CI-Pipelines integrierte werden können, werden wir Beispiele basierend auf dem Credential Check Service von Identeco verwenden. Der Source Code für diesen Artikel ist in unserem öffentlichen Github Repository zu finden und bietet Ihnen ein minimales funktionierendes Beispiel zum weiteren experimentieren und erkunden der Konzepte. Am Ende dieses Artikels werden Sie ein besseres Verständnis dafür haben, wie Sie Ihren API-Dokumentationsprozess optimieren können und sicherstellen, dass Ihre API während ihres gesamten Entwicklungszyklus korrekt und zuverlässig bleibt.

OpenAPI-Tools

Rund um die OpenAPI-Spezifikation gibt es eine Vielzahl von Werkzeugen, die in verschiedenen Sprachen und mit unterschiedlichen Zielsetzungen entwickelt wurden. In diesem Artikel konzentrieren wir uns auf die automatische Generierung einer OpenAPI-Datei aus Rust-Code mit Utoipa und das Testen der generierten Datei mit Schemathesis. Es lohnt sich, openapi.tools zu besuchen, um alternative oder ergänzende Werkzeuge für Ihr Projekt zu entdecken. Eine lobende Erwähnung als Auto-Generator-Alternative zu Utoipa wäre okapi, welches leider nur das Rocket Web Framework unterstützt.

Definition einer API mit Utoipa: Ein Beispiel mit Actix

Die Beispiele in diesem Artikel zeigen die Verwendung von Utoipa bei Identeco und bestehen aus vereinfachten Code-Schnipseln aus unserem Credential Check Service. Insbesondere werden wir uns den Endpunkt /check_credentials ansehen, der HTTP-POST-Anfragen mit einem JSON-Body annimmt. Obwohl der Credential Checker Server mit dem Actix Web-Framework gebaut wurde, ist es wichtig zu wissen, dass Utoipa framework-agnostisch ist und mehrere Beispiele und Extras für andere populäre Web-Frameworks bietet.

Allgemeine Informationen

Der Ausgangspunkt für die Erstellung unserer API-Definition ist ein leeres Struct mit dem Namen ApiDocs. Wir leiten das Makro utoipa::OpenApi ab, das uns erlaubt, allgemeine Informationen über die API zu definieren. Die Makrostruktur folgt der OpenAPI 3.0-Spezifikation und liefert hilfreiche Fehlermeldungen wie “unexpected attribute, expected any of: handlers, components, modifiers, security, tags, external_docs, servers”, falls man eine Orientierungshilfe braucht. Diejenigen, die Makros nicht übermäßig verwenden wollen, werden sich freuen zu lesen, dass man auch einen funktionalen Builder-Ansatz zur Laufzeit verwenden kann. Der Einfachheit halber werden wir uns in diesem Artikel auf die abgeleiteten Makros konzentrieren.

use utoipa::OpenApi;

#[derive(OpenApi)]
#[openapi(
    info(
        title = "Credential Check API definition",
        description = "Enables clients to anonymously check whether their credentials have been involved in a known data breach.",
        contact(
            name = "Identeco",
            email = "contact@identeco.de",
            url = "https://identeco.de"
        ),
        version = "1.0.2"
    ),
    servers(...),
    ...
)]
pub struct ApiDocs;

Request- und Response Schemata

Spezielle Kryptographie ist notwendig, um die Sicherheit und Anonymität der Anmeldedaten bei Überprüfung auf Kompromittierung zu gewährleisten. In unserem vereinfachten Beispiel muss der Benutzer ein kurzes Präfix der gehashten E-Mail zusammen mit den cryptographisch behandelten Anmeldedaten angeben. Da uns die Privatsphäre unserer Nutzer wichtig ist, entscheiden wir uns dazu, die k-Anonymität ihrer Anmeldedaten zu wahren, indem wir die Länge des bereitgestellten Präfixes auf maximal 6 Zeichen begrenzen. Für die Modellierung unserer Request und Response Typen benutzen wir das utoipa::ToSchema-Macro, das Rusts Doc-Kommentare wiederverwendet, um die Beschreibungen für unsere Typen und Felder zu erzeugen und uns durch das #[schema(...)] Makro weiter Kontrolle über OpenAPI Attribute gibt.

use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

/// The request body for the `check_credentials` endpoint.
#[derive(ToSchema, Debug, Serialize, Deserialize)]
struct CcRequest {
    /// Prefix of the hashed email
    #[schema(format = "base64", example = "cri4", min_length = 4, max_length = 6)]
    pub prefix: String,

    /// Cryptographically treated email-password combination
    #[schema(format = "base64", example = "02a8902230d79486d10ec6eb6")]
    pub credentials: String,
}

/// The response body for the `check_credentials` endpoint.
#[derive(ToSchema, Debug, Serialize, Deserialize)]
struct CcResponse {
    // ...
}

Die Spezifikation von Formaten und Einschränkungen von Feldern, sowie das Bereitstellen von Beispielen dient hierbei nicht nur zur Dokumentation, sondern hat später auch einen praktischen Nutzen bei der Validierung unserer API mit anderen OpenAPI-Werkzeugen. Jetzt können wir die Schemata in unseren ApiDocs Struct registrieren:

#[derive(OpenApi)]
#[openapi(
    ...
    components(schemas(CcRequest, CcResponse)),
)]
pub struct ApiDocs;

Definition von Routen

Als nächstes müssen wir die Routen für unsere API definieren. Dies geschieht mit dem Makro #[utoipa::path] über jeder Funktion, die als Endpunkt für Ihr Web-Framework dient. Es gilt zu beachten, dass unsere Beispiele Actix's post Makro benutzen um den Endpunkt als HTTP Post Methode zu definieren. Utoipa ist mit diesen Macros Kompatibel, wenn das Feature Flag actix_extras aktiviert ist; sollte es keine Plugins für ihr Web-Framework geben, können die Endpunkte auch manuell über die post und path Attribute innerhalb des #[utoipa::path] Makros definiert werden. Wie wir bereits mit dem ToSchema Makro gesehen haben, werden die Doc-Kommentare von Rust verwendet, die Beschreibung unseres Endpunktes zu generieren.

use actix_web::{http::StatusCode, post, web::Json, ResponseError};

/// Checks whether an email-password combination is known to been involved in a data breach.
#[utoipa::path(
    request_body = CcRequest,
    responses((
        status = OK,
        body = CcResponse
    ),(
        status = BAD_REQUEST,
        description = "The request body contained ill formatted values",
        body = String,
        examples(
            ("TooLong" = (value = 
                json!(ApiError::PrefixTooLong { len: 7 }.to_string()))
            ),
        ),
    )),
)]
#[post("/check_credentials")]
async fn check_credentials(
    body: Json<CcRequest>
) -> Result<Json<CcResponse>, ApiError> {
    // ...
}

Zunächst weisen wir die Request- und Response-Schemata dem Endpunkt check_credentials zu. Zukünftig sollen diese auch automatisch abgeleitet werden können. Zusätzlich zu einer Response im Falle eines HTTP OK Status-Codes fügen wir auch die möglichen Fehlerantworten mit Beispielen hinzu. Hier kapselt ApiError alle Fehler, die Teil der öffentlichen API sind, an einem Ort:

#[derive(Debug, thiserror::Error)]
enum ApiError {
    #[error("Prefix has a length of {len}, but the maximum is 6")]
    PrefixTooLong { len: usize },
}

/// Used in actix web to convert to an HTTP response.
impl ResponseError for ApiError {
    fn status_code(&self) -> StatusCode {
        match *self {
            ApiError::PrefixTooLong { .. } => StatusCode::BAD_REQUEST,
        }
    }
    fn error_response(&self) -> HttpResponse {
        HttpResponse::build(self.status_code())
            .content_type("text/plain")
            .body(self.to_string())
    }
}

Wir verwenden thiserror um die Definition unserer Error-Typen zu vereinfachen und implementieren das ResponseError Trait von Actix Web, um die Fehler in HTTP-Antworten zu konvertieren. Dank des json! Attributs in den Beispielen können wir die Fehlerbeschreibungen wiederverwenden, anstatt sie für jede Route, in der sie verwendet wird, manuell neu schreiben zu müssen. Dadurch haben wir eine weiterer Ebene der Validierung, da unsere API Dokumentation immer garantiert sein synchron mit dem Code istö.

Ähnlich wie bei den Schemata fügen wir auch die Routen zu unseren ApiDocs hinzu:

#[derive(OpenApi)]
#[openapi(
    ...
    paths(check_credentials)
)]
pub struct ApiDocs;

Benutzerdefiniertes Sicherheitsschema

Wir auch in der OpenAPI Spezifikation, wird das SecurityScheme Attribut verwendet, um die Authentifizierung für die API zu definieren. Während vordefinierte Methoden wie OAuth oder HttpAuth direkt verwendet werden können, ist es mut Utoipa auch möglich, benutzerdefinierte Authentifizierungsschemata zu definieren. Diese müssen jedoch zur Laufzeit mittels des Modify Traits wie folgt definiert werden:

use utoipa::{
    openapi::security::{ApiKey, ApiKeyValue, SecurityScheme},
    Modify, OpenApi,
};

/// Custom security schemas for the API.
struct SecuritySchemas;

impl Modify for SecuritySchemas {
    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
        let components = openapi.components.as_mut().unwrap();
        let value = ApiKeyValue::with_description(
            "My-Api-Key", 
            "Custom authentication header"
        );
        let scheme = SecurityScheme::ApiKey(ApiKey::Header(value));
        components.add_security_scheme("My-Api-Key", scheme);
    }
}

#[derive(OpenApi)]
#[openapi(
    ...
    security(("My-Api-Key" = [])),
    modifiers(&SecuritySchemas),
)]
pub struct ApiDocs;

Und damit sind wir fertig mit der Definition unserer API! 🎉

Dokumentation als Swagger-UI bereitstellen

Vielleicht möchten Sie noch einen Schritt weiter gehen und den Benutzern die Möglichkeit geben, Ihre API-Definition direkt auf Ihrem Server anzuzeigen. Hierfür bietet sich utoipa_swagger_ui als einfaches Plug-in für verschiedene Web-Frameworks an.

Erstellen und Validieren der API-Dokumentation

Nun, da wir unsere API im Code definiert haben, was ist der nächste Schritt? Wir sollten die Vorteile unserer Arbeit nutzen, indem wir eine Datei openapi.yml erstellen und sie automatisch in unserer CI-Pipeline validieren.

Dateierzeugung

Wir können eine OpenAPI-Datei automatisch generieren, indem wir ein kleines Hilfsprogramm wie src/bin/gen_api.rs verwenden:

use identeco_utoipa_example::ApiDocs;

fn main() {
    let content = ApiDocs::generate();
    std::fs::write("openapi.yml", content).unwrap();
}

Mit einem einfachen Befehl, cargo run --bin gen_api (oder einem beliebigen Namen, den Sie für Ihr Binary gewählt haben), können wir eine aktuelle openapi.yml-Datei für unseren Server erzeugen. Damit müssen wir die gleiche Dokumentation nicht mehr zweimal in verschiedenen Formaten schreiben.

Es kann jedoch Situationen geben, in denen ein Entwickler den Server modifiziert und vergisst, die Datei zu generieren, oder schlimmer noch, nicht erkennt, dass seine Änderungen die API unbrauchbar machen.

Pflege und Aktualisierung der OpenAPI-Datei

Um sicherzustellen, dass die generierte Datei openapi.yml im Repository immer aktuell gehalten wird, wenn es Änderungen am Code gibt, können wir einen Test zur CI-Pipeline hinzufügen. Dieser Test erfordert, dass die Entwickler den Build-Schritt ausführen und bestätigen, bevor ihre Änderungen übernommen werden können. Auf diese Weise bleibt die Datei auf dem neuesten Stand und übersehene problematische Änderungen an der API (z. B. die Umbenennung einer Variablen in einem Anforderungstyp) können von den Reviewern leicht erkannt werden.

Sie können diesen Test als separaten Job in Ihr CI integrieren oder ihn in Rust implementieren, um ihn neben cargo test wie folgt auszuführen:

#[test]
fn generated_docs_are_up_to_date() {
    let path = "openapi.yml";
    let current = std::fs::read_to_string(path)
        .expect("The current openapi file must exist");
    let newest = ApiDocs::generate();

    assert_eq!(
        newest, current,
        "
============================================================
NOTE: The generated `{path}` file is not up to date.
Please run `cargo run --bin gen_api` and commit the changes.
============================================================
"
    )
}

An dieser Stelle können pre-commit hooks oder ein benutzerdefiniertes build script hilfreich sein, um den Build-Schritt automatisch durchzuführen.

Validierung mit Schemathese

Die Erstellung von Dokumentation aus Code ist hilfreich in der Praxis, garantiert aber keine absolute Korrektheit. Auch wenn wir bereits eine große Kategorie von möglichen Fehlern mittels Rusts starken Typsystem ausschließen können, kann es immer noch passieren, dass wir die falschen Typen für OpenAPI Details zuordnen oder invalide Beispielwerte angeben. Um solche Probleme zu vermeiden, müssen wir die tatsächlichen Antworten eines Servers mit der Dokumentation abgleichen. Zum Glück gibt es viele verschiedene Werkzeuge, die eine OpenAPI Datei gegen einen laufenden Server testen können. Wir schauen uns hier Schemathesis and, aber Sie können natürlich auch andere Alternativen in Betracht ziehen.

Schemathesis generiert Testfälle auf der Grundlage des OpenAPI-Schemas, indem es eigenschaftsbasierte Tests und verschiedene Strategien zur Erzeugung von Eingaben verwendet. Hier machen es unsere zuvor erwähnten detaillierten Beispiele und Formateinschränkungen einfacher, gültige und ungültige Testfälle zu erzeugen. Mit der Hilfe von Schemathesis hat Identeco’s CI-Pipeline bereits einen schwerwiegenden Fehler abfangen können bevor er in Produktion ging. Der Fehler trat bei einem großem Code-Refactoring auf, bei dem der Response-Typ des check_credentials Endpunktes geändert wurde, aber dessen OpenAPI Definition nicht aktualisiert wurde. Dadurch hätte übersehen werden können, dass die Response mit anderen Feldern serialisiert wurde, was die API für die Kunden unbrauchbar gemacht hätte und einen sofortigen Hotfix erfordert hätte. Schemathesis hat diesen Fehler allerdings frühzeitig erkannt.

CI Pipeline Integration

Um Schemathesis in Ihrer Pipeline laufen zu lassen, können Sie einen Testjob hinzufügen, der einen Dummy-Server startet und die Tests gegen diesen laufen lässt. Es ist wichtig, dass die Einrichtung des Dummy-Servers mit der des Produktionsservers übereinstimmt. Für den Credential Checker von Identeco simulieren wir einfach die Datenbankverbindungen im Speicher und fügen einen gültigen API-Schlüssel für den Test hinzu. Ein Beispielauftrag für die Gitlab CI-Pipeline könnte so aussehen:

test-openapi-spec:
  image: rust:latest
  stage: test
  before_script:
    - apt-get update && apt-get install -y wait-for-it python3 python3-pip
    - pip3 install schemathesis
  script:
    # Build the binary in the foreground
    - cargo build --bin server
    # Start the server (or a dummy) in the background
    - cargo run --bin server &
    # Wait for the server to be ready
    - wait-for-it localhost:8080 --timeout=30
    # Run the `schemathesis` tests
    - >
      st run openapi.yml \
        --checks all \
        --data-generation-method all \
        --validate-schema true \
        --base-url http://localhost:8080 \
        -H "My-Api-Key: 123456"      
    # Kill the server to free the address
    - kill %%

Fazit

In diesem Artikel haben wir untersucht, wie Utoipa und Schemathesis verwendet werden können, um automatisch API-Dokumentation direkt aus Rust-Code zu generieren und zu validieren. Anhand eines realen Beispiels aus dem Credential Check Service von Identeco haben wir gezeigt, wie diese Werkzeuge nahtlos in Rust-Projekte und CI-Pipelines integriert werden können.

Unter Verwendung von Utoipa haben wir gezeigt, wie man eine API definiert, Routen annotiert und benutzerdefinierte Sicherheitsschemata verwendet. Außerdem lernten wir, wie man eine OpenAPI-Datei generiert und validiert, um sicherzustellen, dass sie aktuell und fehlerfrei bleibt.

Mit Schemathesis haben wir demonstrieren können, wie wichtig es ist, unsere API-Dokumentation anhand von Beispielen aus der Praxis zu validieren, um Fehler zu finden, die andernfalls unbemerkt geblieben wären.

Durch den Einsatz dieser leistungsstarken Tools können Sie jetzt Ihren API-Dokumentationsprozess optimieren, die Zuverlässigkeit verbessern und die Wahrscheinlichkeit verringern, dass Fehler in die Produktion einfließen. Dies führt letztendlich zu einer besseren Erfahrung sowohl für Entwickler als auch für Benutzer Ihrer API, während Sie gleichzeitig wertvolle Zeit und Mühe bei der Pflege einer genauen und aktuellen Dokumentation sparen.

Experten kontaktieren

Sie haben weitere Fragen zum Thema oder benötigen konkrete Hilfe? Schreiben Sie uns eine Nachricht oder vereinbaren Sie direkt einen Termin für ein Gespräch.

Weiterlesen

Zum Blog