Teqvault.study β€” Python Series

Phase 5: APIs & Backend

Build real REST APIs with FastAPI β€” the framework used at Microsoft, Uber, and Netflix. From first endpoint to authenticated, database-backed, tested, production-ready backend.

πŸš€ πŸ”Œ πŸ—„οΈ πŸ” πŸ§ͺ
Phase Progress
0%
LESSON 01

How HTTP Really Works

Before writing a single line of FastAPI, understand what's actually happening on the wire. Every framework abstracts HTTP β€” you need to understand what it's abstracting.

🌐 The Request-Response Cycle

Every API interaction is a request from a client and a response from a server. That's it. HTTP is a text-based protocol β€” the actual bytes going across the network are readable text.

Raw HTTP Request (what actually goes over the wire)
# The client sends this text to the server:
POST /api/tasks HTTP/1.1
Host: api.myapp.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
Content-Length: 52

{"title": "Learn FastAPI", "priority": 3}


# The server responds with this text:
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/tasks/42

{"id": 42, "title": "Learn FastAPI", "priority": 3, "done": false}

Every HTTP message has the same structure: a start line (method + path + version, or status code), headers (key-value metadata), a blank line, and an optional body. FastAPI handles all of this β€” but knowing the structure helps you debug anything.

πŸ“‹ HTTP Methods β€” What Each One Means
MethodMeaningHas Body?Idempotent?
GETRead a resource β€” never modify dataNoβœ… Yes β€” same result every time
POSTCreate a new resourceYes❌ No β€” creates new item each call
PUTReplace entire resourceYesβœ… Yes β€” result same if called twice
PATCHPartially update a resourceYesUsually yes
DELETERemove a resourceRarelyβœ… Yes β€” deleting twice = same state
πŸ” Idempotency β€” why it matters

Design your APIs around idempotency

An idempotent operation produces the same result whether called once or ten times. GET /tasks/42 always returns the same task. DELETE /tasks/42 on a deleted task returns 404 but doesn't break anything. POST /tasks is not idempotent β€” calling it 10 times creates 10 tasks.

This matters for retries. When a network call fails, can you safely retry it? If the endpoint is idempotent, yes. If it's a POST that charges a credit card, no β€” you need idempotency keys.

πŸ“Š Status Codes β€” The Ones You'll Use
CodeMeaningWhen to Use
200 OKSuccess with bodySuccessful GET, PUT, PATCH
201 CreatedNew resource createdSuccessful POST
204 No ContentSuccess, no bodySuccessful DELETE
400 Bad RequestClient sent invalid dataValidation failures, malformed JSON
401 UnauthorizedNot authenticatedMissing or invalid token
403 ForbiddenAuthenticated but no permissionAccessing another user's data
404 Not FoundResource doesn't existTask ID not in database
409 ConflictState conflictDuplicate username, already completed
422 Unprocessable EntityValidation error (FastAPI default)Wrong type, missing required field
500 Internal Server ErrorServer crashedUnhandled exception β€” never intentional
πŸ—ΊοΈ REST API Design β€” The Naming Conventions

REST (Representational State Transfer) is an architectural style β€” a set of conventions for designing URLs and using HTTP correctly. Following them makes your API predictable to any developer.

GET/tasksβ€” list all tasks
POST/tasksβ€” create a new task
GET/tasks/{id}β€” get one specific task
PUT/tasks/{id}β€” replace entire task
PATCH/tasks/{id}β€” update fields on a task
DELETE/tasks/{id}β€” delete a task
GET/tasks/{id}/commentsβ€” nested resource: comments for a task
πŸ’‘
URL conventionsURLs are nouns, never verbs. Not /getTasks or /deleteTask β€” the HTTP method IS the verb. Use plural nouns for collections (/tasks not /task). Use lowercase with hyphens for multi-word resources (/user-profiles).
LESSON 02

FastAPI β€” First Steps

FastAPI is the modern Python web framework β€” faster than Flask, cleaner than Django, with automatic API docs, async support, and Pydantic validation built in from day one.

