บทเรียน 42 · Day 8 · Workshop

Wongnok — ออกแบบ API spec จากโจทย์จริง

~20 นาที · ต้องมี: บทเรียน 33–36 (REST), บทเรียน 27 (project layout)

เก้าบทที่ผ่านมาสอนเครื่องมือ — status code, query, Gin, binding, httptest, Swagger
บทนี้ไม่มีของใหม่สักชิ้น มีแค่โจทย์ธุรกิจสี่บรรทัด
คุณจะแปลงมันเป็น API spec ด้วยมือตัวเองก่อน แล้วค่อยเปิดดูของผู้สอน
จากนั้นวิจารณ์ spec นั้นด้วยกฎ 7 ข้อ — รวมถึงสี่บรรทัดที่ขัดกฎชัด ๆ แต่ขัดอย่างมีเหตุผล
ปิดท้ายด้วยการเขียน 2 endpoint ให้เดินได้จริงพร้อมเทส

🧰 ของที่ใช้ในบทนี้ — โค้ดทุกชิ้นข้างล่างรันจริงบน Go 1.26.5 darwin/arm64 · github.com/gin-gonic/gin v1.12.0 · github.com/stretchr/testify v1.12.1 · swag CLI v1.16.4
ไม่ต้องมี Docker ไม่ต้องมี database — บทนี้เก็บข้อมูลไว้ใน slice ในหน่วยความจำ เพราะสิ่งที่เรากำลังพิสูจน์คือรูปร่างของ API ไม่ใช่ชั้นเก็บข้อมูล

1 · โจทย์สี่บรรทัด — อ่านให้เจอ "คำนาม"

นี่คือโจทย์ที่ผู้สอนให้มาทั้งหมด ไม่มีมากกว่านี้ — และของจริงในที่ทำงานก็ประมาณนี้แหละ คือคนสั่งงานพูดเป็นภาษามนุษย์ ไม่ได้พูดเป็น endpoint

โจทย์ Wongnok Recipes

  เว็บรวมสูตรอาหาร
  ผู้เยี่ยมชมทั่วไปค้นสูตรจากชื่อเมนูหรือวัตถุดิบได้
  ถ้าไม่รู้ชื่อ ค้นจาก "ระยะเวลาที่ใช้ปรุง" และ "ความยากง่าย" ได้
  มีระบบสมาชิก ที่สมัครแล้วสร้าง/แก้/ลบสูตรของตัวเอง
  และให้คะแนนสูตรของคนอื่นได้

วิธีเริ่มที่ใช้ได้จริงมีอยู่วิธีเดียว และมันเชยมาก: ขีดเส้นใต้คำนามทุกคำในโจทย์ แล้วถามทีละคำว่า "ของสิ่งนี้มีตัวตนไหม"
เพราะ REST คือสถาปัตยกรรมที่ทุกอย่างเป็นคำนาม (บทเรียน 33) — ถ้าคุณหาคำนามไม่เจอ คุณจะเผลอไปตั้งชื่อ endpoint ด้วยกริยาทันที แล้วได้ /searchRecipe /createRecipe เหมือนที่ทุกคนเคยทำในโปรเจกต์แรก
สามคำที่ใช้บ่อยมากตั้งแต่บรรทัดนี้ไป — endpoint = ที่อยู่หนึ่งจุดที่ยิงเข้าไปได้ (method + path เช่น GET /recipes) · resource = "ของ" หนึ่งชนิดที่ API เอามาให้จับต้อง เช่น สูตรอาหาร สมาชิก · spec = เอกสารที่บอกว่า API มีเส้นไหนบ้าง แต่ละเส้นรับอะไร ตอบอะไร

คำในโจทย์เป็นอะไรกันแน่เพราะอะไร
สูตรอาหาร✅ resource → recipesมีตัวตน · มี id ของตัวเอง · สร้าง/อ่าน/แก้/ลบได้ครบ 4 ท่า
สมาชิก✅ resource → usersมีตัวตน · มีหน้าโปรไฟล์ของตัวเอง · แก้ได้
คะแนน✅ resource ลูก → recipes/:id/ratingมีตัวตน แต่อยู่ลอย ๆ ไม่ได้ — คะแนนต้องเป็นคะแนนของสูตรไหนสักสูตร
รายการโปรด
(ไม่ได้อยู่ในสี่บรรทัด — ผู้สอนเพิ่มเข้ามาใน spec ทีหลัง)
✅ resource ลูก → recipes/:id/favoriteเหมือนกัน — เป็นความสัมพันธ์ระหว่าง "ฉัน" กับ "สูตรนี้"
ชื่อเมนู / วัตถุดิบ❌ ไม่ใช่ resource — เป็นเงื่อนไขค้นหามันคือคุณสมบัติของสูตร ไม่ใช่ของที่มี id แยกต่างหาก
ระยะเวลาที่ใช้ปรุง❌ ไม่ใช่ resource — เป็นเงื่อนไขค้นหา"30 นาที" ไม่ใช่สิ่งของ คุณ DELETE เลข 30 ทิ้งไม่ได้
ความยากง่าย❌ ไม่ใช่ resource — เป็นเงื่อนไขค้นหาเหมือนกัน · เป็นค่าจากรายการจำกัด (easy / medium / hard)

สามแถวล่างคือแถวที่คนพลาดกันมากที่สุด และมันพลาดแบบมีแบบแผน: พอโจทย์เขียนว่า "ค้นจากความยากง่ายได้" สมองจะกระโดดไปที่ GET /difficulty/easy/recipes ทันที เพราะมันอ่านแล้วเหมือนภาษาไทยเป๊ะ
แต่ path มีไว้บอกว่า "ของสิ่งนี้อยู่ที่ไหน" ไม่ใช่ "ฉันอยากได้ของแบบไหน" — นั่นคือกฎข้อ 6 (บทเรียน 36): filter ใช้ query

❌ เอาเงื่อนไขไปยัดใน path

GET /recipes/difficulty/easy
GET /recipes/cooking-time/30
GET /recipes/difficulty/easy/time/30

ทุกครั้งที่เพิ่มเงื่อนไขใหม่ ต้องเพิ่ม route ใหม่ (route = หนึ่งบรรทัดที่เราลงทะเบียนไว้ว่า method + path นี้ ให้ฟังก์ชันไหนรับ) · เงื่อนไข 3 อย่างที่เลือกใส่ไม่ใส่ได้ = ต้องลง route 8 เส้น
แล้วถ้าอยากเรียงลำดับด้วยล่ะ · แบ่งหน้าด้วยล่ะ — ตายตั้งแต่ยังไม่เขียนโค้ด

✅ เงื่อนไขอยู่ใน query

GET /recipes?difficulty=easy
GET /recipes?max_cooking_minutes=30
GET /recipes?q=กะเพรา&difficulty=easy&max_cooking_minutes=30

route เดียวจบ ไม่ว่าจะมีเงื่อนไขกี่อัน · ใส่ไม่ครบก็ได้ · สลับลำดับก็ได้
และวันหลังเพิ่ม &sort=rating ได้โดยไม่กระทบใครที่เรียกอยู่เดิม

🍜 เทียบให้เห็นภาพ — สั่งก๋วยเตี๋ยวที่ร้านประจำ

ร้านก๋วยเตี๋ยวมีร้านเดียว ตั้งอยู่ที่เดิมทุกวัน นั่นคือที่อยู่ของร้าน
เวลาสั่ง เราพูดต่อท้ายว่า "เส้นเล็ก ไม่ใส่ผัก เผ็ดน้อย"
ไม่มีใครไปเปิดร้านใหม่ชื่อ "ร้านก๋วยเตี๋ยวเส้นเล็กไม่ใส่ผักเผ็ดน้อย" ตั้งอยู่อีกตึกหนึ่ง

API คิดแบบเดียวกันเป๊ะ:
· /recipes = ที่อยู่ของร้าน — กองสูตรทั้งหมดอยู่ตรงนี้ที่เดียว
· ?difficulty=easy&max_cooking_minutes=30 = คำสั่งต่อท้าย — บอกว่าอยากได้แบบไหน
อยากสั่งเพิ่มอีกข้อ ก็แค่พูดต่ออีกคำ · ไม่ต้องย้ายร้าน

ที่ไม่เหมือนกัน: คนขายก๋วยเตี๋ยวลวกชามใหม่ให้เราตามที่สั่ง แต่ GET /recipes?... ไม่ได้สร้างอะไรใหม่เลย มันแค่คัดของที่มีอยู่แล้วมาให้ดู — ยิงกี่ครั้ง ของในระบบก็เท่าเดิม

🗣 ศัพท์ที่เพิ่งเรียนไป

สี่คำนี้โผล่ทุกครั้งที่คุยเรื่อง API — ใช้ให้ตรงคำ แล้วอีกฝ่ายจะเข้าใจทันทีว่าคุณติดตรงไหน

resource
"ของ" หนึ่งชนิดที่ API มีให้จับต้อง — มีตัวตน มี id ของตัวเอง · เวลาไม่แน่ใจ ให้ถามว่า "ตัวนี้เป็น resource หรือเป็นแค่ filter" จะได้คำตอบตรงกว่าถามว่า "ควรตั้ง URL ว่าอะไรดี"
path
ท่อนที่บอกที่อยู่ของ resource — อยู่หลังชื่อเว็บ ก่อนเครื่องหมาย ? เช่น /recipes/7 · เลข 7 ที่เสียบเข้าไปในนั้นเรียกว่า path parameter
query string / query parameter
ท่อนหลัง ? ที่บอกเงื่อนไข เช่น ?difficulty=easy · หนึ่งคู่ ชื่อ=ค่า = หนึ่ง query parameter · ประโยคที่เอาไปใช้ได้เลย: "อันนี้ควรเป็น query parameter ไม่ใช่ path parameter ใช่ไหมครับ"
filter
การกรองของออกจากกองเดิม — ไม่ได้เปลี่ยนกอง แค่เลือกมาแสดง · กฎที่ใช้เป็นค่าเริ่มต้นได้เลย: filter ไปอยู่ใน query
(ข้อยกเว้นที่เจอในงานจริง: เงื่อนไขซับซ้อนจนยัดใส่ URL ไม่ไหว ทีมถึงจะไปคุยกันเรื่อง POST /recipes/search ที่ส่งเงื่อนไขมาใน body แทน)

2 · resource ที่ "อยู่ลอย ๆ ไม่ได้"

