FastAPI Sprint, Freiburg 2024

  • What Data Engineering Data Science Python Training Featured
  • When Apr 03, 2024 to Apr 05, 2024 (Europe/Berlin / UTC200)
  • Where Kreativpark Lokhalle Freiburg, Paul-Ehrlich-Straße 5-13, 79106 Freiburg im Breisgau
  • Contact Name
  • Contact Phone +49 30 22430082
  • Attendees Veit Schiele Kristian Rother Tim Weber Frank Hofmann
  • Add event to calendar iCal

FastAPI is a web framework for building APIs with Python 3.6+ based type hints.

Key features are:

  • very high performance thanks to pydantic for the data part and Starlette for the web part.
  • fast and easy to code
  • validation for most Python data types
  • robust, production-ready code with automatic interactive documentation
  • based on the open standards for APIs: OpenAPI formerly known as Swagger) and JSON Schema.

Fast start on the first day

On the very first sprint day, it became clear how quickly a web application can be created with FastAPI. The first prototypes were completed in no time at all.

Testing the API

It didn’t take much longer to write tests with FastAPI’s TestClient class:

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

Next we looked at htmx to provide a web frontend for FastAPI. We hadn’t built an interactive web application this quickly and easily for a long time. At first it was just a single static HTML file:

<!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 this example, the htmx extension client-side-templates supports the rendering of the page in the browser.

Automatic tests for the web frontend

We also wrote tests for this web frontend, but this time with Playwright, for example:

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 also offers a pytest-Plugin that can be used to create Test-Fixtures to test multiple browsers in one test. If the tests are called with headless=False, they can also be used for debugging.

Dynamic routing

At the end of the first day, we wanted to deliver both the API and the HTML pages via the same URL. Unfortunately, we couldn’t use simple middleware here as we wanted to hook into FastAPI’s request/response handling to automatically select a template based on the path function that handled the request:

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 the RenderRoute class, we define a route handler that links into the request/response handling of FastAPI. This enables us to select a Jinja2 based on the path information. In addition, the HTML header is checked to see whether it contains HX-Request or Accept: text/html in order to then select a template with the name of the endpoint and the .html suffix.

This mechanism also allows us to provide the data in different serialisation formats (for example JSON, XML or csv) via the same URL.