บทเรียน 43 · Day 8 · Workshop ทบทวน

API Detective — จับคู่ · จับผิด · จับ annotation

~15 นาที · ต้องมี: บทเรียน 33–36 (REST) และ บทเรียน 41 (Swagger)

บทนี้ไม่มีของใหม่แม้แต่ชิ้นเดียว — เนื้อหามาจากสไลด์ชุด “Day 8 (Post)” ที่อาจารย์เพิ่มมาหลังเรียนจบ เป็นแบบฝึกทบทวนสามส่วน: จับคู่ REST (สไลด์หน้า 41–42) · API Code Review (หน้า 43–47) · จับคู่ annotation ของ Swagger (หน้า 75–76)
กติกาข้อเดียวของบทนี้: ตอบในหัวให้ได้ก่อน แล้วค่อยกดดูเฉลย การอ่านเฉลยผ่าน ๆ ให้ความรู้สึกว่าเข้าใจ แต่ไม่ได้ความเข้าใจ
และในส่วนที่ 2 ผมจะไม่บอกแค่ว่า “เฉลยคืออะไร” — จะบอกด้วยว่า มันผิดกฎข้อไหน และมีหลักฐานจากการรันจริงยืนยันแบบไหน

🧰 ของที่ใช้ในบทนี้ — ทุก output ในหน้านี้มาจากการรันจริงบน

go version go1.26.5 darwin/arm64
github.com/gin-gonic/gin v1.12.0
swag CLI v1.16.4  ·  github.com/swaggo/swag v1.16.6

ส่วนที่ 2 ผมเปิด server ของ Gin ตัวเดียวที่ลงทะเบียนทั้งเส้นแบบแย่และเส้นแบบดี ไว้ด้วยกัน แล้วยิงเทียบกันทีละคู่ด้วย curl — จะได้เห็นว่าสองแบบนี้ ทำงานได้เหมือนกัน แต่คนละคุณภาพ ตรงไหนบ้าง

📖 วิธีใช้บทนี้ให้ได้ผล — ทุกกล่องที่มีปุ่ม “ดูเฉลย” ให้เขียนคำตอบลงกระดาษหรือพิมพ์ในโน้ตก่อน แล้วค่อยกด
ถ้าตอบไม่ได้ นั่นไม่ใช่เรื่องแย่ — มันคือรายชื่อบทที่ต้องกลับไปอ่าน ซึ่งมีค่ากว่าการเดาถูกแบบไม่รู้เหตุผล

ส่วนที่ 1 · จับคู่ REST (สไลด์หน้า 41–42)

สไลด์หน้า 41 ให้โจทย์ 6 ข้อทางซ้าย และตัวเลือก 6 ตัวทางขวา แล้วให้ลากเส้นจับคู่
สังเกตก่อนว่าตัวเลือกทั้งหกไม่ได้อยู่ระนาบเดียวกัน: มีช่องใส่ข้อมูลของ request (path / query / header / body — บทเรียน 35) · มีHTTP method (บทเรียน 33) · และมีสิ่งที่ server ตอบกลับ (status code — บทเรียน 34)
แค่รู้ว่าโจทย์แต่ละข้อพูดถึงระนาบไหน ก็ตัดตัวเลือกไปได้ครึ่งหนึ่งแล้ว

🍜 เทียบให้เห็นภาพ — แอปสั่งอาหารในมือถือ

เปิดแอปสั่งอาหารขึ้นมา คุณทำได้สองอย่างที่ดูคล้ายกัน แต่คนละเรื่องกันเลย
· กดเข้าไปที่ร้านใดร้านหนึ่ง — กดแล้วคุณอยู่ในหน้าของร้านนั้นร้านเดียว ถ้าเอาชื่อร้านออก แอปก็ไม่รู้ว่าจะเปิดหน้าร้านไหนให้
· กดตัวกรองในหน้ารายการร้าน เช่น “ส่งฟรี” หรือ “เปิดอยู่ตอนนี้” — ตัวกรองพวกนี้เอาออกได้ เอาออกแล้วก็ยังเห็นรายการร้านอยู่ดี แค่ไม่ได้คัดให้เท่านั้น

อันแรกคือสิ่งที่ URL เรียกว่า path (/users/123) · อันหลังคือ query (/orders?status=paid)
วิธีแยกที่ใช้ได้ตลอด: ลองลบค่านั้นทิ้งดู
ลบแล้วยังมีความหมาย (แปลว่า “เอาทั้งหมด”) = เป็น query · ลบแล้วไม่รู้จะเอาของชิ้นไหน = เป็น path
(มีข้อยกเว้น — บาง query จำเป็นจริง ๆ เช่น ?q= ของ search ลบแล้วไม่เหลืออะไรให้ค้น แต่มันก็ยังเป็น query เพราะมันไม่ได้ชี้ว่าเป็นของชิ้นไหน)

ที่ไม่เหมือนกัน: ตัวกรองในแอปเราเห็นเป็นปุ่ม แต่สิ่งที่วิ่งไปถึง server จริงคือตัวหนังสือที่ต่อท้าย URL — และมันถูกจดลงไฟล์บันทึกของ server ทุกครั้งที่กด
ต่างจากปุ่มตรงที่เราไม่เห็นตอนมันถูกจด — เดี๋ยวส่วนที่ 2 จะให้เห็นกับตาว่ามันทำให้รหัสผ่านหลุดได้ยังไง

#สิ่งที่ต้องการทำตัวเลือก
1ระบุว่าต้องการ User หมายเลข 123A. Header
2กรอง Order เฉพาะที่ paidB. POST
3ส่ง Access TokenC. Path Parameter
4สร้าง User ใหม่D. Query Parameter
5ส่ง Name + Email สำหรับสร้าง UserE. Status Code
6บอกว่าสร้าง Resource สำเร็จF. Request Body

จับคู่ให้ครบทั้ง 6 คู่ก่อนกดเฉลย — และสำหรับข้อ 1 กับข้อ 2 ให้ตอบเพิ่มอีกอย่างว่า ทำไมสองข้อนี้ถึงไปคนละช่องกัน ทั้งที่ดูเผิน ๆ ก็ “ส่งค่าหนึ่งค่าไปให้ server” เหมือนกัน

#โจทย์คำตอบหน้าตาจริงเพราะอะไร
1ระบุ User 123C. Path Parameter GET /users/123 “ระบุตัวของ” = path · หนึ่ง URL ชี้ไปที่ของชิ้นเดียวเสมอ (กฎข้อ 5, บทเรียน 36)
2กรอง Order ที่ paidD. Query Parameter GET /orders?status=paid “เอาเซ็ตย่อยของของชุดเดิม” = query · ตัดออก/เพิ่ม/สลับลำดับได้โดยไม่แตะ routing — การจับคู่ URL เข้ากับฟังก์ชันที่จะทำงาน (กฎข้อ 6)
3ส่ง Access TokenA. Header Authorization: Bearer … token คือ metadata (ข้อมูลกำกับตัว request ไม่ใช่ตัวเนื้อข้อมูล) ของการเชื่อมต่อ ไม่ใช่เนื้อข้อมูล · และห้ามอยู่ใน URL เด็ดขาด (บทเรียน 35 หัวข้อ 8)
4สร้าง User ใหม่B. POST POST /users “ทำอะไรกับของชิ้นนั้น” = method ไม่ใช่ชื่อใน URI (กฎข้อ 7)
5ส่ง Name + EmailF. Request Body {"name":"…","email":"…"} เนื้อข้อมูลที่จะบันทึกลงระบบ ไปที่ body · ยาวได้ ซ้อนชั้นได้ ไม่ถูกจดลง access log
6บอกว่าสร้างสำเร็จE. Status Code 201 Created สี่ข้อบนคือสิ่งที่ client ส่งไป ข้อนี้คือสิ่งที่ server ตอบกลับ (บทเรียน 34)

คำตอบของคำถามพ่วง (ข้อ 1 กับ 2 ต่างกันตรงไหน) — ข้อ 1 ตอบคำถามว่า “ของชิ้นไหน” ส่วนข้อ 2 ตอบว่า “เอาชุดย่อยแบบไหนของของชุดนั้น”
ทดสอบง่าย ๆ: ตัดค่านั้นทิ้งแล้ว URL ยังมีความหมายอยู่ไหม
/users/ ที่ไม่มี 123 → ไม่รู้ว่าจะเอา user คนไหน = ค่านั้นต้องอยู่ใน path
/orders ที่ไม่มี ?status=paid → ยังมีความหมายอยู่ แปลว่า “เอาทั้งหมด” = ค่านั้นเป็น query

และในโค้ด Gin สองอย่างนี้ดึงด้วยคนละเมธอด — ผมยิงจริงแล้ว echo ค่าที่ดึงได้กลับมาให้ดู ว่าใครดึงด้วยอะไร:

$ curl -s 'http://127.0.0.1:18080/users/123'
{"extracted_by":"c.Param(\"id\")","id":"123","user":{...}}

$ curl -s 'http://127.0.0.1:18080/orders?status=paid'
{"extracted_by":"c.Query(\"status\")","filter_status":"paid","orders":[...]}
🗣 ศัพท์ที่เพิ่งเรียนไป