คะแนนกับรายการโปรดต่างจาก recipes ตรงที่มันไม่มีความหมายถ้าไม่มีพ่อแม่
ลองถามตัวเองว่า GET /ratings/88 ควรตอบอะไร — "คะแนน 4 ดาว" เฉย ๆ เนี่ยนะ ของใคร ให้สูตรไหน? มันไม่มีประโยชน์เลยถ้าไม่รู้ว่าเกาะอยู่กับสูตรไหน
นี่คือกฎข้อ 5: ความสัมพันธ์ให้แสดงด้วย path ที่ซ้อนกัน

  /recipes                         ← กองสูตรทั้งหมด (ค้นหาที่นี่)
     │
     └── /recipes/:id              ← สูตรหนึ่งใบ (อ่าน/แก้/ลบที่นี่)
            │
            ├── /recipes/:id/rating     ← คะแนน "ของสูตรใบนี้"
            │
            └── /recipes/:id/favorite   ← ความเป็นรายการโปรด "ของฉัน ต่อสูตรใบนี้"

  /users
     │
     └── /users/:id                ← โปรไฟล์สมาชิกหนึ่งคน

💡 สังเกตว่า favorite กับ rating ไม่มี id ต่อท้าย — ไม่ใช่ /recipes/7/favorites/123
เพราะคำถามที่ endpoint นี้ตอบไม่ใช่ "รายการโปรดใบที่ 123" แต่คือ "สูตรนี้ ฉัน กดโปรดไว้หรือยัง" — และ "ฉัน" ไม่ได้มาจาก URL มันมาจาก token ใน header Authorization (บทเรียน 35)
token = บัตรผ่านที่ระบบออกให้ตอนล็อกอินสำเร็จ แล้วเราแนบติดไปทุกครั้งที่ยิง API เพื่อบอกว่า "คนที่ยิงมาคือฉัน"
ของที่ระบุตัวตนได้จาก header อยู่แล้ว ไม่ต้องเอามาใส่ซ้ำใน path — ใส่ซ้ำเมื่อไหร่คุณจะได้บั๊ก "ผู้ใช้ A ลบรายการโปรดของผู้ใช้ B" ฟรี ๆ

🎟 เทียบให้เห็นภาพ — ตั๋วหนังกับเลขที่นั่ง

ถ้ามีคนยื่นกระดาษให้แล้วเขียนไว้แค่ว่า "H12" คุณทำอะไรกับมันไม่ได้เลย
H12 ของโรงไหน รอบกี่โมง วันไหน — ขาดสามอย่างนี้ เลขที่นั่งก็เป็นแค่ตัวอักษรกับตัวเลข
ตั๋วจริงจึงเขียนเรียงกันว่า โรง 3 · รอบ 19:30 · ที่นั่ง H12 ไล่จากของใหญ่ไปของเล็ก

/recipes/7/rating อ่านแบบเดียวกันเป๊ะ: กองสูตร → สูตรใบที่ 7 → คะแนนของใบนั้น
ส่วน /ratings/88 เหมือนกระดาษที่เขียนแค่ "เลขที่การจอง 88" — ระบบหลังบ้านเปิดดูก็รู้ว่าโรงไหน รอบไหน ที่นั่งอะไร แต่คนถือกระดาษไม่รู้
และคำถามที่สำคัญกว่าคือ ผู้ใช้จะรู้เลข 88 มาได้ยังไง ถ้าไม่ได้เดินผ่าน /recipes/7 มาก่อน

แล้วคำว่า "ฉัน" ล่ะ? ตอนเข้าโรงหนัง เราไม่ได้เขียนชื่อตัวเองลงบนตั๋ว เรายื่นตั๋วให้พนักงานตรวจที่ปากทางเข้า
token ใน header Authorization คือตั๋วใบนั้น · ยื่นตั๋วแล้ว ไม่ต้องเขียนชื่อตัวเองซ้ำใน URL อีก

ที่ไม่เหมือนกัน: เลขที่นั่ง "H12" ลอย ๆ ระบุตัวไม่ได้จริง แต่ id ของ resource ระบุตัวได้เสมอ — API จริงจึงมี DELETE /comments/123 หรือ PATCH /ratings/88 ใช้กันเป็นปกติ และไม่ได้ผิด อย่าไปสรุปว่าเส้นแบนที่มี id เป็นของพัง
เส้นที่ซ้อนกันแก้ปัญหาคนละข้อ: มันตอบว่า ผู้ใช้จะเดินไปเจอเลข 88 ได้ยังไง ไม่ได้ตอบว่าเลข 88 มีความหมายหรือเปล่า
อีกจุดหนึ่ง: ที่นั่ง H12 มีอยู่จริงในโรงตั้งแต่ก่อนเราซื้อตั๋ว แต่ /recipes/7/rating อาจยังไม่มีข้อมูลอยู่เลย ถ้ายังไม่มีใครให้คะแนนสูตรใบนี้ — path มีอยู่ ไม่ได้แปลว่าข้อมูลมีอยู่

🗣 ศัพท์ที่เพิ่งเรียนไป
nested resource (หรือ sub-resource)
resource ลูกที่อยู่ลอย ๆ ไม่ได้ ต้องเกาะพ่อแม่เสมอ เช่น /recipes/:id/rating · ประโยคที่เอาไปถามได้เลย: "คะแนนควรเป็น nested resource ใต้ recipe หรือแยกเป็น resource ของตัวเองดี" — ถามแบบนี้ senior ตอบได้ทันทีโดยไม่ต้องอธิบายโจทย์ใหม่ทั้งหมด
parent resource
ตัวพ่อแม่ที่ลูกเกาะอยู่ · ในเส้นข้างบนคือ recipes
bearer token
"ตั๋ว" ที่แนบไปใน header แบบ Authorization: Bearer <token> · คำว่า bearer แปลว่า "ใครถือก็ใช้ได้" ซึ่งเป็นเหตุผลที่ห้ามให้มันหลุด · เวลาคุยเรื่องสิทธิ์ให้พูดว่า "user id เอามาจาก token ไม่ได้เอามาจาก path"

3 · ตาคุณบ้าง — เขียน spec เองก่อนเปิดเฉลย

ส่วนนี้ห้ามข้าม ถ้าเลื่อนไปดูเฉลยเลยคุณจะได้แค่ "เออ ก็สมเหตุสมผลดี" ซึ่งเป็นความรู้สึกที่ไร้ค่าที่สุดในการเรียนออกแบบ
เปิดสมุดหรือไฟล์เปล่า เขียน method + path ให้ครบ 10 บรรทัดข้างล่างก่อน เวลา 5 นาทีพอ

ออกแบบก่อนเปิดเฉลย — เขียน METHOD /path ให้แต่ละความต้องการ
ข้อ 6–7 (รายการโปรด) ไม่ได้อยู่ในโจทย์สี่บรรทัด แต่อยู่ใน spec ของผู้สอน เลยเอามาให้ออกแบบด้วย

 1. สมาชิกสร้างสูตรอาหารใหม่
 2. ผู้เยี่ยมชมค้นสูตร (ชื่อเมนู / เวลาปรุง / ความยากง่าย)
 3. เปิดดูสูตรหนึ่งใบแบบเต็ม ๆ
 4. เจ้าของสูตรแก้ไขสูตรของตัวเอง
 5. เจ้าของสูตรลบสูตรของตัวเอง
 6. กดสูตรของคนอื่นเข้ารายการโปรด
 7. เอาสูตรออกจากรายการโปรด
 8. ให้คะแนนสูตรของคนอื่น
 9. ดูโปรไฟล์สมาชิก
10. แก้โปรไฟล์ตัวเอง

เขียนครบ 10 บรรทัดหรือยัง? อย่าเพิ่งกด
เช็คตัวเองสามข้อก่อน: มีกริยาโผล่ใน path ไหม · ใช้พหูพจน์ตลอดหรือสลับไปมา · เงื่อนไขค้นหาอยู่ใน path หรือ query

// Recipes
POST   /recipes                 // 1. สร้าง
GET    /recipes                 // 2. ค้นหา / ดูทั้งหมด
GET    /recipes/:id             // 3. ดูใบเดียว
PUT    /recipes/:id             // 4. แก้
DELETE /recipes/:id             // 5. ลบ

// Favorite
POST   /recipes/:id/favorite    // 6. กดโปรด
DELETE /recipes/:id/favorite    // 7. เอาออก

// Rating
POST   /recipes/:id/rating      // 8. ให้คะแนน

// User
GET    /users/:id               // 9. ดูโปรไฟล์
PUT    /users/:id               // 10. แก้โปรไฟล์

// Auth (ผู้สอนแถมมาให้ ไม่ได้อยู่ใน 10 ข้อ)
GET    /login
GET    /callback
POST   /exchange
GET    /logout

สิ่งที่ควรตรงกับของคุณ: ห้าบรรทัดแรกเป็น CRUD (Create / Read / Update / Delete — สี่ท่าพื้นฐานที่ resource ทุกตัวมี) มาตรฐานที่ บทเรียน 33 สอนไว้เป๊ะ ๆ — POST/GET/PUT/DELETE บน path เดิม ต่างกันแค่ method ไม่ต้องคิดชื่อใหม่สักอัน

จุดที่มักไม่ตรง 1 — ข้อ 2: หลายคนเขียน GET /recipes/search หรือ GET /search?type=recipe · ของผู้สอนคือ GET /recipes เฉย ๆ แล้วให้ query เป็นตัวแยกว่า "ทั้งหมด" หรือ "เฉพาะที่ตรงเงื่อนไข"
"ค้นหา" ไม่ใช่การกระทำที่ต้องมี endpoint ของตัวเอง — มันคือ การขอ /recipes แบบมีเงื่อนไข

จุดที่มักไม่ตรง 2 — ข้อ 7: หลายคนเขียน POST /recipes/:id/unfavorite เพราะมันอ่านแล้วชัดดี
แต่ DELETE สื่อความหมายนี้อยู่แล้ว — พอ method สื่อได้ การเติมคำว่า unfavorite เข้าไปคือพูดซ้ำ (กฎข้อ 7)

จุดที่ไม่มีทางตรง: สี่บรรทัด Auth ท้ายสุด — ไม่มีใครเดาถูกเพราะมันไม่ได้มาจากโจทย์ธุรกิจ มันมาจากข้อจำกัดของเทคโนโลยี ที่จะแกะกันในหัวข้อ 5

✅ ผมเอา spec ทั้ง 14 เส้นไปลงทะเบียนกับ Gin จริง ๆ (ใต้ group /api/v1) เพื่อดูว่ามันมี path ที่ชนกันจนโปรแกรม panic ไหม — เพราะ Gin จะ panic ตอนลงทะเบียน route ไม่ใช่ตอนรับ request (บทเรียน 38)

