FastAPI applikasjon med ekstra markdown dokumentasjon

· 635 ord · 3 min å lese

Har du et WebSocket-endepunkt i en FastAPI-applikasjon du gjerne skulle dokumentert? Her er en løsning som genererer dokumentasjon fra Markdown.

Som regel er den automatisk genererte OpenAPI-dokumentasjonen av FastAPI tilstrekkelig dokumentasjon for HTTP-endepunktene i applikasjonen vår. WebSocket-endepunktene lar seg derimot ikke dokumentere like lett. Og hva med mer komplekse endepunkt som gjerne behøver konkrete kodeeksempler?

Jo, her er det tydelig at noe mer dokumentasjon må på plass.


Koden fra dette innlegget er tilgjengelig på repoet endrekrohn/fastapi-with-md-docs 🧑‍💻

Forbehold 🔗

Ved større prosjekt kan det gi mening å gå direkte til et tyngre verktøy som MkDocs, Docusaurus eller AsyncAPI (for WebSockets). I mitt tilfelle ville jeg derimot unngå å introdusere enda en tjeneste før absolutt nødvendig. Det var også viktig at dette valget ikke påvirket en senere overgang.

Løsningen 🔗

Den endelige løsningen ble å servere HTML fra FastAPI ved hjelp av følgende Python-bibliotek:

  • markdown2 for å lese og konvertere markdown-filer til HTML.
  • Jinja2 for å rendre HTML-responser.
  • Pygments for syntaksutheving.
poetry add markdown2 jinja2 pygments
pip install markdown2 jinja2 pygments

Struktur 🔗

Koden er strukturert på følgende vis.

/application
    /code
        main.py
        docs.py
        config.py
    /docs
        template.html
        **/*.md
    /static
        styles.css

Alle dokumentasjonsfiler legges her i mappen docs. Disse kan videre nøstes i egne undermapper (eksempelvis /docs/feature-1/endpoint-1/client.md). Dette er gjort for å helt smertefritt kunne konvertere til MkDocs senere.

Lesing av fil 🔗

For å lese markdown-filene inn i Python bruker vi os fra standardbiblioteket, og utløser en 404-respons dersom filen vi etterspør ikke finnes.

def get_path(filename: str) -> str:
    return os.getcwd() + f"/docs/{filename}"

def get_md(filepath: str) -> str:
    if not os.path.exists(filepath):
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Could not find file."
        )
    with open(filepath) as md:
        return md.read()

Konvertering av markdown 🔗

Videre ønsker vi å konvertere strengen med markdown til HTML ved hjelp av markdown2-pakken. Her har jeg laget en dataklasse kalt HTML for å gjøre lesbarheten noe bedre.

@dataclass()
class HTML:
    html: Any
    toc: Any
    metadata: dict[str, Any]

def md_to_html(md: str) -> HTML:
    data = markdown2.markdown(
        md, extras=["fenced-code-blocks", "header-ids", "metadata", "toc"]
    )
    return HTML(html=data, toc=data.toc_html, metadata=data.metadata)

Legg merke til at vi også legger inn noen extras. Kort forklart:

  • fenced-code-blocks gir mulighet for å skrive kodeblokker med backticks.
  • header-ids legger inn et id-attributt på overskriftene våre.
  • metadata henter ut ekstra informasjon, også kjent som frontmatter.
  • toc lager en innholdsfortegnelse (table of contents).

Deretter bruker vi Jinja2-templates til å omslutte den genererte HTMLen fra markdown2. Dette gjør vi for å kunne påføre stiler, samt ha en felles navigasjon. Malen template.html er tilgjengelig på repoet.

def get_html(md: str) -> str:
    try:
        data = md_to_html(md)
        with open(get_path("template.html")) as file_:
            return jinja2.Template(file_.read()).render(
                title=data.metadata.get("title", None),
                toc=data.toc,
                content=data.html,
            )
    except Exception:
        raise HTTPException(
            status.HTTP_424_FAILED_DEPENDENCY, detail="Could not hydrate HTML."
        )

Endepunktet 🔗

Til slutt kan vi bruke funksjonene i endepunktet i FastAPI. Legg merke til path-parameteret med type path for å kunne matche alle stier.

@router.get("/docs/{filepath:path}", response_class=HTMLResponse)
def get_extra_doc(filepath: str) -> str:
    return get_html(get_md(get_path(f"{filepath}.md")))

Caching 🔗

I og med at vi leser og konverterer statiske filer kan det gi mening å implementere en basic form for caching. Legg merke til at caching er skrudd av under utvikling.

import functools

def cache(func, /):
    if settings.IS_DEV:
        return func
    return functools.cache(func)

@cache
def get_path(filename: str) -> str:
    ...

@cache
def get_md(filepath: str) -> str:
    ...

@cache
def get_html(md: str) -> str:
    ...

Uthevet kodesyntaks 🔗

For å påføre styling på kodeblokkene våre er vi avhengig av egen CSS. Denne CSS-filen kan vi servere ved hjelp av FastAPIs statiske mounts.

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")

Videre må vi sette denne som stylesheet i Jinja2-malen vår template.html.

<link rel="stylesheet" type="text/css" href="/static/styles.css" />

Kodeuthevingen er generert ved hjelp av pygments. Du kan fint endre denne selv så lenge du beholder klassenavnene.

Løsningen i praksis 🔗

Med endepunktet for å servere dokumentasjonen på plass kan vi bruke funksjonaliteten i andre endepunkt. Jeg foretrekker å linke til dokumentasjonen direkte i docsstringen:

@app.get("/health", tags=["Status"])
def healthcheck():
    """
    This route has additional written documentation at:

    - 📄 [Health](/docs/health)
    """
    return "ok"

Om dokumentasjonen angår en hel OpenAPI-tag, kan du også bruke url-attributtet i externalDocs.

fastapi-with-md-docs