Mathew K Analytics

Lesson 18 · FastAPI deep dive

FastAPI Tutorial #18: Capstone — Production-Ready API

The final video of the eighteen-part series: everything combined into one real working API. Routers, validation, JWT auth, a real database, custom errors,…

⬇ Download notebook

What you'll learn

Data

No separate download needed — the notebook creates or downloads everything it uses.

📓 Full notebook

Download .ipynb

FastAPI Deep-Dive, Video 18: Capstone - End-to-End Production API#

  • The final video of the eighteen-part series: everything combined into one real working API.
  • Routers, validation, JWT auth, a real database, custom errors, all in a small task manager.
  • Let's build it.

Part 1: The Plan - a Small, Real Task Manager API#

from fastapi import FastAPI, Depends, HTTPException, APIRouter, status, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.testclient import TestClient
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
from sqlalchemy.orm import declarative_base, sessionmaker, Session
from sqlalchemy.pool import StaticPool
from passlib.context import CryptContext
import jwt
from datetime import datetime, timedelta, timezone
app = FastAPI(title='Capstone Task Manager API')
print(app.title)
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[1], line 9
      7 from sqlalchemy.orm import declarative_base, sessionmaker, Session
      8 from sqlalchemy.pool import StaticPool
----> 9 from passlib.context import CryptContext
     10 import jwt
     11 from datetime import datetime, timedelta, timezone

ModuleNotFoundError: No module named 'passlib'

Part 2: The Database Layer - Users and Tasks#

engine = create_engine('sqlite:///:memory:', connect_args={'check_same_thread': False}, poolclass=StaticPool)
Base = declarative_base()
class UserModel(Base):
    __tablename__ = 'users'
    id = Column(Integer, primary_key=True)
    username = Column(String, unique=True, nullable=False)
    hashed_password = Column(String, nullable=False)
class TaskModel(Base):
    __tablename__ = 'tasks'
    id = Column(Integer, primary_key=True)
    title = Column(String, nullable=False)
    done = Column(Integer, default=0)
    owner_id = Column(Integer, ForeignKey('users.id'), nullable=False)
Base.metadata.create_all(bind=engine)
SessionLocal = sessionmaker(bind=engine)
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()
print(sorted(Base.metadata.tables.keys()))
['tasks', 'users']

Part 3: The Schemas - Validation and Response Shapes#

class UserCreate(BaseModel):
    username: str = Field(min_length=3, max_length=30)
    password: str = Field(min_length=8)
class UserOut(BaseModel):
    id: int
    username: str
class Token(BaseModel):
    access_token: str
    token_type: str
class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
class TaskUpdate(BaseModel):
    title: str | None = None
    done: bool | None = None
class TaskOut(BaseModel):
    id: int
    title: str
    done: bool
print('schemas ready')
schemas ready

Part 4: Password Hashing and JWT Helpers#

pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
SECRET_KEY = 'capstone-demo-secret-key'
ALGORITHM = 'HS256'
def create_access_token(username: str, minutes: int = 30):
    expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
    return jwt.encode({'sub': username, 'exp': expire}, SECRET_KEY, algorithm=ALGORITHM)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='auth/login')
print('helpers ready')
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[4], line 1
----> 1 pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
      2 SECRET_KEY = 'capstone-demo-secret-key'
      3 ALGORITHM = 'HS256'

NameError: name 'CryptContext' is not defined

Part 5: get_current_user - JWT Plus a Real Database Lookup#

def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username = payload['sub']
    except jwt.PyJWTError:
        raise HTTPException(status_code=401, detail='could not validate credentials')
    user = db.query(UserModel).filter(UserModel.username == username).first()
    if not user:
        raise HTTPException(status_code=401, detail='user no longer exists')
    return user
print('get_current_user ready')
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
      2     try:
      3         payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])

NameError: name 'oauth2_scheme' is not defined

Part 6: The Auth Router - Signup and Login#

auth_router = APIRouter(prefix='/auth', tags=['auth'])
@auth_router.post('/signup', response_model=UserOut, status_code=status.HTTP_201_CREATED)
def signup(payload: UserCreate, db: Session = Depends(get_db)):
    existing = db.query(UserModel).filter(UserModel.username == payload.username).first()
    if existing:
        raise HTTPException(status_code=409, detail='username already taken')
    user = UserModel(username=payload.username, hashed_password=pwd_context.hash(payload.password))
    db.add(user)
    db.commit()
    db.refresh(user)
    return UserOut(id=user.id, username=user.username)
