Yaliyomo

Wiki 11: FastAPI — Usalama (Auth na JWT), CORS, Testing, na Mradi wa Todo API

Mwezi 3 | Lengo kuu: Mwanafunzi aongeze usajili, login (OAuth2 + JWT) na routes zilizolindwa kwenye API yake, aelewe CORS, aandike tests kwa pytest, kisha amalize na kupush mradi wa "Todo API" GitHub.


Maandalizi ya Mwalimu (soma kabla ya wiki kuanza)

Hii ndiyo wiki ngumu zaidi ya mwezi. Usiingie darasani bila kuendesha mradi wote na kuona tests zote zikipita (16 passed).

  1. Soma:
  2. Sakinisha (kwenye venv ya mradi):

    powershell
    pip install "fastapi[standard]" sqlmodel "pwdlib[argon2]" pyjwt pytest
    

    fastapi[standard] tayari ina python-multipart (inahitajika kwa fomu ya login) na httpx (inahitajika na TestClient).

  3. Andaa mradi todo_api kwenye kompyuta ya kufundishia ukifuata Kipindi 1–2 hatua kwa hatua — kisha futa na uanze upya darasani ukiwa na faili zako kama rejea kwenye kompyuta ya pili.

  4. Maneno ya kuelewa kabla: authentication (wewe ni nani?), authorization (unaruhusiwa nini?), hash, salt, token, JWT, Bearer, OAuth2, header ya Authorization, origin, CORS, fixture, assert, dependency override.
  5. Kumbuka: Mwanafunzi ataandika password za majaribio kama siri123. Mwambie mapema: kamwe asitumie password zake halisi kwenye mazoezi.

Malengo ya Wiki (mwisho wa wiki mwanafunzi ataweza...)

  1. Kutofautisha authentication na authorization, na 401 na 403.
  2. Kueleza kwa nini password zinahifadhiwa kama hash, na kutumia pwdlib ku-hash na ku-verify.
  3. Kueleza JWT ni nini (header.payload.signature), kwamba si siri (si encryption) bali haiwezi kughushiwa bila SECRET_KEY.
  4. Kujenga POST /auth/register, POST /auth/token (OAuth2 password flow) na GET /auth/me.
  5. Kulinda routes kwa dependency get_current_user na kuhakikisha mtumiaji anaona data yake tu.
  6. Kueleza CORS na kuweka CORSMiddleware.
  7. Kuandika tests kwa TestClient + pytest, kwa database ya test (in-memory) kupitia dependency_overrides.
  8. Kumaliza, kujaribu, na kupush mradi kamili wa Todo API kwenye GitHub ukiwa na README.

Kipindi 1 — Password Hashing, JWT, Register na Login

Mtiririko wa kipindi

  1. Marudio ya kazi ya nyumbani (Contacts API)
  2. Authentication vs authorization; tatizo la "kila mtu anaona kila kitu"
  3. Hashing: kwa nini si kuhifadhi password wazi; demo ya pwdlib
  4. JWT: muundo, demo ya jwt.encode/decode, jwt.io
  5. Mradi todo_api: config, models za User, security.py, register, token, me
  6. Zoezi darasani
  7. Q&A + kazi ya nyumbani

Elezo kwa mwalimu (soma kwanza wewe)

Authentication = "Wewe ni nani?" (login). Authorization = "Unaruhusiwa kufanya nini?" (ruhusa). Status codes:

  • 401 Unauthorized — hatukujui (hakuna token / token mbovu / imeisha muda). Jina lake linapotosha — maana halisi ni "unauthenticated".
  • 403 Forbidden — tunakujua, lakini huna ruhusa.

Hashing. Hash ni function ya njia moja: hash("siri123") → $argon2id$v=19$m=65536,t=3,p=4$.... Huwezi kurudi nyuma kupata password kutoka hash. Login inafanya kazi hivi: tuna-hash password aliyoandika sasa na kulinganisha na hash iliyohifadhiwa (verify). Faida: database ikiibiwa, wezi hawapati password halisi.

  • Salt: kipande cha random kinachoongezwa kabla ya ku-hash, hivyo watu wawili wenye password ileile wanapata hash tofauti. Argon2 inaweka salt yenyewe na kuihifadhi ndani ya hash string. Ndiyo maana ukiendesha hash("siri123") mara mbili unapata matokeo tofauti — na verify bado inafanya kazi kwa zote.
  • Argon2 ni algorithm ya kisasa inayopendekezwa; ni "polepole kwa makusudi" ili kubahatisha mamilioni ya password kuwe kugumu. PasswordHash.recommended() ya pwdlib inachagua Argon2.
  • Usitumie hashlib.sha256(password) peke yake kwa password — ni haraka mno na haina salt.

JWT (JSON Web Token) — string ndefu yenye sehemu 3 zilizotenganishwa na nukta:

text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJhbWluYSIsImV4cCI6MTkwMDAwMDAwMH0 . YhrVsSS_M1pC...
          header                                  payload                               signature
  • Header: algorithm (HS256).
  • Payload: data ("claims") — sub (subject = mtumiaji ni nani, kwetu username), exp (muda wa kuisha).
  • Signature: imetengenezwa kwa SECRET_KEY. Mtu akibadilisha payload (mfano sub kuwa "admin"), signature haitalingana → decode inakataa.
  • Muhimu: header na payload ni base64 tu — si siri. Mtu yeyote anaweza kuzisoma (onyesha jwt.io). Kwa hiyo usiweke password au siri ndani ya payload.
  • Server haihifadhi token. Inaipokea kila request, inakagua signature na exp, na kusoma sub. Hii inaendana na HTTP kuwa stateless (Wiki 9).

OAuth2 Password Flow (kama FastAPI docs) — kiwango (standard) cha jinsi ya kuomba token:

  1. Client anatuma POST /auth/token yenye form data (si JSON!): username=...&password=.... Hii ndiyo sababu tunatumia OAuth2PasswordRequestForm na inahitaji python-multipart.
  2. Server inarudisha {"access_token": "...", "token_type": "bearer"}. Majina haya ni lazima yawe hivyo kwa standard.
  3. Kwa requests zinazofuata, client anaweka header: Authorization: Bearer <token>.

OAuth2PasswordBearer(tokenUrl="auth/token") inafanya mambo mawili: (a) inasoma header ya Authorization: Bearer ... na kurudisha token (au 401 "Not authenticated" kama haipo); (b) inaiambia /docs iweke kitufe cha Authorize chenye fomu ya login inayoita auth/token.

SECRET_KEY: string ndefu ya random. Tengeneza kwa:

powershell
python -c "import secrets; print(secrets.token_hex(32))"

Isiende GitHub — tunaisoma kutoka environment variable (os.getenv). Ikiwa fupi kuliko herufi 32, PyJWT inatoa InsecureKeyLengthWarning.

Sema hivi kwa mwanafunzi

"Authentication ni mlinzi wa getini anayeuliza 'wewe ni nani? nionyeshe kitambulisho'. Authorization ni mlinzi wa ndani anayesema 'sawa, wewe ni mfanyakazi, lakini ofisi ya mkurugenzi huingii'. 401 = hujaonyesha kitambulisho; 403 = kitambulisho kipo, lakini huna ruhusa."

"Hashing ni kama kusaga muhogo kuwa unga. Kutoka muhogo unapata unga kila mara, lakini kutoka unga huwezi kurudisha muhogo. Tunahifadhi 'unga' tu. Ukija kuingia, tunasaga password yako tena na kulinganisha unga."

