บทเรียน 55 · Day 9 · โจทย์สอบกลางภาค

อ่านโจทย์สอบกลางภาคให้ขาด ก่อนลงมือเขียน

~25 นาที · ควรผ่าน บทเรียน 35 (query · body · header) และ บทเรียน 53 (ตาราง users) มาก่อน

บทนี้มาจากสไลด์ Day 9 ฉบับหลังเรียน หน้า 65–66
สไลด์ฉบับก่อนเรียนมีหน้าโจทย์แค่หน้าเดียว ฉบับหลังเรียนเพิ่มมาอีกหนึ่งหน้าเต็ม ๆ และหน้าที่เพิ่มมาคือหน้าที่ยากกว่า
โจทย์ทั้งหมดมี 9 ข้อ ซึ่งอ่านผ่าน ๆ แล้วดูเหมือน CRUD ธรรมดา — แต่ในนั้นมีคำอยู่ 4 คำที่เปลี่ยนงานทั้งข้อถ้าอ่านข้าม
บทนี้ไม่ได้เฉลยโค้ด เพราะมันคือข้อสอบของคุณ สิ่งที่ผมทำคือ: แปลโจทย์ทีละคำ · ชี้ที่ที่คนพลาดพร้อมผลรันจริง · และบอกสิ่งที่โจทย์ไม่ได้บอกแต่คุณต้องตัดสินใจเอง

🧰 ของที่ใช้ตรวจในบทนี้ (รันวันที่ 25 สิงหาคม 2026)

go version go1.26.5 darwin/arm64
github.com/gin-gonic/gin v1.12.0
RFC 9110 (HTTP Semantics)      # อ่านหัวข้อ 9.3.4 PUT ของจริง

ผลรันทุกอันในบทนี้มาจาก httptest ที่รันจริงบนเครื่องผม ไม่ได้เขียนจากความจำ — คุณ copy โค้ดในกล่องไปรันซ้ำได้

1 · โจทย์ทั้ง 9 ข้อ วางเรียงในที่เดียว

สไลด์แบ่งเป็น 4 กลุ่ม ผมเรียงตามนั้นและใส่เลขข้อกำกับไว้ให้อ้างถึงง่าย:

#กลุ่มสิ่งที่ต้องทำมาจากหน้า
1RecipePUT /api/v1/recipes/:id — replace ข้อมูล recipe ตาม id65 (หน้าใหม่)
2DELETE /api/v1/recipes/:id — ลบแบบ soft delete
3ProfileGET /api/v1/users/:id — :id รับได้แค่ "me"
4PUT /api/v1/users/:id — :id รับได้แค่ "me" และแก้ได้แค่ bio กับ imageUrl
5FavoritePOST /api/v1/recipes/:id/favorite66
6DELETE /api/v1/recipes/:id/favorite
7เพิ่ม query favorite=true|false ที่ GET /api/v1/recipes
8RatingPOST /api/v1/recipes/:id/rating
9ส่ง averageRating เพิ่มใน response ของ GET /api/v1/recipes และ GET /api/v1/recipes/:id

รวมแล้วเป็น endpoint ใหม่ 7 เส้น และเป็นการแก้ของเดิม 2 จุด (ข้อ 7 กับข้อ 9)
ข้อ 7 กับ 9 คือสองข้อที่คนลืมบ่อยที่สุด เพราะมันไม่ได้ขึ้นต้นด้วยคำว่า "สร้าง"

2 · สี่คำในโจทย์ที่เปลี่ยนงานทั้งข้อ

คำในโจทย์อ่านผ่าน ๆ จะเข้าใจว่าที่จริงแปลว่า
replace (ข้อ 1) "แก้ข้อมูล" ส่งอะไรมา ของเดิมถูกแทนทั้งก้อน · ช่องที่ไม่ส่งมาต้องกลายเป็นค่าว่าง ไม่ใช่ค่าเดิม
Soft deleted (ข้อ 2) "ลบ" แถวยังอยู่ในตาราง แค่ประทับ deleted_at · ทุก query หลังจากนี้ต้องกรองมันออก
รับได้เพียงแค่ "me" เท่านั้น (ข้อ 3, 4) "ใส่ id ก็ได้" ใส่ id จริงต้องถูกปฏิเสธ · ตัวตนคนเรียกมาจาก token ไม่ใช่จาก URL
true|false (ข้อ 7) "ส่ง true เพื่อกรอง" มี 3 สถานะ ไม่ใช่ 2 — ไม่ส่ง / true / false ต้องได้ผลคนละแบบ
✅ ฉบับ Post แก้คำนี้ให้ชัดขึ้นจริง

ข้อ 7 ในสไลด์ฉบับก่อนเรียนเขียนว่า favorite=true เฉย ๆ
ฉบับหลังเรียนเปลี่ยนเป็น favorite=true|false
เปลี่ยนไปแค่ 6 ตัวอักษร แต่มันเปลี่ยนโจทย์จากง่ายเป็นยาก — รายละเอียดอยู่หัวข้อ 6 ซึ่งเป็นหัวข้อที่ยาวที่สุดในบทนี้ด้วยเหตุผลนั้นพอดี