หกคำนี้คือคำที่ dev ใช้เรียก “ช่องใส่ข้อมูล” ที่เพิ่งจับคู่ไป — พูดให้ตรงคำ แล้วจะได้คำตอบที่ตรง ทั้งจาก senior และจาก AI

resource
“ของ” ที่ API พูดถึง เช่น user หนึ่งคน order หนึ่งใบ · ก่อนตั้งชื่อ URL ให้ถามตัวเองว่า “resource ของเส้นนี้คืออะไร” แล้วเอาคำนามนั้นเป็นชื่อเส้น
endpoint
API หนึ่งเส้น = method + path คู่กัน เช่น GET /users/123 · ประโยคที่ใช้บ่อยคือ “endpoint นี้รับอะไรบ้าง ตอบอะไรกลับมา”
path parameter (path param)
ค่าที่ฝังอยู่ในเส้นทางของ URL เพื่อระบุว่าของชิ้นไหน · Gin ดึงด้วย c.Param("id") · เวลาถามให้พูดว่า “ตัว id นี้เป็น path param หรือ query param” จะสื่อกว่าถามว่า “ตัวนี้คืออะไร”
query parameter / query string
ส่วนที่ต่อท้ายเครื่องหมาย ? ใช้คัดกรอง เรียงลำดับ หรือแบ่งหน้า · Gin ดึงด้วย c.Query("status") · คำว่า “query string” หมายถึงทั้งก้อนหลัง ?
request body
ก้อนข้อมูลที่แนบไปกับ request (ปกติเป็น JSON) สำหรับเนื้อข้อมูลที่จะบันทึกลงระบบ · ประโยคที่เอาไปพูดในห้องรีวิวได้เลย: “อันนี้ส่งทาง body ดีกว่าส่งทาง query นะ เดี๋ยวมันลง log”
header
ข้อมูลกำกับตัว request เอง เช่น ใครเป็นคนส่ง ส่งมาเป็นรูปแบบไหน — ไม่ใช่เนื้อข้อมูล · Authorization คือ header ที่ใช้ส่ง token

ส่วนที่ 2 · API Code Review (สไลด์หน้า 43–47)

โจทย์บนสไลด์เขียนไว้แบบนี้: “ทีมออกแบบ API แบบนี้ ให้ช่วยปรับให้ RESTful และปลอดภัยขึ้น”

GET  /getUser?id=123
POST /deleteUser?id=123
POST /createUser?name=Alice&password=123456
GET  /getPaidOrders

ก่อนจะไล่ทีละบรรทัด มีเรื่องหนึ่งที่ต้องพูดให้ชัดก่อน เพราะมันเป็นเหตุผลที่ code review แบบนี้ จำเป็น: สี่บรรทัดนี้ทำงานได้จริงทุกบรรทัด
ผมลงทะเบียนทั้งแบบแย่และแบบดีไว้ใน Gin ตัวเดียวกัน แล้วให้มันพิมพ์ตาราง route ของตัวเองออกมา:

$ curl -s http://127.0.0.1:18080/__routes
[
  {"method":"GET","path":"/getUser"},
  {"method":"GET","path":"/getPaidOrders"},
  {"method":"GET","path":"/users/:id"},
  {"method":"GET","path":"/orders"},
  {"method":"GET","path":"/__routes"},
  {"method":"POST","path":"/deleteUser"},
  {"method":"POST","path":"/createUser"},
  {"method":"POST","path":"/users"},
  {"method":"DELETE","path":"/users/:id"}
]
⚠️ ประเด็นที่ต้องเข้าใจก่อนอ่านต่อ

ไม่มีบรรทัดไหนใน 4 บรรทัดนั้น compile ไม่ผ่าน และไม่มีบรรทัดไหน crash
ทุกเส้นตอบ 200 สวย ๆ กลับมาได้หมด — ถ้าเกณฑ์ของคุณคือ “มันทำงานไหม” API ชุดนี้ผ่านฉลุย
เกณฑ์จริงของ code review จึงไม่ใช่ “ทำงานไหม” แต่คือ “คนอื่นเดาถูกไหม · ขยายได้ไหม · มีอะไรรั่วออกไปไหม”

บรรทัดที่ 1 — GET /getUser?id=123

ผิดกฎกี่ข้อ ข้อไหนบ้าง (อ้างกฎ 7 ข้อจาก บทเรียน 36) แล้วเขียนเส้นที่ถูกออกมา
คำใบ้: มันผิดมากกว่าเรื่อง “ชื่อไม่สวย” หนึ่งเรื่อง

❌ ของเดิม

GET /getUser?id=123

ทำงานได้ · แต่ผิดกฎ 3 ข้อพร้อมกัน

✅ ที่ควรเป็น

GET /users/123

URI บอกว่าของอะไร · method บอกว่าทำอะไร

ผิดสามข้อ:

  • กฎข้อ 1 (ใช้ noun ไม่ใช่ verb) — getUser เป็นกริยาเต็มตัว สไลด์บทเรียน 36 ยกตัวอย่าง GET /getUsers ไว้ในช่อง ❌ ตรง ๆ เลย
  • กฎข้อ 7 (อย่าใส่ action ใน URI ถ้า method สื่อได้แล้ว) — คำว่า get ซ้ำกับ method GET ที่อยู่ข้างหน้าอยู่แล้ว พูดสองรอบเปล่า ๆ
  • กฎข้อ 5 (relationship/ตัวระบุใช้ path) — 123 คือ ตัวระบุว่าเป็นของชิ้นไหน ไม่ใช่ตัวกรอง มันจึงต้องอยู่ที่ path ไม่ใช่ query (ตรงกับส่วนที่ 1 ข้อ 1 ที่เพิ่งจับคู่ไป)

และเมื่อยิงจริง สองเส้นนี้ให้ข้อมูลชุดเดียวกันเป๊ะ ต่างกันแค่ “ดึงค่าจากช่องไหน”:

=== BAD  GET /getUser?id=123 ===
{"extracted_by":"c.Query(\"id\")","id":"123","user":{"id":"123","name":"Alice"}}
HTTP 200

=== GOOD GET /users/123 ===
{"extracted_by":"c.Param(\"id\")","id":"123","user":{"id":"123","name":"Alice"}}
HTTP 200

⚠ สังเกตสิ่งที่ไม่มีใน response ทั้งสองเส้นไม่คืนฟิลด์ password กลับมาเลย ทั้งที่ตอนสร้าง user เรารับ password เข้าไป — เพราะ รับเข้า กับ ส่งออก ไม่จำเป็นต้องเป็น struct เดียวกัน (บทเรียน 39 แยก request DTO กับ response DTO คนละ struct กัน ไว้ด้วยเหตุผลนี้)
ถ้า GET /users/123 คืน password ออกมาด้วย ต่อให้ URI สวยตามกฎครบทุกข้อ มันก็คือช่องโหว่ — กฎตั้งชื่อไม่ได้ช่วยอะไรถ้าข้อมูลที่ส่งออกไปผิดตั้งแต่แรก

นี่แหละคือหัวใจของทั้งส่วนนี้ — ผลลัพธ์เหมือนกัน 200 เท่ากัน ความต่างทั้งหมดอยู่ที่คนอ่านคนต่อไปเดาถูกหรือเปล่า
คนที่รู้ว่าคุณมี /users/123 จะเดา /users/456 ได้ทันที แต่คนที่เห็น /getUser?id=123 จะเดา /getOrder?id=… หรือ /getOrderById?orderId=… ดี? — เขาต้องเปิดเอกสารทุกครั้งไปตลอด

บรรทัดที่ 2 — POST /deleteUser?id=123

บรรทัดนี้ผิดทุกอย่างเหมือนบรรทัดแรก บวกอีกหนึ่งเรื่องที่หนักกว่า — เรื่องนั้นคืออะไร?
และคำถามพ่วงที่สำคัญกว่า: ถ้าเรายิง DELETE /users/123 ซ้ำ ๆ 3 ครั้ง เทียบกับยิง POST /deleteUser?id=123 ซ้ำ ๆ 3 ครั้ง status code ที่ได้จะต่างกันไหม

❌ ของเดิม

POST /deleteUser?id=123

✅ ที่ควรเป็น

DELETE /users/123

เรื่องที่หนักกว่า: ใช้ POST ทำงานลบ
ผิดกฎข้อ 7 ในรูปแบบที่เจ็บที่สุด — พอ POST ถูกใช้ทำทุกอย่าง (สร้างก็ POST ลบก็ POST แก้ก็ POST) method ก็ไม่เหลือความหมายอะไรเลย ทุกอย่างย้ายไปอยู่ในสตริง URI ทั้งหมด ซึ่งไม่มีเครื่องมือตัวไหนในโลกอ่านเข้าใจ
ผลตามมาที่จับต้องได้: อะไรก็ตามที่อยู่หลัง POST ไม่มีใครกล้า retry ให้คุณ ตาม RFC 9110 DELETE เป็น idempotent (ยิงซ้ำแล้วผลลัพธ์ปลายทางเท่าเดิม) ส่วน POST ไม่ใช่ — proxy · retry layer · client library จึงตัดสินใจต่างกันสิ้นเชิงเวลาเน็ตกระตุก (บทเรียน 36 หัวข้อ 6)