GET    /api/v1/callback
POST   /api/v1/exchange
GET    /api/v1/login
GET    /api/v1/logout
GET    /api/v1/recipes
POST   /api/v1/recipes
DELETE /api/v1/recipes/:id
GET    /api/v1/recipes/:id
PUT    /api/v1/recipes/:id
DELETE /api/v1/recipes/:id/favorite
POST   /api/v1/recipes/:id/favorite
POST   /api/v1/recipes/:id/rating
GET    /api/v1/users/:id
PUT    /api/v1/users/:id
total routes = 14

ผ่านฉลุย ไม่ panic — /recipes/:id อยู่ร่วมกับ /recipes/:id/favorite ได้ เพราะมันแตกต่างกันที่ segment (= ก้อนข้อความระหว่างเครื่องหมาย / — /recipes/7/favorite มี 3 segment คือ recipes · 7 · favorite) เส้นหนึ่งจบที่ segment ที่ 2 อีกเส้นมีต่อถึงที่ 3 · Gin จึงแยกออก
เอา spec ไปลงทะเบียนดูก่อนเขียน handler (handler = ฟังก์ชันที่รับ request แล้วตอบกลับ) เป็นวิธีตรวจ spec ที่ถูกที่สุด
ใช้เวลา 2 นาที และจับ path ที่ชนกันได้ตั้งแต่ยังไม่มีโค้ดสักบรรทัด

4 · spec นี้ผ่านกฎ 7 ข้อกี่ข้อ

ตอนนี้เรามี spec แล้ว หน้าที่ต่อไปไม่ใช่รีบเขียนโค้ด แต่คือตรวจมันด้วยกฎที่เราเพิ่งเรียน
และตรวจแบบตรงไปตรงมา — ไม่ใช่หาเรื่องชม และไม่ใช่หาเรื่องด่า

กฎ (บทเรียน 36)ผลหลักฐานจาก spec
1. ใช้ noun ไม่ใช่ verb ⚠️ ผ่าน 10 / ตก 4 10 เส้นแรกเป็นคำนามล้วน · แต่ /login /logout /exchange เป็นกริยาเต็ม ๆ (/callback ก้ำกึ่ง)
2. ชื่อ resource consistent ✅ ผ่าน recipes กับ users เป็นพหูพจน์ทั้งคู่ ไม่มี /recipe เอกพจน์โผล่มาปนสักที่
3. lowercase ✅ ผ่าน ทั้ง 14 เส้นเป็นตัวเล็กหมด ไม่มี /Recipes
4. หลายคำใช้ - ➖ ไม่มีให้ตัดสิน spec นี้ไม่มี resource ที่ชื่อยาวเกินหนึ่งคำเลย → กฎนี้ยังไม่ถูกทดสอบ
5. relationship ใช้ path ✅ ผ่านสวย /recipes/:id/favorite · /recipes/:id/rating — อ่านแล้วรู้ทันทีว่าของลูกเกาะอยู่กับใคร
6. filter ใช้ query ⚠️ spec เว้นว่างไว้ GET /recipes ไม่ได้ระบุ query สักตัว ทั้งที่โจทย์บอกชัดว่าต้องค้น 3 แบบ → ช่องว่างที่เราต้องเติมเอง
7. ไม่ใส่ action ถ้า method สื่อได้ ⚠️ ผ่าน 10 / ตก 4 favorite/rating เป็นข้อยกเว้นที่ถูกต้อง (method สื่อไม่ได้ว่า "โปรด" ต่างจาก "ให้คะแนน" ยังไง) · แต่ 4 เส้น Auth ตกจริง
🎯 สองข้อที่ควรเก็บจากตารางนี้

1. กฎข้อ 6 ที่ "ว่างไว้" อันตรายกว่ากฎที่ผิด — ข้อที่ผิดชัด ๆ ทุกคนเห็นและเถียงกันได้ แต่ข้อที่ไม่ได้เขียนจะกลายเป็นการเดาของนักพัฒนาแต่ละคน แล้วคุณจะได้ ?search= จากคนนึง ?keyword= จากอีกคน ใน API เดียวกัน
2. /recipes/:id/favorite ไม่ใช่การละเมิดกฎข้อ 7 — สไลด์เองก็ยกตัวอย่าง PATCH /users/123/suspend และ PATCH /users/123/reset-password ว่าแนะนำ เพราะมันเป็น action ที่ HTTP method สื่อไม่ได้ · กฎข้อ 7 ห้ามแค่การพูดซ้ำสิ่งที่ method พูดไปแล้ว เช่น POST /users/create หรือ POST /users/123/delete

5 · สี่บรรทัดที่ขัดกฎ — และเหตุผลที่มันขัดได้

GET    /login       // กริยา + GET ที่ทำให้เกิดการเปลี่ยนแปลง
GET    /callback    // กริยา
POST   /exchange    // กริยาล้วน ๆ ไม่มีคำนามเลยสักคำ
GET    /logout      // กริยา + GET ที่ทำลาย session

ถ้าเอากฎ 7 ข้อมาจับตรง ๆ สี่บรรทัดนี้ตกทั้งข้อ 1 และข้อ 7 และ /login กับ /logout ยังตกอีกข้อที่หนักกว่านั้น: GET ควรเป็น safe method — คือเรียกกี่ครั้งก็ไม่เปลี่ยนสถานะอะไรในระบบ (บทเรียน 36 เรื่อง idempotency) แต่ GET /logout ทำลาย session ทิ้ง ซึ่งเป็นการเปลี่ยนสถานะเต็มตัว

แล้วทำไมทั้งโลกยังเขียนกันแบบนี้? เพราะสี่เส้นนี้ ไม่ได้ถูกเรียกโดยโค้ดของเรา — มันถูกเรียกโดยเบราว์เซอร์ และเบราว์เซอร์เดินไปหน้าใหม่ด้วย GET เสมอ

📖 สามคำที่จะโผล่ในไดอะแกรมข้างล่าง — อ่านผ่านตาไว้ก่อน
Identity Provider (IdP) = เว็บภายนอกที่รับหน้าที่ยืนยันตัวตนแทนเรา เช่น "เข้าสู่ระบบด้วย Google" · Google คือ IdP · เว็บเราไม่ต้องเก็บรหัสผ่านของใครเลย
302 redirect = status code ที่แปลว่า "ของไม่ได้อยู่ที่นี่ ไปต่อที่ URL นี้" เบราว์เซอร์เห็นแล้วจะเดินไปเองอัตโนมัติ (บทเรียน 34)
code กับ access token = ของสองชิ้นคนละใบ · code คือตั๋วชั่วคราวใช้ครั้งเดียวที่ IdP โยนกลับมาทาง URL · access token คือของจริงที่เอาไปแนบใน header Authorization ตอนเรียก API
เราต้องเอา code ไปแลกเป็น token อีกทีหนึ่ง — นั่นคือหน้าที่ของ POST /exchange

  ผู้ใช้กดปุ่ม "เข้าสู่ระบบ"
      │
      │  1. เบราว์เซอร์ navigate ไป  GET /login       ← เราสั่งให้เป็น POST ไม่ได้
      ▼                                                    (การกดลิงก์ = GET เท่านั้น)
  แอปของเรา ตอบ 302 redirect ไปหน้า login ของ Identity Provider
      │
      │  2. ผู้ใช้พิมพ์รหัสผ่านที่ เว็บของ IdP (ไม่ใช่เว็บเรา)
      ▼
  IdP redirect กลับมา  GET /callback?code=xxxx    ← IdP เป็นคนกำหนดว่าเป็น GET
      │                                                    เราแค่ต้องรับให้ได้
      │  3. ตอนนี้เรามีแค่ "code" ยังไม่มี token
      ▼
  โค้ดฝั่งเรา ยิง  POST /exchange  เอา code ไปแลก access token
      │                                        ← ขั้นนี้ โค้ดเราเป็นคนเรียกเอง
      ▼                                          จึงเลือก method ได้อิสระ
  ได้ token → เก็บไว้ → ยิง /api/v1/recipes ต่อได้

⛓️ เส้นที่เลือกไม่ได้จริง ๆ

GET /login · GET /callback

ตัวที่เรียกคือเบราว์เซอร์ ผ่านการกดลิงก์และการ redirect — ทั้งสองอย่างเป็น GET ตามมาตรฐาน HTTP ไม่มีทางเลือกอื่น
ต่อให้คุณตั้งชื่อสวยแค่ไหน มันก็ยังเป็น GET ที่จุดชนวนกระบวนการ ข้อจำกัดนี้ไม่ได้มาจากความมักง่าย แต่มาจากตัวโปรโตคอล

🔧 เส้นที่ทำให้ RESTful กว่านี้ได้

// แทน POST /exchange
POST   /auth/tokens

// แทน GET /logout
DELETE /auth/tokens/current

"แลก code เป็น token" = สร้าง token ขึ้นมาหนึ่งใบ → POST ลงกอง /auth/tokens ตรงตามกฎข้อ 1
"ออกจากระบบ" = ทำลาย token ที่ถืออยู่ → DELETE · ได้ทั้งคำนาม ได้ทั้ง method ที่ตรงเจตนา และเลิกใช้ GET เปลี่ยนสถานะด้วย

⚠️ อย่าอ่านหัวข้อนี้เป็น "ผู้สอนเขียนผิด" — อ่านว่า "กฎมีขอบเขต และนี่คือขอบของมัน"
กฎ REST 7 ข้อคุมได้ดีมากกับ endpoint ที่โค้ดของเราเป็นคนเรียก (มือถือเรียก API เรา · frontend เรียก backend เรา)
แต่ endpoint ที่เบราว์เซอร์เดินไปเอง หรือระบบภายนอกเป็นคนยิงกลับมา (OAuth callback, webhook ของ payment gateway, redirect กลับจาก 3D Secure) ถูกบังคับรูปร่างด้วยมาตรฐานอื่นที่เราไม่ได้เป็นคนเขียน
ทางที่ทีมส่วนใหญ่เลือกจึงเป็นทางกลาง: แยกออกจากกันให้ชัด — /api/v1/... สำหรับส่วนที่เราคุมและทำให้ RESTful ได้เต็มที่ · /auth/... สำหรับส่วนที่โปรโตคอลเป็นคนกำหนด
สิ่งที่รับไม่ได้จริง ๆ มีอย่างเดียว: ใช้ GET เปลี่ยนสถานะ ในเส้นที่โค้ดเราเรียกเอง เพราะ GET คือสิ่งที่ browser, proxy และ CDN ถือสิทธิ์ยิงซ้ำหรือ cache ได้ตามใจ

🏦 เทียบให้เห็นภาพ — "ขอดูยอด" กับ "ขอถอนเงิน"