@auth_router.post('/login', response_model=Token)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
    user = db.query(UserModel).filter(UserModel.username == form_data.username).first()
    if not user or not pwd_context.verify(form_data.password, user.hashed_password):
        raise HTTPException(status_code=401, detail='incorrect username or password')
    token = create_access_token(user.username)
    return Token(access_token=token, token_type='bearer')
print('auth router ready')
Form data requires "python-multipart" to be installed. 
You can install "python-multipart" with: 

pip install python-multipart

---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
Cell In[6], line 12
     10     db.refresh(user)
     11     return UserOut(id=user.id, username=user.username)
---> 12 @auth_router.post('/login', response_model=Token)
     13 def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
     14     user = db.query(UserModel).filter(UserModel.username == form_data.username).first()
     15     if not user or not pwd_context.verify(form_data.password, user.hashed_password):

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py:1128, in APIRouter.api_route.<locals>.decorator(func)
   1127 def decorator(func: DecoratedCallable) -> DecoratedCallable:
-> 1128     self.add_api_route(
   1129         path,
   1130         func,
   1131         response_model=response_model,
   1132         status_code=status_code,
   1133         tags=tags,
   1134         dependencies=dependencies,
   1135         summary=summary,
   1136         description=description,
   1137         response_description=response_description,
   1138         responses=responses,
   1139         deprecated=deprecated,
   1140         methods=methods,
   1141         operation_id=operation_id,
   1142         response_model_include=response_model_include,
   1143         response_model_exclude=response_model_exclude,
   1144         response_model_by_alias=response_model_by_alias,
   1145         response_model_exclude_unset=response_model_exclude_unset,
   1146         response_model_exclude_defaults=response_model_exclude_defaults,
   1147         response_model_exclude_none=response_model_exclude_none,
   1148         include_in_schema=include_in_schema,
   1149         response_class=response_class,
   1150         name=name,
   1151         callbacks=callbacks,
   1152         openapi_extra=openapi_extra,
   1153         generate_unique_id_function=generate_unique_id_function,
   1154     )
   1155     return func

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py:1067, in APIRouter.add_api_route(self, path, endpoint, response_model, status_code, tags, dependencies, summary, description, response_description, responses, deprecated, methods, operation_id, response_model_include, response_model_exclude, response_model_by_alias, response_model_exclude_unset, response_model_exclude_defaults, response_model_exclude_none, include_in_schema, response_class, name, route_class_override, callbacks, openapi_extra, generate_unique_id_function)
   1063     current_callbacks.extend(callbacks)
   1064 current_generate_unique_id = get_value_or_default(
   1065     generate_unique_id_function, self.generate_unique_id_function
   1066 )
-> 1067 route = route_class(
   1068     self.prefix + path,
   1069     endpoint=endpoint,
   1070     response_model=response_model,
   1071     status_code=status_code,
   1072     tags=current_tags,
   1073     dependencies=current_dependencies,
   1074     summary=summary,
   1075     description=description,
   1076     response_description=response_description,
   1077     responses=combined_responses,
   1078     deprecated=deprecated or self.deprecated,
   1079     methods=methods,
   1080     operation_id=operation_id,
   1081     response_model_include=response_model_include,
   1082     response_model_exclude=response_model_exclude,
   1083     response_model_by_alias=response_model_by_alias,
   1084     response_model_exclude_unset=response_model_exclude_unset,
   1085     response_model_exclude_defaults=response_model_exclude_defaults,
   1086     response_model_exclude_none=response_model_exclude_none,
   1087     include_in_schema=include_in_schema and self.include_in_schema,
   1088     response_class=current_response_class,
   1089     name=name,
   1090     dependency_overrides_provider=self.dependency_overrides_provider,
   1091     callbacks=current_callbacks,
   1092     openapi_extra=openapi_extra,
   1093     generate_unique_id_function=current_generate_unique_id,
   1094 )
   1095 self.routes.append(route)

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py:686, in APIRoute.__init__(self, path, endpoint, response_model, status_code, tags, dependencies, summary, description, response_description, responses, deprecated, name, methods, operation_id, response_model_include, response_model_exclude, response_model_by_alias, response_model_exclude_unset, response_model_exclude_defaults, response_model_exclude_none, include_in_schema, response_class, dependency_overrides_provider, callbacks, openapi_extra, generate_unique_id_function)
    683     self.response_fields = {}
    685 assert callable(endpoint), "An endpoint must be a callable"