⚑ Why FastAPI
FeatureFastAPIFlaskDjango
Speed (requests/sec)Very fast (async)ModerateModerate
Auto API docsβœ… Built-in Swagger + ReDoc❌ Plugin needed❌ Plugin needed
Type hints / Pydanticβœ… First class❌ Manual❌ Manual
Request validationβœ… Automatic❌ ManualPartial (forms)
Learning curveLowVery lowHigh
Best forAPIs, microservicesSimple appsFull-stack web apps
πŸš€ Installation & Your First API
Terminal
pip install "fastapi[standard]"
# [standard] includes uvicorn (the server) and extra tools
Python β€” main.py β€” a complete working API
from fastapi import FastAPI

app = FastAPI(
    title="Task Manager API",
    description="Manage your tasks via REST",
    version="1.0.0"
)

@app.get("/")
def root():
    return {"message": "Task Manager API is running"}

@app.get("/health")
def health_check():
    return {"status": "ok"}

# Run with: uvicorn main:app --reload
# --reload restarts the server on every file save (dev mode)
Terminal β€” run your API
uvicorn main:app --reload
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process
INFO:     Application startup complete.
🎁
Free interactive docs β€” no setup neededThe moment your API runs, go to http://127.0.0.1:8000/docs β€” you get a full Swagger UI where you can test every endpoint in the browser. Also try /redoc for an alternative view. FastAPI generates these from your code automatically β€” no extra work.
πŸ” The ASGI Server β€” Uvicorn

FastAPI is an ASGI framework (Asynchronous Server Gateway Interface). It needs an ASGI server to run β€” that's uvicorn. Think of it like Tomcat to Java or Nginx to PHP.

CommandWhen
uvicorn main:app --reloadDevelopment β€” auto-restarts on changes
uvicorn main:app --host 0.0.0.0 --port 8000Production β€” binds to all interfaces
uvicorn main:app --workers 4Production β€” multiple worker processes

main:app means: find the file main.py, use the variable app inside it. If your file is api/server.py and the FastAPI instance is named application, you'd write api.server:application.

LESSON 03

Routes & Parameters

Path parameters, query parameters, and how FastAPI automatically validates, converts, and documents them β€” all from your type hints alone.

πŸ“¬ Three Ways to Pass Data to an Endpoint
TypeIn URLExampleFastAPI syntax
Path parameterPart of the path/tasks/42{task_id} in route + function arg
Query parameterAfter ?/tasks?done=false&limit=10Function arg with default
Request bodyNot in URL β€” in bodyJSON payloadPydantic model arg
πŸ”§ Path & Query Parameters in Detail
Python β€” routes.py
from fastapi import FastAPI, Path, Query, HTTPException
from typing  import Optional

app = FastAPI()

# ── Path parameter β€” {task_id} in the route ──
@app.get("/tasks/{task_id}")
def get_task(
    task_id: int = Path(..., ge=1, description="The task ID")
):
    """
    FastAPI automatically:
    - converts task_id from string to int
    - validates it's >= 1
    - documents it in /docs
    - returns 422 if it's not an int
    """
    task = db.get_task(task_id)
    if not task:
        # Raise HTTPException to return proper HTTP error responses
        raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
    return task


# ── Query parameters β€” function args with defaults ──
@app.get("/tasks")
def list_tasks(
    done:     Optional[bool] = Query(default=None, description="Filter by done status"),
    priority: Optional[int]  = Query(default=None, ge=1, le=5),
    limit:    int             = Query(default=20, le=100),
    offset:   int             = Query(default=0,  ge=0),
):
    """
    GET /tasks                   β†’ all tasks, first 20
    GET /tasks?done=false        β†’ only pending
    GET /tasks?priority=5        β†’ only critical
    GET /tasks?limit=5&offset=10 β†’ pagination
    """
    tasks = db.all_tasks()
    if done is not None:
        tasks = [t for t in tasks if t["done"] == done]
    if priority:
        tasks = [t for t in tasks if t["priority"] == priority]
    return tasks[offset : offset + limit]