3 · PUT แปลว่า replace — RFC เขียนไว้ตรง ๆ

ไม่ต้องเชื่อผม เปิด RFC 9110 หัวข้อ 9.3.4 อ่านประโยคแรกได้เลย:

The PUT method requests that the state of the target resource be
created or replaced with the state defined by the representation
enclosed in the request message content.

แล้ว RFC ยังบอกต่อว่าต้องตอบ status อะไร ซึ่งเป็นจุดที่โจทย์ไม่ได้บอก แต่คนตรวจงานจะดู:

สถานการณ์RFC บอกให้ตอบระดับความบังคับ
ยังไม่มีของ แล้ว PUT สร้างขึ้นมาใหม่201 CreatedMUST
มีของอยู่แล้ว แล้วแก้สำเร็จ200 OK หรือ 204 No ContentMUST (เลือกได้ 1 ใน 2)
⚠️ ข้อ 4 เรียกตัวเองว่า PUT แต่บรรยายพฤติกรรมของ PATCH

อ่านข้อ 4 อีกรอบ: "PUT /api/v1/users/:id เพื่อ replace ข้อมูลของ user … และ update ได้เพียง bio และ imageUrl เท่านั้น"

สองครึ่งของประโยคนี้ขัดกันเองตามนิยามของ RFC — "replace" คือแทนทั้งก้อน แต่ "แก้ได้แค่สองช่อง" คือแก้บางส่วน ซึ่งคือ PATCH

แล้วทำยังไง: ทำตามโจทย์ — ใช้ PUT และรับแก้แค่ bio กับ imageUrl
โจทย์ข้อสอบชนะ RFC เสมอ แต่ให้รู้ว่ามันไม่ตรงนิยาม เพราะวันที่คุณออกแบบ API เองในงานจริง คุณจะต้องเป็นคนเลือกว่าจะใช้ verb ไหน
และในทางปฏิบัติ ข้อจำกัดนี้มีเหตุผลของมัน: ช่องอื่นในตาราง users (email · name · uid) เป็นของที่ลอกมาจาก Keycloak ให้ผู้ใช้แก้ผ่าน API เราไม่ได้ เดี๋ยว login รอบหน้าก็โดนทับกลับอยู่ดี

🧰 เทียบให้เห็นภาพ — กรอกใบคำร้องใหม่ทั้งใบ กับ ขีดฆ่าแก้ทีละช่อง

ไปติดต่อราชการเพื่อแก้ที่อยู่ มีสองวิธี:
· กรอกใบใหม่ทั้งใบ — เขาฉีกใบเก่าทิ้ง ใช้ใบใหม่แทน ช่องไหนคุณเว้นว่าง ก็ว่างจริง ๆ ไม่ได้ไปลอกจากใบเก่ามาให้
· ยื่นคำร้องขอแก้เฉพาะช่องที่อยู่ — ใบเก่ายังอยู่ เขาแก้ให้ช่องเดียว ช่องอื่นเหมือนเดิม

PUT คือวิธีแรก · PATCH คือวิธีที่สอง
คนที่คิดว่า PUT = "แก้ข้อมูล" มักส่งมาแค่ช่องที่อยากแก้ แล้วงงว่าทำไมชื่อกับเบอร์โทรหายไป — ทั้งที่ระบบทำถูกตามที่บอกทุกอย่าง

ที่ไม่เหมือนกัน: เจ้าหน้าที่เห็นใบที่กรอกไม่ครบจะทักคุณก่อน ("ช่องเบอร์โทรไม่ได้เขียนนะคะ") แต่ API ไม่ทัก — มันทำตามที่ได้รับเป๊ะ ๆ แล้วตอบ 200 กลับมาเหมือนทุกอย่างเรียบร้อย
ความ "เรียบร้อย" ที่ผิดนี้แหละที่ทำให้ bug ของ PUT ถูกเจอช้ากว่าปกติ

🗣 ศัพท์ที่เพิ่งเรียนไป
replace (semantics ของ PUT)
แทนสถานะของ resource ทั้งก้อนด้วยสิ่งที่ส่งมา · ถาม senior ได้ว่า “endpoint นี้ PUT แบบ replace จริง หรือ PATCH ที่ตั้งชื่อว่า PUT” คำถามนี้จะได้คำตอบเร็วกว่าถามว่า “ต้องส่งอะไรมาบ้าง”
partial update (semantics ของ PATCH)
แก้เฉพาะช่องที่ส่งมา ช่องที่ไม่ส่งคงค่าเดิม · พิมพ์บอก AI ได้ว่า “this handler must do a partial update — unset fields keep their current value”
MUST / SHOULD ใน RFC
MUST = ไม่ทำถือว่าผิดมาตรฐาน · SHOULD = ควรทำ เว้นแต่มีเหตุผลชัด · เวลาอ้าง RFC ให้บอกด้วยว่าเป็นระดับไหน จะน่าเชื่อกว่าพูดว่า “RFC บอกว่า…”

4 · :id ที่รับได้แค่ "me" — ทำไมออกแบบแบบนี้