"JWT ni kama tiketi ya basi yenye muhuri wa kampuni. Mtu yeyote anaweza kusoma kilichoandikwa (jina, tarehe), lakini hawezi kughushi muhuri. Kondakta (server) anaangalia muhuri na tarehe tu — hahitaji kupiga simu ofisini kila mara."

"Bearer maana yake 'mbebaji'. Yeyote anayebeba tiketi anaruhusiwa — ndiyo maana token ni kama pesa taslimu: ukiipoteza, aliyeiokota anaitumia. Kwa hiyo ina muda wa kuisha."

Andika code hii live (share screen)

Hatua 1 — Mradi mpya.

powershell
cd $HOME\Documents
mkdir todo_api
cd todo_api
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install "fastapi[standard]" sqlmodel "pwdlib[argon2]" pyjwt pytest
mkdir app, app\routers, tests, demo
New-Item app\__init__.py, app\routers\__init__.py
code .

Hatua 2 — Demo ya hashing. demo/hash_demo.py:

python
from pwdlib import PasswordHash

password_hash = PasswordHash.recommended()

h1 = password_hash.hash("siri123")
h2 = password_hash.hash("siri123")

print(h1)
print(h2)
print("Zinafanana?", h1 == h2)
print("verify sahihi:", password_hash.verify("siri123", h1))
print("verify kosa:  ", password_hash.verify("Siri123", h1))

Output (hash zako zitakuwa tofauti — ni random):

text
$argon2id$v=19$m=65536,t=3,p=4$XlCanl8KWxpUeu89rjj+6g$Y5QsXL9rf7SO6uKuNadlArhSenxN2aTz5TYDVGq8pKY
$argon2id$v=19$m=65536,t=3,p=4$0bN6k7K6Q1mG1w0p6q0x3A$1mJ8...
Zinafanana? False
verify sahihi: True
verify kosa:   False

Uliza: "Kwa nini hash mbili ni tofauti lakini zote zinakubali siri123?" (Salt.)

Hatua 3 — Demo ya JWT. demo/jwt_demo.py:

python
import base64
import json
from datetime import datetime, timedelta, timezone

import jwt

SECRET_KEY = "hii-ni-siri-ya-demo-tu-ndefu-ya-kutosha-32+"

payload = {"sub": "amina", "exp": datetime.now(timezone.utc) + timedelta(minutes=30)}
token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
print("TOKEN:", token)

# 1) Kusoma payload BILA secret — inawezekana! (si siri)
sehemu_ya_kati = token.split(".")[1]
sehemu_ya_kati += "=" * (-len(sehemu_ya_kati) % 4)   # padding ya base64
print("Payload (base64 tu):", json.loads(base64.urlsafe_b64decode(sehemu_ya_kati)))

# 2) Kuthibitisha kwa secret
print("Decode:", jwt.decode(token, SECRET_KEY, algorithms=["HS256"]))

# 3) Secret mbaya
try:
    jwt.decode(token, "siri-nyingine-kabisa-ndefu-ya-kutosha-32+", algorithms=["HS256"])
except jwt.InvalidSignatureError as e:
    print("Secret mbaya:", e)

# 4) Token iliyoisha muda
old = jwt.encode({"sub": "amina", "exp": datetime.now(timezone.utc) - timedelta(minutes=1)},
                 SECRET_KEY, algorithm="HS256")
try:
    jwt.decode(old, SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError as e:
    print("Imeisha muda:", e)

Output (token itatofautiana):

text
TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbWluYSIsImV4cCI6MTc5OTk5OTk5OX0.k3...
Payload (base64 tu): {'sub': 'amina', 'exp': 1799999999}
Decode: {'sub': 'amina', 'exp': 1799999999}
Secret mbaya: Signature verification failed
Imeisha muda: Signature has expired

Bandika token kwenye https://jwt.io — onyesha payload inasomeka. Sisitiza: usiweke siri kwenye payload. (InvalidSignatureError na ExpiredSignatureError zote ni watoto wa jwt.InvalidTokenError — ndiyo tutakayokamata kwenye app.)

Hatua 4 — app/config.py:

python
import os

# Kwenye kazi halisi, SECRET_KEY lazima itoke kwenye environment variable,
# isiandikwe kwenye code inayoenda GitHub.
SECRET_KEY = os.getenv("SECRET_KEY", "badilisha-hii-kwa-siri-ndefu-ya-random-angalau-herufi-32")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///todo.db")

Hatua 5 — app/database.py (kama Wiki 10, lakini URL inatoka config):

python
from typing import Annotated

from fastapi import Depends
from sqlmodel import Session, SQLModel, create_engine

from app.config import DATABASE_URL

engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})


def create_db_and_tables() -> None:
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]

Hatua 6 — app/models.py (sehemu ya users na token; tasks tutaongeza Kipindi 2):

python
from datetime import datetime, timezone

from sqlmodel import Field, SQLModel


# ---------- Users ----------
class UserBase(SQLModel):
    username: str = Field(min_length=3, max_length=30, unique=True, index=True)
    full_name: str | None = None


class User(UserBase, table=True):
    id: int | None = Field(default=None, primary_key=True)
    hashed_password: str


class UserCreate(UserBase):
    password: str = Field(min_length=6, max_length=128)


class UserPublic(UserBase):
    id: int


# ---------- Token ----------
class Token(SQLModel):
    access_token: str
    token_type: str = "bearer"

Eleza: UserCreate ina password (inaingia), User ina hashed_password (inahifadhiwa), UserPublic haina chochote cha password (inatoka). Hii ndiyo faida ya models nyingi tuliyoiona Wiki 10.

Hatua 7 — app/security.py:

python
from datetime import datetime, timedelta, timezone

import jwt
from pwdlib import PasswordHash

from app.config import ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM, SECRET_KEY

password_hash = PasswordHash.recommended()   # Argon2


def hash_password(password: str) -> str:
    return password_hash.hash(password)


def verify_password(plain_password: str, hashed_password: str) -> bool:
    return password_hash.verify(plain_password, hashed_password)


def create_access_token(subject: str, expires_minutes: int = ACCESS_TOKEN_EXPIRE_MINUTES) -> str:
    expire = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes)
    payload = {"sub": subject, "exp": expire}
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


def decode_access_token(token: str) -> str | None:
    """Inarudisha username (sub) au None kama token si sahihi/imeisha muda."""
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.InvalidTokenError:
        return None
    return payload.get("sub")

Hatua 8 — app/deps.py:

python
from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlmodel import select

from app.database import SessionDep
from app.models import User
from app.security import decode_access_token

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")


def get_current_user(token: Annotated[str, Depends(oauth2_scheme)], session: SessionDep) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Token si sahihi au imeisha muda",
        headers={"WWW-Authenticate": "Bearer"},
    )
    username = decode_access_token(token)
    if username is None:
        raise credentials_exception
    user = session.exec(select(User).where(User.username == username)).first()
    if user is None:
        raise credentials_exception
    return user


CurrentUser = Annotated[User, Depends(get_current_user)]

Chora mnyororo ubaoni (au kwenye comment): endpoint ← CurrentUser ← get_current_user ← (oauth2_scheme → token) + (SessionDep → session).

Hatua 9 — app/routers/auth.py:

python
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import select

from app.database import SessionDep
from app.deps import CurrentUser
from app.models import Token, User, UserCreate, UserPublic
from app.security import create_access_token, hash_password, verify_password

