The brief
Build a notes-taking API. Users can register, log in, create personal notes (CRUD), share notes with other users. Full auth, tests, OpenAPI docs.
Step 1: Setup (15 min)
uv init notes-api
cd notes-api
uv add fastapi 'uvicorn[standard]' sqlalchemy alembic 'passlib[bcrypt]' python-jose[cryptography] python-multipart pydantic-settings pydantic[email]
uv add --dev pytest httpx pyright
notes-api/
├── pyproject.toml
├── alembic.ini
├── alembic/
├── src/
│ └── notes_api/
│ ├── main.py
│ ├── config.py
│ ├── db.py
│ ├── models.py # SQLAlchemy
│ ├── schemas.py # Pydantic
│ ├── auth.py # JWT helpers + get_current_user
│ ├── deps.py
│ └── routers/
│ ├── auth.py
│ ├── users.py
│ └── notes.py
└── tests/
├── conftest.py
├── test_auth.py
└── test_notes.py
Step 2: Data models (15 min)
# models.py
from datetime import datetime
from sqlalchemy import String, ForeignKey, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
name: Mapped[str] = mapped_column(String(100))
password_hash: Mapped[str] = mapped_column(String(200))
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
notes: Mapped[list["Note"]] = relationship(back_populates="owner")
class Note(Base):
__tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column(Text)
is_public: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
owner: Mapped["User"] = relationship(back_populates="notes")
Step 3: Schemas (15 min)
# schemas.py
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, ConfigDict
class UserCreate(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
name: str = Field(min_length=2)
class UserResponse(BaseModel):
id: int
email: str
name: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class NoteCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str
is_public: bool = False
class NoteUpdate(BaseModel):
title: str | None = None
content: str | None = None
is_public: bool | None = None
class NoteResponse(BaseModel):
id: int
title: str
content: str
is_public: bool
owner_id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
Step 4: Auth (20 min)
# auth.py
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from sqlalchemy import select
from .config import settings
from .db import get_db
from .models import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
def hash_password(p: str) -> str:
return pwd_context.hash(p)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(user_id: int) -> str:
expire = datetime.utcnow() + timedelta(hours=settings.token_expire_hours)
return jwt.encode({"sub": str(user_id), "exp": expire}, settings.secret_key, algorithm="HS256")
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
user_id = int(payload["sub"])
except (JWTError, ValueError, KeyError):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
user = db.get(User, user_id)
if not user:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found")
return user
Step 5: Endpoints (30 min)
# routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from sqlalchemy import select
from ..db import get_db
from ..models import User
from ..schemas import UserCreate, UserResponse, Token
from ..auth import hash_password, verify_password, create_access_token
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/signup", response_model=UserResponse, status_code=201)
def signup(payload: UserCreate, db: Session = Depends(get_db)):
if db.execute(select(User).where(User.email == payload.email)).scalar_one_or_none():
raise HTTPException(409, "Email already registered")
user = User(email=payload.email, name=payload.name, password_hash=hash_password(payload.password))
db.add(user); db.commit(); db.refresh(user)
return user
@router.post("/login", response_model=Token)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.execute(select(User).where(User.email == form.username)).scalar_one_or_none()
if not user or not verify_password(form.password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
return Token(access_token=create_access_token(user.id))
# routers/notes.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import select
from ..db import get_db
from ..models import Note, User
from ..schemas import NoteCreate, NoteUpdate, NoteResponse
from ..auth import get_current_user
router = APIRouter(prefix="/notes", tags=["notes"])
@router.get("", response_model=list[NoteResponse])
def list_notes(skip: int = 0, limit: int = 50, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
stmt = select(Note).where(Note.owner_id == current_user.id).order_by(Note.updated_at.desc()).offset(skip).limit(limit)
return db.execute(stmt).scalars().all()
@router.post("", response_model=NoteResponse, status_code=201)
def create_note(payload: NoteCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
note = Note(**payload.model_dump(), owner_id=current_user.id)
db.add(note); db.commit(); db.refresh(note)
return note
@router.get("/{note_id}", response_model=NoteResponse)
def get_note(note_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
note = db.get(Note, note_id)
if not note:
raise HTTPException(404)
if note.owner_id != current_user.id and not note.is_public:
raise HTTPException(403)
return note
@router.patch("/{note_id}", response_model=NoteResponse)
def update_note(note_id: int, payload: NoteUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
note = db.get(Note, note_id)
if not note: raise HTTPException(404)
if note.owner_id != current_user.id: raise HTTPException(403)
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(note, k, v)
db.commit(); db.refresh(note)
return note
@router.delete("/{note_id}", status_code=204)
def delete_note(note_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
note = db.get(Note, note_id)
if not note: raise HTTPException(404)
if note.owner_id != current_user.id: raise HTTPException(403)
db.delete(note); db.commit()
Step 6: Tests (25 min)
# tests/conftest.py — see Module 3 testing recap for the full setup
# tests/test_notes.py
def test_create_note(authenticated_client):
response = authenticated_client.post("/notes", json={"title": "T1", "content": "C1"})
assert response.status_code == 201
assert response.json()["title"] == "T1"
def test_user_cant_access_others_note(authenticated_client, other_user, db):
other_note = Note(owner_id=other_user.id, title="Theirs", content="...")
db.add(other_note); db.commit()
response = authenticated_client.get(f"/notes/{other_note.id}")
assert response.status_code == 403
def test_public_note_accessible_to_others(authenticated_client, other_user, db):
other_note = Note(owner_id=other_user.id, title="Public", content="...", is_public=True)
db.add(other_note); db.commit()
response = authenticated_client.get(f"/notes/{other_note.id}")
assert response.status_code == 200
Step 7: Run it (5 min)
# Migrate
alembic upgrade head
# Run
uvicorn notes_api.main:app --reload
Visit http://localhost:8000/docs to try endpoints interactively.
Deliverables
- Working API with signup, login, notes CRUD.
- Auth enforced; ownership checked.
- Tests covering happy paths + 403/404/401.
- OpenAPI docs at /docs.
- README with setup + usage.
What you'll learn
- Real FastAPI project structure.
- Full auth flow (signup, login, JWT, protected routes).
- Resource ownership checks.
- Pydantic separation (create/update/response).
- Test patterns (TestClient + fixtures + overrides).
📊 100-Point Evaluation Rubric (Case 1)
| Evaluation Dimension | Weight | Target Standard (Pass ≥ 80%) |
|---|---|---|
| 1. Functional CRUD + Auth | 40 pts | Register/login with bcrypt-hashed passwords and short-lived JWTs; per-user notes CRUD; sharing works; correct 401/403/404/422 codes. |
| 2. Tests | 30 pts | pytest suite with TestClient: happy paths, unauthenticated (401), cross-user access (403), validation (422); per-test DB rollback. |
| 3. Docs + OpenAPI | 15 pts | /docs shows typed request/response schemas for every route; README runs the app end-to-end. |
| 4. Code Quality | 15 pts | Separated models/schemas/routers, Pydantic v2, response_model everywhere, Alembic migration for the schema. |
📤 Submission Instructions & Guidelines
- Deliverable Formats:
- Option A (Recommended): Upload a single
.ziparchive containing your project directory:src/,tests/,Dockerfile(where applicable), andREADME.mdwith run instructions. - Option B: Upload your
README.mdand include your GitHub repository link in the Submission Notes box below.
- Option A (Recommended): Upload a single
- Pre-Submission Verification:
- Run your full test suite (
pytest) green; include the passing output in your README. - Exercise every endpoint once via
/docs(Swagger UI) and paste one sample request/response per endpoint into your README. - Confirm no secrets are committed (
.envgit-ignored; secrets via environment only).
- Run your full test suite (