คำถามแรกที่ควรถามคือ "ถ้ารับได้แค่คำเดียว จะทำเป็น :id ทำไม"
ทำไมไม่เขียน GET /api/v1/users/me ไปตรง ๆ

คำตอบคือ เผื่อวันข้างหน้า — เส้นทาง URL ถูกจองไว้ในรูปแบบที่จะขยายได้ วันที่แอปมีหน้าโปรไฟล์สาธารณะ GET /api/v1/users/<uuid> จะเสียบเข้ามาได้เลย โดยไม่ต้องเปลี่ยน URL ที่ client เก่าเรียกอยู่

ผมลองจดเส้นนี้แล้วยิงสามแบบ:

r.GET("/api/v1/users/:id", func(c *gin.Context) {
	if c.Param("id") != "me" {
		c.JSON(http.StatusForbidden, gin.H{"error": `only "me" is allowed`})
		return
	}
	// ...
})
/api/v1/users/me    -> 200 {"id":"me"}
/api/v1/users/42    -> 403 {"error":"only \"me\" is allowed"}
/api/v1/users/      -> 404 404 page not found

แถวล่างสุดคือของแถมที่ควรรู้: Gin ไม่ยอมให้ :id เป็นค่าว่าง
ต่อท้ายด้วย / เฉย ๆ ไม่เข้า handler เลย ได้ 404 จาก router ตรง ๆ แปลว่าคุณไม่ต้องเขียนโค้ดเช็คว่า id ว่างไหม

📌 401 หรือ 403 — เลือกยังไง

· ไม่ได้แนบ token มา หรือ token หมดอายุ → 401 Unauthorized ("คุณยังไม่ได้บอกว่าคุณเป็นใคร")
· token ดี แต่พยายามเรียก /users/42 ทั้งที่ระบบรับแค่ me → 403 Forbidden ("รู้แล้วว่าคุณเป็นใคร แต่ทำสิ่งนี้ไม่ได้")
ทั้งสองตัวชื่อชวนสับสนมาตั้งแต่แรก — 401 ควรจะชื่อ Unauthenticated ด้วยซ้ำ
จุดสำคัญกว่าเลข: ตัวตนของคนเรียกต้องมาจาก sub ใน token แล้วเอาไปหาแถวใน users ด้วยคอลัมน์ uid — ห้ามเชื่อค่าอะไรก็ตามที่มาจาก URL ถ้าเชื่อ ใครก็สวมรอยเป็นคนอื่นได้

🧰 เทียบให้เห็นภาพ — บอกชื่อเอง กับ ยื่นบัตรให้ดู

ไปรับพัสดุที่เคาน์เตอร์ มีสองแบบ:
· "ผมมารับของคุณสมชายครับ" — พนักงานเชื่อคำพูด แล้วยกของให้
· ยื่นบัตรประชาชนให้ดู — พนักงานอ่านชื่อจากบัตร ไม่ได้อ่านจากปากคุณ

ค่า :id ใน URL = คำพูด — ใครพิมพ์อะไรมาก็ได้
claim sub ใน token = บัตรที่มีลายเซ็นดิจิทัล ปลอมไม่ได้เพราะ Keycloak เซ็นไว้

ที่ไม่เหมือนกัน: พนักงานที่เผลอเชื่อคำพูด ยังพอมีกล้องวงจรปิดกับสามัญสำนึกช่วยจับผิด ("คนนี้มารับของคนอื่นทุกวันเลยนะ")
แต่ API ที่เชื่อ :id จาก URL ไม่มีทางรู้เลย — request ที่สวมรอยกับ request ปกติหน้าตาเหมือนกันทุกตัวอักษร ต่างกันแค่ตัวเลขใน URL
นี่คือเหตุผลที่โจทย์บังคับให้ :id รับได้แค่ "me": มันตัดทางเลือกที่จะเชื่อ URL ทิ้งตั้งแต่ต้น

5 · Soft delete — แถวไม่ได้หายไปไหน

ตาราง users กับ recipes มีคอลัมน์ deleted_at อยู่แล้ว (ดู บทเรียน 53) และ GORM รู้จักมันเองถ้าตั้งชนิดเป็น gorm.DeletedAt

สิ่งที่ต้องระวังคือ สิ่งที่เกิดขึ้นหลังจากลบ ไม่ใช่ตัวการลบ:

คำถามที่โจทย์ไม่ได้ตอบให้สิ่งที่คุณต้องตัดสินใจ
GET /recipes หลังลบ — เห็นไหมต้องไม่เห็น · GORM กรอง deleted_at IS NULL ให้เอง ถ้าใช้ชนิดถูก
GET /recipes/:id ของที่ถูกลบ404 — ไม่ใช่ 410 Gone และไม่ใช่ตอบข้อมูลกลับไป
ลบซ้ำอันเดิมอีกรอบเลือกได้ แต่ต้องเลือกแล้วทำให้เหมือนกันทุกครั้ง — 404 (ไม่เจอของที่ยังอยู่) หรือ 204 (ผลลัพธ์สุดท้ายเหมือนกัน)
ลบ recipe ที่มีคนกด favorite ไว้แถวใน favorite ค้างอยู่ · ข้อ 7 ต้องกรองมันออกด้วย ไม่งั้นจะมีรายการโปรดที่ชี้ไปหาของที่ไม่มีแล้ว
ตอบอะไรกลับตอนลบสำเร็จ204 No Content (ไม่มี body) เป็นตัวเลือกที่ตรงความหมายที่สุด