router = APIRouter(prefix="/auth", tags=["auth"])


@router.post("/register", response_model=UserPublic, status_code=status.HTTP_201_CREATED)
def register(user_in: UserCreate, session: SessionDep):
    ipo = session.exec(select(User).where(User.username == user_in.username)).first()
    if ipo:
        raise HTTPException(status_code=400, detail="Username tayari imechukuliwa")
    user = User(
        username=user_in.username,
        full_name=user_in.full_name,
        hashed_password=hash_password(user_in.password),
    )
    session.add(user)
    session.commit()
    session.refresh(user)
    return user


@router.post("/token", response_model=Token)
def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], session: SessionDep):
    user = session.exec(select(User).where(User.username == form_data.username)).first()
    if user is None or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Username au password si sahihi",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return Token(access_token=create_access_token(user.username))


@router.get("/me", response_model=UserPublic)
def read_me(current_user: CurrentUser):
    return current_user

Sisitiza: ujumbe wa login ni ule ule kwa "username haipo" na "password mbaya" — hatumwambii mshambuliaji ni kipi kimekosewa.

Hatua 10 — app/main.py:

python
from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.database import create_db_and_tables
from app.routers import auth


@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield


app = FastAPI(title="Todo API", version="1.0.0", lifespan=lifespan)
app.include_router(auth.router)


@app.get("/", tags=["mwanzo"])
def mwanzo():
    return {"ujumbe": "Todo API — nenda /docs"}

Endesha fastapi dev app/main.py. Kwenye /docs:

  1. POST /auth/register → {"username": "amina", "password": "siri123", "full_name": "Amina Said"} → 201 {"username":"amina","full_name":"Amina Said","id":1} (hakuna password!)
  2. Register tena username ileile → 400
  3. GET /auth/me bila login → 401 {"detail":"Not authenticated"}
  4. Bonyeza Authorize 🔒 (juu kulia) → weka amina / siri123 → Authorize → Close
  5. GET /auth/me tena → 200. Fungua sehemu ya "Curl" kwenye /docs — onyesha header -H 'Authorization: Bearer eyJ...'. Sema: "/docs inaweka token yenyewe kwenye kila request."
  6. Fungua todo.db kwa SQLite Viewer → table user → column hashed_password inaanza na $argon2id$.

Maswali ya kumuuliza mwanafunzi

  1. Tofauti ya 401 na 403? — 401: hatukujui (hakuna/token mbovu). 403: tunakujua, huna ruhusa.
  2. Kwa nini hatuhifadhi password wazi? — Database ikivuja, password zote zinavuja (na watu hutumia password moja sehemu nyingi).
  3. Kama hash haiwezi kurudishwa nyuma, login inafanyaje kazi? — Tuna-hash/verify password mpya dhidi ya hash iliyohifadhiwa.
  4. Mtu akiiba JWT, anaweza kusoma nini? Anaweza kubadilisha sub? — Anaweza kusoma payload (si siri). Hawezi kubadilisha bila SECRET_KEY — signature itakataa. Lakini anaweza kuitumia kama ilivyo mpaka iishe muda.
  5. Kwa nini /auth/token inapokea form data badala ya JSON? — Ndivyo standard ya OAuth2 password flow inavyotaka; /docs na zana nyingine zinategemea hilo.
  6. Kwa nini ujumbe wa login ni mmoja kwa makosa mawili? — Ili mshambuliaji asijue kama username ipo.
  7. SECRET_KEY ikivuja nini kinatokea? — Mtu anaweza kutengeneza token ya mtumiaji yeyote. Lazima ibadilishwe (na token zote za zamani zinakufa).

Makosa ya kawaida

Kosa Ujumbe / dalili Suluhisho
pip install pwdlib bila [argon2] pwdlib.exceptions.HasherNotAvailable: The argon2 hash algorithm is not available. Are you sure it's installed? Try to runpip install pwdlib[argon2]. pip install "pwdlib[argon2]"
pip install jwt (package tofauti!) badala ya pyjwt AttributeError: module 'jwt' has no attribute 'encode' pip uninstall jwt kisha pip install pyjwt
python-multipart haipo (ukisakinisha fastapi bila [standard]) RuntimeError: Form data requires "python-multipart" to be installed. pip install "fastapi[standard]" (au pip install python-multipart)
Kutuma JSON kwenye /auth/token 422 "loc":["body","username"],"msg":"Field required" Tuma form: requests.post(url, data={...}) (si json=)
algorithms="HS256" au kusahau algorithms kwenye decode jwt.exceptions.DecodeError: It is required that you pass in a value for the "algorithms" argument when calling decode(). algorithms=["HS256"] (list)
Kutumia datetime.utcnow() DeprecationWarning: datetime.datetime.utcnow() is deprecated datetime.now(timezone.utc)
SECRET_KEY fupi InsecureKeyLengthWarning: The HMAC key is ... bytes long, which is below the minimum recommended length of 32 bytes Tumia secrets.token_hex(32)
tokenUrl="/token" wakati endpoint ni /auth/token Authorize kwenye /docs inarudisha 404 tokenUrl ilingane na path halisi: "auth/token"
Kuweka header bila neno Bearer 401 Not authenticated Authorization: Bearer <token> (nafasi moja baada ya Bearer)
Kurudisha User bila response_model hashed_password inaonekana kwenye response response_model=UserPublic

Zoezi darasani

Zoezi 1.1 — Kwa kutumia requests (server ikiwa inaendesha), andika demo/login_client.py inayo: (a) register user baraka/siri456 (ikiwa tayari yupo, endelea tu), (b) login na kupata token, (c) kuita /auth/me na kuchapisha username.

Zoezi 1.2 — Badilisha UserBase ili username iwe herufi ndogo, namba na _ tu (dokezo: Annotated[str, StringConstraints(pattern=r"^[a-z0-9_]+$")] kutoka pydantic, kama Simu ya Wiki 10). Jaribu kusajili "Baraka Juma".

Zoezi 1.3 — Fungua token yako kwenye jwt.io. Andika: sub ni nini? exp ni namba gani? Geuza exp kuwa tarehe kwa Python.

Jibu

1.1

python
import requests

BASE = "http://127.0.0.1:8000"

r = requests.post(f"{BASE}/auth/register",
                  json={"username": "baraka", "password": "siri456"}, timeout=5)
print("Register:", r.status_code)   # 201 mara ya kwanza, 400 baadaye

r = requests.post(f"{BASE}/auth/token",
                  data={"username": "baraka", "password": "siri456"}, timeout=5)
r.raise_for_status()
token = r.json()["access_token"]
print("Token:", token[:25], "...")

headers = {"Authorization": f"Bearer {token}"}
me = requests.get(f"{BASE}/auth/me", headers=headers, timeout=5)
print("Mimi ni:", me.json()["username"])

1.2

python
from typing import Annotated

from pydantic import StringConstraints

Username = Annotated[str, StringConstraints(min_length=3, max_length=30, pattern=r"^[a-z0-9_]+$")]


class UserBase(SQLModel):
    username: Username = Field(unique=True, index=True)
    full_name: str | None = None

"Baraka Juma" → 422 "type":"string_pattern_mismatch", "msg":"String should match pattern '^[a-z0-9_]+$'".

1.3

python
from datetime import datetime, timezone
print(datetime.fromtimestamp(1799999999, tz=timezone.utc))   # weka exp yako

sub ni username (mfano amina); exp ni sekunde tangu 1 Jan 1970 (Unix timestamp), takriban dakika 30 baada ya login.