# ── Multiple path parameters ──
@app.get("/users/{user_id}/tasks/{task_id}")
def get_user_task(user_id: int, task_id: int):
    return {"user_id": user_id, "task_id": task_id}


# ── Enum path parameter β€” only specific string values ──
from enum import Enum

class SortField(str, Enum):
    created  = "created"
    priority = "priority"
    title    = "title"

@app.get("/tasks/sorted/{field}")
def sorted_tasks(field: SortField):
    # FastAPI validates and documents the allowed values
    return {"sorted_by": field.value}
πŸ” Why HTTPException is the right way to return errors

Don't return error dicts β€” raise exceptions

Returning {"error": "not found"} with a 200 status is wrong β€” the HTTP layer carries status information for a reason. Raising HTTPException(status_code=404, detail="...") returns a proper HTTP 404 with a JSON body. Client libraries, browsers, and logging tools all know how to handle proper status codes. A 404 in the logs means something different from a 500.

🧠 Quick Check
In FastAPI, you define def get_task(task_id: int) for route /tasks/{task_id}. A client requests /tasks/abc. What happens?
LESSON 04

Request & Response Bodies

Pydantic models are the contract between your API and its clients. Define them carefully β€” they drive validation, serialization, documentation, and type safety all at once.

πŸ“¦ Request Bodies β€” Receiving Data
Python β€” schemas.py + routes using them
from pydantic  import BaseModel, Field
from typing    import Optional
from datetime  import date
from fastapi   import FastAPI

app = FastAPI()

# ── Separate schemas for create vs response ──
# This is the right pattern β€” don't reuse one model for everything

class TaskCreate(BaseModel):
    """What the client sends when creating a task."""
    title:    str           = Field(..., min_length=1, max_length=200)
    priority: int           = Field(default=2, ge=1, le=5)
    due:      Optional[date] = None
    tags:     list[str]     = []

class TaskUpdate(BaseModel):
    """What the client sends when updating β€” all fields optional (PATCH)."""
    title:    Optional[str]  = Field(default=None, min_length=1)
    priority: Optional[int]  = Field(default=None, ge=1, le=5)
    done:     Optional[bool] = None
    due:      Optional[date] = None

class TaskResponse(BaseModel):
    """What the server returns β€” includes server-generated fields."""
    id:       int
    title:    str
    priority: int
    done:     bool
    due:      Optional[date]
    tags:     list[str]
    created:  str           # server sets this β€” client doesn't send it


# ── Routes using the schemas ──
@app.post("/tasks", response_model=TaskResponse, status_code=201)
def create_task(task: TaskCreate):
    """
    FastAPI automatically:
    1. Parses the JSON body
    2. Validates it against TaskCreate
    3. Returns 422 if invalid
    4. Calls your function with a proper TaskCreate object
    5. Serializes the return value through TaskResponse
    """
    new_task = db.create(task)
    return new_task   # response_model filters to only TaskResponse fields

@app.patch("/tasks/{task_id}", response_model=TaskResponse)
def update_task(task_id: int, update: TaskUpdate):
    task = db.get(task_id)
    if not task:
        raise HTTPException(404, "Task not found")
    # model_dump(exclude_unset=True) β€” only the fields the client sent
    changes = update.model_dump(exclude_unset=True)
    return db.update(task_id, changes)
πŸ’‘
response_model is your API contractSetting response_model=TaskResponse does two things: it filters the output (so internal fields like passwords are never accidentally returned), and it generates accurate response documentation in /docs. Always set it β€” never return raw dicts from production endpoints.
⚠️ Global Exception Handling
Python β€” custom exception handlers
from fastapi           import Request
from fastapi.responses import JSONResponse

# Catch any unhandled exception β€” return JSON, not an HTML crash page
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    import logging
    logging.getLogger("api").error("Unhandled error", exc_info=exc)
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error"}
        # Never expose exc details to clients in production
    )

