Build a REST API with FastAPI
Create the project
bunpy create --template minimal my-api
cd my-api
bunpy add fastapi uvicorn httpx pytest pytest-asyncioYour pyproject.toml now lists those dependencies. Run bunpy install any time you clone the project on a new machine.
Project layout
my-api/
server.py
pyproject.toml
uv.lock
tests/
test_items.pyDefine the models
FastAPI relies on Pydantic for request and response validation. Create server.py:
from __future__ import annotations
from typing import Optional
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
app = FastAPI(title="Items API", version="1.0.0")
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class Item(BaseModel):
id: int
name: str
description: Optional[str] = None
price: float
in_stock: bool = True
class CreateItemRequest(BaseModel):
name: str
description: Optional[str] = None
price: float
in_stock: bool = True
class UpdateItemRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None
in_stock: Optional[bool] = NoneIn-memory database and dependency injection
A real app would use SQLAlchemy or another ORM. Here we use a plain dict so the example runs without a database server, but the get_db dependency pattern is identical to what you would use in production.
# ---------------------------------------------------------------------------
# Fake database
# ---------------------------------------------------------------------------
_db: dict[int, Item] = {
1: Item(id=1, name="Laptop", price=999.00, description="14-inch, 16 GB RAM"),
2: Item(id=2, name="Mouse", price=29.99),
}
_next_id = 3
def get_db() -> dict[int, Item]:
"""Yield the shared in-memory store. Swap this for a real Session later."""
return _dbCRUD routes
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/items", response_model=list[Item])
def list_items(db: dict[int, Item] = Depends(get_db)):
return list(db.values())
@app.post("/items", response_model=Item, status_code=201)
def create_item(body: CreateItemRequest, db: dict[int, Item] = Depends(get_db)):
global _next_id
item = Item(id=_next_id, **body.model_dump())
db[_next_id] = item
_next_id += 1
return item
@app.get("/items/{item_id}", response_model=Item)
def get_item(item_id: int, db: dict[int, Item] = Depends(get_db)):
item = db.get(item_id)
if not item:
raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
return item
@app.patch("/items/{item_id}", response_model=Item)
def update_item(
item_id: int,
body: UpdateItemRequest,
db: dict[int, Item] = Depends(get_db),
):
item = db.get(item_id)
if not item:
raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
updated = item.model_copy(update=body.model_dump(exclude_unset=True))
db[item_id] = updated
return updated
@app.delete("/items/{item_id}", status_code=204)
def delete_item(item_id: int, db: dict[int, Item] = Depends(get_db)):
if item_id not in db:
raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
del db[item_id]Entry point
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)Run the server:
bunpy server.py
# Listening on http://0.0.0.0:8000Or use the short form:
bunpy run server.pyTest the endpoints manually:
# List all items
curl http://localhost:8000/items
# Create a new item
curl -X POST http://localhost:8000/items \
-H "Content-Type: application/json" \
-d '{"name": "Keyboard", "price": 79.99}'
# Get a single item
curl http://localhost:8000/items/3
# Partial update
curl -X PATCH http://localhost:8000/items/3 \
-H "Content-Type: application/json" \
-d '{"price": 69.99}'
# Delete
curl -X DELETE http://localhost:8000/items/3FastAPI auto-generates interactive docs at http://localhost:8000/docs.
Write async tests
Create tests/test_items.py. The httpx.AsyncClient mounts directly onto the FastAPI app, so no real port is opened.
import pytest
import pytest_asyncio
import httpx
from server import app
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture
async def client():
async with httpx.AsyncClient(app=app, base_url="http://test") as c:
yield c
async def test_list_items(client):
r = await client.get("/items")
assert r.status_code == 200
items = r.json()
assert len(items) >= 2
async def test_create_item(client):
r = await client.post("/items", json={"name": "Headset", "price": 149.99})
assert r.status_code == 201
data = r.json()
assert data["name"] == "Headset"
assert data["id"] > 0
async def test_get_item(client):
r = await client.get("/items/1")
assert r.status_code == 200
assert r.json()["name"] == "Laptop"
async def test_get_item_not_found(client):
r = await client.get("/items/9999")
assert r.status_code == 404
async def test_update_item(client):
r = await client.patch("/items/2", json={"price": 24.99})
assert r.status_code == 200
assert r.json()["price"] == 24.99
async def test_delete_item(client):
# Create first so we don't disturb other tests
create = await client.post("/items", json={"name": "Temp", "price": 1.00})
item_id = create.json()["id"]
r = await client.delete(f"/items/{item_id}")
assert r.status_code == 204
r = await client.get(f"/items/{item_id}")
assert r.status_code == 404Run the test suite:
bunpy test
# Passed 6 tests in 0.42sBundle to a .pyz executable
bunpy build server.py -o api.pyz
./api.pyz
# Listening on http://0.0.0.0:8000The .pyz file is a self-contained ZIP-based archive. Copy it to any machine that has a compatible Python runtime and it runs without a pip install step.
Run in Docker
FROM python:3.12-slim
WORKDIR /app
# Install bunpy
RUN pip install bunpy --no-cache-dir
# Copy lockfile first so Docker layer cache is reused when only source changes
COPY pyproject.toml uv.lock ./
RUN bunpy install --frozen
COPY server.py ./
EXPOSE 8000
CMD ["bunpy", "server.py"]Build and run:
docker build -t my-api .
docker run -p 8000:8000 my-apiOr use the pre-compiled .pyz for an even smaller image - no bunpy needed at runtime:
FROM python:3.12-slim
WORKDIR /app
COPY api.pyz .
EXPOSE 8000
CMD ["python", "api.pyz"]What to add next
- Database: swap
get_dbfor asqlalchemy.orm.Sessionbacked by PostgreSQL. The dependency injection pattern stays identical. - Auth: add an
Authorizationheader check inside a sharedDepends(verify_token)dependency. - Pagination: add
skip: int = 0andlimit: int = 20query parameters tolist_items. - CORS:
app.add_middleware(CORSMiddleware, allow_origins=["*"])for browser clients.