แถวสุดท้ายของตารางคือกับดักที่คนพลาดกันจริง ๆ — ข้อ 2 กับข้อ 7 อยู่คนละหน้าสไลด์ คนละกลุ่มหัวข้อ แต่มันเกี่ยวกันโดยตรง และไม่มีใครบอกคุณ

🧰 เทียบให้เห็นภาพ — ลบชื่อออกจากสารบัญ แต่หน้ายังอยู่ในเล่ม

หนังสือเล่มหนึ่งเลิกขายบทที่ 7 แล้ว วิธีที่ง่ายที่สุดคือลบชื่อบทออกจากสารบัญ
คนเปิดสารบัญหาไม่เจอ = เหมือนบทนั้นไม่มีอยู่
แต่หน้ากระดาษยังอยู่ในเล่มครบ — เปิดข้ามไปตรง ๆ ก็ยังอ่านได้

soft delete ทำงานแบบนี้เป๊ะ: ประทับ deleted_at = ลบชื่อออกจากสารบัญ · ทุก query ที่มี WHERE deleted_at IS NULL = คนที่เปิดผ่านสารบัญ

ที่ไม่เหมือนกัน: สารบัญของหนังสือมีที่เดียว ลบครั้งเดียวจบ
แต่ฐานข้อมูลมี "สารบัญ" อยู่ทุกที่ที่มีคน JOIN ตารางนั้น — ตารางรายการโปรดก็เป็นสารบัญอีกอันหนึ่ง และมันไม่ได้อัปเดตตามให้เอง
ลบ recipe แล้วลืมกรองที่ฝั่งรายการโปรด = ผู้ใช้เห็นชื่อบทในสารบัญเล่มอื่น กดเข้าไปแล้วเจอหน้าว่าง

🗣 ศัพท์ที่เพิ่งเรียนไป
soft delete / hard delete
ประทับเวลาแทนการลบแถว · ตรงข้ามคือ hard delete ที่ลบจริง · ถาม senior ได้ว่า “ตารางนี้ soft delete หรือ hard delete” ก่อนเขียน query แรก เพราะคำตอบเปลี่ยนทุก WHERE ที่คุณจะเขียนต่อจากนั้น
Unscoped() ของ GORM
สั่งให้ query ไม่กรอง deleted_at IS NULL · เป็นทางเดียวที่จะเห็นแถวที่ถูกลบตอน debug · พิมพ์ถาม AI ได้ว่า “show me the soft-deleted rows with Unscoped and their deleted_at”
204 No Content
"สำเร็จ และไม่มีอะไรจะส่งกลับ" — ห้ามมี body · ใช้คำนี้แทนการพูดว่า “ตอบ 200 แต่ body ว่าง” ซึ่งเป็นคนละอย่างและทำให้ client บางตัวงง
404 vs 410 Gone
404 = "ไม่เจอ" (อาจไม่เคยมี หรือมีแล้วหายไป) · 410 = "เคยมี และหายไปถาวรแล้ว" · soft delete ควรตอบ 404 เพราะ 410 เป็นการยืนยันกับคนนอกว่าของชิ้นนี้เคยมีอยู่จริง

6 · favorite=true|false — สามสถานะ ไม่ใช่สอง

นี่คือข้อที่ทำให้คนเสียคะแนนมากที่สุด และเสียแบบที่โค้ดไม่ error เลย

ลองไล่ดูว่าผู้ใช้ยิงมาได้กี่แบบ:

ผู้ใช้ยิงมาว่าแปลว่าเขาต้องการ
GET /api/v1/recipesเอาทั้งหมด ไม่สนใจ favorite
GET /api/v1/recipes?favorite=trueเอาเฉพาะที่กดไว้
GET /api/v1/recipes?favorite=falseเอาเฉพาะที่ยังไม่ได้กด

สามสถานะ แต่ชนิด bool ของ Go เก็บได้แค่สองค่า
ผมเขียน struct สองแบบวางคู่กันแล้วยิงจริงทั้งสี่แบบ ดูผลเทียบกัน:

type Filter struct {
	Favorite *bool `form:"favorite"`   // ← pointer
}
type FilterPlain struct {
	Favorite bool `form:"favorite"`    // ← ธรรมดา
}
/x                   -> *bool=nil (ไม่ได้ส่งมา)  bool=false  Query()=""       Has=false
/x?favorite=true     -> *bool=true                bool=true   Query()="true"   Has=true
/x?favorite=false    -> *bool=false               bool=false  Query()="false"  Has=true
/x?favorite=         -> *bool=false               bool=false  Query()=""       Has=true
⚠️ ดูสองแถวที่หนาไว้ — คอลัมน์ bool ตอบ false เหมือนกันเป๊ะ