ที่เคาน์เตอร์ธนาคาร คำขอสองแบบนี้อยู่คนละโลกกัน
· "ขอดูยอดหน่อยครับ" — ถามซ้ำสิบครั้งก็ได้ตัวเลขเดิม เงินไม่หายไปสักบาท
· "ขอถอน 100 บาท" — พูดซ้ำสิบครั้ง เงินหายไป 1,000

GET คือป้ายที่แปลว่า "ผมแค่มาขอดู"
ปัญหาของ GET /logout คือมันติดป้าย "แค่มาขอดู" ไว้หน้าคำขอที่เปลี่ยนสถานะจริง
แล้วคนกลางทาง — เบราว์เซอร์ที่แอบโหลดลิงก์ล่วงหน้า · proxy · CDN — เชื่อป้ายนั้นทันที จึงยิงมันเมื่อไหร่ก็ได้ หรือเก็บคำตอบไว้แจกต่อ โดยไม่ต้องถามเรา
ผลคือผู้ใช้อาจหลุดออกจากระบบทั้งที่ยังไม่ได้กดอะไรเลย

อย่าจับสองเรื่องนี้ปนกัน — ตัวอย่าง "ขอถอน 100 บาท" ข้างบนสอนคำว่า ซ้ำแล้วทบ เฉย ๆ · แต่ GET /logout ไม่ได้พังเพราะซ้ำแล้วทบ ออกจากระบบสิบครั้งก็เท่ากับครั้งเดียว
มันพังเพราะติดป้ายผิดต่างหาก — โทษของ GET ที่เปลี่ยนสถานะ ไม่ใช่ "ความเสียหายสะสม" แต่คือ "โดนยิงตอนที่เจ้าของยังไม่ได้สั่ง"
เพราะงั้นห้ามสรุปว่า ถ้าซ้ำแล้วไม่ทบก็ใช้ GET ได้ — ใช้ไม่ได้

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

🗣 ศัพท์ที่เพิ่งเรียนไป
safe method
method ที่ไม่เปลี่ยนอะไรในระบบ = "แค่มาขอดู" · ที่เราเขียนเองทุกวันคือ GET กับ HEAD (ตามสเปกยังนับ OPTIONS กับ TRACE เป็น safe ด้วย แต่สองตัวนั้นเราแทบไม่ได้เขียนเอง) · ประโยคที่เอาไปพูดในรีวิวโค้ดได้เลย: "เส้นนี้ใช้ GET ไม่ได้นะ มันไม่ safe — มันเปลี่ยน state"
idempotent
ยิงซ้ำกี่ครั้ง ผลลัพธ์สุดท้ายเท่ากับยิงครั้งเดียว · PUT กับ DELETE เป็น idempotent ส่วน POST ไม่ใช่
safe กับ idempotent ไม่ใช่คำเดียวกัน แต่ก็ไม่ใช่คนละกลุ่มที่ไม่ทับกัน — ของที่ safe เป็น idempotent อยู่แล้วโดยอัตโนมัติ (GET/HEAD เป็นทั้ง safe และ idempotent)
แต่ของที่ idempotent ไม่จำเป็นต้อง safe — PUT/DELETE เปลี่ยน state จริง แค่ซ้ำแล้วไม่ทบ
เพราะงั้นอย่าไปตอบว่า "GET ไม่ใช่ idempotent" เด็ดขาด
redirect (3xx)
คำตอบที่แปลว่า "ไปต่อที่ URL นี้" แล้วเบราว์เซอร์เดินไปเอง · 302 (รวมถึง 301/303) จะเปลี่ยน POST เป็น GET ให้ ตอนเดินไปปลายทาง — และนั่นคือ 302 ที่ OAuth flow ข้างบนใช้ จึงกลายเป็น GET จริงตามที่บอกไว้
แต่ไม่ใช่ redirect ทุกชนิด — 307/308 รักษา method เดิมไว้ (POST ที่เจอ 307 ไปถึงปลายทางยังเป็น POST พร้อม body ครบ) ซึ่งเป็นเหตุผลที่มันถูกสร้างมาแยกกัน
อย่าไปพูดว่า "redirect เดินไปด้วย GET เสมอ" — เดี๋ยวเจอ payment หรือ OAuth flow ที่ใช้ 307 แล้วจะ debug ไม่ออก
callback URL
ที่อยู่ที่เราบอกระบบภายนอกไว้ล่วงหน้า ว่าทำเสร็จแล้วให้ส่งคนกลับมาตรงนี้ · เวลาคุยเรื่องล็อกอินด้วย Google ให้ถามว่า "callback URL ฝั่งเราคือเส้นไหน" จะตรงกว่าถามว่า "มันเด้งกลับมาที่ไหน"

อีกสองจุดที่ควรถกก่อนลงมือเขียน

บรรทัดใน specประเด็นทางเลือกที่ควรถกกับทีม
POST /recipes/:id/rating สมาชิกคนเดิมกดให้คะแนนสองครั้ง จะได้สองคะแนน หรือทับของเดิม? POST สื่อว่า "สร้างใบใหม่" ทุกครั้ง ถ้ากติกาคือ หนึ่งคนต่อหนึ่งสูตรให้ได้คะแนนเดียว → PUT /recipes/:id/rating ตรงกว่า เพราะ PUT เป็น idempotent: ยิงซ้ำ 10 ครั้งผลเท่ากับยิงครั้งเดียว
PUT /users/:id ถ้า :id ไม่ใช่ตัวเราเองล่ะ? spec ไม่ได้บอกว่าใครแก้ใครได้ ตัว path ไม่ผิด แต่ต้องมี middleware (= โค้ดที่คั่นอยู่ก่อน handler คอยตรวจของก่อนปล่อยผ่าน) เทียบ id ใน token กับ :id แล้วตอบ 403 ถ้าไม่ตรง (บทเรียน 34: 401 = ไม่รู้ว่าคุณเป็นใคร · 403 = รู้ว่าคุณเป็นใคร แต่คุณทำไม่ได้)

6 · จาก spec เป็นโค้ดจริง — 2 endpoint

ถึงตรงนี้ spec นิ่งพอจะเขียนโค้ดได้แล้ว ผมเลือกทำสองเส้นที่สอนได้เยอะที่สุด: POST /recipes (มี body ต้อง validate = ตรวจว่าข้อมูลที่ส่งมาถูกกติกาไหม) กับ GET /recipes (มี query — ช่องว่างที่ spec เว้นไว้ในกฎข้อ 6)
โครงไฟล์ยึดตาม บทเรียน 27:

wongnok42/
├── cmd/api/main.go              ← จุดเริ่มโปรแกรม + annotation ระดับ API
├── internal/
│   ├── httputil/response.go     ← รูปแบบ error กลางของทั้ง API
│   └── recipe/
│       ├── dto.go               ← สัญญากับ client (request/response)
│       ├── handler.go           ← Gin handler + annotation ระดับ endpoint
│       └── handler_test.go      ← httptest
└── docs/                        ← swag สร้างให้ ห้ามแก้มือ

เริ่มที่ dto.go — และนี่คือไฟล์ที่สำคัญที่สุดในโปรเจกต์ เพราะมันคือสัญญาเป็นลายลักษณ์อักษรระหว่างเรากับคนที่เรียก API
DTO ย่อจาก Data Transfer Object — แปลตรงตัวคือ "struct ที่มีไว้รับ-ส่งข้อมูล ข้ามเส้นแบ่งเท่านั้น" ไม่ใช่ struct ที่เก็บลง database · เราแยกมันออกมาเพราะ หน้าตาที่ client เห็น ไม่จำเป็นต้องเหมือนหน้าตาที่เราเก็บจริง (เช่น password ที่อยู่ในตารางแต่ห้ามหลุดออกไปใน response)

🏧 เทียบให้เห็นภาพ — ใบฝากเงินกับแฟ้มลูกค้า

ตอนไปธนาคาร เรากรอกใบฝากเงิน ซึ่งมีแค่ไม่กี่ช่อง: เลขบัญชี จำนวนเงิน วันที่
แต่แฟ้มลูกค้าในระบบธนาคารมีมากกว่านั้นเยอะ — ประวัติทั้งหมด คะแนนเครดิต บันทึกภายในของพนักงาน และเราไม่มีวันได้เห็น
ใบที่เรากรอกกับแฟ้มที่เขาเก็บ ไม่ใช่ใบเดียวกัน และไม่ควรเป็นใบเดียวกัน

CreateRecipeRequest = ใบที่ client กรอกส่งเข้ามา · RecipeResponse = ใบที่เราส่งกลับออกไป · ส่วนของที่เก็บจริงในฐานข้อมูลเป็นอีกก้อนหนึ่ง เก็บได้มากกว่านั้น
ธนาคารมีใบฝากกับใบถอนแยกกัน ไม่ได้ใช้ใบเดียวทำทุกเรื่อง — เราก็เหมือนกัน

ที่ไม่เหมือนกัน: ใบฝากเงินเป็นกระดาษ ต้องมีพนักงานนั่งไล่เช็คทีละช่องว่ากรอกครบไหม
แต่ binding:"required,min=1" ที่ห้อยท้าย field ทำให้เรา เขียนกติกาไว้บน field ครั้งเดียว แล้ว ShouldBindJSON บรรทัดเดียวใน handler ตรวจให้ครบทุกช่องพร้อมกัน — ไม่ต้องมีคนมานั่งไล่เช็ค
แต่ tag ไม่ได้ทำงานเอง มันทำงานตอนที่ handler ของเราเรียก ShouldBindJSON / ShouldBindQuery เท่านั้น · handler ที่ลืมเรียก จะปล่อยข้อมูลเน่าผ่านเข้าไปเฉย ๆ โดยไม่มีใครทัก

// internal/recipe/dto.go
package recipe

// CreateRecipeRequest คือ body ที่ POST /recipes รับ
type CreateRecipeRequest struct {
    Name           string `json:"name"            binding:"required"                        example:"ผัดกะเพราหมูสับ"`
    CookingMinutes *int   `json:"cooking_minutes" binding:"required,min=1,max=1440"         example:"15"`
    Difficulty     string `json:"difficulty"      binding:"required,oneof=easy medium hard" example:"easy"`
}

// ListRecipeQuery คือ query string ที่ GET /recipes รับ  ← เติมช่องว่างของกฎข้อ 6
type ListRecipeQuery struct {
    Q                 string `form:"q"`
    Difficulty        string `form:"difficulty"          binding:"omitempty,oneof=easy medium hard"`
    MaxCookingMinutes int    `form:"max_cooking_minutes" binding:"omitempty,min=1,max=1440"`
}

