Skip to content

FastAPI

FastAPI is a modern, high-performance web framework for building APIs with Python 3.8+ based on standard Python type hints. This guide covers setting up your environment, creating endpoints, and understanding how FastAPI documents your code automatically.


Introduction

πŸ› οΈ Setting Up Your Environment

Before writing code, ensure your Python environment is ready. It is best practice to use a virtual environment to manage dependencies.

  1. Create and activate a virtual environment:
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate
  1. Install FastAPI and Uvicorn: Uvicorn is the ASGI server that will serve your FastAPI application.
pip install "fastapi[all]"

πŸš€ Creating Your First Endpoint

To define an API endpoint, you use a decorator (like @app.get()) to associate a URL path with a Python function.

Basic GET Endpoint

Here is how you define a GET endpoint that returns shipment data:

from fastapi import FastAPI

app = FastAPI()

@app.get("/items")
async def get_items():
    return [
        {"id": 101, "item": "Keyboard", "status": "shipped"},
        {"id": 102, "item": "Monitor", "status": "pending"}
    ]

Relative Paths

Notice you do not add the full URL (like http://localhost...) to the decorator. You provide the relative path (e.g., /items).


πŸ§ͺ Testing Your API

Once your code is written in a file (e.g., main.py), you need to run the server to test it.

  1. Run the server:
uvicorn main:app --reload

--reload makes the server restart automatically when you change your code. 2. Test via Browser: Open your browser and navigate to http://127.0.0.1:8000/items. You will see the JSON data returned. 3. Test via Terminal:

curl -X GET "http://127.0.0.1:8000/items"

Automated Testing with TestClient

FastAPI provides a powerful TestClient (built on top of HTTPX) that allows you to write automated integration tests using Pytest. Instead of spinning up a live server, the TestClient invokes your FastAPI application directly in-memory, making tests extremely fast and reliable.

from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_read_items():
    response = client.get("/items")
    assert response.status_code == 200
    assert response.json() == [
        {"id": 101, "item": "Keyboard", "status": "shipped"},
        {"id": 102, "item": "Monitor", "status": "pending"}
    ]

Using TestClient ensures your routing logic, dependency injection, and endpoint functions are all tested together exactly as they would run in production.


πŸ“– Understanding Automatic API Documentation

One of FastAPI's most powerful features is that it follows the OpenAPI standard to generate documentation automatically.

  • Swagger UI: Accessible at http://127.0.0.1:8000/docs. It provides an interactive interface to "Try it out" and call your endpoints directly from the browser.
  • ReDoc: Accessible at http://127.0.0.1:8000/redoc. It provides a clean, organized alternative view of your API structure.

πŸ“ Path Parameters

Path parameters are segments of the URL that act as variables. They are used to find specific information based on a location or ID.

Static Path

A static path is fixed and cannot be changed.

@app.get("/items")
async def read_items():
    return [{"name": "Item 1"}, {"name": "Item 2"}]
  • Test: curl -X GET "http://localhost:8000/items"

Dynamic Path

A dynamic path uses curly braces {} to capture values from the URL.

@app.get("/items/{item_id}")
async def read_item(item_id: str):
    return {"item_id": item_id}
  • Test: curl -X GET "http://localhost:8000/items/computer"
  • Result: The API returns {"item_id": "computer"}.

⚠️ The "Order Matters" Rule

FastAPI matches routes from top to bottom. If a dynamic path is defined before a static path that looks similar, the static path will be "shadowed" and never execute.

Always place static paths before dynamic paths:

# 1. Specific Static Path (Runs first)
@app.get("/items/favorite-item")
async def read_favorite_item():
    return {"item": "This is the favorite!"}

# 2. Generic Dynamic Path (Runs as a fallback)
@app.get("/items/{item_id}")
async def read_item(item_id: str):
    return {"item_id": item_id}

πŸ”€ Modular Routing & Architectural Scaling

As a backend application grows, placing all endpoints within a single main.py file creates a monolithic bottleneck that degrades code readability and developer experience. FastAPI resolves this via APIRouter modules.

APIRouter Architecture

Each subdirectory or resource area (e.g., website views, API endpoints) gets its own isolated router file.

# fastapi-app/routes/website/pages_route.py
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from config.templates import templates

router = APIRouter()  # Decoupled router instance

@router.get("/", response_class=HTMLResponse)
async def root(request: Request) -> HTMLResponse:
    return templates.TemplateResponse(request, "website/homepage.html")

In the main entrypoint (main.py), you simply import and mount the routers:

# fastapi-app/main.py
from fastapi import FastAPI
from routes.website import pages_route

app = FastAPI()
app.include_router(pages_route.router)

Decoupling View Templates (Jinja2Templates)

When rendering web pages, views depend on a Jinja2Templates object. In smaller setups, this object is instantiated in main.py. However, importing it from main.py into router submodules causes circular imports:

  • main.py must import the routers to register them (app.include_router(...)).
  • The routers must import templates from main.py to call .TemplateResponse(...).

Circular Dependency Mitigation Pattern

To break this dependency loop, instantiate Jinja2Templates inside a third, independent configuration file (e.g. config/templates.py). Both main.py and your routers can safely import the shared template instance without spawning import loops.

Build-Time Serialization vs. Dynamic Scanning

When displaying dynamic graphs (like Zettelkasten networks), scanning the filesystem at runtime introduces severe performance costs and forces production containers to host raw Markdown notes.

A production-grade pattern compiles the Markdown folder structure locally via a build script (e.g. zettelkasten_graph.py) to a static JSON file (graph-data.json). The web router then serves this pre-serialized static file directly, ensuring zero filesystem latency in production.


πŸ’Ύ Database Portability & Dual-Engine Fallback

In a professional development lifecycle, requiring a full external database server (like PostgreSQL) for basic local tests or quick scripting degrades the developer experience. A more robust architecture implements a portable dual-engine database setup.

This design dynamically selects the database driver depending on the running environment: 1. Docker Environment (Dev/Prod): Injected environment variables like POSTGRES_HOST or DATABASE_URL trigger the application to establish a connection to a PostgreSQL database container via psycopg. 2. Local Environment (CLI): In the absence of PostgreSQL environment variables, the application transparently falls back to a zero-dependency SQLite database file (maavita.db).

Portability Adapter Implementation

The connection adapter lazily imports PostgreSQL dependencies only when needed, allowing SQLite environments to run without installing additional native binary database libraries:

import os
from typing import Any

conn: Any = None
curs: Any = None

def get_db(name: str | None = None, reset: bool = False) -> None:
    global conn, curs
    ...
    database_url = os.getenv("DATABASE_URL")
    postgres_host = os.getenv("POSTGRES_HOST")

    if database_url or postgres_host:
        # Lazy load psycopg for PostgreSQL environments
        import psycopg
        if database_url:
            conn = psycopg.connect(database_url)
        else:
            conn = psycopg.connect(host=postgres_host, ...)
    else:
        # Fall back to zero-dependency SQLite
        from sqlite3 import connect as sqlite_connect
        conn = sqlite_connect(name or "maavita.db")

This ensures maximum environment portability: local CLI actions (like testing or manual migrations) stay lightweight, while production deployments enjoy the full performance and scalability of PostgreSQL.


πŸ“š References


βš™οΈ Parameters

FastAPI allows various ways to send data to your endpoints, primarily through Path, Query, Request Body, and Headers.

URL / Path Parameters

These are variables that are part of the URL path, often used to identify a specific resource (like a user ID).

@app.get("/hello/{name}")
def hello(name: str):
 return f"Hello {name}"

In the URL: http://127.0.0.1:8000/hello/Jefferson

Query Parameters

Query parameters are attached after a ? in a URL and separated by & (e.g., /search?query=example&limit=10). They are typically used to filter data based on the URL provided.

@app.get("/hello/")
def hello(name: str):
 return f"Hello {name}"

In the URL: http://127.0.0.1:8000/hello/?name=Jefferson

Note: GET requests are meant to be idempotent and cacheable. They don’t usually have a body because caches use the URL (including query params) to store responses.

Request Body

For requests that modify data (like POST, PUT, or PATCH), the body can carry complex data in JSON or other formats.

from fastapi import Body

@app.post("/hello/")
def hello(name: str = Body(embed=True)):
 return {"message": f"Hello {name}"}

The embed=True parameter tells FastAPI to expect JSON formatted like {"name": "Jefferson"} rather than a raw string.

HTTP Header

You can also extract values directly from HTTP headers.

from fastapi import Header

@app.post("/members/")
def hello(name: str = Header()):
 return f"Hello {name}"