Quickstart¶
Ten minutes, one file, a working typed service that runs on two protocols.
1. A first handler¶
# main.py
from slowfw import SlowAPI
app = SlowAPI(title="Quickstart", version="1.0.0")
@app.get("/")
def index(res):
res.json({"hello": "world"})
res is the response object, injected because the parameter is named res.
Mutate it and return nothing — this is the Express style.
2. Or return a value instead¶
Both styles are first-class and mix freely in one application. Use res when
you need control over headers and status; return a value when you do not.
3. Typed parameters¶
from slowfw import Query
@app.get("/search")
def search(q: str = Query(..., min_length=2), limit: int = Query(10, ge=1, le=100)) -> dict:
return {"q": q, "limit": limit}
curl "localhost:8000/search?q=hi&limit=5" # {"q":"hi","limit":5} limit is an int
curl "localhost:8000/search?q=x" # 422, and it says why
curl "localhost:8000/search?limit=99999" # 422, and it says which bound
... means required. Everything on the wire is text; the annotation is what
turns it into the type your function actually wants.
4. Path parameters, in either syntax¶
@app.get("/users/{id:int}") # FastAPI style, typed
def get_user(id: int) -> dict:
return {"id": id}
@app.get("/posts/:slug") # Express style
def get_post(slug: str) -> dict:
return {"slug": slug}
/users/abc is a 404, not a 500 — the :int converter refuses to match before
your handler is ever called.
5. Request bodies¶
from dataclasses import dataclass
@dataclass
class CreateUser:
email: str
age: int = 18
@app.post("/users", status_code=201)
def create_user(payload: CreateUser) -> dict:
return {"email": payload.email, "age": payload.age}
curl -X POST localhost:8000/users -H 'content-type: application/json' \
-d '{"email":"a@b.c","age":"30"}'
A plain dataclass is a DTO. Every field error is reported at once, not one per round trip. (Pydantic models work here too if you prefer them.)
6. Middleware¶
import time
from slowfw.middleware import CORSMiddleware, SecurityHeadersMiddleware
def timing(req, res, next):
started = time.perf_counter()
next() # everything downstream runs here
res.set("X-Elapsed", f"{(time.perf_counter() - started) * 1000:.1f}ms")
app.use(SecurityHeadersMiddleware(), CORSMiddleware(allow_origins=["*"]), timing)
Express's (req, res, next) contract, including short-circuiting: skip
next() and nothing downstream runs. async def middleware works the same way
with await next().
7. Async where it helps¶
import asyncio
@app.get("/fanout")
async def fanout() -> dict:
async def unit(i):
await asyncio.sleep(0.05)
return i * i
return {"results": await asyncio.gather(*(unit(i) for i in range(10)))}
This handler runs under both servers. Under uvicorn it uses the server's loop; under gunicorn's sync workers SlowAPI runs it on a shared background loop. You are not asked to pick a colour for your functions.
8. Free API documentation¶
Already running:
http://localhost:8000/docs— Swagger UIhttp://localhost:8000/redoc— ReDochttp://localhost:8000/openapi.json— the raw document
It is generated from the annotations that also do the validating, so it cannot drift from the implementation.
9. Tests that cover both protocols¶
# test_main.py
import pytest
from slowfw.testing import TestClient
from main import app
@pytest.fixture(params=["wsgi", "asgi"])
def client(request):
with TestClient(app, protocol=request.param) as c:
yield c
def test_search_rejects_a_short_term(client):
assert client.get("/search", params={"q": "x"}).status_code == 422
Every test now runs twice, once per protocol. A regression that only affects one transport cannot reach production quietly.
10. Deploy it¶
gunicorn main:app --workers 4 --bind 0.0.0.0:8000 # WSGI
uvicorn main:app --host 0.0.0.0 --port 8000 # ASGI
Same file. No wsgi.py shim, no asgi.py shim, no rewrite.
Where to go next¶
- Routing for converters, mounting and reverse URLs
- Dependency injection for sharing a database session
- Controllers and modules once one file stops being enough
- Deployment for choosing between the two protocols on purpose