คำตอบของคำถามพ่วง — และมันคือคำตอบที่คนส่วนใหญ่ตอบผิด ผมยิงจริงทั้งสองแบบ ๆ ละ 3 ครั้ง:

##### GOOD: DELETE /users/123  x3 #####
call 1 -> {"deleted":"123","remaining":1}                        | HTTP 200
call 2 -> {"error":"user not found","id":"123","remaining":1}     | HTTP 404
call 3 -> {"error":"user not found","id":"123","remaining":1}     | HTTP 404

##### BAD: POST /deleteUser?id=123  x3 #####
call 1 -> {"deleted":"123","remaining":1}                        | HTTP 200
call 2 -> {"error":"user not found","id":"123","remaining":1}     | HTTP 404
call 3 -> {"error":"user not found","id":"123","remaining":1}     | HTTP 404

เหมือนกันเป๊ะ — ทั้ง status code ทั้ง body ทั้ง state ปลายทาง
ถ้าใครบอกว่า “ลองยิงซ้ำดูสิ แล้วจะเห็นความต่างเรื่อง idempotency” อันนั้นพิสูจน์ไม่ได้จริง ผมรันแล้ว
เพราะ idempotency ไม่ใช่พฤติกรรมที่ server สะท้อนออกมาเป็นตัวเลข แต่เป็น สัญญาที่ผูกกับตัว method เอง ตาม HTTP spec ซึ่ง client · proxy · ชั้น retry เอาไปใช้ตัดสินใจว่า “ยิงซ้ำได้ไหม”
พูดอีกแบบ: การเลือก DELETE แทน POST ไม่ได้เปลี่ยนสิ่งที่ server ทำ — มันเปลี่ยนสิ่งที่คนอื่นกล้าทำกับ server ของคุณ

🛗 เทียบให้เห็นภาพ — ปุ่มเรียกลิฟต์ กับ ปุ่มยืนยันโอนเงิน

ยืนรอลิฟต์แล้วใจร้อน กดปุ่มเรียกซ้ำอีก 5 ครั้ง — ลิฟต์ก็มาคันเดียว รอบเดียว กดกี่ทีผลปลายทางก็เท่าเดิม ทุกคนเลยกดซ้ำได้โดยไม่ต้องคิดอะไร
แต่ถ้าอยู่ในแอปธนาคาร แล้วกด “ยืนยันโอนเงิน” ซ้ำอีก 3 ครั้ง — เงินอาจออกจากบัญชี 3 รอบ ทุกคนเลยกดครั้งเดียวแล้วนั่งลุ้น

DELETE คือปุ่มลิฟต์ · POST คือปุ่มโอนเงิน
แต่ระวังตรงนี้ — ผลรันข้างบนทั้งสองแบบให้ 200 → 404 → 404 เท่ากัน เพราะ handler ทั้งคู่เขียนให้ลบแบบเดียวกัน · ตัวเลขชุดนี้จึงไม่ใช่หลักฐานว่าเส้นไหน idempotent
ความต่างระหว่างปุ่มลิฟต์กับปุ่มโอนเงินอยู่ที่ “ข้างในทำอะไร” (กดซ้ำแล้วเงินออกจากบัญชีเพิ่มอีกรอบไหม) ไม่ใช่ “ยิงแล้วเห็นตัวเลขอะไร”
กดปุ่มลิฟต์ครั้งแรกไฟติด ครั้งที่สองไฟไม่ติดเพิ่ม — สิ่งที่ตอบกลับมาต่างกัน แต่ผลปลายทางเท่าเดิม คือ “ลิฟต์ถูกเรียกแล้ว” เท่ากันทุกครั้งที่กด

ที่ไม่เหมือนกัน: ลิฟต์ถูกสร้างมาให้กดซ้ำแล้วไม่พังจริง ๆ แต่ HTTP ไม่ได้บังคับให้ DELETE ของคุณทำตัวแบบนั้น มันเป็นแค่สัญญาที่เราต้องเขียนโค้ดให้ตรงเอง — ถ้าเผลอเขียน DELETE ให้หักสต็อกทีละ 1 ทุกครั้งที่ถูกเรียก มันก็ผิดสัญญาได้ และไม่มีเครื่องมือตัวไหนมาเตือน

🗣 ศัพท์ที่เพิ่งเรียนไป
idempotent
“ยิงซ้ำกี่ครั้ง ผลปลายทางก็เท่าเดิม” = ปุ่มลิฟต์ · GET PUT DELETE เป็น idempotent ส่วน POST ไม่ใช่ · เวลาถามให้พูดว่า “endpoint นี้ idempotent ไหม” แทนที่จะถามว่า “ยิงซ้ำได้ไหม” — คำนี้ทำให้อีกฝ่ายรู้ทันทีว่าคุณถามถึงสัญญาระดับ HTTP ไม่ใช่ถามว่าเซิร์ฟเวอร์จะพังไหม
safe method
method ที่ไม่เปลี่ยนแปลงข้อมูลเลย เช่น GET · ตัวที่ safe จะ idempotent ด้วยเสมอ แต่ตัวที่ idempotent ไม่จำเป็นต้อง safe — DELETE เปลี่ยนข้อมูล จึง idempotent แต่ไม่ safe
retry
การ “ยิงซ้ำอัตโนมัติ” เวลา request ล้มเหลว — ทั้งตอนไม่ได้คำตอบ (timeout / connection ขาด) และตอนได้ 5xx หรือ 429 กลับมา · คนที่ยิงซ้ำให้มักไม่ใช่คน แต่เป็นโค้ดที่อยู่ระหว่างทาง ซึ่งใช้ method เป็นเงื่อนไขหลักร่วมกับค่าที่ตั้งไว้ในคอนฟิก — ไม่ใช่ว่าเขียนเป็น DELETE แล้วทุกที่จะ retry ให้อัตโนมัติ (Go net/http เองยิงซ้ำให้แค่ GET/HEAD/OPTIONS/TRACE) และ POST ก็ถูกยิงซ้ำได้ถ้าคอนฟิกเปิดไว้
reverse proxy / load balancer
เครื่องที่ยืนอยู่หน้า server ของเรา คอยรับ request แล้วส่งต่อเข้ามา (และกระจายโหลดให้หลายเครื่อง) · มันอ่านโค้ดเราไม่ได้ อ่านเอกสารเราก็ไม่ได้ — สิ่งที่มันใช้ตัดสินใจ (จะ retry ไหม จะส่งไปเครื่องไหน) คือ method + URL + header เท่านั้น นี่คือเหตุผลที่ method กับ URL ต้องสื่อความหมายด้วยตัวเอง
HTTP spec / RFC 9110
เอกสารกลางที่นิยามว่า method ไหนแปลว่าอะไร · ประโยคที่ใช้ปิดการเถียงในห้องรีวิวได้จริง: “ตาม RFC 9110 DELETE เป็น idempotent เราเลยควรใช้ตัวนี้แทน POST”
⚠️ สองกับดักที่โผล่มาระหว่างรันเคสนี้

1. 404 ที่ได้ตอนยิงซ้ำ ไม่ได้แปลว่า “ลบไม่สำเร็จ” — และมันหน้าตาแยกไม่ออกจาก 404 ที่เกิดจากการลบของที่ไม่เคยมี ผมยิงยืนยันแล้ว:

$ curl -X DELETE 'http://127.0.0.1:18080/users/999'   # id ที่ไม่เคยมีอยู่เลย
{"error":"user not found","id":"999","remaining":1} | HTTP 404   ← ครั้งแรกก็ 404 แล้ว

นี่คือเหตุผลที่หลาย API เลือกให้ DELETE ตอบ 204 No Content ทุกครั้ง (ลบแล้วหรือไม่มีอยู่ก็ 204) เพื่อให้ผลลัพธ์ที่ client เห็น สม่ำเสมอตามสัญญา idempotent ไปเลย — เป็นการตัดสินใจออกแบบ ไม่ใช่กฎตายตัว (บทเรียน 36 หัวข้อ 7 เถียงเรื่องนี้ทั้งหัวข้อ)

2. ยิงผิด method บนเส้นที่ถูกต้อง Gin ตอบ 404 ไม่ใช่ 405 — ตรงนี้ทำให้คนที่เพิ่งเปลี่ยนมาเขียนแบบ RESTful งงหนักมาก:

$ curl -o /dev/null -w 'HTTP %{http_code}\n' -X POST 'http://127.0.0.1:18080/users/123'
HTTP 404          # ทั้งที่ /users/:id มีอยู่จริง แค่ผิด method

# พิสูจน์สาเหตุด้วย httptest (เซิร์ฟเวอร์ตัวเดียวกัน มีทั้ง GET และ DELETE บน /users/:id):
HandleMethodNotAllowed=false -> POST /users/123 = 404  Allow: ""
HandleMethodNotAllowed=true  -> POST /users/123 = 405  Allow: "GET, DELETE"

gin.New() ตั้ง HandleMethodNotAllowed = false เป็นค่า default ต้องเปิดเองถึงจะได้ 405 พร้อม header Allow ที่ลิสต์ทุก method ที่ path นั้นรับได้ — ในเคสนี้คือ GET, DELETE ไม่ใช่แค่ตัวเดียว
ถ้าไม่เปิด คนที่เรียก API ของคุณจะเห็น 404 แล้วสรุปว่า “ไม่มี endpoint นี้” ทั้งที่ความจริงคือ “มี แต่คุณใช้ method ผิด”

