The exercise catalog CRUD
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”resource router ตัวแรกใน FitTrack: app/routers/exercises.py เปิด route สามตัวเหนือ model Exercise, schemas และ ExerciseRepo ที่คุณสร้างไว้ใน The Domain Model →:
GET /exercises— catalog ที่ คุณ เห็นได้: exercise สาธารณะทุกตัว (ตัว global ที่ seed มา) บวกกับตัว private ที่คุณสร้างเองPOST /exercises— สร้าง exercise ใหม่ที่คุณเป็นเจ้าของGET /exercises/{id}— ดึง exercise ตัวเดียว ถ้าเป็นตัวที่คุณเห็นได้
ทุก route ต้องผ่าน auth: แต่ละตัวรับ Depends(get_current_user) เพื่อรู้ว่า ใคร เป็นคนถาม และ Depends(get_session) เพื่อขอ database session แล้วส่งงานจริงต่อให้ ExerciseRepo บทนี้วางรูปแบบที่ router ทุกตัวหลังจากนี้จะทำตาม — HTTP layer บาง ๆ เหนือ repository — และวางกฎ visibility (สาธารณะหรือของฉัน) ที่ catalog ทั้งหมดหมุนรอบ จากนั้น Ownership & validation → จะเพิ่มครึ่งที่แก้ข้อมูล (PATCH/DELETE)
Exercise มี flag is_public และ created_by ที่เป็น null ได้ การจับคู่แบบนี้เข้ารหัส exercise สองแบบไว้ในตัวเดียว: global catalog entries (seed มา, is_public = true, created_by = null — “Barbell Squat”, “Bench Press”) ที่ทุกคนใช้ร่วมกัน และ personal exercises ที่ user คิดขึ้นเอง (created_by = ตัวเขา ปกติเป็น private) บรรทัดที่สำคัญที่สุดใน module นี้จึงเป็นกฎ visibility: คุณเห็น exercise ได้ถ้าเป็นสาธารณะ หรือ คุณสร้างเอง การเขียนกฎนี้ให้เป็น SQL WHERE clause — ไม่ใช่ Python loop วนทุกแถว — คือสิ่งที่ทำให้ catalog ทั้งถูกต้องและ scale ได้
ตัว router เองถูกออกแบบให้ บาง อย่างตั้งใจ งานของ FastAPI ใน exercises.py คือแปลง HTTP เป็นการเรียกฟังก์ชัน แล้วแปลงผลลัพธ์กลับเป็น HTTP: ดึง user id จาก token, รับ session, เรียก repository method หนึ่งตัว แล้วคืนผลลัพธ์ ส่วน SQL ทั้งหมดอยู่ใน ExerciseRepo การแยกแบบนี้คือเหตุผลที่ repository เดียวกัน reuse ได้ทีหลังทั้งจาก test และจาก workouts module โดยไม่ต้องลาก FastAPI ไปด้วย และเป็นเหตุผลที่ route handler แต่ละตัวเหลือแค่สามสี่บรรทัดที่อ่านง่าย
จุดละเอียดสุดท้ายคือ “not found” หมายความว่าอะไร GET /exercises/{id} ด้วย id ที่ไม่มีอยู่ ชัดเจนว่าเป็น 404 แต่ GET สำหรับ exercise private ที่คนอื่นเป็นเจ้าของ ก็เป็น 404 เช่นกัน — ไม่ใช่ 403 — เพราะการบอกคนนอกว่า “ตัวนี้มีอยู่นะแต่คุณเอาไปไม่ได้” คือการเปิดเผยว่ามีอยู่ตั้งแต่แรก สำหรับการอ่าน คุณอาจเห็นสิ่งนั้นได้ หรือไม่ก็ในมุมของคุณคือไม่มีอยู่ (Ownership & validation → ลากเส้นตรงข้ามสำหรับ write บน exercise ที่คุณ เห็นได้)
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Filtering visibility in the SQL query vs. fetching every exercise and filtering in Python
- Pros: database ทำสิ่งที่ถูกสร้างมาเพื่อทำ —
where is_public or created_by = :meที่ทำ index ได้คืนเฉพาะแถวที่ user เห็นได้ ดังนั้น app ไม่เคยโหลดข้อมูล private ของคนอื่นเข้า memory, ขนาด response ถูกจำกัดด้วยสิ่งที่เกี่ยวข้อง และกฎอยู่ในที่เดียวเป๊ะ ๆ ยังเร็วอยู่แม้ catalog จะโตเป็นพันแถว - Cons: ตอนนี้ predicate ของ visibility เป็น SQL ที่คุณต้องอ่านเพื่อเข้าใจ access control แทนที่จะเป็น Python
ifที่เห็นชัด ๆ และความผิดพลาดเล็ก ๆ ในWHEREclause คือ bug ข้อมูลรั่ว จึงคู่ควรกับ test ความปลอดภัยและ performance ชนะขาด — filter ใน Python แปลว่าต้อง fetch ทุกอย่างมาก่อน ซึ่งทั้งช้ากว่าและเป็นหลุมความเป็นส่วนตัวที่ซ่อนอยู่
A thin router delegating to ExerciseRepo vs. writing the select() directly in the route handler
- Pros: handler อ่านออกมาเป็น intent (“list exercise ที่ user นี้เห็นได้”), SQL ทำ unit-test ได้โดยไม่ต้องมี HTTP client และ module ของ workouts กับ progress reuse repository style เดียวกัน การเปลี่ยนวิธีที่ query ทำงานไม่แตะ route เลย
- Cons: นี่คืออีกหนึ่ง layer และอีกหนึ่งไฟล์สำหรับสิ่งที่วันนี้เป็นแค่ query บรรทัดเดียว — indirection ที่คุณไม่จำเป็นต้องมีจริง ๆ สำหรับสาม route แต่คุ้มค่าทันทีที่ query ถูก reuse หรืองอก join ขึ้นมา ซึ่งทุก resource ในนี้ล้วนไปถึงจุดนั้นในที่สุด
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. app/repositories/exercise.py
หัวข้อที่มีชื่อว่า “1. app/repositories/exercise.py”router เรียก method เหล่านี้ ส่วน SQL ที่บังคับ visibility อยู่ตรงนี้
# app/repositories/exercise.py — data access for the exercise catalog.import uuid
from sqlalchemy import or_, selectfrom sqlalchemy.ext.asyncio import AsyncSession
from app.models.exercise import Exercisefrom app.schemas.exercise import ExerciseCreate
class ExerciseRepo: """Async data access for exercises. Takes a live AsyncSession."""
def __init__(self, session: AsyncSession) -> None: self.session = session
async def list_visible(self, user_id: uuid.UUID) -> list[Exercise]: """Every public exercise plus the caller's own — the whole rule is this one WHERE clause; nothing the user may not see is loaded.""" result = await self.session.execute( select(Exercise) .where(or_(Exercise.is_public.is_(True), Exercise.created_by == user_id)) .order_by(Exercise.name) ) return list(result.scalars().all())
async def create(self, data: ExerciseCreate, owner_id: uuid.UUID) -> Exercise: exercise = Exercise( name=data.name, muscle_group=data.muscle_group, is_public=data.is_public, created_by=owner_id, # the creator owns it; never trust a client-sent owner ) self.session.add(exercise) await self.session.commit() await self.session.refresh(exercise) return exercise
async def get(self, exercise_id: uuid.UUID) -> Exercise | None: """Fetch by id with no visibility filter — the caller decides what to do with a row the user isn't allowed to see.""" return await self.session.get(Exercise, exercise_id)2. app/routers/exercises.py
หัวข้อที่มีชื่อว่า “2. app/routers/exercises.py”# app/routers/exercises.py — HTTP for the exercise catalog. Thin: it maps# requests to ExerciseRepo calls and results back to responses.import uuid
from fastapi import APIRouter, Depends, HTTPException, statusfrom sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_userfrom app.db import get_sessionfrom app.models.exercise import Exercisefrom app.repositories.exercise import ExerciseRepofrom app.schemas.exercise import ExerciseCreate, ExerciseRead
router = APIRouter(prefix="/exercises", tags=["exercises"])
@router.get("", response_model=list[ExerciseRead])async def list_exercises( user_id: uuid.UUID = Depends(get_current_user), session: AsyncSession = Depends(get_session),) -> list[Exercise]: """The visible catalog: public exercises plus the caller's own.""" return await ExerciseRepo(session).list_visible(user_id)
@router.post("", response_model=ExerciseRead, status_code=status.HTTP_201_CREATED)async def create_exercise( data: ExerciseCreate, user_id: uuid.UUID = Depends(get_current_user), session: AsyncSession = Depends(get_session),) -> Exercise: """Create an exercise owned by the caller.""" return await ExerciseRepo(session).create(data, owner_id=user_id)
@router.get("/{exercise_id}", response_model=ExerciseRead)async def get_exercise( exercise_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user), session: AsyncSession = Depends(get_session),) -> Exercise: """A single exercise — but only if the caller may see it. A private exercise owned by someone else is a 404, not a 403: we don't reveal that it exists.""" exercise = await ExerciseRepo(session).get(exercise_id) if exercise is None or not (exercise.is_public or exercise.created_by == user_id): raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Exercise not found") return exercise3. ต่อ router เข้า app/main.py
หัวข้อที่มีชื่อว่า “3. ต่อ router เข้า app/main.py”main.py ยังเล็กอยู่ — แค่ include router (ตามที่ The Python toolchain → สัญญาไว้ว่าจะเป็นแบบนี้เสมอ):
from fastapi import FastAPI
from app.routers import exercises
app = FastAPI(title="FitTrack API")
app.include_router(exercises.router)
@app.get("/health")def health() -> dict[str, str]: return {"status": "ok"}ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”เริ่ม API (uv run fastapi dev app/main.py) พร้อมกับ local Supabase stack ที่รันอยู่ ทุก route ต้องผ่าน auth ดังนั้นก่อนอื่นขอ JWT จาก Supabase Auth ด้วยการ sign in เข้า local GoTrue server (ถ้าคุณยังไม่ได้สร้าง user เปลี่ยน token?grant_type=password เป็น signup)
# Grab the anon key printed by `supabase status`, then sign in for a token.export SUPABASE_ANON_KEY="eyJhbGciOiExample.anon.key"
TOKEN=$(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":"you@example.com","password":"password123"}' | jq -r .access_token)
echo "${TOKEN:0:16}..." # a JWT: eyJhbGciOiJIUzI1...list catalog — ด้วย database ใหม่ คุณจะเห็น exercise สาธารณะอะไรก็ตามที่ seed สร้างไว้ (ว่างก็ไม่เป็นไร):
curl -s localhost:8000/exercises -H "Authorization: Bearer $TOKEN" | jq[]สร้างตัวของคุณเองสักตัว แล้วเก็บ id ไว้:
ID=$(curl -s -X POST localhost:8000/exercises \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Bulgarian Split Squat","muscle_group":"legs","is_public":false}' \ | tee /dev/stderr | jq -r .id){ "id": "8f2c1e6a-6d1b-4e2a-9c33-6a0b2d4e5f67", "name": "Bulgarian Split Squat", "muscle_group": "legs", "is_public": false, "created_by": "b1e7...your-user-id...", "created_at": "2026-07-14T09:12:00Z"}ดึงกลับมาด้วย id แล้วยืนยันกฎ missing-vs-forbidden — id มั่ว ๆ ให้ 404:
curl -s localhost:8000/exercises/$ID -H "Authorization: Bearer $TOKEN" | jq .namecurl -s -o /dev/null -w "%{http_code}\n" \ localhost:8000/exercises/00000000-0000-0000-0000-000000000000 \ -H "Authorization: Bearer $TOKEN""Bulgarian Split Squat"404สุดท้าย ยืนยันตัว gate เอง: ถ้า ไม่มี token ทุก route เป็น 401
curl -s -o /dev/null -w "%{http_code}\n" localhost:8000/exercises401ตรวจสอบความเข้าใจ:
- ทำไมกฎ public-or-mine ถึงเขียนเป็น SQL
WHEREclause แทนที่จะ fetch exercise ทุกตัวมาแล้ว filter ใน Python? บอกทั้งเหตุผลด้าน performance และด้าน privacy GET /exercises/{id}คืน404สำหรับ exercise private ที่คุณไม่ได้เป็นเจ้าของ แม้ว่าแถวนั้นจะมีอยู่จริง การคืน403แทนจะเปิดเผยอะไร?- route handler แต่ละตัวมีสามหรือสี่บรรทัด SQL จริง ๆ อยู่ที่ไหน และการแยกแบบนั้นให้ประโยชน์อะไรตอนคุณเขียน test?
create()เซ็ตcreated_byจากget_current_userไม่เคยจาก request body ทำไม ownership ต้องมาจาก token ไม่ใช่จาก client?
app/routers/exercises.py คือ resource router ตัวแรกของ FitTrack: GET /exercises (สาธารณะ + ของตัวเอง), POST /exercises (เจ้าของคือ caller) และ GET /exercises/{id} — แต่ละตัวผ่าน auth ด้วย Depends(get_current_user) และได้ session จาก Depends(get_session) router ยัง บาง อยู่ ส่งทุก query ต่อให้ ExerciseRepo ซึ่ง list_visible เขียนกฎ access ทั้งหมดเป็น where is_public or created_by = :me clause เดียว ดังนั้นไม่มีอะไรที่ user เห็นไม่ได้ถูกโหลดเลย exercise ตัวเดียวที่ caller เห็นไม่ได้คืน 404 ไม่ใช่ 403 เพื่อไม่ให้รั่วว่ามีอยู่จริง และ router ต่อสายเข้า app/main.py ด้วย include_router คุณตรวจสอบครบ end to end ด้วย Supabase JWT จริงกับ curl ต่อไป Ownership & validation → เพิ่ม PATCH กับ DELETE — ที่ซึ่ง “คุณเห็นได้แต่แก้ไม่ได้” คือ 403 — และวาง Pydantic validation ทับ write path