The current user
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”GET /me — endpoint ที่ตอบ “ฉันเป็นใคร และ profile ของฉันคืออะไร” /me ของบทที่แล้วแค่ echo id จาก token ตอนนี้ทำงานจริงแล้ว: เอา user id ที่ verify แล้ว, เปิด async database session, lookup row ที่ตรงกันใน profiles, แล้ว return ออกไป — หรือ 404 ถ้าบังเอิญไม่มี profile สำหรับ id นั้น
นี่คือ route แรกที่ compose dependency สอง ตัว: get_current_user (จาก Verify the Supabase JWT →) เพื่อยืนยัน ใคร และ get_session (จาก Async database →) เพื่อได้ connection ไว้อ่าน การจับคู่นั้น — “user id ที่ verify แล้วบวก session” — คือรูปร่างเป๊ะที่ทุก protected, data-touching route ใน FitTrack ใช้ ดังนั้น /me คือ template
JWT ถือ id ของ user ไว้ใน sub claim อยู่แล้ว ดังนั้น /me แบบ naive จะ return ค่านั้นแล้วจบก็ได้ แต่ “current user” ใน product หมายถึง profile ของ user — display_name ของเขา, เขา join เมื่อไหร่ — และนั่นอยู่ใน table profiles ไม่ใช่ใน token token พิสูจน์ identity; database ถือ record /me คือที่ที่ทั้งสองมาเจอกัน: authenticate จาก token แล้วอ่านจาก row ที่ key ด้วย identity นั้น
กลไกเป็นแค่ FastAPI dependency ซ้อนกัน user_id: CurrentUser รัน JWT check และ inject id session: Annotated[AsyncSession, Depends(get_session)] เปิด session ตลอดชีวิตของ request แล้วปิดให้หลังจากนั้น ดังนั้น handler ไม่เคยจัดการ connection lifecycle เอง ทั้งคู่รันก่อน body; ถ้า token เสีย handler ไม่เคย execute ดังนั้นเมื่อโค้ดคุณรัน คุณมี id ที่น่าเชื่อถือและ session ที่มีชีวิตอยู่แล้ว สังเกตว่า id ที่เรา key คือ sub ของ token ดังนั้น /me return ได้แค่ profile ของคุณเอง เท่านั้น — ไม่มี id ใน URL ให้ดัดแปลง คุณสมบัตินั้น, “identity มาจาก token ที่ verify แล้ว ไม่เคยจาก input ที่ client ส่ง,” คือกระดูกสันหลังของ authorization ของ FitTrack
หมายเหตุตรง ๆ หนึ่งเรื่องเกี่ยวกับลำดับ: typed SQLAlchemy Profile model ยังไม่มี — เป็นเนื้อหาของ module ถัดไปพอดี ดังนั้น /me อ่าน profile ด้วย raw SQL query แบบ parameterized ผ่าน session ไปก่อน นั่นไม่ใช่ hack แต่เป็นวิธีที่ถูกต้องในการอ่านหนึ่ง row และช่วยให้บทนี้ focus ที่ pattern auth-บวก-session แทนที่จะดึงทั้ง ORM มาก่อน The domain model → แนะนำ Profile model และ repository แล้ว API ของ Exercises และ Workouts อ่านผ่านพวกนั้นแทน raw SQL การได้เห็นทั้งสองทำให้คุณค่าของ model ชัดเจน
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”derive user จาก sub ของ token เทียบกับ รับ user id เป็น path/query parameter
- Pros: ผู้เรียกทำได้แค่ในนามตัวเองเท่านั้น — ไม่มี id ใน request ที่เขาเปลี่ยนเพื่ออ่าน profile ของคนอื่นได้ ดังนั้น bug ทั้งชนิด “insecure direct object reference” เป็นไปไม่ได้โดยโครงสร้าง; handler ไม่ต้องการ ownership check เพิ่มสำหรับ
/me - Cons: pattern นี้ fit แค่ endpoint “current user” — อะไรที่ legitimately อ้างถึง resource อื่น ด้วย id (workout เฉพาะตัว, exercise) ยังต้องการ ownership check ชัด ๆ ใน query ดังนั้น pattern นี้เป็น default ที่แข็งแรง ไม่ใช่ universal; route ทีหลัง combine “id จาก token” กับ filter “and
where user_id = :me”
อ่าน /me ด้วย raw parameterized query ตอนนี้ เทียบกับ รอสร้าง ORM model ก่อน
- Pros:
/meship ใน auth module ที่ควรอยู่ โดยไม่มี forward dependency บนโค้ดที่ยังไม่มี; การอ่านแบบ parameterized เดียวเล็ก, ชัด, และปลอดภัยจาก injection เพราะค่าถูก bind ไม่เคย interpolate - Cons: ผลลัพธ์เป็น row ที่ไม่ typed แทนที่จะเป็น
Profile/ProfileReadที่ validate แล้ว ดังนั้นยังไม่มี schema บังคับรูปร่าง response และ query นี้ถูกเขียนใหม่ module ถัดไป นั่นเป็นชิ้นใช้แล้วทิ้งที่ตั้งใจและจำกัดไว้ — ทางเลือกคือลาก domain layer ทั้งหมดเข้ามาในบท auth
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. app/main.py
หัวข้อที่มีชื่อว่า “1. app/main.py”แทนที่ /me แบบ echo ของบทที่แล้วด้วยตัวที่อ่าน profile ตอนนี้ handler พึ่ง session ด้วย:
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, statusfrom sqlalchemy import textfrom sqlalchemy.ext.asyncio import AsyncSession
from app.auth import CurrentUserfrom app.db import get_session
app = FastAPI(title="FitTrack API")
@app.get("/health")def health() -> dict[str, str]: """Liveness check — no auth, no database, just proof the app is up.""" return {"status": "ok"}
@app.get("/me")async def read_me( user_id: CurrentUser, session: Annotated[AsyncSession, Depends(get_session)],) -> dict: """Return the current user's profile.
The id comes from the verified token, so this only ever returns the caller's own row. A 404 means the JWT is valid but no profile row was provisioned for that user. """ result = await session.execute( text( "select id, display_name, created_at " "from profiles where id = :id" ), {"id": user_id}, ) row = result.mappings().first() if row is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found", ) return dict(row)มีสองรายละเอียดที่ควรพูดถึง query ใช้ bound parameter (:id กับ {"id": user_id}) ไม่เคย string interpolation — ค่าเปลี่ยน SQL ไม่ได้ และ get_session (async) ทำให้ทั้ง handler เป็น async def ดังนั้นการอ่าน database ถูก await โดยไม่ block event loop response เป็น dict ธรรมดาไปก่อน; module ถัดไปจะให้ schema ProfileRead
2. แถว profile มาจากไหน
หัวข้อที่มีชื่อว่า “2. แถว profile มาจากไหน”/me return 404 เมื่อไม่มี profiles row สำหรับ id นั้น ใน FitTrack row นั้นถูกสร้างอัตโนมัติในทันทีที่ user sign up: Supabase module เพิ่ม trigger บน auth.users ที่ insert profiles row ที่ตรงกัน (ดู Auth & RLS →) ดังนั้นในการทำงานปกติทุก token ที่ valid มี profile อยู่เบื้องหลัง และ 404 ตรงนี้ส่งสัญญาณว่า user ถูกสร้างก่อน trigger นั้นมีอยู่ — failure ที่มีประโยชน์และตรงไปตรงมา แทนที่จะเป็น crash
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”start API แล้วคว้า token เป๊ะเหมือนบทที่แล้ว:
uv run fastapi dev app/main.pyTOKEN=$(curl -s "http://127.0.0.1:54321/auth/v1/token?grant_type=password" \ -H "apikey: $SUPABASE_ANON_KEY" \ -H "Content-Type: application/json" \ -d '{"email":"lifter@example.com","password":"password123"}' | jq -r .access_token)ดึง profile ของคุณ — คราวนี้คุณได้ row ไม่ใช่แค่ id:
curl -s localhost:8000/me -H "Authorization: Bearer $TOKEN"{"id":"3f4a1c2e-9b7d-4e1a-8c6f-2d5b0a1e7c93","display_name":"","created_at":"2026-07-14T09:12:44.201Z"}ยืนยันว่า guard ยังทำงาน — ไม่มี token เป็น 401 (auth dependency รันก่อน ก่อนงาน database ใด ๆ):
curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/me401เพื่อ check แบบรันจริงว่า path not-found ทำงาน ลบ profile row ของคุณชั่วคราวแล้วเรียก /me อีกครั้ง — token ที่ valid ที่ไม่มี profile ต้องเป็น 404 ไม่ใช่ 500:
psql "$DATABASE_URL" -c "delete from profiles where id = '3f4a1c2e-9b7d-4e1a-8c6f-2d5b0a1e7c93';"curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/me -H "Authorization: Bearer $TOKEN"404(re-run Supabase seed ของคุณ หรือ sign up ใหม่ เพื่อคืน row) สามผลลัพธ์ — 200 พร้อม row, 401 ไม่มี token, 404 สำหรับ token ที่ valid ที่ไม่มี profile — หมายถึง endpoint compose auth และ database ถูกต้อง
ตรวจสอบความเข้าใจ:
/meไม่รับ id ใน URL แต่ return profile ของคุณ ไม่ใช่ของใครอื่น id ที่ใช้ query มาจากไหน และทำไมนั่นทำให้การโจมตี “act as another user” เป็นไปไม่ได้ที่นี่?- dependency สองตัวรันก่อน body ของ
read_meแต่ละตัวให้อะไร และเกิดอะไรกับ database query ถ้า token ไม่ valid? - ทำไม SQL เขียนด้วย bound
:idparameter และ{"id": user_id}แทนที่จะ format id ลงใน query string? - token ที่ valid return
404จาก/meกรณีนี้บอกอะไรคุณเฉพาะเจาะจง — และทำไมถึงไม่ใช่สถานการณ์เดียวกับ401?
GET /me คือ protected endpoint ที่ realize เต็มตัวตัวแรกของ FitTrack และเป็น template สำหรับทุกตัวที่ตามมา: Depends(get_current_user) ยืนยัน ใคร จาก JWT ที่ verify แล้ว, Depends(get_session) ให้ async session ไว้อ่าน, และ handler lookup profiles row ที่ key ด้วย sub ของ token — return row นั้น หรือ 404 เมื่อไม่มี profile เพราะ id มาจาก token และไม่เคยจาก URL, /me return ได้แค่ record ของผู้เรียกเอง ซึ่งคือ authorization pattern ที่ทั้ง API พึ่งพา เราอ่านด้วย parameterized raw query ไปก่อนเพราะ ORM model มาถัดไป; เรา verify 200 / 401 / 404 กับ Supabase token จริง “typed model” นั้นคือสิ่งที่มาต่อไปพอดี: The domain model → สร้าง SQLAlchemy model, Pydantic schema, และ repository ที่แทนที่ raw query นี้และ back ทุก endpoint ตั้งแต่ Exercises เป็นต้นไป