Kipindi 2 — Protected Routes, CORS, na Testing

Mtiririko wa kipindi

  1. Marudio ya kazi ya nyumbani
  2. Tasks zenye owner_id; kulinda routes kwa CurrentUser; mtumiaji anaona zake tu
  3. CORS: demo ya ukurasa wa HTML kwenye port 5500; error; CORSMiddleware
  4. Testing: TestClient, pytest, fixtures, database ya test, dependency_overrides
  5. Zoezi darasani
  6. Q&A + kazi ya nyumbani

Elezo kwa mwalimu (soma kwanza wewe)

Kulinda route ni rahisi sana sasa: ongeza argument current_user: CurrentUser. Kama token haipo/mbovu, get_current_user inarusha 401 na endpoint haiendeshwi kabisa. Ikiwa sawa, endpoint inapata object ya User.

Umiliki (ownership). Kila Task ina owner_id (foreign key kwenda user.id). Sheria:

  • Kuunda: owner_id = current_user.id — kamwe usimruhusu client atume owner_id (ndiyo maana haiko kwenye TaskCreate).
  • Kusoma list: where(Task.owner_id == current_user.id).
  • Kusoma/kubadilisha/kufuta moja: kama task si yake → tunarudisha 404 (si 403). Kwa nini? 403 ingemwambia "task namba 7 ipo, lakini si yako" — tunavujisha taarifa. 404 ni salama zaidi. (Zote mbili zinakubalika; tunachagua 404.)

CORS (Cross-Origin Resource Sharing). Origin = scheme + host + port. http://127.0.0.1:5500 na http://127.0.0.1:8000 ni origins tofauti (port tofauti). Browser, kwa usalama, haimruhusu JavaScript ya ukurasa wa origin A kusoma response kutoka API ya origin B — isipokuwa API iseme waziwazi "origin A naruhusu" kupitia header Access-Control-Allow-Origin.

  • CORS ni sheria ya browser tu. requests, curl, /docs (ambayo iko kwenye origin ileile) hazihusiki — ndiyo maana mwanafunzi hajawahi kuiona mpaka sasa.
  • Kwa requests "zisizo rahisi" (mfano zenye header Authorization au Content-Type: application/json), browser kwanza inatuma preflight: OPTIONS request kuuliza ruhusa.
  • CORSMiddleware ya FastAPI inajibu preflight na kuongeza headers. allow_origins iwe orodha maalum; ["*"] inaruhusu wote (sawa kwa majaribio, si kwa production yenye cookies/credentials).

Testing. Badala ya kubofya /docs kila mara, tunaandika code inayojaribu API yetu yenyewe.

  • TestClient(app) — client "bandia" anayeongea na app moja kwa moja bila server halisi (hakuna haja ya fastapi dev). API yake inafanana na requests: client.get(...), client.post(..., json=...), data=..., headers=....
  • pytest — inatafuta faili test_*.py na functions test_*, inaziendesha, na kuripoti. assert ikishindwa → test inafeli.
  • Fixture — function yenye @pytest.fixture inayoandaa kitu (mfano client, au token). Test inaiomba kwa jina la argument — hii ni dependency injection ya pytest!
  • Database ya test — hatutaki tests ziharibu todo.db yetu. Tunaunda database ya memory ("sqlite://") mpya kwa kila test, na kwa app.dependency_overrides[get_session] = ... tunaiambia FastAPI: "badala ya get_session ya kawaida, tumia hii". StaticPool inahakikisha connections zote zinatumia database ileile ya memory.
  • TestClient(app) bila with haiendeshi lifespan, hivyo todo.db haiguswi kabisa.
  • Kuendesha: pytest inahitaji kujua folda ya mradi iko kwenye sys.path ili from app.main import app ifanye kazi. Njia mbili: (a) python -m pytest (inaongeza folda ya sasa), au (b) faili pyproject.toml yenye pythonpath = ["."] kisha pytest peke yake inafanya kazi. Tutafanya (b).

Sema hivi kwa mwanafunzi

"Kulinda route ni kama kuweka mlinzi mlangoni: unaongeza tu current_user: CurrentUser na mlinzi yupo. Hakuna tiketi — hakuna kuingia."

"Kila mtu ana droo yake kwenye kabati la ofisi. Hata ukijua namba ya droo ya mwenzako, ukiifungua tunakuambia 'droo hiyo haipo' — hatukwambii 'ipo lakini si yako'."

"CORS ni kama sheria ya shule: mzazi (browser) hamruhusu mtoto (JavaScript ya ukurasa) kupokea zawadi kutoka kwa mtu asiyemjua (origin nyingine), mpaka mtu huyo aonyeshe barua ya ruhusa (Access-Control-Allow-Origin). Lakini wewe mtu mzima (requests, curl) huna mzazi anayekuzuia."

"Tests ni kama mwalimu anayesahihisha mtihani wa API yako kila unapobadilisha code — sekunde 2, bila kubofya chochote. Ukivunja kitu bila kujua, test itakuambia."

Andika code hii live (share screen)

Hatua 1 — Ongeza Tasks kwenye app/models.py (chini ya Token):

python
# ---------- Tasks ----------
class TaskBase(SQLModel):
    title: str = Field(min_length=1, max_length=100)
    description: str | None = None
    done: bool = False


class Task(TaskBase, table=True):
    id: int | None = Field(default=None, primary_key=True)
    owner_id: int = Field(foreign_key="user.id", index=True)
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))


class TaskCreate(TaskBase):
    pass


class TaskPublic(TaskBase):
    id: int
    owner_id: int
    created_at: datetime


class TaskUpdate(SQLModel):
    title: str | None = Field(default=None, min_length=1, max_length=100)
    description: str | None = None
    done: bool | None = None

Eleza default_factory: function inayoitwa kila task mpya inapoundwa (kama field(default_factory=list) ya dataclasses). Kama tungeandika default=datetime.now(...), ingehesabiwa mara moja tu wakati wa kuimport — tasks zote zingepata saa ileile.

Hatua 2 — app/routers/tasks.py:

python
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import select

from app.database import SessionDep
from app.deps import CurrentUser
from app.models import Task, TaskCreate, TaskPublic, TaskUpdate

router = APIRouter(prefix="/tasks", tags=["tasks"])


def get_own_task(task_id: int, session: SessionDep, current_user: CurrentUser) -> Task:
    task = session.get(Task, task_id)
    # Kama task si yako, tunajibu 404 (si 403) ili tusifichue kwamba ipo.
    if task is None or task.owner_id != current_user.id:
        raise HTTPException(status_code=404, detail="Task haipo")
    return task


OwnTask = Annotated[Task, Depends(get_own_task)]


@router.post("/", response_model=TaskPublic, status_code=status.HTTP_201_CREATED)
def create_task(task_in: TaskCreate, session: SessionDep, current_user: CurrentUser):
    task = Task(**task_in.model_dump(), owner_id=current_user.id)
    session.add(task)
    session.commit()
    session.refresh(task)
    return task