# Custom exception class + handler
class NotFoundError(Exception):
    def __init__(self, resource: str, id: int):
        self.resource = resource
        self.id       = id

@app.exception_handler(NotFoundError)
async def not_found_handler(request: Request, exc: NotFoundError):
    return JSONResponse(
        status_code=404,
        content={"detail": f"{exc.resource} {exc.id} not found"}
    )
LESSON 05

Database with SQLModel

SQLModel combines SQLAlchemy (the Python database powerhouse) with Pydantic β€” so your database models and API schemas share the same class. One definition, no duplication.

πŸ—„οΈ Installation & Setup
Terminal
pip install sqlmodel
# SQLModel includes SQLAlchemy and Pydantic β€” no separate installs
Python β€” database.py β€” connection setup
from sqlmodel import create_engine, SQLModel, Session

# SQLite for development β€” zero setup, file-based
DATABASE_URL = "sqlite:///tasks.db"

# PostgreSQL for production:
# DATABASE_URL = "postgresql://user:password@localhost:5432/mydb"

engine = create_engine(
    DATABASE_URL,
    echo=True,   # logs every SQL query (disable in production)
    connect_args={"check_same_thread": False}  # SQLite-only setting
)

def create_db_and_tables():
    SQLModel.metadata.create_all(engine)   # creates tables if they don't exist

def get_session():
    with Session(engine) as session:
        yield session   # FastAPI dependency injection uses this
πŸ“‹ Defining Models
Python β€” models.py β€” SQLModel table definitions
from sqlmodel  import SQLModel, Field
from typing    import Optional
from datetime  import date, datetime

# ── The base fields shared across schemas ──
class TaskBase(SQLModel):
    title:    str           = Field(min_length=1, max_length=200)
    priority: int           = Field(default=2, ge=1, le=5)
    done:     bool          = False
    due:      Optional[date] = None

# ── The database table β€” table=True creates the SQL table ──
class Task(TaskBase, table=True):
    id:      Optional[int] = Field(default=None, primary_key=True)
    created: datetime       = Field(default_factory=datetime.utcnow)

# ── API schemas β€” inherit from TaskBase ──
class TaskCreate(TaskBase):
    pass   # exactly the base fields β€” no id, no created

class TaskUpdate(SQLModel):
    title:    Optional[str]  = None
    priority: Optional[int]  = None
    done:     Optional[bool] = None
    due:      Optional[date] = None

class TaskResponse(TaskBase):
    id:      int
    created: datetime
πŸ”§ CRUD Operations with Sessions
Python β€” full CRUD endpoints with database
from fastapi  import FastAPI, Depends, HTTPException
from sqlmodel  import Session, select
from models    import Task, TaskCreate, TaskUpdate, TaskResponse
from database  import get_session, create_db_and_tables

app = FastAPI()

@app.on_event("startup")
def on_startup():
    create_db_and_tables()  # runs once on server start


# ── CREATE ──
@app.post("/tasks", response_model=TaskResponse, status_code=201)
def create_task(task_in: TaskCreate, session: Session = Depends(get_session)):
    task = Task.model_validate(task_in)  # create Task from TaskCreate
    session.add(task)
    session.commit()
    session.refresh(task)  # load server-generated id and created
    return task


# ── READ ALL ──
@app.get("/tasks", response_model=list[TaskResponse])
def list_tasks(
    done:   bool | None = None,
    offset: int          = 0,
    limit:  int          = 20,
    session: Session    = Depends(get_session)
):
    stmt = select(Task)
    if done is not None:
        stmt = stmt.where(Task.done == done)
    return session.exec(stmt.offset(offset).limit(limit)).all()


# ── READ ONE ──
@app.get("/tasks/{task_id}", response_model=TaskResponse)
def get_task(task_id: int, session: Session = Depends(get_session)):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(404, f"Task {task_id} not found")
    return task