"ไม่ได้ส่ง favorite มาเลย" กับ "ส่ง favorite=false มา" เป็นคนละความต้องการโดยสิ้นเชิง — อันแรกแปลว่า "เอาทั้งหมด" อันหลังแปลว่า "เอาเฉพาะที่ยังไม่ได้กด"
แต่ถ้าคุณประกาศเป็น bool ธรรมดา คุณจะแยกมันไม่ออกตลอดกาล
และนี่ไม่ error · ไม่มี warning · เทสที่ยิงแค่ favorite=true ก็ผ่านหมด
มันจะโผล่ตอนคนตรวจงานยิง ?favorite=false แล้วได้ผลเหมือนไม่ได้ใส่อะไรเลย

ทางแก้มีสองทาง เลือกทางไหนก็ได้ แต่ต้องตั้งใจเลือก:

ทางเขียนยังไงข้อดี / ข้อเสีย
*bool Favorite *bool `form:"favorite"` แล้วเช็ค if f.Favorite != nil ✅ ได้ validation ของ Gin ฟรี (ส่งค่ามั่วมาได้ 400)
⚠️ ต้อง dereference ทุกที่ที่ใช้
c.GetQuery() v, ok := c.GetQuery("favorite") — ok บอกว่ามีคีย์นี้ไหม ✅ อ่านง่าย ไม่มี pointer
⚠️ ได้ string ต้องแปลงเอง และต้องเขียนเช็คค่ามั่วเอง
📌 ของแถมสองอย่างที่เจอตอนรัน

① ?favorite= (มีคีย์ แต่ไม่มีค่า) — *bool ได้ false ไม่ใช่ nil และไม่ error
แปลว่าคนที่พิมพ์ ?favorite= มาจะได้ผลเหมือนพิมพ์ ?favorite=false ซึ่งอาจไม่ใช่สิ่งที่เขาตั้งใจ — ถ้าอยากกันต้องใช้ GetQuery แล้วเช็คสตริงว่างเอง
② Gin ใช้ strconv.ParseBool ซึ่งรับเยอะกว่าที่คิด:
· ?favorite=1 → ผ่าน (= true) · ?favorite=TRUE → ผ่าน
· ?favorite=yes → 400 strconv.ParseBool: parsing "yes": invalid syntax
ถ้าเอกสาร API ของคุณเขียนว่ารับ true|false แต่โค้ดรับ 1 ด้วย — นั่นคือช่องว่างระหว่างเอกสารกับของจริง ซึ่งต้องเลือกว่าจะแก้เอกสารหรือแก้โค้ด

🧰 เทียบให้เห็นภาพ — ช่องติ๊กในใบสมัคร

ใบสมัครมีช่อง "เคยทำงานที่นี่มาก่อนหรือไม่" แล้วมีช่องติ๊ก [ ] เดียว
คนกรอกติ๊ก = เคย · ไม่ติ๊ก = อ่านไม่ออกว่าอะไร เพราะมันปนกันระหว่าง "ไม่เคย" กับ "ยังไม่ได้อ่านคำถามนี้"

ใบสมัครที่ออกแบบดีจะให้ สองช่อง — [ ] เคย · [ ] ไม่เคย ใบที่เว้นทั้งคู่คือใบที่กรอกไม่ครบ และคนตรวจรู้ทันทีว่าต้องโทรกลับไปถาม

bool ธรรมดา = ช่องติ๊กช่องเดียว · *bool = ช่องติ๊กสองช่อง (และรู้ว่าเว้นว่าง)
ที่ไม่เหมือนกัน: ใบสมัครที่กรอกไม่ครบ คนตรวจเห็นด้วยตาว่าว่าง แต่ bool ใน Go ไม่มีสภาพ "ว่าง" ให้เห็น — มันเติม false ให้เองเงียบ ๆ ตั้งแต่วินาทีที่ประกาศตัวแปร คุณจึงไม่มีทางรู้เลยว่าค่านั้น "เขาตอบว่าไม่" หรือ "เขาไม่ได้ตอบ"

7 · averageRating — สไลด์แก้ให้แล้วหนึ่งจุด ยังเหลืออีกหนึ่ง

เทียบสองฉบับบรรทัดต่อบรรทัดในข้อ 9:

ฉบับก่อนเรียน

Update response ให้ส่ง
average_rating เพิ่มเข้ามาด้วย

ฉบับหลังเรียน

Update response ให้ส่ง
averageRating เพิ่มเข้ามาด้วย

เปลี่ยนจาก snake_case เป็น camelCase — ซึ่งตรงกับที่เหลือของ API แล้ว (สังเกตว่าข้อ 4 ก็เขียน imageUrl ไม่ใช่ image_url)

📌 คอลัมน์ในฐานกับช่องใน JSON ไม่จำเป็นต้องชื่อเดียวกัน