บรรทัดที่ 3 — POST /createUser?name=Alice&password=123456

ถึงบรรทัดที่สำคัญที่สุดของทั้งบท · บรรทัดนี้ผิดกฎการตั้งชื่อเหมือนสองบรรทัดบน แต่ถ้าจับได้แค่นั้นแปลว่ายังพลาดเรื่องที่หนักที่สุดไป

สมมติเส้นนี้ขึ้น production แล้วมีคนสมัครสมาชิกวันละหมื่นคน
server ตอบ 200 ทุกครั้ง ไม่มี error สักตัว ไม่มีใครแฮ็กอะไรเลย
แล้ววันหนึ่งรหัสผ่านของผู้ใช้ทุกคนหลุดออกไป — หลุดทางไหน?
(คำใบ้: gin.Default() มีอะไรติดมาให้บ้าง)

หลุดทาง access log ของตัวเราเอง — gin.Default() มี gin.Logger() ติดมาให้ตามค่า default และ Logger ตัวนั้นพิมพ์ path พร้อม query string ทั้งก้อนลง log ทุก request

ผมยิงสองแบบติดกัน แล้ว grep หารหัสผ่านในไฟล์ log:

❌ password ทาง query

$ curl -X POST \
  'http://127.0.0.1:18080/createUser?name=Alice&password=123456'

$ grep -n '123456' gin-access.log
7:[GIN] 2026/08/21 - 13:01:28 | 200 | 54.083µs |
   127.0.0.1 | POST     "/createUser?name=Alice&password=123456"

รหัสผ่านถูกเขียนลงไฟล์แบบ plaintext อ่านได้ตรง ๆ

✅ password ทาง body

$ curl -X POST 'http://127.0.0.1:18080/users' \
  -H 'Content-Type: application/json' \
  -d '{"name":"alice","password":"123456"}'

$ grep -n '123456' gin-access.log   # ไม่มีบรรทัดใหม่เพิ่ม
8:[GIN] 2026/08/21 - 13:01:28 | 201 | 195.958µs |
   127.0.0.1 | POST     "/users"

log เห็นแค่ path เปล่า ๆ · ไม่มีร่องรอยของรหัสผ่านเลย

นับให้เห็นชัด ๆ ว่าทั้งไฟล์มีรหัสผ่านโผล่กี่ครั้ง:

$ grep -c '123456' gin-access.log
1

$ grep -n 'password' gin-access.log
7:[GIN] 2026/08/21 - 13:01:28 | 200 | 54.083µs |  127.0.0.1 | POST  "/createUser?name=Alice&password=123456"

เจอครั้งเดียว คือบรรทัดของ /createUser เท่านั้น — ทั้งที่ฝั่ง POST /users server ก็อ่านรหัสผ่านได้ครบเหมือนกัน (response ตอบกลับมาว่า password_len:6) แต่ body ไม่ถูก log

เส้นที่ถูก:

POST /users
Content-Type: application/json

{"name":"Alice","password":"123456"}

// server ตอบ:
HTTP 201 Created
{"created":"1000","extracted_by":"c.ShouldBindJSON (request body)","name":"alice","password_len":6}

สังเกต 201 ไม่ใช่ 200 — เส้นแบบเดิมตอบ 200 ส่วนเส้นที่ถูกตอบ 201 Created ซึ่งเป็นรหัสของ “สร้างของใหม่ขึ้นมาสำเร็จ” (บทเรียน 34) — และมันคือคำตอบของข้อ 6 ในส่วนที่ 1 พอดี

⚠️ ทำไม query ถึงอันตรายกว่า body ทั้งที่ “ก็ส่งไปเหมือนกัน”

เพราะ query เป็นส่วนหนึ่งของ URL และ URL ถูกจดไว้ตลอดทาง — access log ของ server (เห็นกับตาข้างบนแล้ว) · log ของ reverse proxy กับ load balancer ที่ request วิ่งผ่านก่อนถึงเรา · ประวัติ browser · header Referer · และระบบรวม log ที่ทั้งทีมเปิดดูได้และเก็บย้อนหลังเป็นเดือน
HTTPS ช่วยไม่ได้เลยกับเรื่องนี้ — HTTPS กันคนดักกลางทาง ไม่ได้กันปลายทางที่ตั้งใจจดเอง
กฎที่เอาไปใช้ได้ตลอดชีวิต: อะไรที่หลุดแล้วต้องรีเซ็ต ห้ามอยู่ใน URL — password, token, OTP, เลขบัตร (บทเรียน 35 หัวข้อ 8)
และสังเกตด้วยว่าปัญหานี้ไม่หายไปเพราะรีวิวชื่อ endpoint — ต่อให้เปลี่ยนชื่อเป็น POST /users?password=123456 ที่ชื่อสวยแล้ว รหัสผ่านก็ยังลง log อยู่ดี · ต้องย้ายช่อง ไม่ใช่แค่เปลี่ยนชื่อ

📦 เทียบให้เห็นภาพ — หน้าซองพัสดุ กับ ของที่อยู่ในกล่อง

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

?password=123456 คือการเขียนรหัสผ่านไว้บนหน้าซอง · ส่งทาง body คือการใส่ไว้ในกล่อง
นี่คือเหตุผลที่ grep หารหัสผ่านทั้งไฟล์ log แล้วเจอแค่ครั้งเดียว — ครั้งที่มันถูกเขียนไว้หน้าซองเท่านั้น ส่วนอันที่อยู่ในกล่องไม่โผล่ใน access log ที่ gin.Default() เปิดให้ดีฟอลต์
แต่ “ไม่โผล่ที่นี่” ไม่เท่ากับ “ไม่มีที่ไหนจดเลย” — ถ้าทีมเปิด middleware ที่ log body ไว้ดีบัก หรือมี WAF / APM / proxy ที่ถอด TLS อยู่ข้างหน้า body ก็ถูกจดได้เต็ม ๆ เหมือนกัน · ส่ง body จึงปลอดภัยกว่า ไม่ใช่ปลอดภัยสมบูรณ์ (รหัสผ่านยังต้องแฮชเสมอ)

ที่ไม่เหมือนกัน: หน้าซองพัสดุใครเดินผ่านก็อ่านได้ แต่ URL ที่วิ่งบน HTTPS ถูกเข้ารหัสระหว่างทางจริง ๆ คนดักกลางทางอ่านไม่ออก
ปัญหาจึงไม่ได้อยู่ที่ “ระหว่างทาง” แต่อยู่ที่ปลายทางที่ตั้งใจจด URL เก็บไว้เอง — อย่าเผลอสรุปว่า “ใส่ HTTPS แล้วจบ” เพราะมันแก้คนละปัญหากัน

🗣 ศัพท์ที่เพิ่งเรียนไป
access log
ไฟล์ที่ server จดไว้ว่า “ใครยิงอะไรมาเมื่อไหร่ ได้ status อะไรกลับไป” · gin.Default() เปิดให้อัตโนมัติ · คำถามที่ควรถามทุกครั้งก่อนใส่อะไรลง URL คือ “ค่านี้จะไปโผล่ใน access log ไหม”
plaintext
“ข้อความเปล่า ๆ อ่านได้ตรง ๆ” ไม่ได้ถูกเข้ารหัสหรือแฮชไว้ · รหัสผ่านที่โผล่ในไฟล์ log คือ plaintext
secret
คำรวมของ “อะไรก็ตามที่หลุดแล้วต้องรีเซ็ตทันที” — password, token, OTP, API key · กฎสั้น ๆ ที่พูดกับทีมได้เลย: “secret ห้ามอยู่ใน URL” ประโยคเดียวจบ ไม่ต้องอธิบายยาว
DTO (Data Transfer Object)
struct ที่ทำหน้าที่เป็น “แบบฟอร์ม” ของข้อมูลขาเข้าและขาออก · ที่ต้องแยก request DTO กับ response DTO ก็เพราะ ของที่รับเข้ามาไม่จำเป็นต้องส่งกลับออกไปทั้งหมด (รับ password เข้ามา แต่ไม่คืน password ออกไป)

🔍 เกร็ดเล็ก ๆ ที่ต้องพูด เพราะเดี๋ยวจะงงตอนเทียบกับสไลด์ — URL ต้นทางบนสไลด์ส่ง name=Alice (A ใหญ่) แต่เฉลยบนสไลด์เขียน body เป็น {"name":"alice"} (a เล็ก)
ไม่ใช่บั๊ก และไม่ใช่ว่า REST หรือ JSON binding แปลงเป็นตัวพิมพ์เล็กให้ — มันไม่ได้แปลงอะไรให้เลย เป็นแค่คนพิมพ์สไลด์พิมพ์ไม่ตรงกันเฉย ๆ (ผมยิงจริงตามเฉลยเป๊ะ ๆ ได้ {"created":"1000","name":"alice",…} คือ ส่งอะไรไปก็ได้อันนั้นกลับมา)

บรรทัดที่ 4 — GET /getPaidOrders

บรรทัดนี้ดูไม่มีพิษภัยที่สุดในสี่บรรทัด · ไม่มีรหัสผ่าน ไม่มีการลบอะไร
แล้วมันจะพังตอนไหน?
คำใบ้: ลองนึกว่าอาทิตย์หน้า PM ขอ “ดู order ที่ยกเลิก” และอาทิตย์ถัดไปขอ “ดู order ที่จ่ายแล้วของเดือนนี้”