@router.get("/", response_model=list[TaskPublic])
def list_tasks(
    session: SessionDep,
    current_user: CurrentUser,
    done: bool | None = None,
    offset: int = 0,
    limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
    query = select(Task).where(Task.owner_id == current_user.id)
    if done is not None:
        query = query.where(Task.done == done)
    query = query.order_by(Task.id).offset(offset).limit(limit)
    return session.exec(query).all()


@router.get("/{task_id}", response_model=TaskPublic)
def read_task(task: OwnTask):
    return task


@router.patch("/{task_id}", response_model=TaskPublic)
def update_task(task: OwnTask, task_in: TaskUpdate, session: SessionDep):
    task.sqlmodel_update(task_in.model_dump(exclude_unset=True))
    session.add(task)
    session.commit()
    session.refresh(task)
    return task


@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task: OwnTask, session: SessionDep):
    session.delete(task)
    session.commit()

Kwenye app/main.py ongeza from app.routers import auth, tasks na app.include_router(tasks.router).

Futa todo.db (model mpya ya Task) — simamisha server, futa faili, washa tena. Kisha kwenye /docs:

  1. Bila Authorize: GET /tasks/ → 401. Angalia — kila endpoint ya tasks ina kufuli 🔒.
  2. Authorize kama amina → unda tasks 2 → GET /tasks/ → 2.
  3. Sajili baraka, Logout kwenye Authorize, ingia kama baraka → GET /tasks/ → []. GET /tasks/1 → 404. Sema: "Hii ndiyo authorization: Baraka hawezi kuona tasks za Amina."

Hatua 3 — CORS demo. Unda frontend/index.html:

html
<!doctype html>
<html lang="sw">
<head><meta charset="utf-8"><title>Todo Frontend</title></head>
<body>
  <h1>Jaribio la CORS</h1>
  <button id="btn">Ita API</button>
  <pre id="matokeo"></pre>
  <script>
    document.getElementById("btn").onclick = async () => {
      const out = document.getElementById("matokeo");
      try {
        const r = await fetch("http://127.0.0.1:8000/");
        out.textContent = JSON.stringify(await r.json(), null, 2);
      } catch (e) {
        out.textContent = "KOSA: " + e;
      }
    };
  </script>
</body>
</html>

Kwenye terminal ya PILI (server ya FastAPI ikiendelea kwenye ya kwanza):

powershell
cd frontend
python -m http.server 5500

Fungua http://127.0.0.1:5500 → bonyeza "Ita API" → KOSA: TypeError: Failed to fetch. Fungua F12 → Console:

text
Access to fetch at 'http://127.0.0.1:8000/' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Sema: "Server ilijibu (angalia terminal ya FastAPI — 200!), lakini browser imezuia JavaScript kusoma jibu."

Sasa kwenye app/main.py ongeza:

python
from fastapi.middleware.cors import CORSMiddleware

# ... baada ya app = FastAPI(...)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5500", "http://127.0.0.1:5500"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Bonyeza tena → {"ujumbe": "Todo API — nenda /docs"}. Kwenye Network tab → response headers → access-control-allow-origin: http://127.0.0.1:5500. Simamisha server ya 5500 kwa Ctrl+C.

Hatua 4 — Testing. Unda pyproject.toml kwenye mzizi wa mradi:

toml
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

tests/conftest.py (pytest inaisoma yenyewe — fixtures zilizo humu zinapatikana kwa tests zote):

python
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool

from app.database import get_session
from app.main import app