ตาราง users ใช้ image_url (snake_case — ธรรมเนียมของ SQL)
JSON ที่ส่งออกใช้ imageUrl (camelCase — ธรรมเนียมของ JavaScript ที่ฝั่งหน้าเว็บใช้)
สองอันนี้เป็นคนละชั้นกัน และเชื่อมกันด้วย struct tag บรรทัดเดียว:
ImageURL string `gorm:"column:image_url" json:"imageUrl"`
คนที่พยายามทำให้ชื่อเหมือนกันทั้งระบบ มักจบด้วยการยอมให้ชั้นหนึ่งผิดธรรมเนียมของตัวเอง

⚠️ จุดที่สไลด์ยังพิมพ์ผิดอยู่ในฉบับ Post — favoite

บรรทัดตัวอย่างใต้ข้อ 7 ยังเขียนว่า:
GET /api/v1/recipes?favoite=true
ตกตัว r ไป (ที่ถูกคือ favorite) และยังไม่ได้แก้ในฉบับหลังเรียน
ที่ต้องระวังคือ query parameter ที่สะกดผิด ไม่ทำให้ error — Gin จะมองว่า "ไม่ได้ส่ง favorite มา" แล้วคืนรายการทั้งหมดตามปกติ
ถ้าคุณลอกบรรทัดนี้ไปทำเป็นเทส เทสจะผ่าน ทั้งที่ฟีเจอร์ไม่ทำงาน
ยึดจากบรรทัดหัวข้อที่เขียนว่า favorite=true|false ซึ่งสะกดถูก

8 · จด route ทั้ง 10 เส้นโดยไม่ให้ Gin panic

หลังทำครบ ระบบจะมี route ที่มี :id อยู่หลายเส้นซ้อนกันบน path เดียวกัน
Gin มีกฎข้อหนึ่งที่ทำให้โปรแกรมตายตั้งแต่ยังไม่ทันรับ request แรก ถ้าคุณตั้งชื่อ parameter ไม่ตรงกัน:

e.GET("/api/v1/recipes/:id", ...)
e.GET("/api/v1/recipes/:recipeId/favorite", ...)   // ชื่อคนละชื่อ
PANIC: ':recipeId' in new path '/api/v1/recipes/:recipeId/favorite'
conflicts with existing wildcard ':id' in existing prefix '/api/v1/recipes/:id'
⚠️ กฎนี้ดักคุณบางที ซึ่งแย่กว่าดักทุกที

ผมลองเทียบสามชุด ได้ผลต่างกันแบบที่ต้องจำ:

ชุดที่จดผล
GET :id + GET :recipeId/favorite❌ PANIC
GET :id + POST :recipeId/favorite✅ จดได้ ไม่ panic
ชุดจริงของโจทย์ทั้ง 10 เส้น (ชื่อ :id ตรงกันหมด)✅ จดได้ครบ

แถวกลางคือตัวอันตราย — Gin แยก routing tree ตาม HTTP method ชื่อ parameter ที่ไม่ตรงกันจึงลอดผ่านได้ถ้าอยู่คนละ method
แปลว่าโค้ดที่ตั้งชื่อมั่ว ๆ รันได้สบายเป็นเดือน แล้ววันที่มีคนเพิ่ม GET /recipes/:recipeId/favorite เข้าไป ทั้งแอปตายตอน start โดยที่คนเพิ่มไม่ได้ทำอะไรผิดเลย
กฎง่าย ๆ: resource เดียวกันใช้ชื่อ parameter เดียวตลอดทั้งโปรเจกต์

9 · ตารางสรุป — endpoint กับ status ที่ควรตอบ

ย้ำว่าโจทย์ไม่ได้กำหนด status code ให้ ตารางนี้คือข้อเสนอที่อิง RFC 9110 ไม่ใช่คำสั่งจากสไลด์ ถ้าคุณเลือกต่างจากนี้ ให้เลือกแล้วทำให้เหมือนกันทั้งระบบ

#Endpointสำเร็จไม่เจออื่น ๆ ที่ต้องคิดถึง
1PUT /recipes/:id200404400 ถ้า body ไม่ครบ · 403 ถ้าไม่ใช่เจ้าของ
2DELETE /recipes/:id204404ลบซ้ำต้องได้ผลเหมือนเดิมทุกครั้ง
3GET /users/:id200—403 ถ้า :id ไม่ใช่ me · 401 ถ้าไม่มี token
4PUT /users/:id200—ช่องนอกจาก bio/imageUrl ที่ส่งมา — จะเมินหรือ 400 ต้องเลือก
5POST /recipes/:id/favorite201 หรือ 204404กดซ้ำอันเดิมห้ามพัง — ต้องมี unique key กันแถวซ้ำ
6DELETE /recipes/:id/favorite204404ยกเลิกสิ่งที่ไม่เคยกด — เลือกว่าจะ 404 หรือ 204
7GET /recipes?favorite=…200—400 ถ้าค่าไม่ใช่ bool · ไม่ส่ง ≠ false
8POST /recipes/:id/rating201404ให้คะแนนซ้ำ = ทับของเดิม ไม่ใช่เพิ่มแถว · ช่วงคะแนนที่รับได้
9เติม averageRating200—recipe ที่ยังไม่มีใครให้คะแนน ส่งอะไร — 0 หรือ null
📌 สามช่องในคอลัมน์ขวาที่โจทย์เงียบสนิท และคนตรวจจะกดแน่