# ── UPDATE ──
@app.patch("/tasks/{task_id}", response_model=TaskResponse)
def update_task(task_id: int, update: TaskUpdate, session: Session = Depends(get_session)):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(404, f"Task {task_id} not found")
    changes = update.model_dump(exclude_unset=True)
    task.sqlmodel_update(changes)
    session.add(task)
    session.commit()
    session.refresh(task)
    return task


# ── DELETE ──
@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, session: Session = Depends(get_session)):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(404, f"Task {task_id} not found")
    session.delete(task)
    session.commit()
    # Return None β€” status 204 means "no content"
LESSON 06

Authentication β€” API Keys & JWT

Two levels of auth: simple API keys for server-to-server, and JWT tokens for user authentication. Understand both β€” when to use each and how to implement them properly.

πŸ”‘ Level 1 β€” API Key Authentication

API keys are the simplest auth pattern β€” a secret string in a header. Perfect for server-to-server calls, webhooks, and internal tools.

Python β€” API key auth
from fastapi           import Security, HTTPException, status
from fastapi.security  import APIKeyHeader
import os, secrets

# Reads the X-API-Key header from every request
api_key_scheme = APIKeyHeader(name="X-API-Key")

VALID_API_KEYS = {
    os.getenv("API_KEY_ADMIN"): "admin",
    os.getenv("API_KEY_READONLY"): "reader",
}

def get_current_role(api_key: str = Security(api_key_scheme)) -> str:
    role = VALID_API_KEYS.get(api_key)
    if not role:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or missing API key"
        )
    return role

# Protect an endpoint β€” inject the dependency
@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(
    task_id: int,
    role: str       = Depends(get_current_role),  # auth happens here
    session: Session = Depends(get_session)
):
    if role != "admin":
        raise HTTPException(403, "Admin role required")
    # ... delete logic

# Generate a secure API key
def generate_api_key() -> str:
    return secrets.token_urlsafe(32)   # 32 bytes = 43 characters, URL-safe
πŸ” Level 2 β€” JWT Token Authentication

JWT (JSON Web Token) is the standard for user authentication. Users log in with a username/password, receive a token, and send that token with every subsequent request.

Terminal
pip install "python-jose[cryptography]" passlib[bcrypt]
# jose = JWT creation/validation  |  passlib = password hashing
Python β€” auth.py β€” complete JWT implementation
from jose          import JWTError, jwt
from passlib.context import CryptContext
from fastapi        import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from datetime       import datetime, timedelta
from pydantic       import BaseModel
import os

SECRET_KEY   = os.getenv("JWT_SECRET_KEY")  # long random string in .env
ALGORITHM    = "HS256"
TOKEN_EXPIRE = 30   # minutes

pwd_ctx = CryptContext(schemes=["bcrypt"])
oauth2  = OAuth2PasswordBearer(tokenUrl="token")

class Token(BaseModel):
    access_token: str
    token_type:   str

def hash_password(plain: str) -> str:
    return pwd_ctx.hash(plain)

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_ctx.verify(plain, hashed)

def create_access_token(data: dict) -> str:
    payload = data.copy()
    payload["exp"] = datetime.utcnow() + timedelta(minutes=TOKEN_EXPIRE)
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

def get_current_user(token: str = Depends(oauth2)):
    credentials_err = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload  = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username = payload.get("sub")
        if not username: raise credentials_err
    except JWTError:
        raise credentials_err
    user = db.get_user_by_name(username)
    if not user: raise credentials_err
    return user

# ── Login endpoint β€” returns a JWT ──
@app.post("/token", response_model=Token)
def login(form: OAuth2PasswordRequestForm = Depends()):
    user = db.get_user_by_name(form.username)
    if not user or not verify_password(form.password, user.hashed_password):
        raise HTTPException(401, "Incorrect username or password")
    token = create_access_token({"sub": user.username})
    return {"access_token": token, "token_type": "bearer"}

# ── Protected endpoint ──
@app.get("/me")
def get_me(current_user = Depends(get_current_user)):
    return current_user
LESSON 07

