Skip to content

1. Your first API

Beginner · 8 min read

An API is a set of URLs that return data instead of web pages. With FastAPI each URL is a normal Python function with a decorator on top.

1.1 Hello, API

Save this as main.py:

from fastapi import FastAPI

app = FastAPI(title="GenAI API")

@app.get("/health")
def health():
    return {"status": "ok"}

Run it:

fastapi dev main.py                 # development: auto-reload on save
# or
uvicorn main:app --reload           # the same, with uvicorn directly

Open http://127.0.0.1:8000/health → {"status":"ok"}. Whatever you return (dict, list, Pydantic model) is converted to JSON.

Free interactive docs

Open http://127.0.0.1:8000/docs. FastAPI lists every endpoint with a Try it out button — no Postman needed. The raw OpenAPI spec is at /openapi.json.

1.2 Test without a server — TestClient

TestClient calls your app directly in Python. These notes use it to show real responses:

from fastapi.testclient import TestClient

client = TestClient(app)
r = client.get("/health")
print(r.status_code, r.json())
Output
200 {'status': 'ok'}

1.3 Path parameters

Part of the URL is a variable. The type hint converts and validates it:

@app.get("/documents/{doc_id}")
def get_document(doc_id: int):
    return {"doc_id": doc_id, "type": type(doc_id).__name__}

print(client.get("/documents/42").json())
r = client.get("/documents/abc")
print(r.status_code, r.json()["detail"][0]["msg"])
Output
{'doc_id': 42, 'type': 'int'}
422 Input should be a valid integer, unable to parse string as an integer

422 Unprocessable Content = "your request is the wrong shape". FastAPI sends it automatically — your function never runs.

1.4 Query parameters

Function arguments that are not in the path come from the query string (?key=value):

@app.get("/search")
def search(q: str, top_k: int = 5, namespace: str | None = None):
    return {"q": q, "top_k": top_k, "namespace": namespace}

print(client.get("/search", params={"q": "refund policy"}).json())
print(client.get("/search?q=refund&top_k=3&namespace=support").json())
print(client.get("/search").status_code)            # q is required
Output
{'q': 'refund policy', 'top_k': 5, 'namespace': None}
{'q': 'refund', 'top_k': 3, 'namespace': 'support'}
422

Add limits with Query, the same way you use Field in Pydantic:

from typing import Annotated
from fastapi import Query

@app.get("/v2/search")
def search_v2(q: Annotated[str, Query(min_length=2)], top_k: Annotated[int, Query(ge=1, le=20)] = 5):
    return {"q": q, "top_k": top_k}

print(client.get("/v2/search", params={"q": "rag", "top_k": 50}).json()["detail"][0]["msg"])
Output
Input should be less than or equal to 20

1.5 HTTP methods

Method Use it to Decorator
GET read data (search, fetch a document) @app.get
POST create or run something (chat, ingest, embed) @app.post
PUT / PATCH replace / partly update @app.put / @app.patch
DELETE remove (a document from the index) @app.delete

LLM calls are almost always POST: the input is large, and it isn't a simple read.

@app.post("/echo")
def echo(payload: dict):
    return {"you_sent": payload}

print(client.post("/echo", json={"message": "hi"}).json())
Output
{'you_sent': {'message': 'hi'}}

A plain dict body accepts anything. In the next topic you replace it with a Pydantic model.

1.6 Organising routes — APIRouter

Big apps split routes across files and plug them in with a prefix:

from fastapi import APIRouter

docs_router = APIRouter(prefix="/v1/docs", tags=["documents"])

@docs_router.get("/")
def list_docs():
    return ["policy.pdf", "faq.pdf"]

app.include_router(docs_router)
print(client.get("/v1/docs/").json())
Output
['policy.pdf', 'faq.pdf']

tags group the endpoints on the /docs page.

Practice

  • Add GET /models that returns a list of model names you support.
  • Add GET /documents/{doc_id}/chunks?limit=10 with limit between 1 and 100.

Next: Request & response models — validated JSON in and out.