① กด favorite ซ้ำ — ถ้าไม่มี unique index บน (user_id, recipe_id) คุณจะได้แถวซ้ำ แล้ว ?favorite=true จะคืน recipe เดิมซ้ำหลายรอบ
② ให้ rating ซ้ำ — โจทย์ใช้คำว่า POST ซึ่งปกติแปลว่า "เพิ่มใหม่" แต่ในทางความหมาย คนหนึ่งคนควรมีคะแนนต่อ recipe หนึ่งค่าเดียว (ไม่งั้นกดรัว ๆ ก็ปั่นคะแนนได้) — ท่าที่ใช้กันคือ upsert: มีแล้วทับ ไม่มีแล้วเพิ่ม
③ averageRating ของ recipe ที่ยังไม่มีคะแนน — AVG() ของ SQL คืน NULL ไม่ใช่ 0 ถ้าปล่อยผ่านจะได้ "averageRating": null โผล่ไปหาหน้าเว็บ
ทั้งสามข้อไม่มีคำตอบที่ถูกต้องข้อเดียว — แต่มีคำตอบที่ผิดคือ "ไม่ได้คิดถึงมัน"

🗣 ศัพท์ที่เพิ่งเรียนไป
idempotent
ยิงซ้ำกี่ครั้งผลสุดท้ายเหมือนเดิม · PUT กับ DELETE ควรเป็นแบบนี้ · ถาม senior ได้ว่า “endpoint นี้ต้อง idempotent ไหม ถ้า client retry แล้วจะเกิดอะไรขึ้น”
soft delete
ลบด้วยการประทับเวลาแทนการลบแถวจริง · พิมพ์ถาม AI ได้ว่า “make this GORM query exclude soft-deleted rows and add a partial unique index that ignores them”
unique constraint
กฎที่ฐานข้อมูลบังคับเองว่าคู่ค่านี้ห้ามซ้ำ · เป็นทางเดียวที่กันแถวซ้ำได้จริงตอนมีหลาย request พร้อมกัน (เช็คในโค้ดก่อน insert กันไม่ได้) · พูดว่า “ใส่ unique constraint บน (user_id, recipe_id)” จะตรงกว่า “กันกดซ้ำ”
upsert
"มีแล้วทับ ไม่มีแล้วเพิ่ม" ในคำสั่งเดียว · ใน Postgres คือ INSERT … ON CONFLICT … DO UPDATE · ใช้คำนี้ตอนคุยเรื่อง rating ซ้ำ จะสื่อกว่าอธิบายยาว ๆ
three-state / nullable boolean
ค่าที่มีได้สามสถานะ: จริง · เท็จ · ไม่ได้ระบุ · บอก AI ตรง ๆ ว่า “this filter is tri-state — absent must not be treated as false” แล้วมันจะเลือก *bool ให้เอง

10 · เช็กลิสต์ก่อนส่ง

☐ ?favorite=false ให้ผลต่างจากไม่ใส่ favorite เลย
☐ ?favorite=yes ได้ 400 ไม่ใช่เงียบ ๆ แล้วคืนทั้งหมด
☐ recipe ที่ soft delete แล้ว ไม่โผล่ใน GET /recipes และไม่โผล่ในรายการโปรด
☐ GET /users/42 ถูกปฏิเสธ · GET /users/me ผ่าน
☐ ตัวตนคนเรียกมาจาก token ไม่ใช่จาก URL หรือ body
☐ กด favorite อันเดิมสองครั้ง ไม่พัง และไม่ได้แถวซ้ำ
☐ ให้ rating อันเดิมสองครั้ง คะแนนถูกทับ ไม่ใช่เพิ่มแถว
☐ averageRating ของ recipe ที่ยังไม่มีคะแนน ส่งค่าที่ตั้งใจ ไม่ใช่ null โดยบังเอิญ
☐ ทุก route ใช้ชื่อ parameter :id ตรงกันหมด — แอป start ขึ้นจริง
☐ PUT /recipes/:id ที่ไม่มี id นั้น ได้ 404 ไม่ใช่สร้างใหม่เงียบ ๆ

ทดสอบความเข้าใจ

1. โจทย์ข้อ 7 เขียนว่า favorite=true|false — ถ้าประกาศ struct เป็น Favorite bool `form:"favorite"` จะเกิดอะไรขึ้น?

ผลรันจริงจาก httptest:
/x (ไม่ส่ง) → bool=false
/x?favorite=false → bool=false
เหมือนกันเป๊ะ ทั้งที่ความต้องการของผู้ใช้คนละเรื่อง — อันแรกคือ "เอาทั้งหมด" อันหลังคือ "เอาเฉพาะที่ยังไม่ได้กด"
ตัวเลือกที่ 1 ผิดตรงที่คิดว่ามันจะ error — มันเงียบสนิท ซึ่งคือปัญหาทั้งหมด ถ้ามัน error คุณจะเจอตั้งแต่ยังไม่ส่งงาน
ตัวเลือกที่ 2 คือกับดักตัวจริง เพราะฟังดูมีเหตุผลมาก — bool มีสองค่า โจทย์มีสองค่า แต่โจทย์จริงมีสามสถานะ สถานะที่สามคือ "ไม่ได้พูดถึง" ซึ่งไม่มีที่อยู่ใน bool
จุดที่ต้องจำ: ทุกครั้งที่ query parameter เป็น optional ให้ถามตัวเองว่า "ไม่ส่งมา" กับ "ส่งค่า zero มา" ต้องได้ผลต่างกันไหม — ถ้าต่าง ต้องใช้ pointer หรือ GetQuery