Dependency Injection

FastAPI's Depends() system is one of its most powerful features β€” it makes authentication, database sessions, configuration, and shared logic reusable and testable with almost no boilerplate.

βš™οΈ What Dependency Injection Does

When FastAPI sees Depends(something) in a function signature, it calls something() first, passes the result in, and handles cleanup. This lets you declare "I need a database session" or "I need the current user" without every endpoint knowing how to obtain them.

Python β€” dependency patterns
from fastapi import Depends, FastAPI, Query
from sqlmodel import Session

app = FastAPI()

# ── Dependency: database session ──
def get_db() -> Session:
    with Session(engine) as session:
        yield session    # yield = context manager β€” cleans up after request

# ── Dependency: pagination params ──
class Pagination:
    def __init__(self,
        offset: int = Query(0,  ge=0),
        limit:  int = Query(20, le=100)
    ):
        self.offset = offset
        self.limit  = limit

# ── Dependency: current user (from JWT) ──
def get_user(token: str = Depends(oauth2_scheme)):
    return decode_and_validate_token(token)

# ── Chained dependency: admin user ──
def require_admin(user = Depends(get_user)):
    if user.role != "admin":
        raise HTTPException(403, "Admin access required")
    return user   # Depends can chain β€” admin depends on get_user


# ── Endpoints use dependencies β€” clean and readable ──
@app.get("/tasks")
def list_tasks(
    pagination: Pagination = Depends(Pagination),  # pagination logic
    session:    Session    = Depends(get_db),       # db session
    user                    = Depends(get_user),     # authentication
):
    # The function body only cares about business logic
    stmt = select(Task).where(Task.user_id == user.id)
    return session.exec(stmt.offset(pagination.offset).limit(pagination.limit)).all()

@app.delete("/tasks/{id}", status_code=204)
def delete_task(
    id:      int,
    session: Session = Depends(get_db),
    admin              = Depends(require_admin),   # chained: must be admin
):
    # Only admins reach this code
    task = session.get(Task, id)
    session.delete(task)
    session.commit()
πŸ’‘
Why DI makes testing easyIn tests, you can override any dependency: app.dependency_overrides[get_db] = get_test_db. This swaps the real database for an in-memory test database without changing a single line of your endpoint code. This is the pattern that makes FastAPI apps so testable.
LESSON 08

Testing APIs with pytest

FastAPI's TestClient lets you make real HTTP requests to your API in tests β€” no server needed, no mocking. Combined with dependency overrides, your tests are fast, isolated, and trustworthy.

πŸ§ͺ Test Setup β€” In-Memory Database
Terminal
pip install httpx pytest pytest-asyncio
Python β€” tests/conftest.py β€” shared test fixtures
import pytest
from fastapi.testclient import TestClient
from sqlmodel            import create_engine, SQLModel, Session
from sqlmodel.pool        import StaticPool

from main     import app
from database import get_session

# In-memory SQLite β€” created fresh for each test
TEST_ENGINE = create_engine(
    "sqlite://",            # no file β€” in memory only
    connect_args={"check_same_thread": False},
    poolclass=StaticPool,   # same connection for whole test
)

def get_test_session():
    with Session(TEST_ENGINE) as session:
        yield session

@pytest.fixture(autouse=True)
def fresh_db():
    """Recreate tables before every test β€” clean slate."""
    SQLModel.metadata.create_all(TEST_ENGINE)
    yield
    SQLModel.metadata.drop_all(TEST_ENGINE)

@pytest.fixture
def client():
    """TestClient with the real database swapped for test database."""
    app.dependency_overrides[get_session] = get_test_session
    yield TestClient(app)
    app.dependency_overrides.clear()
βœ… Writing Real Tests
Python β€” tests/test_tasks.py
import pytest

