FastAPI
What FastAPI Is
FastAPI is a Python web framework for building APIs. It has three distinctive features:
- Async first: All route handlers are
async def. Built on ASGI (Asynchronous Server Gateway Interface), not the older WSGI. - Type hints drive everything: You declare what types your functions expect, and FastAPI automatically validates input, generates error messages, and produces documentation.
- Auto-generated docs: Visit
https://api.jongyang.xyz/docsto see the full interactive API documentation โ generated automatically from the code.
The Application Object and Routers
The main app is created in app/main.py:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.routers.admin import router as admin_router
from app.routers.public import router as public_router
from app.routers.webhooks import router as webhooks_router
from app.tasks.scheduler import start_scheduler, stop_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
start_scheduler() # โ runs on startup
yield # โ app serves requests here
stop_scheduler() # โ runs on shutdown
app = FastAPI(title="Service Booking Platform API", version="0.1.0", lifespan=lifespan)
app.add_middleware(CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS,
allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
app.include_router(public_router) # /api/shops, /api/bookings, /api/availability
app.include_router(admin_router) # /api/admin/*
app.include_router(webhooks_router) # /webhooks/omise
@app.get("/health")
async def health():
return {"status": "ok"}
Routers are used to organize routes into groups. Each file in routers/ has its own APIRouter. The main app includes them with include_router():
app (FastAPI)
โโโ public_router (prefix: /api)
โ โโโ shops_router (prefix: /shops) โ /api/shops, /api/shops/{slug}
โ โโโ availability_router (prefix: /availability) โ /api/availability/{id}
โ โโโ bookings_router (prefix: /bookings) โ /api/bookings, /api/bookings/{id}
โโโ admin_router (prefix: /api/admin)
โ โโโ auth_router โ /api/admin/auth/login
โ โโโ resources_router โ /api/admin/resources
โ โโโ bookings_router โ /api/admin/bookings
โ โโโ ...
โโโ webhooks_router
โโโ /webhooks/omise
Path Parameters and Query Parameters
Routes can capture parts of the URL as parameters:
@router.get("/{booking_id}", response_model=BookingOut)
async def get_booking(booking_id: uuid.UUID, session: AsyncSession = Depends(get_db)):
# booking_id comes from the URL: GET /api/bookings/705f95b6-...
booking = (await session.execute(
select(Booking).where(Booking.id == booking_id)
)).scalar_one_or_none()
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
return booking
FastAPI automatically validates the UUID format. If the URL contains an invalid UUID, it returns 422 before your code even runs. Here's a query parameter example:
@router.get("/{resource_id}", response_model=AvailabilityOut)
async def get_availability(
resource_id: UUID, # path param
booking_date: date = Query(..., alias="date"), # query param: ?date=2026-06-25
duration_min: int = Query(..., ge=15, le=480), # query param: &duration_min=60
session: AsyncSession = Depends(get_db),
):
# URL: /api/availability/uuid?date=2026-06-25&duration_min=60
# ge=15 means "greater than or equal to 15" โ rejects 10, accepts 15+
# le=480 means "less than or equal to 480" โ rejects 481, accepts โค480
...
Request Body with Pydantic
When a client sends a POST request with a JSON body, FastAPI uses Pydantic to validate it. From schemas/public.py:
class BookingCreate(BaseModel):
resource_id: UUID # required, must be valid UUID
service_id: UUID | None = None # optional (only for staff bookings)
booking_date: date # required, auto-parsed from "2026-06-25"
start_time: time # required, auto-parsed from "07:00:00"
duration_min: int = Field(default=60, ge=15, le=480) # optional, defaults to 60
customer_name: str = Field(min_length=1, max_length=200) # required, length validated
customer_phone: str = Field(min_length=1, max_length=20)
If the client sends invalid data (missing required field, wrong type, out of range), FastAPI automatically returns a 422 response with a detailed error message. Your code never runs โ validation happens first.
Response Models
The response_model parameter tells FastAPI what to include in the response. It filters and serializes the return value:
class BookingOut(BaseModel):
model_config = ConfigDict(from_attributes=True) # allows reading from ORM objects
id: UUID
customer_name: str
customer_phone: str
booking_date: date
status: str
total_price: Decimal
promptpay_qr_url: str | None = None
expires_at: datetime | None = None
created_at: datetime
# Note: hashed_password is NOT here โ even if the DB object has it, it won't be returned
@router.post("", response_model=BookingOut, status_code=201)
async def create_booking(body: BookingCreate, ...):
booking = Booking(...) # ORM object with many fields
return booking # FastAPI extracts only the fields defined in BookingOut
Dependency Injection with Depends()
The Depends() function is one of FastAPI's most powerful features. It lets you declare dependencies that FastAPI will create, inject, and clean up automatically.
The most important dependency in the project is get_db:
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session: # creates a DB session
try:
yield session # gives the session to the route handler
finally:
await session.close() # cleans up after the request ends
Every route that needs the database declares it as a dependency:
async def create_booking(
body: BookingCreate, # from request body
session: AsyncSession = Depends(get_db), # FastAPI calls get_db() automatically
):
# session is ready to use here
# FastAPI handles:
# 1. Creating the DB session before the function runs
# 2. Passing it as the `session` argument
# 3. Closing it after the function returns (even on exception)
...
You can chain dependencies. The get_current_admin dependency itself depends on the database session:
_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/admin/auth/login")
async def get_current_admin(
token: str = Depends(_oauth2), # extracts JWT from Authorization header
session: AsyncSession = Depends(get_db),
) -> ShopAdmin:
try:
payload = decode_access_token(token)
admin_id = uuid.UUID(payload["sub"])
except (ValueError, KeyError):
raise HTTPException(status_code=401, detail="Invalid or expired token")
admin = await session.get(ShopAdmin, admin_id)
if admin is None:
raise HTTPException(status_code=401, detail="Admin account not found")
return admin # this ShopAdmin object becomes available in the route handler
Any admin route that adds admin: ShopAdmin = Depends(get_current_admin) gets automatic auth enforcement โ if the token is missing or invalid, FastAPI returns 401 before the route runs.
HTTPException โ Returning Errors
When something goes wrong, raise HTTPException with a status code and detail message:
resource = (await session.execute(...)).scalar_one_or_none()
if resource is None:
raise HTTPException(status_code=404, detail="Resource not found")
if body.start_time not in available:
raise HTTPException(status_code=409, detail="Requested time slot is not available")
if body.booking_date < dt_date.today():
raise HTTPException(status_code=422, detail="Booking date cannot be in the past")
The Lifespan Context Manager
The lifespan function runs startup and shutdown code. The APScheduler background job scheduler must be started when the app starts and stopped when it shuts down:
@asynccontextmanager
async def lifespan(app: FastAPI):
# STARTUP: runs before the app starts accepting requests
start_scheduler()
yield # app runs here, accepting and handling requests
# SHUTDOWN: runs after the last request, before the process exits
stop_scheduler()
app = FastAPI(lifespan=lifespan)
๐ง Self-Check Quiz
1. What does Depends(get_db) do and why is it better than creating the DB session inside the function?
get_db() before the route handler runs and injects the session as an argument. After the handler returns (success or exception), FastAPI calls the cleanup code in get_db() (closing the session). This ensures the session is always closed โ even on error. Creating it inside the function would require manual try/finally cleanup in every route, and it couldn't be shared with other dependencies in the same request.2. If you add Depends(get_current_admin) to a route but the request has no Authorization header, what happens and why?
OAuth2PasswordBearer automatically extracts the Bearer token from the Authorization header. If there's no token, it raises a 401. If there's a token but it's invalid or expired, get_current_admin raises HTTPException(401). Either way, the route handler is never called.3. What does response_model=BookingOut do? What problem does it solve?
BookingOut schema โ extra fields on the ORM object are excluded. This prevents accidentally leaking sensitive data (like hashed passwords) that might be on the ORM object but shouldn't be in the response.4. A client POSTs a booking with duration_min: 5. The field is declared as Field(ge=15). What does FastAPI return and what does ge=15 mean?
ge=15 means "greater than or equal to 15". FastAPI automatically validates the field and returns 422 Unprocessable Entity with a detail message explaining the constraint violation. The route handler code never runs โ Pydantic catches it during deserialization.5. Why does the lifespan function use yield in the middle instead of two separate functions?
yield separates startup (before) from shutdown (after) in a single function. Code before yield runs on startup; code after runs on shutdown. This is the "async context manager" pattern โ it guarantees shutdown code always runs even if something goes wrong. Two separate functions wouldn't guarantee that a resource opened on startup is always closed on shutdown.