2. จด route สองเส้นนี้ในแอปเดียวกัน: e.GET("/recipes/:id", …) และ e.POST("/recipes/:recipeId/favorite", …) — เกิดอะไรขึ้น?

ผมรันเทียบสามชุด:
· GET :id + GET :recipeId/favorite → PANIC ':recipeId' … conflicts with existing wildcard ':id'
· GET :id + POST :recipeId/favorite → จดได้ ไม่ panic
· ชุดจริงของโจทย์ 10 เส้น (ชื่อตรงกันหมด) → จดได้ครบ
ตัวเลือกที่ 1 ถูกสำหรับกรณี method เดียวกัน แต่ผิดสำหรับกรณีในคำถาม ซึ่งเป็นคนละ method
ตัวเลือกที่ 3 อันตรายที่สุด เพราะมันถูกครึ่งเดียว — จดได้จริง แต่ไม่ปลอดภัยถาวร
จุดที่ต้องจำ: bug ที่ "ยังไม่ระเบิด" ไม่ใช่ bug ที่ไม่มี — มันแค่รอคนที่ไม่รู้เรื่องมาเพิ่มโค้ดบรรทัดเดียวแล้วรับผิดแทน ตั้งชื่อ parameter ให้ตรงกันทั้งโปรเจกต์ตั้งแต่แรกจบเรื่อง

3. ข้อ 4 บอกว่า "PUT /api/v1/users/:id เพื่อ replace ข้อมูล … และ update ได้เพียงแค่ bio และ imageUrl" — ควรทำยังไง?

โจทย์ข้อนี้ขัดกันเองตามนิยาม RFC จริง — "replace" คือแทนทั้งก้อน แต่ "แก้ได้แค่สองช่อง" คือพฤติกรรมของ PATCH
แต่นี่คือข้อสอบ ไม่ใช่ API ที่คุณออกแบบเอง — เกณฑ์การตรวจคือโจทย์
ตัวเลือกที่ 2 คือคำตอบที่ "ถูกตามหลักการ" และอันตรายที่สุด: ช่อง email · name · uid เป็นข้อมูลที่ลอกมาจาก Keycloak ให้ผู้ใช้เคลียร์ทิ้งผ่าน API เราไม่ได้ — และถึงเคลียร์ไป login รอบหน้าก็ถูกทับกลับอยู่ดี
ตัวเลือกที่ 1 กับ 3 คือการเปลี่ยนโจทย์เอง ซึ่งในข้อสอบแปลว่าทำผิดข้อ
จุดที่ต้องจำ: เจอ spec ที่ขัดกับมาตรฐาน ให้ทำสองอย่างพร้อมกัน — ทำตาม spec และ พูดออกมาว่ามันไม่ตรงมาตรฐานตรงไหน การเงียบแล้วทำตามอย่างเดียว กับการแอบแก้ spec เอง แย่พอ ๆ กัน

4. ทำข้อ 2 (soft delete recipe) เสร็จแล้ว มีข้อไหนที่ต้องกลับไปแก้ตามอีก?

ข้อ 2 กับข้อ 7 อยู่คนละหน้าสไลด์และคนละกลุ่มหัวข้อ (Recipe กับ Favorite) แต่ผูกกันโดยตรง — แถวใน favorite ที่ชี้ไปหา recipe ที่ถูก soft delete ยังอยู่ครบ ถ้า ?favorite=true ไม่ join แล้วกรอง recipes.deleted_at IS NULL ผู้ใช้จะเห็นรายการโปรดที่กดเข้าไปแล้วได้ 404
ตัวเลือกที่ 4 ถูกครึ่งเดียว — PUT ก็ต้องเช็คจริง แต่ GORM กรอง deleted_at IS NULL ให้เองอยู่แล้วถ้าใช้ gorm.DeletedAt คำว่า "ข้อเดียวที่กระทบ" คือส่วนที่ผิด
ตัวเลือกที่ 2 ไม่มีในโจทย์ — ข้อ 3 ไม่ได้ขอให้นับอะไร
จุดที่ต้องจำ: โจทย์ที่แบ่งเป็นหัวข้อสวย ๆ ทำให้รู้สึกว่าแต่ละข้อแยกกัน แต่ฐานข้อมูลไม่ได้แบ่งตามหัวข้อในสไลด์ — ทุกครั้งที่เพิ่มเงื่อนไขการกรองที่ตารางหนึ่ง ให้ไล่ดูว่ามีใคร join ตารางนั้นอยู่บ้าง

อ้างอิง