Backend Development with FastAPI
Back to Build Real APIs

Build 1 — Notes API (Full CRUD + Auth + Tests)

Build a complete notes API with JWT auth, per-user CRUD, note sharing, tests, and OpenAPI docs.

112 minutesProject submission
🎯 Free Guest Mode: You are learning for free. Sign in to save your completion progress and quiz answers.

Assignment Instructions

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 DimensionWeightTarget Standard (Pass ≥ 80%)
1. Functional CRUD + Auth40 ptsRegister/login with bcrypt-hashed passwords and short-lived JWTs; per-user notes CRUD; sharing works; correct 401/403/404/422 codes.
2. Tests30 ptspytest suite with TestClient: happy paths, unauthenticated (401), cross-user access (403), validation (422); per-test DB rollback.
3. Docs + OpenAPI15 pts/docs shows typed request/response schemas for every route; README runs the app end-to-end.
4. Code Quality15 ptsSeparated models/schemas/routers, Pydantic v2, response_model everywhere, Alembic migration for the schema.

📤 Submission Instructions & Guidelines

  1. Deliverable Formats:
    • Option A (Recommended): Upload a single .zip archive containing your project directory: src/, tests/, Dockerfile (where applicable), and README.md with run instructions.
    • Option B: Upload your README.md and include your GitHub repository link in the Submission Notes box below.
  2. 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 (.env git-ignored; secrets via environment only).
Checking your access…