--> 686 self.dependant = get_dependant(
    687     path=self.path_format, call=self.endpoint, scope="function"
    688 )
    689 for depends in self.dependencies[::-1]:
    690     self.dependant.dependencies.insert(
    691         0,
    692         get_parameterless_sub_dependant(depends=depends, path=self.path_format),
    693     )

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py:310, in get_dependant(path, call, name, own_oauth_scopes, parent_oauth_scopes, use_cache, scope)
    308     if param_details.depends.scopes:
    309         sub_own_oauth_scopes = list(param_details.depends.scopes)
--> 310 sub_dependant = get_dependant(
    311     path=path,
    312     call=param_details.depends.dependency,
    313     name=param_name,
    314     own_oauth_scopes=sub_own_oauth_scopes,
    315     parent_oauth_scopes=current_scopes,
    316     use_cache=param_details.depends.use_cache,
    317     scope=param_details.depends.scope,
    318 )
    319 dependant.dependencies.append(sub_dependant)
    320 continue

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py:288, in get_dependant(path, call, name, own_oauth_scopes, parent_oauth_scopes, use_cache, scope)
    286 for param_name, param in signature_params.items():
    287     is_path_param = param_name in path_param_names
--> 288     param_details = analyze_param(
    289         param_name=param_name,
    290         annotation=param.annotation,
    291         value=param.default,
    292         is_path_param=is_path_param,
    293     )
    294     if param_details.depends is not None:
    295         assert param_details.depends.dependency

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py:525, in analyze_param(param_name, annotation, value, is_path_param)
    519 use_annotation_from_field_info = get_annotation_from_field_info(
    520     use_annotation,
    521     field_info,
    522     param_name,
    523 )
    524 if isinstance(field_info, (params.Form, temp_pydantic_v1_params.Form)):
--> 525     ensure_multipart_is_installed()
    526 if not field_info.alias and getattr(field_info, "convert_underscores", None):
    527     alias = param_name.replace("_", "-")

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py:121, in ensure_multipart_is_installed()
    119 except ImportError:
    120     logger.error(multipart_not_installed_error)
--> 121     raise RuntimeError(multipart_not_installed_error) from None

RuntimeError: Form data requires "python-multipart" to be installed. 
You can install "python-multipart" with: 

pip install python-multipart

Part 7: The Tasks Router - Create and List, Scoped Per User#