❌ ของเดิม

GET /getPaidOrders
GET /getPendingOrders      ← อาทิตย์หน้า
GET /getCancelledOrders    ← อาทิตย์ถัดไป
GET /getPaidOrdersThisMonth  ← แล้วก็…

ทุกตัวกรองใหม่ = route ใหม่ + handler ใหม่ + เอกสารใหม่
(handler = ฟังก์ชันที่ทำงานเมื่อมีคนยิงเข้ามาที่เส้นนั้น)

✅ ที่ควรเป็น

GET /orders?status=paid
GET /orders?status=pending
GET /orders?status=cancelled
GET /orders?status=paid&from=2026-08-01

เส้นเดิมตัวเดียว — เพิ่ม/ตัด/สลับลำดับตัวกรองได้โดยไม่แตะ routing

ผิดกฎข้อ 1 (verb) · กฎข้อ 7 (action ใน URI) · และตัวหลักคือ กฎข้อ 6 — filter ต้องใช้ query
คำว่า Paid ที่ฝังอยู่ในชื่อ endpoint คือค่าของตัวกรอง ที่ถูกจับแช่แข็งไว้ในชื่อเส้น พอค่าเปลี่ยน ชื่อเส้นก็ต้องเปลี่ยนตาม

ยิงจริงทั้งสองเส้น ได้ข้อมูลชุดเดียวกัน (ข้อมูลทดสอบมี 4 order — paid 2, pending 1, cancelled 1):

=== BAD  GET /getPaidOrders ===
{"orders":[{"id":"o1","status":"paid"},{"id":"o3","status":"paid"}]}
HTTP 200

=== GOOD GET /orders?status=paid ===
{"extracted_by":"c.Query(\"status\")","filter_status":"paid",
 "orders":[{"id":"o1","status":"paid"},{"id":"o3","status":"paid"}]}
HTTP 200

o1 กับ o3 เท่ากันทั้งสองเส้น — อีกครั้งที่ ความต่างไม่ได้อยู่ที่ผลลัพธ์วันนี้ แต่อยู่ที่ต้นทุนของวันพรุ่งนี้

⚠️ กับดักที่มากับ c.Query — ลืมส่งตัวกรองแล้วไม่มีใครเตือน

ย้ายไปใช้ query แล้วได้ความยืดหยุ่นมาก็จริง แต่ได้กับดักนี้แถมมาด้วย:

$ curl -w ' | HTTP %{http_code}\n' 'http://127.0.0.1:18080/orders'   # ลืมใส่ ?status
{"extracted_by":"c.Query(\"status\")","filter_status":"",
 "orders":[{"id":"o1","status":"paid"},{"id":"o2","status":"pending"},
           {"id":"o3","status":"paid"},{"id":"o4","status":"cancelled"}]} | HTTP 200

c.Query("status") กับ param ที่ไม่ได้ส่งมา คืน empty string เงียบ ๆ ไม่ error ไม่ 400 · ผลคือ frontend ที่ลืมแนบ ?status=paid ไปหนึ่งครั้ง จะได้ order ทั้งหมดรวมที่ยกเลิกแล้ว กลับไปแสดงบนหน้าจอ โดยที่ status ยังเป็น 200
ทางแก้: ถ้าตัวกรองนั้นจำเป็น ต้องเช็คเองว่าว่างหรือเปล่าแล้วตอบ 400 · ถ้าไม่จำเป็นก็ต้องตั้งใจว่า “ว่าง = เอาทั้งหมด” และเขียนไว้ในเอกสาร ไม่ใช่ปล่อยให้เป็นอุบัติเหตุ

สรุปส่วนที่ 2 เป็นตารางเดียว

ของเดิมควรเป็นผิดกฎข้อผลจริงที่วัดได้
GET /getUser?id=123 GET /users/123 1 (verb) · 7 (action ใน URI) · 5 (ตัวระบุต้องอยู่ path) ผลเหมือนกันทั้งคู่ 200 — ต่างที่คนอื่นเดาชื่อเส้นถัดไปไม่ได้
POST /deleteUser?id=123 DELETE /users/123 1 · 7 · ใช้ POST ทำงานลบ ยิงซ้ำ 3 ครั้งได้ 200/404/404 เท่ากันทั้งคู่ — ความต่างคือสัญญา idempotent ที่ retry layer ใช้
POST /createUser?name=…&password=… POST /users + body 1 · 7 · ความลับอยู่ใน URL grep -c '123456' gin-access.log → 1 (มาจากเส้นเดิมเท่านั้น)
GET /getPaidOrders GET /orders?status=paid 1 · 7 · 6 (filter ต้องใช้ query) ได้ o1,o3 เหมือนกัน — ต่างที่ตัวกรองตัวถัดไปต้องสร้าง route ใหม่หรือไม่

เส้นเรื่องเดียวที่ร้อยทั้งสี่บรรทัด — ไม่มีบรรทัดไหน “ทำงานไม่ได้”
สามบรรทัดแรกเสียตรงความเดาได้และความหมายของ method · บรรทัดที่สามเสียตรงความปลอดภัย · บรรทัดที่สี่เสียตรงความขยายได้
ซึ่งทั้งสามอย่างนี้ compiler ไม่มีทางจับได้ และเทสที่เขียนแบบธรรมดาก็ไม่จับ เพราะเทสจะยิงเส้นเดิมแล้วได้ 200 ตามที่คาดไว้ทุกครั้ง
มีแค่คนอ่านโค้ด เท่านั้นที่จับได้ — นั่นแหละคือเหตุผลที่ code review มีอยู่

ส่วนที่ 3 · จับคู่ annotation ของ Swagger (สไลด์หน้า 75–76)

ส่วนสุดท้ายกลับมาที่ บทเรียน 41 — โจทย์ 6 ข้อ ตัวเลือก 6 ตัว จับคู่ให้ครบ
เตือนไว้ก่อนอ่าน: ข้อ 6 มีปัญหา ผมทดสอบแล้วเฉลยบนสไลด์ไม่ตรงกับผลจริง เดี๋ยวจะแยกไปคุยกันหลังเฉลยหลัก

🏷 เทียบให้เห็นภาพ — ป้ายที่แปะข้างกล่อง กับ คนที่เดินอ่านป้ายไปทำสารบัญ

นึกถึงโกดังที่เต็มไปด้วยกล่อง แล้วมีคนแปะป้ายกระดาษไว้ข้างกล่องแต่ละใบ เขียนว่า “กล่องนี้ใส่อะไร ใครมีสิทธิ์หยิบ ถ้าหาไม่เจอให้ตอบว่าอะไร”
ทุกสิ้นเดือนมีพนักงานคนหนึ่งเดินทั้งโกดัง อ่านป้ายทุกใบแล้วพิมพ์ออกมาเป็นสารบัญหนึ่งเล่ม ให้คนนอกเปิดดูได้โดยไม่ต้องเดินเข้าโกดังเอง

· ป้ายบนกล่อง = คอมเมนต์ที่เราเขียนไว้เหนือฟังก์ชันในไฟล์ .go
· พนักงานที่เดินอ่านป้าย = คำสั่ง swag init ที่เราพิมพ์ในเทอร์มินัล
· สารบัญที่ได้ = ไฟล์ swagger.json ที่ Swagger UI เอาไปแสดงเป็นหน้าเว็บ
พอมองแบบนี้ก็เห็นทันทีว่าทำไม --parseInternal ไม่ใช่พวกเดียวกับอีก 5 ตัว — มันไม่ใช่ป้าย มันคือคำสั่งที่บอกพนักงานว่าให้เดินตรงไหนบ้าง

ที่ไม่เหมือนกัน: พนักงานคนนี้เปิดกล่องดูอยู่บ้าง — ตอนสร้าง definitions มันไปอ่าน struct จริงในโค้ดพร้อม json tag ไม่ได้ลอกจากคอมเมนต์ (แก้ชื่อฟิลด์ในโค้ด เอกสารก็เปลี่ยนตาม)
แต่สิ่งที่มันไม่เคยตรวจเลยคือ @Router ที่เราเขียน ตรงกับ r.GET / r.POST ที่ลงทะเบียนไว้จริงหรือเปล่า — เขียน @Router ผิดเส้น เอกสารก็ผิดเงียบ ๆ ไม่มี error สักบรรทัด เก็บภาพนี้ไว้ เดี๋ยวท้ายส่วนนี้จะได้เห็นตัวอย่างจริง

#สิ่งที่ต้องการตัวเลือก
1ตั้งชื่อ API ว่า “Wongnok API”A. @Param id path string true "..."
2รับ ID จาก URL /users/{id}B. @Router /users [post]
3บอกว่า error ใช้ struct จาก httputilC. @title
4กำหนดว่า endpoint นี้ต้องมี tokenD. @Failure 404 {object} httputil.ErrorResponse
5ผูก handler เข้ากับ POST /usersE. --parseInternal
6ให้ swag สแกนโค้ดใน internal/ ด้วยF. @Security BearerAuth