// RecipeResponse คือหน้าตาสูตรอาหารที่ API คืนกลับไป
type RecipeResponse struct {
    ID             string `json:"id"              example:"3f0c1a7e-2b19-4c5e-9f3a"`
    Name           string `json:"name"            example:"ผัดกะเพราหมูสับ"`
    CookingMinutes int    `json:"cooking_minutes" example:"15"`
    Difficulty     string `json:"difficulty"      example:"easy"`
}

สามอย่างที่ตั้งใจใส่ และเหตุผลของแต่ละอัน

ที่เขียนไว้ทำไม
CookingMinutes *int (pointer) เพราะ binding:"required" มอง 0 ว่า "ไม่ได้ส่งมา" (บทเรียน 39) · ใช้ pointer เพื่อแยก "ส่ง 0 มาจริง" ออกจาก "ไม่ส่งเลย" — ถึงเคสนี้ 0 นาทีจะไม่สมเหตุสมผลอยู่แล้ว แต่นิสัยนี้ต้องติดตัวเพราะ field ประเภทราคาหรือจำนวนคงเหลือ 0 คือค่าที่ถูกต้อง
omitempty ใน query query ทุกตัวเป็น ตัวเลือก ไม่ใช่ของบังคับ · แต่ถ้าส่งมาแล้วต้องถูก — ส่ง ?difficulty=insane ต้องได้ 400 ไม่ใช่ผลลัพธ์ว่างเปล่าเงียบ ๆ
form: ไม่ใช่ json: ShouldBindQuery อ่าน tag ชื่อ form เท่านั้น · ใส่แต่ json แล้วค่าจะไม่เข้า field เลย แล้วไปติด required ทั้งที่ client ส่งมาถูกทุกอย่าง (บทเรียน 39)
example:"15" ทั้งที่ field เป็น int struct tag ในภาษา Go เป็น string ได้อย่างเดียว เลยต้องใส่เครื่องหมายคำพูดเสมอ · swag เป็นคนแปลงกลับให้ตรงกับ type จริงตอนสร้างเอกสาร — เดี๋ยวจะเห็นว่ามันออกมาเป็น "example": 15 (ตัวเลข ไม่มีเครื่องหมายคำพูด) ใน swagger.json
🗣 ศัพท์ที่เพิ่งเรียนไป
DTO (Data Transfer Object)
struct ที่มีไว้รับ-ส่งข้ามเส้นแบ่งเท่านั้น ไม่ใช่ตัวที่เก็บลงฐานข้อมูล · ประโยคที่เอาไปพูดได้: "อันนี้เป็น DTO หรือเป็น model ที่ผูกกับตารางในฐานข้อมูล" — ถามแบบนี้จะรู้ทันทีว่าแก้ field ได้อิสระแค่ไหน
request body / response body
ก้อนข้อมูลที่ส่งเข้ามากับคำขอ และก้อนที่ส่งกลับไปกับคำตอบ · เวลารายงานบั๊กให้พูดว่า "request body มี field นี้ แต่ response ไม่มี" จะชัดกว่าพูดว่า "ข้อมูลไม่ตรงกัน"
struct tag
ข้อความในเครื่องหมาย ` ที่ห้อยท้าย field · เป็น string เสมอ · json: ใช้ตอนแปลง JSON · form: ใช้ตอนอ่าน query · binding: ใช้ตอนตรวจความถูกต้อง
validation · binding
validation = ตรวจว่าข้อมูลที่ส่งมาถูกกติกาไหม ถ้าไม่ผ่านต้องตอบ 400 · binding = คำที่ Gin ใช้เรียกขั้นตอน "อ่าน request แล้วยัดใส่ struct พร้อมตรวจไปในตัว" · เวลาถาม AI ให้พูดว่า "binding tag ของ Gin ตัวไหนใช้เช็คค่าอยู่ในรายการที่กำหนด" จะได้ oneof กลับมาเลย

ก่อนไป handler ต้องมีอีกไฟล์เล็ก ๆ ก่อน — internal/httputil/response.go เพราะ handler ทุกตัวจะอ้างถึงมันตอนตอบ error
มีแค่ struct เดียว แต่ทำให้ error ทุกอันของทั้ง API หน้าตาเหมือนกันหมด client เขียนโค้ดอ่าน error ครั้งเดียวใช้ได้กับทุก endpoint:

// internal/httputil/response.go
package httputil

// ErrorResponse คือรูปแบบ error เดียวของทั้ง API
type ErrorResponse struct {
    Error string `json:"error" example:"invalid request body"`
}

ต่อด้วย handler — โครงเดียวกับ บทเรียน 38 คือ handler เป็น method บน struct เพื่อให้ฉีด dependency เข้ามาได้:

// internal/recipe/handler.go
package recipe

import (
    "net/http"
    "strconv"
    "strings"

    "github.com/gin-gonic/gin"

    "wongnok42/internal/httputil"   // <module>/internal/httputil — module ชื่อ wongnok42
)

// Handler ถือ "ที่เก็บข้อมูล" ไว้ในตัวเอง — บทนี้คือ slice ธรรมดา
// วันที่ต่อ database จริง ก็เปลี่ยนแค่สอง field นี้เป็น *gorm.DB
type Handler struct {
    store  []RecipeResponse
    nextID int
}

func NewHandler(seed []RecipeResponse) *Handler {
    return &Handler{store: seed, nextID: len(seed) + 1}
}

// Create godoc
//
//	@Summary		สร้างสูตรอาหารใหม่
//	@Description	สมาชิกสร้างสูตรอาหารของตัวเอง
//	@Tags			recipes
//	@Accept			json
//	@Produce		json
//	@Param			request	body		recipe.CreateRecipeRequest	true	"ข้อมูลสูตรอาหาร"
//	@Success		201		{object}	recipe.RecipeResponse
//	@Failure		400		{object}	httputil.ErrorResponse
//	@Security		BearerAuth
//	@Router			/recipes [post]
func (h *Handler) Create(c *gin.Context) {
    var req CreateRecipeRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, httputil.ErrorResponse{Error: err.Error()})
        return
    }
    created := RecipeResponse{
        ID:             strconv.Itoa(h.nextID),
        Name:           req.Name,
        CookingMinutes: *req.CookingMinutes,   // ปลอดภัยแล้ว: required การันตีว่าไม่ nil
        Difficulty:     req.Difficulty,
    }
    h.nextID++
    h.store = append(h.store, created)

    c.JSON(http.StatusCreated, created)   // 201 ไม่ใช่ 200 — บทเรียน 34
}

// List godoc
//
//	@Summary		ค้นหาสูตรอาหาร
//	@Description	ผู้เยี่ยมชมทั่วไปค้นจากชื่อเมนู เวลาปรุง หรือความยากง่ายได้
//	@Tags			recipes
//	@Produce		json
//	@Param			q					query		string	false	"คำค้นจากชื่อเมนู"
//	@Param			difficulty			query		string	false	"ความยากง่าย"	Enums(easy, medium, hard)
//	@Param			max_cooking_minutes	query		int		false	"ใช้เวลาปรุงไม่เกินกี่นาที"
//	@Success		200					{array}		recipe.RecipeResponse
//	@Failure		400					{object}	httputil.ErrorResponse
//	@Router			/recipes [get]
func (h *Handler) List(c *gin.Context) {
    var q ListRecipeQuery
    if err := c.ShouldBindQuery(&q); err != nil {
        c.JSON(http.StatusBadRequest, httputil.ErrorResponse{Error: err.Error()})
        return
    }

    out := make([]RecipeResponse, 0, len(h.store))   // ← บรรทัดนี้มีเหตุผล ดูหัวข้อ 7
    for _, r := range h.store {
        if q.Q != "" && !strings.Contains(r.Name, q.Q) {
            continue
        }
        if q.Difficulty != "" && r.Difficulty != q.Difficulty {
            continue
        }
        if q.MaxCookingMinutes > 0 && r.CookingMinutes > q.MaxCookingMinutes {
            continue
        }
        out = append(out, r)
    }

    c.JSON(http.StatusOK, out)
}

func (h *Handler) Register(rg *gin.RouterGroup) {
    rg.POST("/recipes", h.Create)
    rg.GET("/recipes", h.List)
}

💡 สังเกตว่า filter ทั้งสามข้อเขียนด้วยรูปทรงเดียวกันเป๊ะ: "ถ้าไม่ได้ส่งมา ก็ข้ามไป ไม่ต้องกรอง"
นี่คือผลพลอยได้ของการเลือก query ตั้งแต่ต้น — เพิ่มเงื่อนไขที่ 4 คือเพิ่ม if อีกก้อนเดียว ไม่ต้องแตะ route ไม่ต้องแตะ handler อื่น
ถ้าเมื่อกี้เราเลือกยัดเงื่อนไขลง path ตอนนี้เราจะกำลังนั่งเขียน handler ที่ 8 อยู่

swag init — spec ที่สร้างจากโค้ด ไม่ใช่จากความทรงจำ

ต่อจาก บทเรียน 41 · ชิ้นสุดท้ายที่ยังขาดคือ main.go ซึ่งเก็บ annotation (= คอมเมนต์รูปแบบพิเศษที่ swag อ่านไปสร้างเอกสาร) ระดับทั้ง API (ชื่อ เวอร์ชัน host และนิยามของ BearerAuth ที่ handler อ้างถึงด้วย @Security)
ถ้าไม่มีบล็อก @securityDefinitions ตรงนี้ คำว่า BearerAuth ใน handler จะไม่มีความหมาย:

// cmd/api/main.go
package main

import (
    "github.com/gin-gonic/gin"

    "wongnok42/internal/recipe"
)

//	@title			Wongnok Recipes API
//	@version		1.0
//	@description	API รวมสูตรอาหารของ Wongnok
//	@host			localhost:8080
//	@BasePath		/api/v1

//	@securityDefinitions.apikey	BearerAuth
//	@in							header
//	@name						Authorization
func main() {
    r := gin.Default()
    v1 := r.Group("/api/v1")
    recipe.NewHandler(nil).Register(v1)
    r.Run(":8080")
}

ทีนี้สั่ง swag init ได้แล้ว — ยืนอยู่ที่ root ของโปรเจกต์ (โฟลเดอร์ที่มี go.mod) แล้วรัน · เพราะ main.go ไม่ได้อยู่ที่ root จึงต้องบอกทางให้ swag ด้วย -g:

$ swag init -g cmd/api/main.go
2026/08/20 22:17:38 Generate swagger docs....
2026/08/20 22:17:38 Generate general API Info, search dir:./
2026/08/20 22:17:38 warning: failed to get package name in dir: ./, error: execute go list command, exit status 1, stdout:, stderr:no Go files in <path ของโปรเจกต์คุณ>
2026/08/20 22:17:38 Generating recipe.CreateRecipeRequest
2026/08/20 22:17:38 Generating recipe.RecipeResponse
2026/08/20 22:17:38 Generating httputil.ErrorResponse
2026/08/20 22:17:38 create docs.go at docs/docs.go
2026/08/20 22:17:38 create swagger.json at docs/swagger.json
2026/08/20 22:17:38 create swagger.yaml at docs/swagger.yaml

บรรทัด warning: failed to get package name in dir: ./ ยังขึ้นอยู่ดีแม้คำสั่งจะสำเร็จ — ไม่ต้องตกใจ ดูบรรทัด create ... สามบรรทัดท้ายเป็นหลัก
ทีนี้ส่วนที่น่าสนใจกว่า: เปิด docs/swagger.json ดูว่า binding tag กลายเป็นอะไร

"recipe.CreateRecipeRequest": {
  "type": "object",
  "required": ["cooking_minutes", "difficulty", "name"],
  "properties": {
    "cooking_minutes": {
      "type": "integer",
      "maximum": 1440,
      "minimum": 1,
      "example": 15
    },
    "difficulty": {
      "type": "string",
      "enum": ["easy", "medium", "hard"],
      "example": "easy"
    },
    "name": { "type": "string", "example": "ผัดกะเพราหมูสับ" }
  }
}
🎁 สามอย่างที่เกิดขึ้นเองโดยเราไม่ได้เขียนใน annotation เลย

1. binding:"required" → "required": [...] ครบทั้งสาม field
2. min=1,max=1440 → "minimum": 1, "maximum": 1440
3. oneof=easy medium hard → "enum": ["easy","medium","hard"]
แถม CookingMinutes *int ขึ้นเอกสารเป็น "type": "integer" ธรรมดา — swag ถอด pointer ทิ้งให้ เพราะ pointer เป็นรายละเอียดของภาษา Go ไม่ใช่ส่วนหนึ่งของสัญญา HTTP

ความหมายที่แท้จริงของเรื่องนี้: กฎ validation กับเอกสารเป็นของชิ้นเดียวกัน
วันไหนคุณแก้ max=1440 เป็น max=600 เอกสารจะเปลี่ยนตามทันทีที่รัน swag init — คุณลืมอัปเดตเอกสารไม่ได้ เพราะไม่เคยมีเอกสารแยกให้ลืม

⚙️ ฝั่ง GET /recipes ก็ได้ของฟรีเหมือนกัน — Enums(easy, medium, hard) ที่เขียนต่อท้าย @Param difficulty กลายเป็น "enum": ["easy","medium","hard"] ในตัว query parameter จริง ๆ
ความหมายคือ ค่าที่ยอมรับได้ถูกเขียนไว้ในเอกสารตรง ๆ — คนที่มาเรียก API ของคุณไม่ต้องเดาว่าพิมพ์ easy หรือ Easy หรือ 1
เทียบกับตอนไม่ใส่: เขาจะรู้ค่าที่ถูกต้องก็ต่อเมื่อลองพิมพ์ผิดแล้วโดน 400 ตอบกลับมา ซึ่งเป็นวิธีเรียนรู้ API ที่แย่ที่สุด

7 · เทสที่พิสูจน์ว่า spec เป็นจริง

spec ที่ไม่มีเทสคือความตั้งใจ ไม่ใช่สัญญา
เทสข้างล่างใช้ table-driven (บทเรียน 16) + httptest.NewRecorder (บทเรียน 40) โดยไม่ต้องเปิด server จริง ไม่ต้องมี database

// internal/recipe/handler_test.go
package recipe_test          // _test ต่อท้าย = เทสจากข้างนอก เห็นเฉพาะของที่ export

import (
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"

    "github.com/gin-gonic/gin"
    "github.com/stretchr/testify/assert"

    "wongnok42/internal/recipe"
)

func newRouter() *gin.Engine {
    gin.SetMode(gin.TestMode)          // ปิด log ของ gin ให้ output เทสอ่านออก
    r := gin.New()
    seed := []recipe.RecipeResponse{
        {ID: "1", Name: "ผัดกะเพราหมูสับ", CookingMinutes: 15, Difficulty: "easy"},
        {ID: "2", Name: "แกงมัสมั่นไก่",   CookingMinutes: 90, Difficulty: "hard"},
    }
    recipe.NewHandler(seed).Register(r.Group("/api/v1"))
    return r
}

func do(t *testing.T, method, target, body string) *httptest.ResponseRecorder {
    t.Helper()
    req := httptest.NewRequest(method, target, strings.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    w := httptest.NewRecorder()      // recorder ใหม่ทุกครั้ง — บทเรียน 40
    newRouter().ServeHTTP(w, req)
    return w
}

func TestCreateRecipe(t *testing.T) {
    tests := []struct {
        name     string
        body     string
        wantCode int
    }{
        {"ครบทุกช่อง",          `{"name":"ต้มยำกุ้ง","cooking_minutes":25,"difficulty":"medium"}`, http.StatusCreated},
        {"ไม่ส่งชื่อเมนู",       `{"cooking_minutes":25,"difficulty":"medium"}`,                    http.StatusBadRequest},
        {"difficulty นอกรายการ", `{"name":"ต้มยำกุ้ง","cooking_minutes":25,"difficulty":"insane"}`, http.StatusBadRequest},
        {"cooking_minutes = 0",  `{"name":"ต้มยำกุ้ง","cooking_minutes":0,"difficulty":"easy"}`,    http.StatusBadRequest},
        {"JSON พัง",             `{"name":`,                                                        http.StatusBadRequest},
    }
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            w := do(t, http.MethodPost, "/api/v1/recipes", tc.body)
            assert.Equal(t, tc.wantCode, w.Code)
            t.Logf("code=%d body=%s", w.Code, w.Body.String())
        })
    }
}

⚠️ อ่านบรรทัดนี้ให้ดี — มันจะกลับมาหลอกคุณตอนทำภารกิจ
do() เรียก newRouter() ใหม่ทุกครั้งที่ถูกเรียก
แปลว่าทุก request เริ่มจาก handler ใบใหม่ ที่มีสูตรอยู่ 2 ใบเท่าเดิมเสมอ — ของที่ request ก่อนหน้าสร้างหรือลบไป จะหายไปหมด
นี่คือสิ่งที่เราต้องการสำหรับเทสข้างบน เพราะทำให้แต่ละเคสไม่ไปยุ่งกับเคสอื่น รันสลับลำดับกันก็ได้ผลเท่าเดิม
แต่พอไหร่ที่คุณอยากเทสว่า "ลบแล้ว ค่อย GET ใบเดิม ต้องได้ 404" คุณจะใช้ do() ไม่ได้ ต้องสร้าง router ตัวเดียว แล้วยิงสองครั้งใส่ตัวเดิม (ผมยกโค้ดให้ในภารกิจข้อ 6)

ผลรันจริง (ตัดมาเฉพาะบรรทัด log):

=== RUN   TestCreateRecipe/ครบทุกช่อง
    handler_test.go:53: code=201 body={"id":"3","name":"ต้มยำกุ้ง","cooking_minutes":25,"difficulty":"medium"}
=== RUN   TestCreateRecipe/ไม่ส่งชื่อเมนู
    handler_test.go:53: code=400 body={"error":"Key: 'CreateRecipeRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag"}
=== RUN   TestCreateRecipe/difficulty_นอกรายการ
    handler_test.go:53: code=400 body={"error":"Key: 'CreateRecipeRequest.Difficulty' Error:Field validation for 'Difficulty' failed on the 'oneof' tag"}
=== RUN   TestCreateRecipe/cooking_minutes_=_0
    handler_test.go:53: code=400 body={"error":"Key: 'CreateRecipeRequest.CookingMinutes' Error:Field validation for 'CookingMinutes' failed on the 'min' tag"}
=== RUN   TestCreateRecipe/JSON_พัง
    handler_test.go:53: code=400 body={"error":"unexpected EOF"}

🔎 อ่านสองบรรทัดนี้เทียบกัน
cooking_minutes = 0 ตกที่ tag min ไม่ใช่ required — เพราะเราใช้ *int ตัว required จึงผ่าน (ส่ง 0 มาจริง pointer ไม่ nil) แล้วไปตกที่ min=1 แทน · นี่คือหลักฐานว่า pointer ทำงานอย่างที่ตั้งใจ
ส่วน JSON พัง ได้ unexpected EOF ซึ่งไม่ใช่ validation error แต่เป็น parse error — คนละชนิดกัน (บทเรียน 39) ต่างกันที่สาเหตุ แต่เหมือนกันที่สัญญากับ client คือ 400 → เทสจึงผูกกับ status เป็นหลัก ไม่ใช่ผูกกับข้อความ error

ฝั่ง GET — เทสว่า filter ทั้งสามแบบตามโจทย์ทำงานจริง:

=== RUN   TestListRecipes/ไม่ใส่_filter
    code=200 body=[{"id":"1",...,"difficulty":"easy"},{"id":"2",...,"difficulty":"hard"}]
=== RUN   TestListRecipes/ค้นจากชื่อ            // ?q=กะเพรา
    code=200 body=[{"id":"1","name":"ผัดกะเพราหมูสับ","cooking_minutes":15,"difficulty":"easy"}]
=== RUN   TestListRecipes/ค้นจากความยาก         // ?difficulty=hard
    code=200 body=[{"id":"2","name":"แกงมัสมั่นไก่","cooking_minutes":90,"difficulty":"hard"}]
=== RUN   TestListRecipes/ค้นจากเวลาปรุง         // ?max_cooking_minutes=30
    code=200 body=[{"id":"1","name":"ผัดกะเพราหมูสับ","cooking_minutes":15,"difficulty":"easy"}]
=== RUN   TestListRecipes/ไม่มีอะไรตรง           // ?q=พิซซ่า
    code=200 body=[]
=== RUN   TestListRecipes/difficulty_นอกรายการ    // ?difficulty=insane
    code=400 body={"error":"Key: 'ListRecipeQuery.Difficulty' Error:Field validation for 'Difficulty' failed on the 'oneof' tag"}
PASS
ok  	wongnok42/internal/recipe	0.329s

บรรทัด "ไม่มีอะไรตรง → 200 []" คือบรรทัดที่ต้องหยุดอ่าน
ค้นแล้วไม่เจอ ไม่ใช่ 404 — เพราะ resource /recipes มีอยู่จริง เราเข้าถึงมันได้สำเร็จ แค่ผลลัพธ์ว่างเปล่า (บทเรียน 34)
404 เก็บไว้ใช้กับ GET /recipes/9999 ซึ่งคือการขอของที่ไม่มีตัวตน

ทายก่อนรัน — ในโค้ด List ผมเขียนว่า out := make([]RecipeResponse, 0, len(h.store))
ถ้าเปลี่ยนเป็น var out []RecipeResponse (ซึ่งเป็นวิธีที่ Go เขียนกันปกติ) แล้วไม่มีสูตรไหนตรงเงื่อนไขเลย

var a []RecipeResponse                  // nil slice
b := make([]RecipeResponse, 0)          // empty slice

json.Marshal(a)   // -> ?
json.Marshal(b)   // -> ?

ทั้งคู่มี len() == 0 เหมือนกัน — แล้ว client ที่เรียก API จะได้ body ต่างกันไหม? และถ้าต่าง มันพังตรงไหนในโลกจริง

=== RUN   TestNilSliceVsEmptySlice
    nilslice_test.go:15: var a []RecipeResponse      -> null
    nilslice_test.go:16: make([]RecipeResponse, 0)   -> []
--- PASS: TestNilSliceVsEmptySlice (0.00s)

ต่างกัน และเป็นความต่างที่ทำให้แอปฝั่ง client พังจริง
โค้ดฝั่งหน้าเว็บมักเขียนประมาณ data.map(...) หรือ data.length — ถ้าได้ [] มา จะได้หน้าจอว่างเปล่าอย่างสุภาพ แต่ถ้าได้ null มาจะได้ error ทันที

และมันเป็นบั๊กที่ซ่อนตัวเก่งมาก เพราะตอน dev มีข้อมูลทดสอบอยู่ในระบบ ทุกคำค้นจึงเจอผลลัพธ์เสมอ — จะไปเจอตอนผู้ใช้จริงพิมพ์คำที่ไม่มีในระบบ

กฎที่ควรติดตัวไปตลอด: endpoint ที่สัญญาว่าจะคืน array ต้องคืน array เสมอ แม้จะว่าง
ในทางปฏิบัติคือ make([]T, 0) ก่อนวนลูป — ยาวขึ้นสองคำ แต่ประกาศเจตนาชัดว่า "ที่นี่คือกอง ไม่ใช่ค่าที่อาจไม่มี"
สังเกตว่า Swagger บอกไว้แล้วว่า @Success 200 {array} recipe.RecipeResponse — null คือการผิดสัญญาที่เราเขียนไว้เอง

⚠️ อีกหนึ่งบรรทัดที่ต้องรู้ไว้ก่อนเอาขึ้นจริง — ผมลองยิง method ที่ไม่มีในระบบ:

DELETE /api/v1/recipes -> code=404 body="404 page not found"

เราลงไว้แค่ POST กับ GET บน path นี้ ดังนั้น DELETE ควรได้ 405 Method Not Allowed ตามหลัก HTTP (บทเรียน 34) แต่ Gin ตอบ 404
เพราะค่าเริ่มต้นของ HandleMethodNotAllowed คือ false (บทเรียน 38) — ต้องตั้ง r.HandleMethodNotAllowed = true เอง
ผลกระทบจริง: คนที่เรียก API ของคุณแล้วได้ 404 จะไปนั่งไล่ว่า "path ผิดตรงไหน" ทั้งที่ path ถูกแล้ว ผิดแค่ method — เสียเวลาไปครึ่งวันได้สบาย ๆ

8 · ปิด Day 8 — และของที่ยังไม่ได้สอน

ทั้งวันนี้เดินเป็นเส้นเดียวจากคำถามว่า "API คืออะไร" มาถึงโค้ดที่รันได้: บท 33 ให้ทุกอย่างเป็น resource + method · บท 34 ให้ status code เป็นคำตอบแรกที่ client อ่าน · บท 35 แยกว่าข้อมูลชิ้นไหนควรไปทาง query / body / header · บท 36 ให้กฎการตั้งชื่อกับ idempotency · บท 37 เปิดฝา net/http ให้เห็นว่า server จริง ๆ มีแค่ interface เดียว · บท 38 กับ บท 39 เอา Gin มาลดโค้ดซ้ำด้วย routing / binding / middleware · บท 40 ทำให้ทั้งหมด เทสได้โดยไม่ต้องเปิด server · บท 41 ทำให้เอกสารเกิดจากโค้ดแทนที่จะเกิดจากความทรงจำ · และบทนี้ให้คุณทำทั้งหมดนั้นย้อนกลับ — จากโจทย์ภาษามนุษย์สี่บรรทัด กลายเป็น spec ที่วิจารณ์ได้ แล้วกลายเป็น endpoint ที่มีเทสยืนยัน

⚠️ สิ่งที่ Day 8 ยังไม่ได้สอน และคุณจะต้องใช้แน่ ๆ
1. auth ของจริง — เรามี @Security BearerAuth ในเอกสาร และมี /login /callback /exchange ใน spec แต่ยังไม่มีโค้ดสักบรรทัดที่ตรวจ token จริง · บท 39 สอนกลไก middleware ไว้แล้ว แต่การ verify JWT, การอ่าน user id ออกจาก token, การตอบ 401 กับ 403 ให้ถูกจังหวะ ยังเป็นช่องว่างอยู่ — และนี่คือช่องว่างที่ห้ามเดาเอง
2. pagination — GET /recipes ของเราคืนทุกใบ ตอนนี้มี 2 ใบเลยดูดี วันที่มี 50,000 ใบ endpoint นี้จะยิง DB ยาว ๆ แล้วส่ง JSON ก้อนมหึมากลับไป · ของที่ต้องเพิ่มคือ ?page= กับ ?limit= พร้อมข้อมูลจำนวนทั้งหมดใน response — และมันเป็นเรื่องที่ต้องออกแบบตั้งแต่ต้น เพราะเปลี่ยนทีหลังคือการเปลี่ยนสัญญากับทุกคนที่เรียกอยู่
3. ต่อ database จริง — บทนี้เก็บใน slice ทั้งหมด การเอา GORM จาก บท 27 มาแทน h.store คือขั้นถัดไปที่ตรงที่สุด
4. CORS — origin คือคู่ของ (โปรโตคอล + โดเมน + พอร์ต) เช่น http://localhost:5173 · frontend ที่รันอยู่คนละ origin กับ API (http://localhost:8080) จะโดนเบราว์เซอร์บล็อกให้เอง ทั้งที่ server เราตอบ 200 ปกติ — ต้องใส่ middleware ที่ตอบ header Access-Control-Allow-Origin กลับไปเพิ่ม

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

1. โจทย์บอกว่าค้นสูตรจาก "ระยะเวลาที่ใช้ปรุง" และ "ความยากง่าย" ได้ — ข้อไหนออกแบบตรงตามกฎ REST ที่สุด?

"ความยากง่าย" กับ "เวลาปรุง" ไม่ใช่ resource — คุณ DELETE เลข 30 ทิ้งไม่ได้ มันเป็นแค่คุณสมบัติของสูตร ดังนั้นมันคือ filter ซึ่งกฎข้อ 6 บอกให้ใช้ query
ตัวเลือกที่ 1 ผิดเพราะยัด filter ลง path — เงื่อนไข 3 อย่างที่เลือกใส่ไม่ใส่ได้จะกลายเป็น route ที่ต้องลงทะเบียนถึง 8 เส้น และเพิ่มเงื่อนไขใหม่ทีต้องเพิ่ม route อีกชุด · ตัวเลือกที่ 2 ผิดสองชั้น: search เป็นกริยา (กฎข้อ 1) และ POST สื่อว่า สร้างของใหม่ ทั้งที่การค้นหาไม่ได้เปลี่ยนอะไรในระบบเลย · ตัวเลือกที่ 4 ผิดเพราะ searchRecipes เป็นกริยาและเป็น camelCase ผิดทั้งกฎข้อ 1 และข้อ 3
ที่ถูกคือ การค้นหาไม่ใช่การกระทำที่ต้องมี endpoint ของตัวเอง มันคือการขอ /recipes แบบมีเงื่อนไข

2. GET /logout ใน spec ของผู้สอน — ข้อสรุปไหนตรงที่สุด?

logout เป็นกริยา (ขัดข้อ 1) และเป็น action ใน URI (ขัดข้อ 7) · หนักกว่านั้นคือมันใช้ GET ซึ่งควรเป็น safe method ไปทำลาย session
แต่เหตุผลที่ทั้งโลกยังเขียนแบบนี้คือ เส้นตระกูล auth ถูกเรียกโดยเบราว์เซอร์ ผ่านการกดลิงก์และการ redirect ซึ่งเป็น GET เสมอ — เราสั่งให้เป็น POST ไม่ได้ ทางที่ RESTful กว่าคือ DELETE /auth/tokens/current ซึ่งใช้ได้ดีเมื่อโค้ดของเราเป็นคนเรียกเอง
ตัวเลือกที่ 1 ผิดชัดเจน — logout เปลี่ยนสถานะเต็มตัว ไม่ใช่การอ่าน · ตัวเลือกที่ 2 มองแคบไป: แก้ชื่อให้เป็นคำนามแล้วยังเหลือปัญหา GET เปลี่ยนสถานะอยู่ดี · ตัวเลือกที่ 3 แรงเกินจริง — spec แบบนี้ทำงานได้และใช้กันทั่วโลก และ POST /logout ก็ยังไม่แก้ปัญหาว่ามันเป็นกริยาอยู่ดี
บทเรียนที่แท้จริง: กฎ REST คุมได้เฉพาะเส้นที่โค้ดเราเป็นคนเรียก ส่วนเส้นที่โปรโตคอลอื่นกำหนดรูปร่างไว้แล้ว ให้แยกออกไปเป็น /auth/... แทนที่จะฝืนหรือแกล้งไม่เห็น

3. field CookingMinutes *int ที่มี binding:"required,min=1,max=1440" — หลังรัน swag init เอกสารที่ได้เป็นอย่างไร?

ผมรัน swag init -g cmd/api/main.go แล้วเปิด docs/swagger.json ดูของจริง ได้ "required": ["cooking_minutes","difficulty","name"] และภายใน cooking_minutes มี "maximum": 1440 กับ "minimum": 1 ครบ — ทั้งหมดมาจาก binding tag โดยเราไม่ได้เขียนอะไรเพิ่มใน annotation เลย
ตัวเลือกที่ 1 กับ 3 ผิดตรงที่คิดว่า swag ไม่อ่าน binding — มันอ่าน และนั่นคือจุดที่ทำให้ เอกสารกับ validationเป็นของชิ้นเดียวกัน แก้ที่เดียวเปลี่ยนทั้งคู่ · ตัวเลือกที่ 4 สลับความหมายของ pointer: pointer มีไว้แยก "ส่ง 0 มาจริง" ออกจาก "ไม่ส่งเลย" ต่างหาก ตัวที่บอกว่าบังคับหรือไม่คือ required ซึ่งใส่ไว้แล้ว
หลักฐานเพิ่มเติมจากผลรันเดียวกัน: swag ถอด pointer ทิ้ง เหลือ "type":"integer" ธรรมดา เพราะ pointer เป็นรายละเอียดของภาษา Go ไม่ใช่ส่วนหนึ่งของสัญญา HTTP

4. GET /recipes?q=พิซซ่า แล้วไม่มีสูตรไหนตรงเลย — handler ควรตอบอะไร และเขียนโค้ดยังไง?

สองเรื่องรวมกันในข้อเดียว
เรื่อง status: resource /recipes มีอยู่จริงและเราเข้าถึงสำเร็จ แค่ผลลัพธ์ว่าง → 200 · 404 เก็บไว้ใช้กับ GET /recipes/9999 ซึ่งคือการขอของที่ไม่มีตัวตน (บทเรียน 34)
เรื่อง body: ผมรันเทียบให้ดูแล้ว var a []RecipeResponse ได้ null ส่วน make([]RecipeResponse, 0) ได้ [] — ทั้งที่ len() เป็น 0 เท่ากัน
ตัวเลือกที่ 2 ผิดเพราะทำให้ client แยกไม่ออกระหว่าง "ค้นแล้วไม่เจอ" กับ "path พิมพ์ผิด" · ตัวเลือกที่ 3 ผิดเพราะ null ทำให้โค้ดฝั่งหน้าเว็บที่เขียน data.map(...) พังทันที และมันจะไปพังตอนผู้ใช้จริงค้นคำที่ไม่มีในระบบ ไม่ใช่ตอน dev · ตัวเลือกที่ 4 ผิดเพราะ 204 แปลว่า "สำเร็จและไม่มี body เลย" ซึ่งขัดกับสัญญา @Success 200 {array} ที่เราประกาศไว้เอง — ผลลัพธ์ว่างยังเป็นผลลัพธ์อยู่ดี

🛠️ ภารกิจ — ทำอีก 2 endpoint ให้เดินได้จริง

หัวข้อ 6 ทำ POST /recipes กับ GET /recipes ไปแล้ว ตาคุณทำสองเส้นที่เหลือของ CRUD: GET /recipes/:id กับ DELETE /recipes/:id
เป้าหมายไม่ใช่ "ให้มันตอบ 200 ได้" — เป้าหมายคือ ให้ทุกกรณีที่ผิดพลาดได้ ตอบ status ที่ถูกต้อง และมีเทสยืนยันทุกกรณี

  1. ตั้งโปรเจกต์: สร้างโฟลเดอร์ว่างใหม่ wongnok42/ แล้ว cd เข้าไป · ทุกคำสั่งต่อจากนี้รันจากโฟลเดอร์นี้ทั้งหมด
    $ mkdir wongnok42 && cd wongnok42
    $ go mod init wongnok42
    $ go get github.com/gin-gonic/gin@v1.12.0
    $ go get github.com/stretchr/testify@v1.12.1
    ชื่อ module ต้องเป็น wongnok42 ให้ตรงกับ import ในหัวข้อ 6 (wongnok42/internal/httputil) ไม่งั้นต้องไล่แก้ import ทุกไฟล์เอง
    แล้ววางโครงโฟลเดอร์ตามหัวข้อ 6: cmd/api · internal/recipe · internal/httputil
  2. คัดลอกโค้ดจากหัวข้อ 6–7 มาให้รันได้ก่อน — ครบ 5 ไฟล์: internal/httputil/response.go · internal/recipe/dto.go · internal/recipe/handler.go · cmd/api/main.go · internal/recipe/handler_test.go
    ยืนยันด้วย go test ./... -v ว่าเห็น code=201 จริง
    ยังไม่ต้องเขียนอะไรใหม่ในข้อนี้ — ต้องมีฐานที่เขียวก่อนถึงจะต่อยอดได้
  3. เขียน GET /recipes/:id ด้วย c.Param("id") (บทเรียน 38)
    ต้องแยกให้ครบ สามทาง: เจอ → 200 · ไม่เจอ → 404 · id ไม่ใช่ตัวเลข → 400
    หมายเหตุ: c.Param("id") คืน string เสมอ และ RecipeResponse.ID ของเราก็เป็น string — ถ้าอยากได้ 400 ตอน /recipes/abc ต้องเรียก strconv.Atoi เช็ครูปแบบเองก่อน แล้วค่อยไปหาใน h.store
    คำถามที่ต้องตอบให้ได้ก่อนเขียน: ทำไม "ไม่เจอ" กับ "id ผิดรูปแบบ" ถึงไม่ควรใช้ status เดียวกัน
  4. เขียน DELETE /recipes/:id — ลบสำเร็จควรตอบ 204 No Content (ไม่มี body) หรือ 200 พร้อม body? เลือกอย่างใดอย่างหนึ่งแล้วเขียนเหตุผลลงไปใน comment
    แล้วตอบคำถามสำคัญกว่านั้น: ลบ id เดิมซ้ำครั้งที่สองควรได้อะไร — 204 เหมือนเดิม หรือ 404? ทั้งสองคำตอบมีคนใช้จริง ขอแค่คุณอธิบายได้ว่าทำไม (ใบ้: บทเรียน 36 เรื่อง idempotency)
  5. ใส่ annotation Swagger ให้ครบทั้งสองเส้น แล้วรัน swag init -g cmd/api/main.go จาก root ของโปรเจกต์
    ถ้ายังไม่ได้ลง swag CLI ให้ลงก่อน (บทเรียน 41): go install github.com/swaggo/swag/cmd/swag@latest
    เปิด docs/swagger.json แล้วยืนยันว่ามี path /recipes/{id} โผล่มาจริง พร้อม parameter ชื่อ id ที่ "in": "path"
  6. เขียนเทสพิสูจน์ แบบ table-driven ครอบทุกกรณีข้างบน (บทเรียน 40) โดยใช้ helper do(t, method, target, body) จากหัวข้อ 7
    อย่าลืมเคสที่คนลืมกันบ่อยสุด: ลบแล้วค่อย GET ใบเดิม — ต้องได้ 404 · ถ้ายังได้ 200 แปลว่าโค้ดลบไม่จริง
    แต่เคสนี้ใช้ do() ไม่ได้ เพราะมันสร้าง router ใหม่ทุกครั้ง (ดูกล่องเตือนในหัวข้อ 7) — ต้องมี helper อีกตัวที่รับ router เข้ามา แล้วยิงสองครั้งใส่ตัวเดิม:
    func doOn(t *testing.T, r *gin.Engine, method, target, body string) *httptest.ResponseRecorder {
        t.Helper()
        req := httptest.NewRequest(method, target, strings.NewReader(body))
        req.Header.Set("Content-Type", "application/json")
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)              // ← ใช้ router ที่ส่งเข้ามา ไม่สร้างใหม่
        return w
    }
    
    // แล้วในเทสเขียนแบบนี้
    r := newRouter()
    doOn(t, r, http.MethodDelete, "/api/v1/recipes/1", "")
    w := doOn(t, r, http.MethodGet, "/api/v1/recipes/1", "")
    assert.Equal(t, http.StatusNotFound, w.Code)
  7. เทสที่พิสูจน์ว่าเทสของคุณเทสจริง: ลองแก้ handler ให้ตอบ 200 แทน 404 ตอนไม่เจอ แล้วรันเทสใหม่
    ถ้าเทสยังเขียวอยู่ แปลว่าเทสของคุณยังไม่ได้เทสอะไร — กลับไปแก้เทส แล้วค่อยแก้ handler กลับ
  8. ท้าทาย 1: ตั้ง r.HandleMethodNotAllowed = true แล้วยิง PATCH /api/v1/recipes/1 — ทายก่อนรัน ว่าได้ status อะไร มี header Allow ไหม และในนั้นมีค่าอะไรบ้าง
  9. ท้าทาย 2: เพิ่ม ?page= กับ ?limit= ให้ GET /recipes โดยให้ limit มีค่าเริ่มต้นและมีเพดาน (เช่น binding:"omitempty,min=1,max=100")
    แล้วตอบว่า client จะรู้ได้ยังไงว่ามีทั้งหมดกี่หน้า — ต้องเปลี่ยน response จาก array เปล่า ๆ เป็นอะไร และการเปลี่ยนนี้กระทบใครบ้าง

สองสถานะนี้ตอบคำถามคนละคำถาม และคนที่ต้องแก้ปัญหาก็คนละคน

400 = "คำขอของคุณผิดรูปแบบ" — /recipes/abc ทั้งที่ id เป็นตัวเลข แปลว่าโค้ดฝั่งที่เรียกมาเขียนผิด ยิงกี่ครั้งก็ผิดเหมือนเดิม ต้องแก้ที่โค้ดฝั่งนั้น
404 = "คำขอถูกต้องแล้ว แต่ของไม่มี" — /recipes/9999 รูปแบบถูกทุกอย่าง แค่สูตรใบนั้นไม่มีในระบบ (หรือถูกลบไปแล้ว) ไม่มีใครเขียนโค้ดผิด และพรุ่งนี้ถ้ามีคนสร้างสูตรที่ได้ id นี้ คำขอเดิมก็จะสำเร็จ

ถ้ายุบสองอย่างนี้เป็น status เดียว คนที่ debug จะเสียเวลาไล่ผิดทาง — เห็น 404 แล้วไปนั่งไล่ว่า route ลงถูกไหม ทั้งที่ความจริงคือส่ง id มาผิดรูปแบบ
นี่คือหลักเดียวกับที่ใช้มาทั้ง Day 8: status code คือประโยคแรกที่ client อ่าน — พูดให้ตรงตั้งแต่ประโยคแรก ประหยัดเวลาคนอื่นได้เป็นวัน

💬 ข้อที่ผมอยากเห็นคำตอบที่สุดคือข้อ 4 กับท้าทาย 2 — เพราะทั้งคู่ไม่มีคำตอบเดียวที่ถูก มีแต่คำตอบที่คุณอธิบายเหตุผลได้ และนั่นคือสิ่งที่แยกคนที่ "เขียน API ได้" ออกจากคนที่ "ออกแบบ API เป็น"
ถ้าออกแบบ spec ส่วนไหนแล้วไม่แน่ใจ ส่ง spec มาให้ดูได้เลย — เอามาถกกันว่าทำไมเลือกแบบนั้น สนุกกว่าเฉลยอยู่แล้ว
และถ้ารันแล้วได้ output ไม่ตรงกับในบทนี้ บอกเวอร์ชันมาด้วย (ทุกบรรทัดในบทนี้รันบน Go 1.26.5 darwin/arm64 · gin v1.12.0 · testify v1.12.1 · swag CLI v1.16.4)

อ้างอิง