class TestCreateTask:
    def test_create_task_success(self, client):
        response = client.post("/tasks", json={
            "title":    "Learn FastAPI",
            "priority": 3
        })
        assert response.status_code == 201
        data = response.json()
        assert data["title"]    == "Learn FastAPI"
        assert data["priority"] == 3
        assert data["done"]     == False
        assert "id"             in data   # server assigned an ID
        assert "created"        in data

    def test_create_task_empty_title(self, client):
        response = client.post("/tasks", json={"title": ""})
        assert response.status_code == 422   # validation failure

    def test_create_task_invalid_priority(self, client):
        response = client.post("/tasks", json={"title": "Test", "priority": 10})
        assert response.status_code == 422


class TestGetTask:
    def test_get_existing_task(self, client):
        # Create a task first
        created = client.post("/tasks", json={"title": "Test"}).json()
        task_id = created["id"]

        response = client.get(f"/tasks/{task_id}")
        assert response.status_code == 200
        assert response.json()["id"] == task_id

    def test_get_nonexistent_task(self, client):
        response = client.get("/tasks/99999")
        assert response.status_code == 404


class TestUpdateTask:
    def test_partial_update(self, client):
        task = client.post("/tasks", json={"title": "Original", "priority": 1}).json()

        response = client.patch(f"/tasks/{task['id']}", json={"done": True})
        assert response.status_code == 200
        updated = response.json()
        assert updated["done"]     == True
        assert updated["title"]    == "Original"   # unchanged
        assert updated["priority"] == 1            # unchanged


# Run all tests:
# pytest tests/ -v
PHASE PROJECT 🏁

Task Manager API β€” Full Stack

Build a complete, production-quality REST API β€” authenticated, database-backed, tested, and documented. This is the centerpiece of your backend portfolio.

πŸ—ΊοΈ Project Structure
task-api/ β”œβ”€β”€ .env # JWT_SECRET_KEY, DATABASE_URL β”œβ”€β”€ requirements.txt # fastapi[standard] sqlmodel jose passlib httpx pytest β”œβ”€β”€ main.py # FastAPI app, startup events, router includes β”œβ”€β”€ database.py # engine, get_session dependency β”œβ”€β”€ models.py # SQLModel tables + Pydantic schemas β”œβ”€β”€ auth.py # JWT creation/validation, password hashing β”œβ”€β”€ routers/ β”‚ β”œβ”€β”€ tasks.py # /tasks CRUD endpoints β”‚ └── users.py # /users, /token, /me endpoints └── tests/ β”œβ”€β”€ conftest.py # fixtures, test client, in-memory DB β”œβ”€β”€ test_tasks.py # CRUD tests └── test_auth.py # login, token, protected routes
βœ… Requirements Checklist
πŸ“‹
Your API must include:
βœ… Full CRUD β€” POST, GET (list + single), PATCH, DELETE for tasks
βœ… JWT auth β€” register, login returns token, protected endpoints
βœ… User ownership β€” users can only see/edit their own tasks
βœ… Query params β€” filter by done, priority; paginate with limit/offset
βœ… Proper status codes β€” 201, 204, 404, 401, 403, 422
βœ… response_model on every endpoint β€” no raw dicts
βœ… SQLModel + SQLite β€” data persists between restarts
βœ… 10+ tests β€” success cases, error cases, auth failures
βœ… Auto docs β€” /docs works, all endpoints documented
βœ… .env β€” no secrets in source code
🎯 Extend the Challenge

After the core API works:

  • Add task tags stored as a separate Tag table with a many-to-many relationship
  • Add a GET /tasks/stats endpoint returning counts by status and priority
  • Add rate limiting β€” max 100 requests per minute per user using a simple in-memory counter
  • Add a GET /tasks/export endpoint that returns the user's tasks as a CSV file download
  • Add CORS middleware so your future React frontend can call this API
πŸš€
Phase 5 Complete. You now understand HTTP deeply, can build production-quality REST APIs with FastAPI, connect to databases with SQLModel, implement authentication with JWT, use dependency injection for clean testable code, and write a proper test suite. Phase 6 is AI Integration β€” where you add an LLM layer on top of everything you've built.
Roadmap