FastAPI-Sprint 2024, Freiburg

  • Was Agile Entwciklung Data Science Data Engineering Python Training Featured
  • Wann 03.04.2024 bis 05.04.2024 (Europe/Berlin / UTC200)
  • Wo Kreativpark Lokhalle Freiburg, Paul-Ehrlich-Straße 5-13, 79106 Freiburg im Breisgau
  • Name des Kontakts
  • Telefon des Kontakts +49 30 22430082
  • Teilnehmer Veit Schiele Kristian Rother Tim Weber Frank Hofmann
  • Termin zum Kalender hinzufügen iCal

FastAPI ist ein Web-Framework zum Erstellen von APIs mit auf Python 3.6+ basierenden Type-Hints.

Die Hauptmerkmale sind:

  • sehr hohe Leistung dank pydantic für den Datenteil und Starlette für den Web-Teil
  • schnell und einfach zu programmieren
  • Validierung für die meisten Python-Datentypen
  • robuster, produktionsreifer Code mit automatischer interaktiver Dokumentation
  • basierend auf den offenen Standards für APIs: OpenAPI (früher bekannt als Swagger) und JSON Schema.

Schneller Start am ersten Tag

Schon am ersten Sprint-Tag machte sich bemerkbar, wie schnell sich mit FastAPI eine Web-Anwendung erstellen lässt. Nach kürzester Zeit waren die ersten Prototypen fertiggestellt.

Testen der API

Nicht viel länger dauerte es, um sie mit der TestClient-Klasse von FastAPI Tests zu schreiben:

from fastapi.testclient import TestClient

from fastapi_htmx.main import app


def test_items():
    client = TestClient(app)
    response = client.get('/items')
    assert response.status_code == 200
    j = response.json()
    assert len(j) == 3
    assert j[0]["name"] == "Alpha"

Web-Frontend

Als nächstes schauten wir uns htmx an um ein Web-Frontend für FastAPI bereitstellen zu können. So schnell und einfach hatten wir schon lange keine interaktive Web-Anwendung gebaut. Zunächst war es auch nur eine einzelne statische HTML-Datei:

<!DOCTYPE html>
<html>
    <head>
        <script src="https://unpkg.com/htmx.org@1.9.11"></script>
        <script src="https://unpkg.com/nunjucks@3.2.4/browser/nunjucks.min.js"></script>
        <script src="https://unpkg.com/htmx.org@1.9.11/dist/ext/client-side-templates.js"></script>
    <body>
        <h1>Hello World!</h1>
        <p>Items:</p>
        <div
            hx-ext="client-side-templates"
            hx-get="/items"
            hx-trigger="load"
            hx-target="#items"
            nunjucks-array-template="render-items"
        >
            <div id="items"></div>
            <template id="render-items">
                <ul>
                    {% for item in data %}
                    <li>{{ item.name }}</li>
                    {% endfor %}
                </ul>
            </template>
        </div>
    </body>
</html>

In diesem Beispiel wird mit der htmx-Erweiterung client-side-templates das Rendering der Seite im Browser unterstützt.

Automatische Tests für das Web-Frontend

Auch für dieses Web-Frontend schrieben wir Tests, diesmal jedoch mit Playwright, z.B.:

from playwright.sync_api import sync_playwright


def test_frontend_local():
    with sync_playwright() as p:
        browser = p.firefox.launch(headless=True)
        context = browser.new_context()
        page = browser.new_page()
        page.goto("http://localhost:8000/")
        page.locator("#item_summary").click()
        page.locator("#item_summary").fill("Some item")
        page.locator("input[name=\"owner\"]").click()
        page.locator("input[name=\"owner\"]").fill("Kristian Rother")
        page.get_by_role("button", name="Add").click()

        html = page.inner_html('#item')
        assert "Summary: Some item" in html
        assert "Owner: Kristian Rother" in html

        context.close()
        browser.close()

Playwright bietet auch ein pytest-Plugin, mit dem sich Test-Fixtures erstellen lassen um in einem Test auch mehrere Browser testen zu können. Werden die Tests mit headless=False aufgerufen, können sie auch gut zur Fehlersuche verwendet werden.

Dynamisches Routing

Zum Abschluss des ersten Tages wollten wir sowohl die API wie auch die HTML-Seiten über dieselbe URL ausliefern. Leider konnten wir hier nicht eine einfache Middleware verwenden, da wir uns in die Anfrage/Antwort-Bearbeitung von FastAPI einklinken wollten um automatisch eine Vorlage auszuwählen zu können, die auf der Pfad-Funktion basiert, die die Anfrage bearbeitet hat:

def get_template_render_route_class(template_dir: str) -> type:
    """Return APIRoute subclass that renders HTML templates from JSON.

    This is a function that returns a class, mainly because we'd like to be able
    to customize the template_dir, but we don't call __init__() on the class
    ourself, FastAPI does it.
    """

    class RenderRoute(APIRoute):

        # This method will be called on startup to get the actual handler.
        def get_route_handler(self) -> Callable:
            # Get the handler from the parent class.
            original = super().get_route_handler()

            # Set up Jinja to be able to render templates.
            jinja_env = Environment(
                loader=FileSystemLoader(template_dir),
            )

            def is_hx(request: Request) -> bool:
                """Check whether the request is sent by HTMX."""
                # HTMX always sets this header, see
                # <https://htmx.org/docs/#request-headers>.
                return request.headers.get("HX-Request", "") == "true"

            def wants_html(request: Request) -> bool:
                """Check whether the client requested HTML."""
                # This is just a proof of concept, more solid `Accept` header
                # parsing would be required.
                return request.headers.get("Accept", "") == "text/html"

            def should_render(request: Request) -> bool:
                """Check whether this request should be rendered as HTML."""
                return is_hx(request) or wants_html(request)

            def get_template(endpoint: Callable|None) -> Template|None:
                """Get Jinja template instance for a given path operation."""
                if endpoint is None:
                    # We don't know the function that handled this request,
                    # therefore we can't select a corresponding template.
                    return None

                # Simply add `.html` to the function name and try to look this
                # up as a template file.
                try:
                    return jinja_env.get_template(f"{endpoint.__name__}.html")
                except TemplateNotFound:
                    return None

            # This is the actual function that is called on every request.
            async def route_handler(request: Request) -> Response:
                # Handle the request and get back a response.
                response = await original(request)

                if (
                    # rendering as HTML makes sense
                    should_render(request)
                    # the response is actually JSON that we can work with
                    and isinstance(response, PreserveJSONResponse)
                    # we have a template available
                    and (template := get_template(self.dependant.call))
                ):
                    # Render the Jinja template and return as HTML response.
                    return HTMLResponse(template.render(
                        data=response.original_data,
                    ))

                # If anything failed or the requirements were not met, return
                # the original response.
                return response

            # Provide FastAPI with the route handler.
            return route_handler

    return RenderRoute

In der RenderRoute-Klasse definieren wir einen Route-Handler, der sich in das request/response-Handling von FastAPI einklinkt. Damit kommen wir in die Lage, ein Jinja2-Template auf Basis der Pfad-Informationen auszuwählen. Dies wurde erforderlich, da eine einfache Middleware keinen Zugriff auf diese Informationen hätte. Zudem wird im HTML-Header nachgeschaut, ob er HX-Request oder Accept: text/html enthält um anschließend ein Template mit dem Namen des Endpunkts und dem .html-Suffix auszuwählen.

Dieser Mechanismus erlaubt uns auch, über dieselbe URL die Daten in unterschiedlichen Serialisierungsformaten (z.B. JSON, XML oder csv) bereitzustellen.