Ownership & validation
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”ครึ่งที่แก้ข้อมูลของ app/routers/exercises.py วางทับ read route จาก The exercise catalog CRUD →:
PATCH /exercises/{id}— update แบบ partial ของ exercise ที่คุณเป็นเจ้าของDELETE /exercises/{id}— ลบ exercise ที่คุณเป็นเจ้าของ
ทั้งสองตัวเป็น own-only: มีแค่ user ใน column created_by เท่านั้นที่แก้หรือลบแถวได้ นั่นให้ failure mode สองแบบที่ต้องทำให้ถูก — 404 เมื่อ exercise ไม่มีอยู่, 403 เมื่อมีอยู่แต่เป็นของคนอื่น — และเป็นที่แรกที่ FitTrack ต้องตัดสินใจว่าจะคืนตัวไหน ควบคู่กับ ownership เราพึ่ง Pydantic v2 validation: schema ExerciseUpdate ที่ field constraint ปฏิเสธ input ไร้สาระด้วย 422 ก่อนโค้ดของเราจะรัน ทั้งสองอย่างรวมกันเป็น guardrail ที่ทุก write endpoint ใน API reuse
read กับ write ลากเส้น 403/404 ไว้คนละที่ ตรงข้าม กัน และเหตุผลคือสิ่งที่ caller ได้รับอนุญาตให้รู้ ตอน read exercise private ที่คุณไม่ได้เป็นเจ้าของคือ 404 — คุณไม่ควรรู้ด้วยซ้ำว่ามีอยู่จริงหรือเปล่า แต่เป้าหมายของ PATCH/DELETE คือสิ่งที่คุณ เห็นได้: exercise ใน global catalog (is_public = true) ทุกคนเห็นได้ และบางคนอาจลองแก้ “Bench Press” exercise นั้นมีอยู่จริง ๆ — การแสร้งว่าไม่มีด้วย 404 คือการโกหกที่ client พิสูจน์หักล้างได้ คำตอบที่ซื่อสัตย์คือ 403 Forbidden: มีจริง คุณแค่ไม่ได้เป็นเจ้าของ ดังนั้น write path เช็ค existence ก่อน (404 ถ้าแถวหายไป) แล้วเช็ค ownership ทีหลัง (403 ถ้า created_by ไม่ใช่คุณ) exercise global ที่ seed มามี created_by = null ดังนั้นไม่มีใครเป็นเจ้าของ และ ไม่มีใคร แก้ได้ — ถูกต้องเป๊ะสำหรับ catalog ที่ใช้ร่วมกัน
PATCH เป็น update แบบ partial และ Pydantic v2 ทำให้เรื่องนี้สะอาด ExerciseUpdate มีทุก field เป็น optional และ model_dump(exclude_unset=True) คืนเฉพาะ key ที่ client ส่งมาจริง ๆ — ดังนั้น {"name": "New Name"} เปลี่ยนชื่อโดยไม่แตะ muscle_group หรือ is_public และ field ที่ client ละไว้ถูกทิ้งไว้เหมือนเดิมเป๊ะ นั่นคือความต่างระหว่าง PATCH (เปลี่ยน field พวกนี้) กับ PUT (แทนที่ทั้งก้อน) FitTrack ใช้ PATCH เพราะ client ที่แก้ field เดียวไม่ควรต้องส่ง field อื่นกลับมาแล้วเสี่ยงทับของเดิมทิ้ง
validation อยู่ที่ ประตู ทุก constraint — name ที่ไม่ว่าง, ความยาวมีขอบเขต, boolean จริง — อยู่บน Pydantic schema แบบ declarative ดังนั้น FastAPI validate body ก่อน เข้า handler ของคุณ แล้วคืน 422 ที่แม่นยำพร้อม field ที่ผิด ถ้ามีอะไรผิด คุณไม่เคยเขียน if not name: raise ... — schema คือ specification, error message มาให้ฟรี และ handler ของคุณรันกับข้อมูลที่รู้อยู่แล้วว่าอยู่ในรูปที่ถูกต้องเสมอ
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”403 for a resource you can see but don’t own vs. 404 to hide its existence on writes
- Pros:
403คือ status ที่ซื่อสัตย์สำหรับ catalog — exercise สาธารณะทุกคนเห็นได้ ดังนั้นการปฏิเสธการแก้ด้วย404(“ไม่มี exercise นี้”) คือคำกล่าวอ้างที่ client หักล้างได้ง่าย ๆ ด้วยการอ่านรายการนั้นเมื่อครู่นี้เอง403บอกสิ่งที่เป็นจริงเป๊ะ ๆ และบอกด้วยว่า client ทำอะไรกับแถวนั้นไม่ได้ ซึ่ง handle ได้ถูกต้องง่ายกว่า - Cons: การแยก “มีอยู่” ออกจาก “ของคุณ” แปลว่า response ยืนยันว่าแถวมีอยู่ให้กับคนที่แก้ไม่ได้ — เป็นการเปิดเผยข้อมูลเล็กจิ๋ว สำหรับ exercise private ที่คุณไม่ได้เป็นเจ้าของ คุณจะอยากได้
404(และ read ก็ให้แบบนั้นอยู่แล้ว) แต่ exercise สาธารณะของ catalog ทำให้403เป็นตัวเลือกที่ถูกสำหรับ write ถึงอย่างนั้นก็เป็นดุลยพินิจ ไม่ใช่กฎตายตัว
Declarative Pydantic field constraints vs. hand-written validation inside the handler
- Pros: constraint (
min_length,max_length, types) นั่งอยู่บน schema เป็น spec เดียวที่อ่านง่าย FastAPI บังคับใช้ก่อน handler รันแล้วคืน422ที่มีโครงสร้างพร้อมระบุ field ที่ผิด ทั้งหมด document อัตโนมัติใน/docshandler เหลือแต่ business logic ล้วน ๆ - Cons: กฎที่ข้าม field หรือขึ้นกับ context มาก ๆ (“ชื่อนี้ต้องไม่ซ้ำสำหรับ user นี้”) เขียนเป็น field constraint ง่าย ๆ ไม่ได้ ยังต้องเช็คใน handler หรือ repository และคุณต้องรู้ศัพท์ validator ของ Pydantic สำหรับกฎรูปร่างของ input ธรรมดา ๆ แบบ declarative ชนะขาด
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. app/schemas/exercise.py
หัวข้อที่มีชื่อว่า “1. app/schemas/exercise.py”เพิ่ม partial-update schema ควบคู่กับ ExerciseCreate/ExerciseRead ทุก field เป็น optional และ constraint ตรงกับ ExerciseCreate
# app/schemas/exercise.py — add ExerciseUpdate to the existing schemas.from pydantic import BaseModel, Field
class ExerciseUpdate(BaseModel): """Partial update: every field optional. Only the keys the client sends are applied (see the router's exclude_unset)."""
name: str | None = Field(default=None, min_length=1, max_length=120) muscle_group: str | None = Field(default=None, min_length=1, max_length=60) is_public: bool | None = None2. app/repositories/exercise.py
หัวข้อที่มีชื่อว่า “2. app/repositories/exercise.py”เพิ่มอีกสอง method บน ExerciseRepo — update apply เฉพาะ field ที่ถูกเซ็ต, delete ลบแถว ทั้งคู่ commit
# app/repositories/exercise.py — add to ExerciseRepo.from app.schemas.exercise import ExerciseUpdate
class ExerciseRepo: # ... __init__, list_visible, create, get from the previous lesson ...
async def update(self, exercise: Exercise, data: ExerciseUpdate) -> Exercise: """Apply only the fields the client sent; leave the rest untouched.""" for field, value in data.model_dump(exclude_unset=True).items(): setattr(exercise, field, value) await self.session.commit() await self.session.refresh(exercise) return exercise
async def delete(self, exercise: Exercise) -> None: await self.session.delete(exercise) await self.session.commit()3. app/routers/exercises.py
หัวข้อที่มีชื่อว่า “3. app/routers/exercises.py”ทั้งสอง route ทำตามรูปเดียวกัน: โหลดแถว, 404 ถ้าไม่มี, 403 ถ้าไม่ใช่ของคุณ แล้วค่อยทำ แยกขั้นตอน “load and authorise” ออกเป็น helper เล็ก ๆ เพื่อไม่ให้ PATCH กับ DELETE ค่อย ๆ เพี้ยนจากกัน
# app/routers/exercises.py — add to the router from the previous lesson.from app.schemas.exercise import ExerciseUpdate
async def _load_owned( exercise_id: uuid.UUID, user_id: uuid.UUID, repo: ExerciseRepo) -> Exercise: """Fetch an exercise the caller is allowed to *modify*, or raise. 404 when it doesn't exist; 403 when it exists but isn't theirs (public catalog exercises are visible to all but owned by none).""" exercise = await repo.get(exercise_id) if exercise is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Exercise not found") if exercise.created_by != user_id: raise HTTPException( status.HTTP_403_FORBIDDEN, detail="You do not own this exercise" ) return exercise
@router.patch("/{exercise_id}", response_model=ExerciseRead)async def update_exercise( exercise_id: uuid.UUID, data: ExerciseUpdate, user_id: uuid.UUID = Depends(get_current_user), session: AsyncSession = Depends(get_session),) -> Exercise: repo = ExerciseRepo(session) exercise = await _load_owned(exercise_id, user_id, repo) return await repo.update(exercise, data)
@router.delete("/{exercise_id}", status_code=status.HTTP_204_NO_CONTENT)async def delete_exercise( exercise_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user), session: AsyncSession = Depends(get_session),) -> None: repo = ExerciseRepo(session) exercise = await _load_owned(exercise_id, user_id, repo) await repo.delete(exercise)ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”ขอ token เหมือนใน บทที่แล้ว แล้วสร้าง exercise ที่คุณเป็นเจ้าของเพื่อให้มีอะไรให้แก้:
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)
ID=$(curl -s -X POST localhost:8000/exercises \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"name":"Split Squat","muscle_group":"legs","is_public":false}' | jq -r .id)เปลี่ยนชื่อด้วย partial update — ส่งแค่ name และ muscle_group อยู่รอดโดยไม่ถูกแตะ:
curl -s -X PATCH localhost:8000/exercises/$ID \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"name":"Bulgarian Split Squat"}' | jq '{name, muscle_group}'{ "name": "Bulgarian Split Squat", "muscle_group": "legs"}ยืนยัน validation gate: name ว่างละเมิด min_length=1 FastAPI จึงปฏิเสธด้วย 422 ก่อน handler รัน — ไม่มีแถวแย่ ๆ ถูกเขียนลงไปเลย:
curl -s -X PATCH localhost:8000/exercises/$ID \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"name":""}' | jq '.detail[0] | {loc, msg}'{ "loc": ["body", "name"], "msg": "String should have at least 1 character"}ยืนยัน ownership: การแก้ exercise ใน global catalog (ตัวที่ created_by = null ซึ่งคุณอ่านได้แต่ไม่ได้เป็นเจ้าของ) เป็น 403 ส่วน id ที่ไม่มีอยู่เป็น 404:
# PUBLIC_ID = the id of any seeded is_public exercise from GET /exercisescurl -s -o /dev/null -w "forbidden: %{http_code}\n" -X DELETE \ localhost:8000/exercises/$PUBLIC_ID -H "Authorization: Bearer $TOKEN"curl -s -o /dev/null -w "missing: %{http_code}\n" -X DELETE \ localhost:8000/exercises/00000000-0000-0000-0000-000000000000 \ -H "Authorization: Bearer $TOKEN"forbidden: 403missing: 404สุดท้าย ลบตัวที่คุณเป็นเจ้าของแล้วยืนยันว่าหายไปจริง:
curl -s -o /dev/null -w "delete: %{http_code}\n" -X DELETE \ localhost:8000/exercises/$ID -H "Authorization: Bearer $TOKEN"curl -s -o /dev/null -w "get: %{http_code}\n" \ localhost:8000/exercises/$ID -H "Authorization: Bearer $TOKEN"delete: 204get: 404ตรวจสอบความเข้าใจ:
- ตอน read exercise private ที่คุณไม่ได้เป็นเจ้าของเป็น
404ตอน write exercise ที่คุณไม่ได้เป็นเจ้าของเป็น403ทำไมสอง path ถึงเลือก status ต่างกันสำหรับ “ไม่ใช่ของคุณ”? model_dump(exclude_unset=True)คืนอะไรสำหรับ body{"name": "X"}และทำให้PATCHทิ้งmuscle_groupไว้เหมือนเดิมอย่างไร?- การส่ง
{"name": ""}คืน422และไม่เคยไปถึง handler ของคุณ layer ไหนเป็นคนปฏิเสธ และกฎนั้นประกาศไว้ที่ไหน? - exercise global ที่ seed มามี
created_by = nullไล่ดูว่า_load_ownedคืนอะไรกับแถวแบบนั้น และทำไมนั่นคือพฤติกรรมที่คุณต้องการสำหรับ catalog ที่ใช้ร่วมกัน
ตอนนี้ exercise catalog เป็น CRUD เต็ม: PATCH /exercises/{id} และ DELETE /exercises/{id} มาสมทบกับ read route ทั้งคู่เป็น own-only helper _load_owned ที่ใช้ร่วมกันบังคับลำดับที่สำคัญ — 404 เมื่อแถวไม่มีอยู่, 403 เมื่อมีอยู่แต่ created_by ไม่ใช่ caller — ซึ่งคือคำตอบที่ซื่อสัตย์สำหรับ catalog ที่ exercise สาธารณะทุกคนเห็นได้แต่ไม่มีใครนอกจากเจ้าของแก้ได้ PATCH เป็น partial update จริง ๆ ผ่าน ExerciseUpdate + model_dump(exclude_unset=True) apply เฉพาะ field ที่ส่งมา และ Pydantic v2 field constraint ปฏิเสธ input ที่ผิดรูปด้วย 422 ที่ประตู ก่อนโค้ด handler ใด ๆ จะรัน คุณตรวจสอบ partial update, 422 และ matrix 403/404/204 ด้วย curl นั่นทำให้ Exercises API เสร็จสมบูรณ์ — ต่อไป Workouts API → log training session ทั้งหมดพร้อม set ทุกตัวใน transaction เดียว