จับคู่ให้ครบ 6 คู่ · แล้วตอบเพิ่มอีกข้อว่า ตัวเลือกไหนไม่ใช่ annotation เลย และมันต่างจากอีก 5 ตัวยังไง

เฉลย: 1→C · 2→A · 3→D · 4→F · 5→B · 6→E (ตามสไลด์ — แต่ข้อ 6 มีปัญหา อ่านต่อข้างล่าง)

ผมสร้างโปรเจกต์เล็ก ๆ ที่มี main.go ที่ root และ handler กับ httputil อยู่ใน internal/ แล้วรัน swag init จากนั้นเปิด docs/swagger.json ดูทีละจุดด้วย jq (เครื่องมือ command line สำหรับดึงค่าจาก JSON) — ว่า annotation แต่ละตัวไปโผล่ตรงไหน:

ข้อ 1 → C. @title — ไปอยู่ที่ .info.title คือระดับเอกสารทั้งไฟล์

$ jq -c '.info.title' docs/swagger.json
"Wongnok API"

ข้อ 2 → A. @Param id path string true "User ID" — แตกเป็น 5 ฟิลด์ใต้ operation นั้น

$ jq -c '.paths."/users/{id}".get.parameters' docs/swagger.json
[{"type":"string","description":"User ID","name":"id","in":"path","required":true}]

ข้อ 3 → D. @Failure 404 {object} httputil.ErrorResponse — ทำสองอย่างพร้อมกัน

$ jq -c '.paths."/users/{id}".get.responses."404"' docs/swagger.json
{"description":"Not Found","schema":{"$ref":"#/definitions/httputil.ErrorResponse"}}

$ jq -c '.definitions|keys' docs/swagger.json
["handler.User","httputil.ErrorResponse"]

คือทั้งใส่ response 404 ใต้ endpoint นั้น และสร้าง schema เข้า .definitions แล้วอ้างกลับด้วย $ref
สังเกตคำว่า "Not Found" — อันนั้น swag เติมให้เองจากเลข 404 ไม่ได้มาจากที่เราเขียน · และชื่อ key เป็นรูปแบบ แพ็กเกจ.ชื่อ struct ไม่ใช่ import path เต็ม

ข้อ 4 → F. @Security BearerAuth

$ jq -c '.paths."/users/{id}".get.security' docs/swagger.json
[{"BearerAuth":[]}]

$ jq -c '.securityDefinitions' docs/swagger.json
{"BearerAuth":{"type":"apiKey","name":"Authorization","in":"header"}}

ข้อ 5 → B. @Router /users [post] — กลายเป็น key ของ path + key ของ method

$ jq -c '.paths | to_entries | map({path:.key, methods:(.value|keys)})' docs/swagger.json
[{"path":"/users","methods":["post"]},{"path":"/users/{id}","methods":["get"]}]

คำตอบของคำถามพ่วง: ตัวเลือก E. --parseInternal คือตัวเดียวที่ไม่ใช่ annotation — อีก 5 ตัวเป็นคอมเมนต์ในไฟล์ .go ส่วนตัวนี้เป็น flag ที่พิมพ์ต่อท้ายคำสั่ง swag init ในเทอร์มินัล คนละที่กันคนละเวลากันเลย
ซึ่งพอมันเป็นของคนละชนิด มันก็เลยเป็นข้อที่ตรวจง่ายที่สุดว่าเฉลยถูกหรือเปล่า — แค่รันคำสั่งดูสองรอบก็รู้ และผมรันแล้ว…

ข้อ 6 — ตรวจแล้วเฉลยสไลด์ไม่ผ่าน

เรื่องนี้ บทเรียน 41 หัวข้อ “--parseDependency --parseInternal — ผลจริงต่างจากสไลด์” พิสูจน์ไว้แล้ว บนโปรเจกต์อีกโครงหนึ่ง · ที่เอามาเล่าซ้ำตรงนี้เพราะสไลด์ Post เอาความเข้าใจผิดอันเดียวกันมาทำเป็นเฉลยของแบบฝึกหัด — ถ้าอ่านบท 41 มาแล้วจะข้ามไปหัวข้อถัดไปได้เลย

ก่อนอื่นอ่านข้อความช่วยเหลือของ swag เองก่อน — นี่คือต้นเหตุที่ทุกคนเข้าใจตรงกันแบบนี้:

$ swag init --help          # swag version v1.16.4
   --parseInternal    Parse go files in internal packages, disabled by default (default: false)

อ่านผิวเผินก็เหมือนตรงกับโจทย์เป๊ะ (“ให้สแกนโค้ดใน internal/”)
แต่ก่อนจะเชื่อ ให้ตอบก่อนว่า: จะพิสูจน์ยังไงว่ามันจริง?
แล้วทายว่าถ้าเอาโปรเจกต์ที่มี handler กับ httputil อยู่ใน internal/ มารัน swag init โดยไม่ใส่ flag นี้เลย — เอกสารที่ได้จะมี endpoint ทั้งสองเส้นไหม

ไม่ใส่ flag ก็สแกนครบตั้งแต่แรกแล้ว:

$ rm -rf docs && swag init -g main.go -d ./      # ไม่มี --parseInternal
2026/08/21 13:00:44 Generate swagger docs....
2026/08/21 13:00:44 Generate general API Info, search dir:./
2026/08/21 13:00:44 Generating handler.User
2026/08/21 13:00:44 Generating httputil.ErrorResponse
2026/08/21 13:00:44 create docs.go at docs/docs.go
2026/08/21 13:00:44 create swagger.json at docs/swagger.json
2026/08/21 13:00:44 create swagger.yaml at docs/swagger.yaml

handler.User กับ httputil.ErrorResponse อยู่ใน internal/ ทั้งคู่ และออกมาครบทั้ง /users และ /users/{id}

เทียบไบต์ต่อไบต์ระหว่างมี flag กับไม่มี:

$ swag init -g main.go -d ./                # ไม่ใส่ flag
$ cp docs/swagger.json run1_noflag.json
$ swag init -g main.go -d ./ --parseInternal
$ cp docs/swagger.json run2_withflag.json

$ md5 -q run1_noflag.json run2_withflag.json
fd317e5a6fe6f8a6eafea68d2a4fc7f2
fd317e5a6fe6f8a6eafea68d2a4fc7f2   # ← ค่าเดียวกันเป๊ะ

ทำไมใช้ md5 ไม่ใช้ diff? — diff เวลาไฟล์เหมือนกัน มันไม่พิมพ์อะไรออกมาเลย ซึ่งหน้าตาแยกไม่ออกจาก “คำสั่งไม่ได้รัน”
md5 พิมพ์ค่าออกมาให้เห็นทั้งสองบรรทัด เอาไปเทียบกับของคุณเองได้ด้วย

ลองครบทั้ง 4 ชุด flag บนโปรเจกต์นี้ — ไม่ใส่อะไรเลย · --parseInternal · --parseDependency · ใส่ทั้งคู่ — ได้ md5 ค่าเดียวกันหมดทั้ง 4 รอบ และ definitions เท่ากันที่ 2 ตัว (handler.User · httputil.ErrorResponse) ทุกรอบ
ทุกชุด flag ที่ผมลอง --parseInternal ไม่เคยทำให้ผลต่างเลยสักครั้ง

โปรเจกต์ตัวอย่างใน บทเรียน 41 โครงต่างจากนี้เล็กน้อย และที่นั่น --parseDependency ทำให้ชื่อ definition เปลี่ยนเป็น internal_user.* — แต่ --parseInternal ก็ยังไม่มีผลอยู่ดี ทั้งสองโปรเจกต์ · แปลว่าผลของ --parseDependency ขึ้นกับโครงโปรเจกต์ ส่วน --parseInternal ไม่ขึ้นกับอะไรเลยเพราะมันไม่ได้แตะเส้นทางนี้ตั้งแต่ต้น

แล้วมันทำอะไรจริง ๆ? ไปดูซอร์สของ swaggo/swag v1.16.6 ตรง ๆ:

$ grep -rn "ParseInternal" . --include "*.go" | grep -v _test.go
golist.go:51:	ignoreInternal := pkg.Goroot && !parser.ParseInternal
parser.go:134:	// ParseInternal whether swag should parse internal packages
parser.go:135:	ParseInternal bool
parser.go:1868:	ignoreInternal := pkg.Internal && !parser.ParseInternal
cmd/swag/main.go:259:		ParseInternal:       ctx.Bool(parseInternalFlag),
gen/gen.go:114:	// ParseInternal whether swag should parse internal packages
gen/gen.go:115:	ParseInternal bool
gen/gen.go:220:	p.ParseInternal = config.ParseInternal

ได้มา 8 บรรทัด แต่ 6 บรรทัดเป็นแค่การประกาศ field และการส่งค่าต่อกันไปมา (main.go อ่านจาก flag → gen.go ส่งเข้า parser) · ที่เหลืออีก 2 บรรทัดที่ไฮไลต์ไว้ คือที่เดียวที่เอาค่านี้ไปตัดสินใจอะไรจริง ๆ:

จุดที่ถูกใช้จริง มีแค่สองที่ และทั้งคู่อยู่ในฟังก์ชันตระกูล getAllGoFileInfoFromDeps* — คือเส้นทางอ่าน dependency เท่านั้น (pkg.Goroot = แพ็กเกจของ Go เอง · pkg.Internal = internal ของไลบรารีที่เราพึ่งพา)
แปลว่า flag นี้พูดถึง internal/ ของคนอื่น ไม่ใช่ของโปรเจกต์เรา และต้องใช้คู่กับ --parseDependency ถึงจะมีความหมาย

