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).
- Soma:
- FastAPI — "Security - First Steps": https://fastapi.tiangolo.com/tutorial/security/first-steps/
- FastAPI — "OAuth2 with Password (and hashing), Bearer with JWT tokens": https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/ (hii ndiyo msingi wa Kipindi 1–2; docs za sasa zinatumia pwdlib (Argon2) na PyJWT — sisi tunafuata hivyo hivyo)
- FastAPI — "CORS": https://fastapi.tiangolo.com/tutorial/cors/
- FastAPI — "Testing": https://fastapi.tiangolo.com/tutorial/testing/
- SQLModel — "Test Applications with FastAPI and SQLModel": https://sqlmodel.tiangolo.com/tutorial/fastapi/tests/
- Ukipata muda: https://jwt.io (bandika token kuona ndani yake)
-
Sakinisha (kwenye venv ya mradi):
pip install "fastapi[standard]" sqlmodel "pwdlib[argon2]" pyjwt pytest
fastapi[standard]tayari inapython-multipart(inahitajika kwa fomu ya login) nahttpx(inahitajika naTestClient). -
Andaa mradi
todo_apikwenye 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. - 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. - 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...)
- Kutofautisha authentication na authorization, na 401 na 403.
- Kueleza kwa nini password zinahifadhiwa kama hash, na kutumia
pwdlibku-hash na ku-verify. - Kueleza JWT ni nini (header.payload.signature), kwamba si siri (si encryption) bali haiwezi kughushiwa bila
SECRET_KEY. - Kujenga
POST /auth/register,POST /auth/token(OAuth2 password flow) naGET /auth/me. - Kulinda routes kwa dependency
get_current_userna kuhakikisha mtumiaji anaona data yake tu. - Kueleza CORS na kuweka
CORSMiddleware. - Kuandika tests kwa
TestClient+pytest, kwa database ya test (in-memory) kupitiadependency_overrides. - 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
- Marudio ya kazi ya nyumbani (Contacts API)
- Authentication vs authorization; tatizo la "kila mtu anaona kila kitu"
- Hashing: kwa nini si kuhifadhi password wazi; demo ya
pwdlib - JWT: muundo, demo ya
jwt.encode/decode, jwt.io - Mradi
todo_api: config, models za User, security.py, register, token, me - Zoezi darasani
- 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 — naverifybado 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:
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 (mfanosubkuwa "admin"), signature haitalingana →decodeinakataa. - 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 kusomasub. Hii inaendana na HTTP kuwa stateless (Wiki 9).
OAuth2 Password Flow (kama FastAPI docs) — kiwango (standard) cha jinsi ya kuomba token:
- Client anatuma
POST /auth/tokenyenye form data (si JSON!):username=...&password=.... Hii ndiyo sababu tunatumiaOAuth2PasswordRequestFormna inahitajipython-multipart. - Server inarudisha
{"access_token": "...", "token_type": "bearer"}. Majina haya ni lazima yawe hivyo kwa standard. - 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:
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.
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:
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):
$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:
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):
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:
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):
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):
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:
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:
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:
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:
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:
POST /auth/register→{"username": "amina", "password": "siri123", "full_name": "Amina Said"}→ 201{"username":"amina","full_name":"Amina Said","id":1}(hakuna password!)- Register tena username ileile → 400
GET /auth/mebila login → 401{"detail":"Not authenticated"}- Bonyeza Authorize 🔒 (juu kulia) → weka
amina/siri123→ Authorize → Close GET /auth/metena → 200. Fungua sehemu ya "Curl" kwenye/docs— onyesha header-H 'Authorization: Bearer eyJ...'. Sema: "/docsinaweka token yenyewe kwenye kila request."- Fungua
todo.dbkwa SQLite Viewer → tableuser→ columnhashed_passwordinaanza na$argon2id$.
Maswali ya kumuuliza mwanafunzi
- Tofauti ya 401 na 403? — 401: hatukujui (hakuna/token mbovu). 403: tunakujua, huna ruhusa.
- Kwa nini hatuhifadhi password wazi? — Database ikivuja, password zote zinavuja (na watu hutumia password moja sehemu nyingi).
- Kama hash haiwezi kurudishwa nyuma, login inafanyaje kazi? — Tuna-hash/verify password mpya dhidi ya hash iliyohifadhiwa.
- Mtu akiiba JWT, anaweza kusoma nini? Anaweza kubadilisha
sub? — Anaweza kusoma payload (si siri). Hawezi kubadilisha bilaSECRET_KEY— signature itakataa. Lakini anaweza kuitumia kama ilivyo mpaka iishe muda. - Kwa nini
/auth/tokeninapokea form data badala ya JSON? — Ndivyo standard ya OAuth2 password flow inavyotaka;/docsna zana nyingine zinategemea hilo. - Kwa nini ujumbe wa login ni mmoja kwa makosa mawili? — Ili mshambuliaji asijue kama username ipo.
SECRET_KEYikivuja 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
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
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
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
- Marudio ya kazi ya nyumbani
- Tasks zenye
owner_id; kulinda routes kwaCurrentUser; mtumiaji anaona zake tu - CORS: demo ya ukurasa wa HTML kwenye port 5500; error;
CORSMiddleware - Testing:
TestClient,pytest, fixtures, database ya test,dependency_overrides - Zoezi darasani
- 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 atumeowner_id(ndiyo maana haiko kwenyeTaskCreate). - 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
AuthorizationauContent-Type: application/json), browser kwanza inatuma preflight:OPTIONSrequest kuuliza ruhusa. CORSMiddlewareya FastAPI inajibu preflight na kuongeza headers.allow_originsiwe 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 yafastapi dev). API yake inafanana narequests:client.get(...),client.post(..., json=...),data=...,headers=....- pytest — inatafuta faili
test_*.pyna functionstest_*, inaziendesha, na kuripoti.assertikishindwa → test inafeli. - Fixture — function yenye
@pytest.fixtureinayoandaa kitu (mfano client, au token). Test inaiomba kwa jina la argument — hii ni dependency injection ya pytest! - Database ya test — hatutaki tests ziharibu
todo.dbyetu. Tunaunda database ya memory ("sqlite://") mpya kwa kila test, na kwaapp.dependency_overrides[get_session] = ...tunaiambia FastAPI: "badala yaget_sessionya kawaida, tumia hii".StaticPoolinahakikisha connections zote zinatumia database ileile ya memory. TestClient(app)bilawithhaiendeshilifespan, hivyotodo.dbhaiguswi kabisa.- Kuendesha:
pytestinahitaji kujua folda ya mradi iko kwenyesys.pathilifrom app.main import appifanye kazi. Njia mbili: (a)python -m pytest(inaongeza folda ya sasa), au (b) failipyproject.tomlyenyepythonpath = ["."]kishapytestpeke yake inafanya kazi. Tutafanya (b).
Sema hivi kwa mwanafunzi
"Kulinda route ni kama kuweka mlinzi mlangoni: unaongeza tu
current_user: CurrentUserna 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):
# ---------- 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:
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:
- Bila Authorize:
GET /tasks/→ 401. Angalia — kila endpoint ya tasks ina kufuli 🔒. - Authorize kama
amina→ unda tasks 2 →GET /tasks/→ 2. - Sajili
baraka, Logout kwenye Authorize, ingia kamabaraka→GET /tasks/→[].GET /tasks/1→ 404. Sema: "Hii ndiyo authorization: Baraka hawezi kuona tasks za Amina."
Hatua 3 — CORS demo. Unda frontend/index.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):
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:
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:
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:
[tool.pytest.ini_options] pythonpath = ["."] testpaths = ["tests"]
tests/conftest.py (pytest inaisoma yenyewe — fixtures zilizo humu zinapatikana kwa tests zote):
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:
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:
pytest -v
Output:
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
- Kwa nini
owner_idhaiko kwenyeTaskCreate? — Client angeweza kuunda task kwa jina la mtu mwingine; server inaiweka kutokacurrent_user. - Baraka akiomba
/tasks/1ya Amina anapata nini na kwa nini? — 404; hatutaki kufichua kwamba task ipo. - Kwa nini hatukuwahi kuona CORS error tukitumia
requestsau/docs? — CORS ni sheria ya browser kwa JavaScript ya origin tofauti;requestssi browser,/docsiko origin ileile. http://localhost:5500nahttp://127.0.0.1:5500ni origin moja? — Hapana! Majina ya host tofauti → origins tofauti. Ndiyo maana tumeweka zote mbili.- Fixture ni nini? — Function ya pytest inayoandaa kitu kwa ajili ya tests; test inaiomba kwa jina la argument.
- Kwa nini tunatumia database ya memory kwenye tests? — Ni haraka, mpya kila test (tests hazitegemeani), na haiharibu data halisi.
app.dependency_overridesinafanya nini? — Inabadilisha dependency (hapaget_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/naPOST /tasks/bila token → 401 - (c) PATCH
done=truekisha 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
# 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
- Marudio ya kazi ya nyumbani; tests zote za Kipindi 2 zipite
- Kusoma mahitaji ya mradi pamoja (orodha hapa chini)
- Mwanafunzi anaandika mwenyewe (mwalimu anaangalia screen yake na kusaidia tu akikwama)
- Tests za vipengele vipya;
pytestyote ipite - README, requirements.txt, .gitignore, push GitHub
- 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):
- Yote ya Kipindi 1–2 yanafanya kazi (register, token, me, CRUD ya tasks zinazomilikiwa, CORS).
GET /tasks/stats→{"jumla": n, "zimekamilika": k, "zimebaki": n-k}kwa mtumiaji aliyeingia.- Utafutaji:
GET /tasks/?q=nenoinachuja kwa title. POST /auth/change-passwordyenye body{"old_password": "...", "new_password": "..."}→ 204; password ya zamani ikiwa si sahihi → 400.SECRET_KEYkutoka environment variable (tayari kwenyeconfig.py) — README ieleze jinsi ya kuiweka.- Tests: angalau 12, zote zipite, zikiwemo za vipengele 2–4.
- Repo
todo-apiGitHub yenyeREADME.md,requirements.txt,.gitignore(.venv/,__pycache__/,*.db,.env),pyproject.toml.
Kuweka environment variable kwenye PowerShell (kwa session ya terminal hiyo tu):
$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:
/statsiwe 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:
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:
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}"):
@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:
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):
@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:
.venv/ __pycache__/ .pytest_cache/ *.db .env
README.md (mfano):
# 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:
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
- Nionyeshe: request ya
GET /tasks/5inapita njia gani kwenye code yako mpaka kupata jibu? — Routertasks→read_task→OwnTask→get_own_task→ (SessionDep,CurrentUser→get_current_user→oauth2_scheme→ token →decode_access_token) →session.get→ umiliki → response kupitiaTaskPublic. - Ukibadilisha
SECRET_KEYserver ikiwa inaendesha na watu wameingia, nini kinatokea? — Token zote za zamani zinakuwa batili (401); kila mtu aingie upya. - Kwa nini
change-passwordinahitaji password ya zamani wakati mtumiaji tayari ana token? — Kama token imeibiwa, mwizi asiweze kubadilisha password na kumfungia mwenye akaunti. - Kwa nini
.enviko kwenye.gitignore? — Ina siri (kamaSECRET_KEY). - 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
# 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.
- Kipaumbele (priority) na tarehe ya mwisho: ongeza
priority: int = Field(default=3, ge=1, le=5)nadue_date: date | None = NonekwenyeTaskBase(naTaskUpdate). Ongeza query parameterpangakwenyeGET /tasks/inayokubali"id","priority","due_date"(nyingine → 400). GET /tasks/overdue— tasks za mtumiaji ambazodue_dateimepita (ndogo kuliko leo) nadone == False.DELETE /auth/me— mtumiaji afute akaunti yake pamoja na tasks zake zote → 204.- Tests 3 mpya za vipengele hivi.
- Swali (andika kwenye README sehemu ya "Nilichojifunza"): Eleza kwa sentensi 3–5 safari ya token: kuanzia login mpaka
GET /tasks/.
Jibu
app/models.py
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
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
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
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/tokeninapokea form data na kurudishaaccess_token+token_type - Anatumia
Authorization: Bearer <token>kwarequestsna kwa/docs(Authorize) - Analinda endpoints kwa
CurrentUserna kutekeleza umiliki (owner_id) - Anaeleza origin na CORS, na kuweka
CORSMiddleware - Anaandika tests kwa
TestClient+ pytest fixtures +dependency_overrides -
pytestinapita yote (≥ 12) -
SECRET_KEYhaiko hardcoded kwenye repo kama siri halisi; inasomwa kutoka environment - Repo
todo-apiiko GitHub na README, requirements.txt, .gitignore sahihi - Mwanafunzi anaweza kuonyesha (demo) API yake akieleza kila sehemu