@pytest.fixture(name="session")
def session_fixture():
    engine = create_engine(
        "sqlite://",                               # database ya memory, mpya kila test
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    SQLModel.metadata.create_all(engine)
    with Session(engine) as session:
        yield session


@pytest.fixture(name="client")
def client_fixture(session: Session):
    def get_session_override():
        return session

    app.dependency_overrides[get_session] = get_session_override
    client = TestClient(app)
    yield client
    app.dependency_overrides.clear()


@pytest.fixture(name="auth_headers")
def auth_headers_fixture(client: TestClient) -> dict[str, str]:
    client.post("/auth/register", json={"username": "amina", "password": "siri123"})
    r = client.post("/auth/token", data={"username": "amina", "password": "siri123"})
    token = r.json()["access_token"]
    return {"Authorization": f"Bearer {token}"}

tests/test_auth.py:

python
from fastapi.testclient import TestClient


def test_register_inafanikiwa(client: TestClient):
    r = client.post("/auth/register", json={"username": "juma", "password": "siri123"})
    assert r.status_code == 201
    data = r.json()
    assert data["username"] == "juma"
    assert "password" not in data
    assert "hashed_password" not in data


def test_register_username_iliyopo(client: TestClient):
    client.post("/auth/register", json={"username": "juma", "password": "siri123"})
    r = client.post("/auth/register", json={"username": "juma", "password": "nyingine1"})
    assert r.status_code == 400


def test_register_password_fupi(client: TestClient):
    r = client.post("/auth/register", json={"username": "juma", "password": "123"})
    assert r.status_code == 422


def test_login_password_mbaya(client: TestClient):
    client.post("/auth/register", json={"username": "juma", "password": "siri123"})
    r = client.post("/auth/token", data={"username": "juma", "password": "kosa"})
    assert r.status_code == 401


def test_me_bila_token(client: TestClient):
    r = client.get("/auth/me")
    assert r.status_code == 401


def test_me_na_token(client: TestClient, auth_headers: dict):
    r = client.get("/auth/me", headers=auth_headers)
    assert r.status_code == 200
    assert r.json()["username"] == "amina"


def test_token_bandia(client: TestClient):
    r = client.get("/auth/me", headers={"Authorization": "Bearer abc.def.ghi"})
    assert r.status_code == 401

Endesha:

powershell
pytest -v

Output:

text
tests/test_auth.py::test_register_inafanikiwa PASSED                     [ 14%]
tests/test_auth.py::test_register_username_iliyopo PASSED                [ 28%]
tests/test_auth.py::test_register_password_fupi PASSED                   [ 42%]
tests/test_auth.py::test_login_password_mbaya PASSED                     [ 57%]
tests/test_auth.py::test_me_bila_token PASSED                            [ 71%]
tests/test_auth.py::test_me_na_token PASSED                              [ 85%]
tests/test_auth.py::test_token_bandia PASSED                             [100%]

============================== 7 passed in 1.20s ==============================

Vunja kitu kwa makusudi: kwenye register, badilisha status_code=status.HTTP_201_CREATED kuwa 200, endesha pytest → FAILED ... assert 200 == 201. Rudisha. Sema: "Hii ndiyo kazi ya tests — kukamata makosa kabla mteja hajayaona."

Angalia pia: todo.db haikuguswa (tests zinatumia database ya memory).

Maswali ya kumuuliza mwanafunzi

  1. Kwa nini owner_id haiko kwenye TaskCreate? — Client angeweza kuunda task kwa jina la mtu mwingine; server inaiweka kutoka current_user.
  2. Baraka akiomba /tasks/1 ya Amina anapata nini na kwa nini? — 404; hatutaki kufichua kwamba task ipo.
  3. Kwa nini hatukuwahi kuona CORS error tukitumia requests au /docs? — CORS ni sheria ya browser kwa JavaScript ya origin tofauti; requests si browser, /docs iko origin ileile.
  4. http://localhost:5500 na http://127.0.0.1:5500 ni origin moja? — Hapana! Majina ya host tofauti → origins tofauti. Ndiyo maana tumeweka zote mbili.
  5. Fixture ni nini? — Function ya pytest inayoandaa kitu kwa ajili ya tests; test inaiomba kwa jina la argument.
  6. Kwa nini tunatumia database ya memory kwenye tests? — Ni haraka, mpya kila test (tests hazitegemeani), na haiharibu data halisi.
  7. app.dependency_overrides inafanya nini? — Inabadilisha dependency (hapa get_session) kwa nyingine wakati wa tests.

Makosa ya kawaida

Kosa Ujumbe / dalili Suluhisho
Kusahau kufuta todo.db baada ya kuongeza Task / column sqlite3.OperationalError: no such table: task au no such column Simamisha server, futa todo.db, washa tena
pytest haipati app ModuleNotFoundError: No module named 'app' (ndani ya ImportError while loading conftest) Ongeza pyproject.toml yenye pythonpath = ["."], au endesha python -m pytest kutoka mzizi wa mradi
httpx haipo RuntimeError: The starlette.testclient module requires the httpx package to be installed. pip install "fastapi[standard]" (au pip install httpx)
Faili la test lisilo na jina test_*.py no tests ran in 0.01s Ita test_kitu.py na functions def test_...
Kusahau app.dependency_overrides.clear() Tests nyingine zinatumia session ya zamani; makosa ya ajabu Weka clear() baada ya yield kwenye fixture
Kusahau poolclass=StaticPool sqlite3.OperationalError: no such table: user kwenye tests Ongeza poolclass=StaticPool
allow_origins=["http://127.0.0.1:5500/"] (yenye / mwishoni) CORS bado inazuia Origin haina / wala path: "http://127.0.0.1:5500"
Kufungua index.html kwa kubofya mara mbili (file://) Origin ni null; CORS inazuia Tumia python -m http.server 5500
Kusahau kuongeza current_user kwenye endpoint moja Endpoint hiyo inafanya kazi bila login (shimo la usalama!) Kila endpoint ya tasks iwe na CurrentUser au OwnTask; andika test ya 401 kwa kila moja

Zoezi darasani

Zoezi 2.1 — Andika tests/test_tasks.py yenye tests hizi:

  • (a) kuunda task na kuiona kwenye list
  • (b) GET /tasks/ na POST /tasks/ bila token → 401
  • (c) PATCH done=true kisha DELETE → 204, kisha GET → 404
  • (d) user wa pili haoni task ya wa kwanza

Zoezi 2.2 — Andika test inayothibitisha kwamba token iliyoisha muda inakataliwa (dokezo: create_access_token("x", expires_minutes=-1)).

Zoezi 2.3 — Andika test ya CORS: client.options("/tasks/", headers={"Origin": "http://127.0.0.1:5500", "Access-Control-Request-Method": "GET"}) na uthibitishe header access-control-allow-origin.

Jibu
python
# tests/test_tasks.py
from fastapi.testclient import TestClient

from app.security import create_access_token


def test_create_na_list(client: TestClient, auth_headers: dict):
    r = client.post("/tasks/", json={"title": "Kusoma"}, headers=auth_headers)
    assert r.status_code == 201
    assert r.json()["done"] is False

    r = client.get("/tasks/", headers=auth_headers)
    assert r.status_code == 200
    assert len(r.json()) == 1


def test_tasks_zinahitaji_login(client: TestClient):
    assert client.get("/tasks/").status_code == 401
    assert client.post("/tasks/", json={"title": "x"}).status_code == 401


def test_patch_na_delete(client: TestClient, auth_headers: dict):
    task_id = client.post("/tasks/", json={"title": "Kufua"}, headers=auth_headers).json()["id"]

    r = client.patch(f"/tasks/{task_id}", json={"done": True}, headers=auth_headers)
    assert r.json()["done"] is True
    assert r.json()["title"] == "Kufua"

    assert client.delete(f"/tasks/{task_id}", headers=auth_headers).status_code == 204
    assert client.get(f"/tasks/{task_id}", headers=auth_headers).status_code == 404


def test_huwezi_kuona_task_ya_mwingine(client: TestClient, auth_headers: dict):
    task_id = client.post("/tasks/", json={"title": "Siri ya Amina"}, headers=auth_headers).json()["id"]

    client.post("/auth/register", json={"username": "baraka", "password": "siri456"})
    token = client.post("/auth/token", data={"username": "baraka", "password": "siri456"}).json()["access_token"]
    baraka = {"Authorization": f"Bearer {token}"}

    assert client.get(f"/tasks/{task_id}", headers=baraka).status_code == 404
    assert client.get("/tasks/", headers=baraka).json() == []


# 2.2
def test_token_iliyoisha_muda(client: TestClient):
    client.post("/auth/register", json={"username": "zawadi", "password": "siri123"})
    old = create_access_token("zawadi", expires_minutes=-1)
    r = client.get("/auth/me", headers={"Authorization": f"Bearer {old}"})
    assert r.status_code == 401


# 2.3
def test_cors_preflight(client: TestClient):
    r = client.options(
        "/tasks/",
        headers={"Origin": "http://127.0.0.1:5500", "Access-Control-Request-Method": "GET"},
    )
    assert r.headers["access-control-allow-origin"] == "http://127.0.0.1:5500"

pytest -q → 13 passed.


Kipindi 3 — MRADI: Todo API Kamili (users + auth + SQLite) kwenye GitHub

Mtiririko wa kipindi

  1. Marudio ya kazi ya nyumbani; tests zote za Kipindi 2 zipite
  2. Kusoma mahitaji ya mradi pamoja (orodha hapa chini)
  3. Mwanafunzi anaandika mwenyewe (mwalimu anaangalia screen yake na kusaidia tu akikwama)
  4. Tests za vipengele vipya; pytest yote ipite
  5. README, requirements.txt, .gitignore, push GitHub
  6. Demo ya mwanafunzi (anaonyesha API yake kwenye /docs) + Q&A + kazi ya nyumbani

Elezo kwa mwalimu (soma kwanza wewe)

Leo mwanafunzi ndiye anayeshare screen, si wewe. Kazi yako ni kuuliza maswali na kumsaidia akikwama kwa muda mrefu. Usimwandikie code — mwongoze ("Angalia error inasema nini? Mstari gani? Endpoint hii ina CurrentUser?").

Msingi tayari upo (Kipindi 1–2). Mradi unaongeza vipengele vinne vipya na "ubora wa kitaalamu":

Mahitaji ya mradi (msomee mwanafunzi):

  1. Yote ya Kipindi 1–2 yanafanya kazi (register, token, me, CRUD ya tasks zinazomilikiwa, CORS).
  2. GET /tasks/stats → {"jumla": n, "zimekamilika": k, "zimebaki": n-k} kwa mtumiaji aliyeingia.
  3. Utafutaji: GET /tasks/?q=neno inachuja kwa title.
  4. POST /auth/change-password yenye body {"old_password": "...", "new_password": "..."} → 204; password ya zamani ikiwa si sahihi → 400.
  5. SECRET_KEY kutoka environment variable (tayari kwenye config.py) — README ieleze jinsi ya kuiweka.
  6. Tests: angalau 12, zote zipite, zikiwemo za vipengele 2–4.
  7. Repo todo-api GitHub yenye README.md, requirements.txt, .gitignore (.venv/, __pycache__/, *.db, .env), pyproject.toml.

Kuweka environment variable kwenye PowerShell (kwa session ya terminal hiyo tu):

powershell
$env:SECRET_KEY = python -c "import secrets; print(secrets.token_hex(32))"
fastapi dev app/main.py

Vigezo vya kusahihisha (rubric) — alama 20:

Kipengele Alama
Register/login/me zinafanya kazi; password imehashiwa 4
Tasks: CRUD + ownership (404 kwa task ya mwingine) 4
stats + q + change-password 4
Tests ≥ 12 zinapita 4
GitHub: README wazi, hakuna .venv/*.db/siri 2
Code safi: routers, models tofauti, hakuna hashed_password kwenye response 2

Sema hivi kwa mwanafunzi

"Leo wewe ndiye fundi mkuu. Mimi ni msimamizi tu — nitauliza maswali. Ukikwama, soma error kwa sauti kwanza; nusu ya majibu yamo ndani ya error yenyewe."

"Mradi huu ni wa kwanza unaoweza kuweka kwenye CV: 'Nimejenga REST API yenye authentication ya JWT, database, na tests.' Hilo ni jambo kubwa — waajiri wanalitafuta."

"Kumbuka mtego wa route order: /stats iwe juu ya /{task_id}."

Andika code hii live (share screen)

Leo mwanafunzi ndiye anaandika; wewe tumia vipande hivi ikiwa tu amekwama (au kuonyesha mwisho wa kipindi).

Muundo wa mwisho:

text
todo_api/
├── .gitignore
├── README.md
├── pyproject.toml
├── requirements.txt
├── app/
│   ├── __init__.py
│   ├── config.py
│   ├── database.py
│   ├── deps.py
│   ├── main.py
│   ├── models.py
│   ├── security.py
│   └── routers/
│       ├── __init__.py
│       ├── auth.py
│       └── tasks.py
└── tests/
    ├── conftest.py
    ├── test_auth.py
    └── test_tasks.py

Utafutaji (q) kwenye list_tasks — ongeza parameter na mstari mmoja:

python
    q: str | None = None,
):
    query = select(Task).where(Task.owner_id == current_user.id)
    if done is not None:
        query = query.where(Task.done == done)
    if q:
        query = query.where(Task.title.contains(q))

stats — JUU ya @router.get("/{task_id}"):

python
@router.get("/stats")
def stats(session: SessionDep, current_user: CurrentUser) -> dict[str, int]:
    tasks = session.exec(select(Task).where(Task.owner_id == current_user.id)).all()
    zimekamilika = sum(1 for t in tasks if t.done)
    return {"jumla": len(tasks), "zimekamilika": zimekamilika, "zimebaki": len(tasks) - zimekamilika}

change-password — model kwenye app/models.py:

python
class PasswordChange(SQLModel):
    old_password: str
    new_password: str = Field(min_length=6, max_length=128)

na endpoint kwenye app/routers/auth.py (ongeza PasswordChange kwenye import ya models):

python
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
def change_password(data: PasswordChange, current_user: CurrentUser, session: SessionDep):
    if not verify_password(data.old_password, current_user.hashed_password):
        raise HTTPException(status_code=400, detail="Password ya zamani si sahihi")
    current_user.hashed_password = hash_password(data.new_password)
    session.add(current_user)
    session.commit()

Kwa nini session.add(current_user) inafanya kazi? Kwa sababu get_current_user ilipata current_user kwa session ileile (FastAPI inaita get_session mara moja kwa request).

.gitignore:

text
.venv/
__pycache__/
.pytest_cache/
*.db
.env

README.md (mfano):

markdown
# Todo API

REST API ya kusimamia tasks, iliyojengwa kwa FastAPI, SQLModel (SQLite), na JWT auth.

## Kuendesha
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
$env:SECRET_KEY = python -c "import secrets; print(secrets.token_hex(32))"
fastapi dev app/main.py
```
Fungua http://127.0.0.1:8000/docs

## Endpoints
| Method | Path | Maelezo | Login? |
|--------|------|---------|--------|
| POST | /auth/register | Kujisajili | Hapana |
| POST | /auth/token | Kuingia (form data) | Hapana |
| GET | /auth/me | Taarifa zangu | Ndiyo |
| POST | /auth/change-password | Kubadilisha password | Ndiyo |
| GET/POST | /tasks/ | Orodha / kuunda | Ndiyo |
| GET | /tasks/stats | Takwimu | Ndiyo |
| GET/PATCH/DELETE | /tasks/{id} | Task moja | Ndiyo |

## Tests
```powershell
pytest -v
```

Git:

powershell
pip freeze > requirements.txt
pytest
git init
git add .
git status          # hakikisha .venv na todo.db HAZIMO
git commit -m "Todo API: auth ya JWT, tasks, tests"
git branch -M main
git remote add origin https://github.com/<jina-lako>/todo-api.git
git push -u origin main

Maswali ya kumuuliza mwanafunzi

  1. Nionyeshe: request ya GET /tasks/5 inapita njia gani kwenye code yako mpaka kupata jibu? — Router tasks → read_task → OwnTask → get_own_task → (SessionDep, CurrentUser → get_current_user → oauth2_scheme → token → decode_access_token) → session.get → umiliki → response kupitia TaskPublic.
  2. Ukibadilisha SECRET_KEY server ikiwa inaendesha na watu wameingia, nini kinatokea? — Token zote za zamani zinakuwa batili (401); kila mtu aingie upya.
  3. Kwa nini change-password inahitaji password ya zamani wakati mtumiaji tayari ana token? — Kama token imeibiwa, mwizi asiweze kubadilisha password na kumfungia mwenye akaunti.
  4. Kwa nini .env iko kwenye .gitignore? — Ina siri (kama SECRET_KEY).
  5. Test yako ya "user wa pili haoni task" inalinda nini? — Authorization/umiliki — kosa hapa lingevujisha data ya watu.

Makosa ya kawaida

Kosa Ujumbe / dalili Suluhisho
/stats chini ya /{task_id} 422 int_parsing kwa task_id = "stats" Hamisha /stats juu
Kusahau kuimport PasswordChange NameError: name 'PasswordChange' is not defined from app.models import ..., PasswordChange
.venv imeingia GitHub Repo kubwa sana (maelfu ya faili) git rm -r --cached .venv, ongeza .gitignore, commit tena
todo.db imeingia GitHub Faili la .db linaonekana kwenye repo git rm --cached todo.db, ongeza *.db kwenye .gitignore
git push inakataa error: failed to push some refs ... (fetch first) (repo ya GitHub iliundwa na README) git pull origin main --rebase kisha git push, au unda repo GitHub bila README
$env:SECRET_KEY haipo kwenye terminal mpya App inatumia default ya config.py Environment variable ya $env: ni ya session hiyo tu; iweke tena
Tests zinapita mmoja mmoja lakini si pamoja Data inavuja kati ya tests Hakikisha session fixture inaunda engine mpya kila test na dependency_overrides.clear()

Zoezi darasani

Mradi wenyewe ndio zoezi. Kwa tests za vipengele vipya:

Zoezi 3.1 — Test ya stats: unda tasks 2 (moja done: true), thibitisha {"jumla": 2, "zimekamilika": 1, "zimebaki": 1}.

Zoezi 3.2 — Test ya change-password: password ya zamani mbaya → 400; sahihi → 204; login kwa ya zamani → 401; kwa mpya → 200.

Zoezi 3.3 — Test ya q: unda "Kununua sukari" na "Kusoma", GET /tasks/?q=sukari irudishe moja.

Jibu
python
# kwenye tests/test_tasks.py
def test_stats(client, auth_headers):
    client.post("/tasks/", json={"title": "a"}, headers=auth_headers)
    client.post("/tasks/", json={"title": "b", "done": True}, headers=auth_headers)
    r = client.get("/tasks/stats", headers=auth_headers)
    assert r.json() == {"jumla": 2, "zimekamilika": 1, "zimebaki": 1}


def test_search(client, auth_headers):
    client.post("/tasks/", json={"title": "Kununua sukari"}, headers=auth_headers)
    client.post("/tasks/", json={"title": "Kusoma"}, headers=auth_headers)
    r = client.get("/tasks/", params={"q": "sukari"}, headers=auth_headers)
    assert [t["title"] for t in r.json()] == ["Kununua sukari"]


# kwenye tests/test_auth.py
def test_change_password(client, auth_headers):
    r = client.post("/auth/change-password",
                    json={"old_password": "kosa", "new_password": "mpya1234"},
                    headers=auth_headers)
    assert r.status_code == 400

    r = client.post("/auth/change-password",
                    json={"old_password": "siri123", "new_password": "mpya1234"},
                    headers=auth_headers)
    assert r.status_code == 204

    assert client.post("/auth/token", data={"username": "amina", "password": "siri123"}).status_code == 401
    assert client.post("/auth/token", data={"username": "amina", "password": "mpya1234"}).status_code == 200

Pamoja na tests za Kipindi 2 → 16 passed.


Kazi ya Nyumbani

Kuwasilisha: kipindi kijacho.

  1. Kipaumbele (priority) na tarehe ya mwisho: ongeza priority: int = Field(default=3, ge=1, le=5) na due_date: date | None = None kwenye TaskBase (na TaskUpdate). Ongeza query parameter panga kwenye GET /tasks/ inayokubali "id", "priority", "due_date" (nyingine → 400).
  2. GET /tasks/overdue — tasks za mtumiaji ambazo due_date imepita (ndogo kuliko leo) na done == False.
  3. DELETE /auth/me — mtumiaji afute akaunti yake pamoja na tasks zake zote → 204.
  4. Tests 3 mpya za vipengele hivi.
  5. Swali (andika kwenye README sehemu ya "Nilichojifunza"): Eleza kwa sentensi 3–5 safari ya token: kuanzia login mpaka GET /tasks/.
Jibu

app/models.py

python
from datetime import date, datetime, timezone

class TaskBase(SQLModel):
    title: str = Field(min_length=1, max_length=100)
    description: str | None = None
    done: bool = False
    priority: int = Field(default=3, ge=1, le=5)
    due_date: date | None = None


class TaskUpdate(SQLModel):
    title: str | None = Field(default=None, min_length=1, max_length=100)
    description: str | None = None
    done: bool | None = None
    priority: int | None = Field(default=None, ge=1, le=5)
    due_date: date | None = None

(Futa todo.db — columns mpya.)

app/routers/tasks.py

python
from datetime import date

SORT_COLUMNS = {"id": Task.id, "priority": Task.priority, "due_date": Task.due_date}


@router.get("/", response_model=list[TaskPublic])
def list_tasks(
    session: SessionDep,
    current_user: CurrentUser,
    done: bool | None = None,
    q: str | None = None,
    panga: str = "id",
    offset: int = 0,
    limit: Annotated[int, Query(ge=1, le=100)] = 20,
):
    if panga not in SORT_COLUMNS:
        raise HTTPException(status_code=400, detail=f"panga iwe moja ya {list(SORT_COLUMNS)}")
    query = select(Task).where(Task.owner_id == current_user.id)
    if done is not None:
        query = query.where(Task.done == done)
    if q:
        query = query.where(Task.title.contains(q))
    query = query.order_by(SORT_COLUMNS[panga]).offset(offset).limit(limit)
    return session.exec(query).all()


# juu ya /{task_id}
@router.get("/overdue", response_model=list[TaskPublic])
def overdue(session: SessionDep, current_user: CurrentUser):
    query = select(Task).where(
        Task.owner_id == current_user.id,
        Task.done == False,  # noqa: E712
        Task.due_date != None,  # noqa: E711
        Task.due_date < date.today(),
    )
    return session.exec(query).all()

app/routers/auth.py

python
from app.models import Task  # ongeza kwenye imports


@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
def delete_me(current_user: CurrentUser, session: SessionDep):
    tasks = session.exec(select(Task).where(Task.owner_id == current_user.id)).all()
    for t in tasks:
        session.delete(t)
    session.delete(current_user)
    session.commit()

Tests

python
from datetime import date, timedelta


def test_panga_mbaya(client, auth_headers):
    assert client.get("/tasks/?panga=rangi", headers=auth_headers).status_code == 400


def test_overdue(client, auth_headers):
    jana = (date.today() - timedelta(days=1)).isoformat()
    kesho = (date.today() + timedelta(days=1)).isoformat()
    client.post("/tasks/", json={"title": "Imechelewa", "due_date": jana}, headers=auth_headers)
    client.post("/tasks/", json={"title": "Bado", "due_date": kesho}, headers=auth_headers)
    client.post("/tasks/", json={"title": "Imeisha", "due_date": jana, "done": True}, headers=auth_headers)
    r = client.get("/tasks/overdue", headers=auth_headers)
    assert [t["title"] for t in r.json()] == ["Imechelewa"]


def test_delete_me(client, auth_headers):
    client.post("/tasks/", json={"title": "x"}, headers=auth_headers)
    assert client.delete("/auth/me", headers=auth_headers).status_code == 204
    # token bado ni "halali" kisaini, lakini user hayupo tena -> 401
    assert client.get("/auth/me", headers=auth_headers).status_code == 401

Swali (mfano wa jibu): Mtumiaji anatuma username na password kwa POST /auth/token kama form. Server inatafuta user, ina-verify password dhidi ya hash, na kutengeneza JWT yenye sub (username) na exp, iliyosainiwa kwa SECRET_KEY. Client anaihifadhi na kuituma kwenye header Authorization: Bearer <token> kwa kila request. Kwenye GET /tasks/, oauth2_scheme inasoma token, get_current_user inathibitisha signature na muda, inampata user kwenye database, na endpoint inarudisha tasks zenye owner_id yake tu.


Ukiulizwa swali usilolijua

Usibuni jibu kuhusu usalama — kosa la usalama ni ghali. Sema: "Swali zuri; usalama ni eneo pana, tuangalie docs rasmi". Tafuta: "fastapi refresh token", "fastapi oauth2 scopes", "OWASP password storage", "pytest fixture scope". Vyanzo rasmi: FastAPI Security https://fastapi.tiangolo.com/tutorial/security/ · FastAPI Testing https://fastapi.tiangolo.com/tutorial/testing/ · PyJWT https://pyjwt.readthedocs.io/ · pwdlib https://frankie567.github.io/pwdlib/ · pytest https://docs.pytest.org/ · MDN CORS https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS


Checklist ya mwisho wa wiki

  • Anaeleza authentication vs authorization, 401 vs 403
  • Anaeleza hashing + salt na kutumia pwdlib (hash, verify)
  • Anaeleza sehemu 3 za JWT na kwamba payload si siri
  • Anajua /auth/token inapokea form data na kurudisha access_token + token_type
  • Anatumia Authorization: Bearer <token> kwa requests na kwa /docs (Authorize)
  • Analinda endpoints kwa CurrentUser na kutekeleza umiliki (owner_id)
  • Anaeleza origin na CORS, na kuweka CORSMiddleware
  • Anaandika tests kwa TestClient + pytest fixtures + dependency_overrides
  • pytest inapita yote (≥ 12)
  • SECRET_KEY haiko hardcoded kwenye repo kama siri halisi; inasomwa kutoka environment
  • Repo todo-api iko GitHub na README, requirements.txt, .gitignore sahihi
  • Mwanafunzi anaweza kuonyesha (demo) API yake akieleza kila sehemu