ส่วนตัวที่เดินไฟล์ในโปรเจกต์เราจริง ๆ ข้ามอะไรบ้าง — อยู่ในฟังก์ชัน walkWith:

if !parseVendor && f.Name() == "vendor" ||   // ignore "vendor"
    f.Name() == "docs" ||                       // exclude docs
    len(f.Name()) > 1 && f.Name()[0] == '.' && f.Name() != ".." {
    return filepath.SkipDir
}

ข้ามแค่ vendor · docs · โฟลเดอร์ที่ขึ้นต้นด้วยจุด · และรายการใน --exclude — ไม่มีคำว่า internal อยู่ในเงื่อนไขเลย นี่คือหลักฐานตรง ๆ ว่า ./internal ไม่เคยถูกข้าม

แล้วอะไรทำให้ internal/ หายไปจากเอกสารได้จริง? คือ --exclude ต่างหาก:

$ swag init -g main.go -d ./ --exclude ./internal
$ jq -c '.paths' docs/swagger.json
{}
$ jq -c '.definitions' docs/swagger.json
null

สรุปข้อ 6: คำตอบที่ถูกคือ “ไม่ต้องใส่ flag อะไรเลย — swag สแกน internal/ ของโปรเจกต์เราให้อยู่แล้ว” · ถ้าจะจับคู่กับ flag จริง ๆ ตัวที่คุมเรื่องนี้คือ -d (ไดเรกทอรีที่ให้สแกน) และ --exclude ไม่ใช่ --parseInternal
และสาเหตุที่คนเข้าใจผิดกันหมดก็ไม่ใช่ความผิดของใคร — ข้อความใน --help ของ swag เองกำกวม มันไม่ได้บอกว่าหมายถึง internal ของ dependency

⚠️ ข้อ 4 ถูก แต่ตอบครึ่งเดียว — และอีกครึ่งที่ขาดทำให้ปุ่ม Authorize หาย

@Security BearerAuth แค่ “ผูก” endpoint เข้ากับ scheme ชื่อ BearerAuth เท่านั้น · ตัว scheme จริงมาจากคนละ annotation ที่ main.go: @securityDefinitions.apikey BearerAuth + @in header + @name Authorization
ผมลองลบสามบรรทัดนั้นทิ้งแล้ว gen ใหม่ — swag ไม่ error เลย ผ่านปกติ:

$ swag init -g main.go -d ./ -q && jq -c '{securityDefinitions:.securityDefinitions, opSecurity:.paths."/users/{id}".get.security}' docs/swagger.json
{"securityDefinitions":null,"opSecurity":[{"BearerAuth":[]}]}

ได้เอกสารที่ endpoint อ้าง scheme ที่ไม่มีอยู่จริง → ปุ่ม Authorize ใน Swagger UI หายไป ใส่ token ไม่ได้ ทดลองยิงจริงไม่ได้
เป็นกับดักตระกูลเดียวกับ บทเรียน 41 หัวข้อ 10 ทั้งหมด: เอกสารผิดแบบไม่มีใครเตือน เพราะ swag ไม่เคยเช็คว่าสิ่งที่เราเขียนตรงกับความจริงหรือเปล่า
เช่นเดียวกับ @Router ที่ไม่ถูกตรวจกับ route จริงของ Gin เลย — เขียน path หรือ method ผิด เอกสารก็ผิดเงียบ ๆ ไม่มี warning สักบรรทัด

🗣 ศัพท์ที่เพิ่งเรียนไป
annotation
คอมเมนต์ที่เขียนไว้ให้เครื่องมืออ่าน ไม่ใช่ให้คนอ่านอย่างเดียว · ของ swaggo คือบรรทัดที่ขึ้นต้นด้วย @ เหนือ handler · เวลาถามให้พูดว่า “annotation ตัวไหนที่ทำให้ฟิลด์นี้โผล่ใน swagger.json” จะได้คำตอบตรงกว่าถามว่า “แก้ตรงไหน”
flag (CLI flag)
ตัวเลือกที่พิมพ์ต่อท้ายคำสั่งในเทอร์มินัล เช่น --parseInternal · อยู่คนละที่กับ annotation และคนละเวลาด้วย — annotation อยู่ในไฟล์ ส่วน flag อยู่ในคำสั่ง
schema / definition
“หน้าตาของก้อนข้อมูล” ที่เอกสารบรรยายไว้ว่ามีฟิลด์อะไรบ้าง ชนิดอะไร · ใน swagger.json อยู่ใต้ key ชื่อ definitions · อันนี้คือรูปแบบ Swagger 2.0 ที่ swaggo สร้าง — ถ้าเป็น OpenAPI 3 จะย้ายไปอยู่ใต้ components.schemas แทน
$ref
ตัวชี้ไปหา schema ที่นิยามไว้ที่อื่น — แทนที่จะเขียนหน้าตาของ error ซ้ำในทุก endpoint ก็นิยามครั้งเดียวแล้วชี้มาหา
generated (ไฟล์ที่ gen ออกมา)
ไฟล์ที่เครื่องสร้างให้ ไม่ใช่คนพิมพ์เอง เช่นทุกอย่างในโฟลเดอร์ docs/ · ประโยคที่ต้องจำไว้: “อย่าไปแก้ไฟล์ที่ generated เพราะ gen ใหม่ทีเดียวก็หายหมด ต้องไปแก้ที่ต้นทาง”

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

1. ทีมส่ง POST /createUser?name=Alice&password=123456 ขึ้น production · ไม่มี error ไม่มีใครแฮ็ก แล้วรหัสผ่านหลุดได้ยังไง?

ผมยิงจริงแล้ว grep หารหัสผ่านในไฟล์ log ได้บรรทัดนี้: [GIN] … | 200 | … | POST "/createUser?name=Alice&password=123456"
ส่วน POST /users ที่ส่งรหัสผ่านตัวเดียวกันทาง body ได้ log แค่ POST "/users" เปล่า ๆ · grep -c '123456' ทั้งไฟล์ได้ 1 ครั้ง คือบรรทัดของ /createUser เท่านั้น
ตัวเลือกที่ 1 กับ 2 เป็นความเข้าใจผิดคู่แฝดกัน: HTTPS เข้ารหัสทั้ง query และ body เหมือนกันหมดตอนวิ่งบนเน็ต — ปัญหาไม่ได้อยู่ระหว่างทาง แต่อยู่ที่ปลายทางที่ตั้งใจจด URL เอาไว้ ทั้ง access log ของ server, log ของ proxy/load balancer, ประวัติ browser และ header Referer
ตัวเลือกที่ 4 ผิดเพราะ *gin.Context เกิดใหม่ต่อหนึ่ง request ไม่ข้ามกัน
และจำไว้ว่าแค่เปลี่ยนชื่อเส้นเป็น POST /users?password=… ก็ยังลง log อยู่ดี — ต้องย้ายช่อง ไม่ใช่แค่เปลี่ยนชื่อ

2. ยิง DELETE /users/123 ซ้ำ 3 ครั้ง เทียบกับ POST /deleteUser?id=123 ซ้ำ 3 ครั้ง — status code ที่ได้ต่างกันไหม?

ผมยิงจริงทั้งสองแบบ ๆ ละ 3 ครั้ง ได้ 200 → 404 → 404 เหมือนกันทุกตัวอักษร ทั้ง body และ state ปลายทางก็เหมือนกัน
ความต่างของ DELETE กับ POST อยู่ที่ สัญญาที่ผูกกับตัว method ตาม HTTP spec (RFC 9110) ซึ่ง client · proxy · ชั้น retry เอาไปใช้ตัดสินใจว่า “ถ้าเน็ตกระตุกแล้วไม่รู้ว่าถึงหรือเปล่า จะยิงซ้ำได้ไหม”
ตัวเลือกที่ 1 กับ 3 เดาว่า server ต้องตอบต่างกัน — ไม่จริง ผมรันแล้ว · ตัวเลือกที่ 4 เข้าใจคำว่า idempotent ผิด: มันแปลว่า “ผลลัพธ์ปลายทางเท่าเดิม” ไม่ได้แปลว่า “status code เท่าเดิม”
เสริมอีกจุด: 204 ในตัวเลือกที่ 1 ไม่ใช่คำตอบผิดในเชิงออกแบบเลย — หลาย API เลือกให้ DELETE ตอบ 204 ทุกครั้ง (บทเรียน 36 หัวข้อ 7) แต่นั่นเป็นสิ่งที่เราต้องเขียนโค้ดให้เป็นเอง ไม่ใช่สิ่งที่ได้มาฟรีจากการเปลี่ยน method

3. เปลี่ยนจาก GET /getPaidOrders มาเป็น GET /orders?status=paid เรียบร้อย แล้ว frontend ลืมแนบ ?status ไปหนึ่งครั้ง — เกิดอะไรขึ้น?