tasks_router = APIRouter(prefix='/tasks', tags=['tasks'])
@tasks_router.post('', response_model=TaskOut, status_code=status.HTTP_201_CREATED)
def create_task(payload: TaskCreate, current_user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
    task = TaskModel(title=payload.title, owner_id=current_user.id)
    db.add(task)
    db.commit()
    db.refresh(task)
    return TaskOut(id=task.id, title=task.title, done=bool(task.done))
@tasks_router.get('', response_model=list[TaskOut])
def list_tasks(current_user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
    tasks = db.query(TaskModel).filter(TaskModel.owner_id == current_user.id).all()
    return [TaskOut(id=t.id, title=t.title, done=bool(t.done)) for t in tasks]
print('list/create ready')
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[7], line 3
      1 tasks_router = APIRouter(prefix='/tasks', tags=['tasks'])
      2 @tasks_router.post('', response_model=TaskOut, status_code=status.HTTP_201_CREATED)
----> 3 def create_task(payload: TaskCreate, current_user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
      4     task = TaskModel(title=payload.title, owner_id=current_user.id)
      5     db.add(task)

NameError: name 'get_current_user' is not defined

Part 8: Single-Task Endpoints - Ownership, Update, and Delete#

class TaskNotFoundError(Exception):
    pass
def get_owned_task(task_id: int, current_user: UserModel, db: Session):
    task = db.get(TaskModel, task_id)
    if not task or task.owner_id != current_user.id:
        raise TaskNotFoundError()
    return task
@tasks_router.get('/{task_id}', response_model=TaskOut)
def get_task(task_id: int, current_user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
    task = get_owned_task(task_id, current_user, db)
    return TaskOut(id=task.id, title=task.title, done=bool(task.done))
@tasks_router.patch('/{task_id}', response_model=TaskOut)
def update_task(task_id: int, payload: TaskUpdate, current_user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
    task = get_owned_task(task_id, current_user, db)
    if payload.title is not None:
        task.title = payload.title
    if payload.done is not None:
        task.done = int(payload.done)
    db.commit()
    db.refresh(task)
    return TaskOut(id=task.id, title=task.title, done=bool(task.done))
@tasks_router.delete('/{task_id}', status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int, current_user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
    task = get_owned_task(task_id, current_user, db)
    db.delete(task)
    db.commit()
print('detail endpoints ready')
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[8], line 9
      6         raise TaskNotFoundError()
      7     return task
      8 @tasks_router.get('/{task_id}', response_model=TaskOut)
----> 9 def get_task(task_id: int, current_user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
     10     task = get_owned_task(task_id, current_user, db)
     11     return TaskOut(id=task.id, title=task.title, done=bool(task.done))

NameError: name 'get_current_user' is not defined

Part 9: Wiring It Together - Error Handling and include_router#

@app.exception_handler(TaskNotFoundError)
async def task_not_found_handler(request: Request, exc: TaskNotFoundError):
    return JSONResponse(status_code=404, content={'detail': 'task not found'})
app.include_router(auth_router)
app.include_router(tasks_router)
client = TestClient(app)
print(sorted(app.openapi()['paths'].keys()))
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[9], line 1
----> 1 @app.exception_handler(TaskNotFoundError)
      2 async def task_not_found_handler(request: Request, exc: TaskNotFoundError):
      3     return JSONResponse(status_code=404, content={'detail': 'task not found'})
      4 app.include_router(auth_router)

NameError: name 'app' is not defined

Part 10: The Full Flow - Real Users, Real Isolation, Real Errors#

signup_resp = client.post('/auth/signup', json={'username': 'alex', 'password': 'hunter22'})
print(signup_resp.status_code, signup_resp.json())
login_resp = client.post('/auth/login', data={'username': 'alex', 'password': 'hunter22'})
token = login_resp.json()['access_token']
headers = {'Authorization': f'Bearer {token}'}
created = client.post('/tasks', json={'title': 'write capstone lesson'}, headers=headers).json()
print(created)
print(client.get('/tasks', headers=headers).json())
updated = client.patch(f"/tasks/{created['id']}", json={'done': True}, headers=headers).json()
print(updated)
client.post('/auth/signup', json={'username': 'sam', 'password': 'anotherpass'})
sam_login = client.post('/auth/login', data={'username': 'sam', 'password': 'anotherpass'})
sam_headers = {'Authorization': f"Bearer {sam_login.json()['access_token']}"}
print(client.get(f"/tasks/{created['id']}", headers=sam_headers).status_code)
print(client.get('/tasks').status_code)
print(client.post('/auth/signup', json={'username': 'ab', 'password': 'short'}).status_code)
print(client.delete(f"/tasks/{created['id']}", headers=headers).status_code)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[10], line 1
----> 1 signup_resp = client.post('/auth/signup', json={'username': 'alex', 'password': 'hunter22'})
      2 print(signup_resp.status_code, signup_resp.json())
      3 login_resp = client.post('/auth/login', data={'username': 'alex', 'password': 'hunter22'})

NameError: name 'client' is not defined

Wrap-Up: What You Built#

  • A real database layer: two related SQLAlchemy models, a shared in-memory engine, and get_db.
  • Dedicated Pydantic schemas for every request body and response, never leaking a raw password.
  • Reusable password-hashing and JWT helpers, unchanged from their own dedicated videos.
  • get_current_user combining a decoded JWT with a real database lookup for the current user.
  • An auth router handling signup and login, each with its own specific, correct error codes.
  • A tasks router where every single endpoint is scoped to the currently authenticated user.
  • A shared ownership-checking helper that treats missing and not-owned identically, on purpose.
  • A dedicated exception handler turning a custom exception into one consistent error shape.
  • Every router and handler wired in before the very first real request ever went out.
  • That's the full eighteen-part FastAPI Deep-Dive series. Congratulations, and thanks for building along.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.