ผมยิง GET /orders โดยไม่ใส่ query จริง ๆ ได้ {"filter_status":"", "orders":[o1 paid, o2 pending, o3 paid, o4 cancelled]} | HTTP 200 — ครบทั้ง 4 รายการรวม cancelled
c.Query ไม่เคยบ่นเรื่อง param ที่ไม่ได้ส่งมา มันคืน empty string แล้วเดินต่อ
ตัวเลือกที่ 1 ผิดเพราะ Gin ไม่รู้จัก “query ที่จำเป็น” เลย เว้นแต่เราจะ bind เป็น struct พร้อม binding:"required" เอง (บทเรียน 39) · ตัวเลือกที่ 2 ผิดเพราะ query ไม่ได้เป็นส่วนหนึ่งของการ match route — /orders กับ /orders?status=paid เข้า handler ตัวเดียวกัน · ตัวเลือกที่ 3 ผิดเพราะ c.Query คืน string ไม่ใช่ pointer จึงไม่มี nil
อาการนี้อันตรายตรงที่ status ยังเป็น 200 — ไม่มีอะไรฟ้อง มีแต่ผู้ใช้ที่เห็นข้อมูลผิด

4. โปรเจกต์วาง handler กับ httputil ไว้ใน internal/ · ถ้ารัน swag init -g main.go -d ./ โดยไม่ใส่ --parseInternal จะได้อะไร?

ผมรันเทียบไบต์ต่อไบต์: md5 ของ swagger.json ที่มีและไม่มี --parseInternal ออกมาเป็นค่าเดียวกันทุกไบต์ — ทั้งแบบเดี่ยวและแบบคู่กับ --parseDependency
และ log ของ swag ตอนไม่ใส่ flag ก็ขึ้น Generating handler.User กับ Generating httputil.ErrorResponse ครบทั้งคู่ ทั้งที่สองตัวนั้นอยู่ใน internal/
ดูจากซอร์สของ swag v1.16.6 ยิ่งชัด: ParseInternal ถูกอ่านแค่สองจุด (pkg.Goroot และ pkg.Internal) ซึ่งอยู่ในเส้นทางอ่าน dependency ทั้งคู่ — มันหมายถึง internal ของไลบรารีที่เราพึ่งพา ไม่ใช่ของเรา
ส่วนตัวเดินไฟล์ในโปรเจกต์เรา (walkWith) ข้ามแค่ vendor, docs, โฟลเดอร์ที่ขึ้นต้นด้วยจุด และ --exclude · ไม่มีเงื่อนไขคำว่า internal อยู่เลย
ตัวเลือกที่ 2 คือผลของ --exclude ./internal ต่างหาก (ผมรันแล้วได้ paths = {} และ definitions = null) · ตัวเลือกที่ 3 กับ 4 ไม่เกิดขึ้นเลยจากการรันจริง
บทเรียนที่ใหญ่กว่าคำตอบ: ข้อความใน --help ก็กำกวมได้ วิธีเดียวที่เชื่อถือได้คือรันสองรอบแล้ว diff

🛠️ ภารกิจ — สร้าง “คู่เทียบ” ของตัวเองแล้วพิสูจน์ด้วยมือ

บทนี้ทั้งบทคือการอ่านผลที่ผมรันไว้ให้ · ภารกิจนี้คือการรันเอง เพราะสิ่งที่ทำให้คุณเถียงกับ code review ของคนอื่นได้ ไม่ใช่การจำกฎ 7 ข้อ แต่คือหลักฐานที่คุณผลิตเองได้

  1. ตั้งโปรเจกต์: โฟลเดอร์ใหม่ · go mod init apidet · go get github.com/gin-gonic/gin · main.go ที่มี r := gin.Default() แล้ว r.Run(":18080") (บทเรียน 38)
  2. ลงทะเบียนทั้งแปดเส้นในตัวเดียวกัน — สี่เส้นแบบแย่จากสไลด์ และสี่เส้นที่รีวิวแล้ว · เก็บข้อมูลไว้ใน map หรือ slice ในหน่วยความจำพอ ไม่ต้องมี database
    เพิ่ม GET /__routes ที่คืน r.Routes() ออกมาเป็น JSON — จะได้ยืนยันด้วยตาว่าลงครบจริง ไม่ใช่คิดไปเอง
  3. พิสูจน์เรื่องรหัสผ่าน (ข้อสำคัญที่สุด): เปลี่ยน gin.Default() ให้เขียน log ลงไฟล์ gin-access.log · ยิงสองแบบ: POST /createUser?password=123456 กับ POST /users ที่ส่งรหัสผ่านใน body
    แล้วสั่ง grep -c '123456' gin-access.log
    ต้องได้ 1 — ถ้าได้ 2 แปลว่าเผลอ log body เอง ให้ไปหาว่าโค้ดตรงไหนทำ
  4. พิสูจน์เรื่อง idempotency ให้ตัวเองเห็นว่า “พิสูจน์ไม่ได้”: ยิง DELETE /users/123 สามครั้ง และ POST /deleteUser?id=123 สามครั้ง เก็บ status code ทุกครั้ง
    ต้องได้เหมือนกันทั้งสองชุด — แล้วเขียนสองสามบรรทัดตอบตัวเองว่า ถ้าตัวเลขเท่ากัน แล้วเราเปลี่ยนมาใช้ DELETE ไปเพื่ออะไร
  5. ลองของแถม: ยิง POST /users/123 (ผิด method) แล้วดู status
    จากนั้นตั้ง r.HandleMethodNotAllowed = true แล้วยิงซ้ำ
    ตอบให้ได้ว่าเลขเปลี่ยนจากอะไรเป็นอะไร และทำไมค่า default ถึงเป็นแบบแรก
  6. ฝั่ง Swagger: ย้าย handler ของคุณเข้า internal/ แล้วรัน swag init สองรอบ — รอบหนึ่งใส่ --parseInternal อีกรอบไม่ใส่ — แล้ว diff ไฟล์ swagger.json ทั้งสอง
    จากนั้นลบ @securityDefinitions ที่ main.go ทิ้ง แล้ว gen ใหม่ · เปิด Swagger UI ดูว่าปุ่ม Authorize ยังอยู่ไหม (บทเรียน 41)
  7. ท้าทาย: เขียนเทสที่จับการถอยหลังได้ — เทสที่ fail ทันทีถ้ามีคนเผลอเพิ่ม route ชื่อ /getSomething กลับเข้ามา
    ใบ้: r.Routes() คืน slice ที่วนอ่านได้ · เขียน assert ว่า ทุก path ต้องไม่มีตัวพิมพ์ใหญ่และต้องไม่ขึ้นต้นด้วยคำกริยาในลิสต์ที่คุณกำหนด
    (ต่อยอดจาก บทเรียน 16)

เพราะการเลือก method ไม่ได้เปลี่ยนสิ่งที่ server ของคุณทำ — มันเปลี่ยนสิ่งที่คนอื่นกล้าทำกับ server ของคุณ

ลองนึกภาพ: มือถือของผู้ใช้ยิง request ลบไป แล้วเน็ตหลุดตอนกำลังรอคำตอบ
ตอนนี้ client ไม่รู้เลยว่า request ถึง server หรือยัง — จะยิงซ้ำดีไหม?
ถ้าเป็น DELETE คำตอบคือ ยิงซ้ำได้เลย เพราะ HTTP spec รับประกันว่าผลลัพธ์ปลายทางเท่าเดิม
ถ้าเป็น POST คำตอบคือ ห้ามยิงซ้ำ เพราะ POST ไม่มีสัญญานี้ · ชั้น retry ทุกตัวที่เขียนถูกต้องจะยอมแพ้แล้วโยน error ให้ผู้ใช้แทน

พูดให้สั้นที่สุด: ตัวเลข status code เป็นเรื่องของ request ครั้งนี้ · ตัว method เป็นเรื่องของ request ครั้งหน้าที่คุณยังไม่รู้ว่าจะมี
เครื่องมือที่อ่านสัญญานี้ก็ไม่ใช่คนด้วยซ้ำ — เป็น proxy, load balancer, HTTP client library และชั้น retry ที่อยู่ระหว่างทาง ซึ่งไม่มีทางเปิดเอกสารของคุณอ่านได้ อ่านได้แค่ method เท่านั้น

💬 ข้อที่ผมอยากให้จำจากทั้งสามส่วน — ส่วนที่ 1 สอนว่า “ข้อมูลชิ้นนี้ควรไปช่องไหน” · ส่วนที่ 2 สอนว่า “มันทำงานได้ ไม่ได้แปลว่ามันถูก” · ส่วนที่ 3 สอนว่า “เฉลยที่พิมพ์อยู่บนสไลด์ก็ผิดได้ ถ้ายังไม่มีใครรันมันดู”
ข้อ 6 ของส่วนที่ 3 ไม่ได้ผิดเพราะใครสะเพร่า — มันผิดเพราะ swag init --help เขียนกำกวมจริง ๆ · ทางเดียวที่แยกออกคือรันสองรอบแล้ว diff
ถ้าคุณรันแล้วได้ผลไม่ตรงกับในหน้านี้ ส่งผลมาได้เลย — ทุกบรรทัดในหน้านี้วัดบน Go 1.26.5 darwin/arm64 · gin v1.12.0 · swag CLI v1.16.4 + swaggo/swag v1.16.6 เท่านั้น เวอร์ชันอื่นผมไม่รับประกัน

อ้างอิง