บทเรียน 41 · Day 8 · Swagger

Swagger — เอกสารที่ไม่มีวันเก่ากว่าโค้ด

~18 นาที · ต้องมี: บทเรียน 27 (project layout cmd/ + internal/), บทเรียน 38 (Gin routing)

สไลด์ของคอร์สนี้เขียนประโยคหนึ่งไว้ ผมว่ามันคือทั้งบท: “เอกสารที่ไม่ตรงกับโค้ด อันตรายกว่าไม่มีเอกสาร”
เพราะไม่มีเอกสาร frontend จะมาถาม · แต่มีเอกสารที่ผิด frontend จะเชื่อ แล้วเขียนโค้ดทั้งหน้าจอบนคำโกหกนั้น
บทนี้จะไม่สอนแค่ “พิมพ์คอมเมนต์แล้วรัน swag init” — จะพาไปทำให้มันพังทีละแบบให้ดูกับตา แล้วอ่าน error จริงว่ามันบอกอะไร

🧰 ของที่บทนี้ใช้จริง — ตัวเลข ผลรัน และข้อความ error ทุกบรรทัดในหน้านี้ มาจากการรันบนเครื่องจริง ไม่ได้พิมพ์เอาเอง

ถ้าเครื่องคุณได้ผลไม่ตรง อย่าเพิ่งคิดว่าตัวเองผิด — เวอร์ชันต่างกันนิดเดียวผลก็ต่างได้ หัวข้อ 9 จะสอนวิธีเช็คด้วยตัวเอง

1 · ปัญหาที่ Swagger เกิดมาแก้

ลองนึกภาพจริง ๆ: คุณเขียน POST /api/v1/users เสร็จ แล้วเปิด Google Docs พิมพ์ว่า body ต้องมี name กับ email ส่ง link ให้ frontend
อีกสองสัปดาห์คุณเพิ่ม binding:"required" ที่ email — แล้วลืมกลับไปแก้ Docs

ที่เกิดขึ้นต่อจากนั้นไม่ใช่ “เอกสารไม่อัปเดตนิดหน่อย” แต่คือ: frontend ส่ง request ที่ตรงตามเอกสารเป๊ะ ๆ แล้วได้ 400 กลับไป เขาจะไม่สงสัยเอกสารก่อน เขาจะสงสัยโค้ดตัวเองก่อนเป็นชั่วโมง
นี่คือเหตุผลที่ประโยค “เอกสารที่ไม่ตรงกับโค้ด อันตรายกว่าไม่มีเอกสาร” ไม่ใช่คำสวย ๆ — เอกสารที่ผิดคือ bug ที่ไม่มีใครเห็นว่ามันเป็น bug

❌ เขียนเอกสารด้วยมือ

  • อยู่คนละที่กับโค้ด → แก้โค้ดแล้วลืมแก้เอกสาร
  • ไม่มีใครรู้ว่าเวอร์ชันไหนล่าสุด
  • frontend ต้องเดา หรือเดินมาถาม
  • ผิดได้เงียบ ๆ ไม่มีอะไรเตือน

✅ generate จากโค้ด

  • คอมเมนต์อยู่ติดกับ handler (ฟังก์ชันที่รับงานของ URL นั้น) ในไฟล์เดียวกัน
  • เข้า git ไปพร้อม commit เดียวกับโค้ด
  • frontend เปิด Swagger UI กดยิงลองได้เอง
  • ยังผิดได้อยู่ ถ้าลืม generate ใหม่ — หัวข้อ 10 จะพิสูจน์ให้ดู

⚠️ อย่าเพิ่งเชื่อคำโฆษณา Swagger ไม่ได้ทำให้เอกสารตรงกับโค้ดโดยอัตโนมัติ
สิ่งที่มันทำคือ ย้ายเอกสารมาอยู่ข้าง ๆ โค้ด เพื่อให้คุณมีโอกาสแก้พร้อมกัน
ตัวที่ทำให้มันตรงจริง ๆ คือขั้นตอนรัน swag init ที่คุณต้องไม่ลืม — และนั่นคือกับดักอันดับหนึ่งของบทนี้

2 · OpenAPI คือ “มาตรฐาน” · Swagger คือ “ชุดเครื่องมือ”

สองคำนี้ถูกใช้ปนกันจนคนใหม่งง แยกให้ขาดตั้งแต่ตอนนี้ แล้วทุกอย่างที่เหลือจะเข้าที่:

📄 OpenAPI Specification (OAS)

คือมาตรฐานว่า “จะอธิบาย REST API เป็นไฟล์ยังไงให้เครื่องอ่านรู้เรื่อง”
ผลลัพธ์คือไฟล์ JSON หรือ YAML หนึ่งไฟล์ — เป็นแค่ข้อความ ไม่ใช่โปรแกรม รันไม่ได้ ไม่มีอะไรเกี่ยวกับ Go เลย

🧰 Swagger

คือชุดเครื่องมือที่กินไฟล์ spec นั้นเข้าไป แล้วเปลี่ยนเป็นของที่ใช้ได้จริง — หน้าเว็บ, โค้ด client, ตัวตรวจ spec

📄 เทียบให้เห็นภาพ — ไฟล์ PDF กับโปรแกรมที่เปิด PDF

ทุกคนเคยส่งไฟล์ PDF ให้กัน · “PDF” คือข้อตกลงว่าไฟล์แบบนี้ต้องหน้าตายังไง ตัวไฟล์เองเปิดตัวเองไม่ได้ ทำอะไรไม่ได้เลย มันเป็นแค่ก้อนข้อความ
ส่วนที่ทำให้เราได้ประโยชน์จริงคือโปรแกรมที่เปิดมัน — Preview, Chrome, Adobe Reader
คนละยี่ห้อ คนละบริษัท แต่เปิดไฟล์ใบเดียวกันได้หมด เพราะทุกตัวทำตามข้อตกลงเดียวกัน

เรื่องนี้ก็วางตัวแบบเดียวกันเป๊ะ:
· OpenAPI = ข้อตกลงว่า “ไฟล์อธิบาย API ต้องหน้าตายังไง” — ที่เดียวกับคำว่า “PDF”
· docs/swagger.json = ไฟล์หนึ่งใบที่เขียนตามข้อตกลงนั้น — ที่เดียวกับไฟล์ .pdf ของเรา
· Swagger UI · Postman · openapi-generator = โปรแกรมที่เปิดไฟล์ใบนั้นไปใช้ต่อ — ที่เดียวกับ Preview / Chrome

ที่ไม่เหมือนกัน: PDF ทำมาให้คนอ่านเป็นหลัก แต่ swagger.json ทำมาให้เครื่องอ่านก่อน แล้วเครื่องค่อยเปลี่ยนเป็นหน้าเว็บให้คนดูอีกที
และ PDF ส่งไปแล้วก็จบ ส่วน swagger.json ต้องสร้างใหม่ทุกครั้งที่โค้ดเปลี่ยน ไม่งั้นมันจะเก่ากว่าโค้ดทันที — ซึ่งเป็นปัญหาที่ทั้งบทนี้พูดถึง

เครื่องมือทำอะไร
Swagger UIหน้าเว็บอ่านเอกสาร + กดยิง API ได้จริง ← ตัวที่บทนี้ใช้
Swagger Editorเขียน/ตรวจ spec แล้วเห็นผลทันที
Swagger Codegenร่างโค้ด client หรือ server จาก spec

ส่วนเรื่องเวอร์ชัน เป็นจุดที่คนใหม่พลาดตอนไปหา Google บ่อยมาก เพราะชื่อมันเปลี่ยนกลางคัน:

เวอร์ชันชื่อที่คนเรียกกันเครื่องมือฝั่ง Go
Swagger 2.0= OpenAPI 2.0swaggo/swag v1 ← ที่คอร์สนี้ใช้
OpenAPI 3.xรุ่นใหม่กว่าswaggo/swag v2 (ยังเป็น rc)

นี่ไม่ใช่เกร็ดไร้สาระ — มันโผล่ในไฟล์ที่คุณจะได้จริง บรรทัดแรกของ docs/swagger.json ที่ผมรันออกมาคือ:

{
  "swagger": "2.0",
  "info": {
    "description": "API สำหรับจัดการข้อมูลผู้ใช้ของ Wongnok",
    "title": "Wongnok API",
    "contact": {},
    "version": "1.0"
  },
  "host": "localhost:8080",
  "basePath": "/api/v1",
  "schemes": ["http", "https"],
  ...

มันเขียนว่า "swagger": "2.0" ไม่ใช่ "openapi": "3.x"
ถ้าวันหนึ่งคุณเอาไฟล์นี้ไปให้เครื่องมือที่รับแต่ OpenAPI 3 แล้วมันบ่น — คุณจะรู้ทันทีว่าปัญหาอยู่ตรงไหน แทนที่จะงงอยู่ครึ่งวัน

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

คำพวกนี้คือชื่อของสิ่งที่คุณเพิ่งเข้าใจไป · ใช้ให้ตรงคำแล้วเวลาถาม senior หรือถาม AI จะได้คำตอบตรงขึ้นมาก

spec (specification)
ไฟล์ข้อตกลงที่อธิบายว่า API มีอะไรบ้าง — ในบทนี้คือ swagger.json · เวลาถามให้พูดว่า “ขอ spec ของ API ตัวนี้หน่อย” จะสื่อกว่า “ขอเอกสารหน่อย” เพราะคำแรกหมายถึงไฟล์ที่เครื่องอ่านได้
endpoint
ทางเข้าหนึ่งช่องของ API = คู่ของ “method + path” หนึ่งคู่ เช่น GET /users/{id} · คนละอย่างกับ API ทั้งตัว — API หนึ่งตัวมีหลาย endpoint · พูดว่า “endpoint ไหนพัง” ชัดกว่า “API พัง”
ข้อควรรู้ตอนไปเปิด swagger.json อ่านเอง: ในตัวไฟล์ spec เขาไม่ได้ใช้คำว่า endpoint แต่ไล่เป็น paths → path → method แล้วเรียกก้อนที่อยู่ใต้ method ว่า operation · ส่วน endpoint เป็นคำที่คนพูดกันในชีวิตจริง ความหมายกว้างกว่าเล็กน้อย (บางคนใช้หมายถึงแค่ตัว path เฉย ๆ)
OpenAPI 2.0 = Swagger 2.0
ชื่อสองชื่อของมาตรฐานเวอร์ชันเดียวกัน (เขาเปลี่ยนชื่อกลางทาง) · เวลาไปหาใน Google ให้ระบุเลขเสมอ — ถามว่า “อันนี้เป็น OpenAPI 2.0 หรือ 3.0” ไม่ใช่แค่ “เป็น Swagger ใช่ไหม”
machine-readable
“เครื่องอ่านต่อได้” — ต่างจาก Google Docs ที่มีแต่คนอ่านได้ · นี่คือเหตุผลที่ Postman import ไฟล์นี้แล้วสร้าง request ให้ได้เอง
source of truth
“ตัวจริงที่ทุกอย่างต้องอ้างอิง” · ประโยคที่ dev ใช้กันคือ “ให้โค้ดเป็น source of truth ของเอกสาร” = เชื่อโค้ดเป็นหลัก แล้วสร้างเอกสารจากโค้ด ไม่ใช่เขียนเอกสารแยกไว้อีกที่

3 · flow ทั้งหมดมี 5 ขั้น และเราแตะแค่ขั้นเดียว

ก่อนอื่นขอนิยามคำที่จะเจอทั้งบท: annotation ในที่นี้คือ คอมเมนต์ Go ธรรมดา ๆ ที่ขึ้นต้นบรรทัดด้วย @ เช่น // @Summary ดึงข้อมูล User
คอมไพเลอร์ Go มองว่ามันเป็นคอมเมนต์เปล่า ๆ ไม่มีความหมาย — มีแต่โปรแกรม swag เท่านั้นที่อ่านมันแล้วเอาไปทำอะไรต่อ

วิธีอ่านแผนภาพ: ไล่จากซ้ายไปขวา ช่องแรกช่องเดียวคือของที่มือเราพิมพ์ ที่เหลือเครื่องสร้างให้หมด

   1                2               3                  4                5
เขียน annotation ─▶ รัน swag init ─▶ ได้โฟลเดอร์ docs/ ─▶ Swagger UI ─▶ เอา spec ไปใช้ต่อ

คอมเมนต์ //  @...   สแกนโค้ด        docs.go            /swagger/         GitLab viewer
เหนือ handler       ทั้งโปรเจกต์     swagger.json       index.html        Postman
                                    swagger.yaml                         openapi-generator

└─ เราเขียนเอง ─┘   └──────────── เครื่องสร้างให้อัตโนมัติ ────────────┘

จำโครงนี้ไว้ให้แม่น เพราะเกือบทุกอาการพังในบทนี้ คือ “ขั้นใดขั้นหนึ่งไม่ได้เกิดขึ้น”:
ขั้น 2 ไม่ได้รัน → เอกสารเป็นของเก่า · ขั้น 3 ไม่มีไฟล์ → UI ไม่มีอะไรอ่าน · ขั้น 4 route ผิด → เปิดหน้าไม่เจอ

4 · ติดตั้งสามตัว — และกับดักเวอร์ชันที่สไลด์ไม่ได้เตือน

ของที่ต้องลงมีสามชิ้น หน้าที่ต่างกันชัดเจน อย่าสับสน:

# 1) swag CLI — โปรแกรมที่อ่านคอมเมนต์ในโค้ดแล้วสร้าง docs/ (ไม่ได้อยู่ใน go.mod)
$ go install github.com/swaggo/swag/cmd/swag@latest
$ swag --version
swag version v1.16.4

# 2) + 3) library ฝั่ง Gin สองตัว — handler ที่เสิร์ฟหน้า Swagger UI (อยู่ใน go.mod)
$ go get -u github.com/swaggo/gin-swagger
$ go get -u github.com/swaggo/files

รันที่ไหน? คำสั่ง go install (ข้อ 1) รันจากโฟลเดอร์ไหนก็ได้ เพราะมันลงโปรแกรมลงเครื่อง ไม่เกี่ยวกับโปรเจกต์
แต่ go get (ข้อ 2) ต้องรันในโฟลเดอร์ root ของโปรเจกต์ — โฟลเดอร์ที่มีไฟล์ go.mod อยู่ — เพราะมันไปเขียนบรรทัดใหม่ลงใน go.mod

ตัวหน้าที่อยู่ที่ไหน
swag (CLI)อ่าน annotation แล้วสร้าง docs/เครื่องคุณ ($(go env GOPATH)/bin)
gin-swaggerhandler ที่เสิร์ฟหน้า Swagger UIgo.mod ของโปรเจกต์
filesไฟล์ static (HTML/JS/CSS) ของ Swagger UIgo.mod ของโปรเจกต์

💡 พิมพ์ swag แล้วขึ้น command not found — ไม่ได้แปลว่าลงไม่สำเร็จ go install วางไฟล์ไว้ที่ $(go env GOPATH)/bin ซึ่ง shell อาจยังไม่รู้จัก
แก้ด้วย export PATH=$PATH:$(go env GOPATH)/bin (ใส่ไว้ใน ~/.zshrc จะได้ไม่ต้องพิมพ์ทุกครั้ง)

⚠️ กับดักที่สไลด์ไม่ได้พูดถึง — CLI ใหม่กว่า library แล้ว build พัง

อ่านสองคำสั่งข้างบนอีกรอบ แล้วสังเกตว่ามันไม่ได้คุยกัน:
go install ...@latest ลง CLI รุ่นล่าสุดลงเครื่อง · แต่ go get gin-swagger ลาก github.com/swaggo/swag เวอร์ชันที่มันเลือกให้เข้ามาใน go.mod
บนเครื่องผม gin-swagger v1.6.1 ลาก swag library v1.8.12 เข้ามา ขณะที่ CLI เป็น v1.16.4

docs/docs.go ที่ CLI รุ่นใหม่สร้าง จึงอ้าง field ที่ library รุ่นเก่ายังไม่มี ผลคือ โปรเจกต์คอมไพล์ไม่ผ่าน ทั้งที่คุณยังไม่ได้เขียนโค้ดผิดสักบรรทัด:

📎 เทียบให้เห็นภาพ — ไฟล์ Word ที่เครื่องอีกเครื่องเปิดไม่ได้

เคยเจอไหม: เพื่อนใช้ Word รุ่นใหม่ ใส่ลูกเล่นที่รุ่นใหม่เพิ่งมี แล้วส่งไฟล์มาให้
พอเราเปิดด้วย Word รุ่นเก่าในเครื่องตัวเอง มันอ่านคำสั่งใหม่ ๆ พวกนั้นไม่ออก ไฟล์เพี้ยนทั้งที่เพื่อนไม่ได้ทำอะไรผิดเลย

ที่เกิดขึ้นตรงนี้คือเรื่องเดียวกัน แค่เปลี่ยนตัวละคร:
· swag CLI ในเครื่อง = คนที่สร้างไฟล์ (Word รุ่นใหม่) — มันเขียน docs/docs.go ให้เรา
· github.com/swaggo/swag ใน go.mod = ตัวที่ต้องรู้จักคำสั่งในไฟล์นั้น (Word รุ่นเก่า)
· unknown field LeftDelim = “มีคำสั่งในไฟล์ที่ฉันไม่รู้จัก”

ที่ไม่เหมือนกัน: Word รุ่นเก่ามักจะเปิดได้แต่เพี้ยน คือพังเงียบ ๆ แต่ Go คอมไพล์ไม่ผ่านไปเลยตั้งแต่ต้น — ซึ่งจริง ๆ แล้วดีกว่า เพราะเรารู้ทันที
และอีกจุด: Word สองรุ่นเป็นคนละโปรแกรมที่แยกกันอยู่ได้ แต่ CLI กับ library ตัวนี้เป็นของชุดเดียวกันที่ถูกแยกไว้คนละที่ จึงต้องอัปให้ตามกันเสมอ
จุดที่ต่างที่สุดและห้ามจำผิด: Word ต้องเปิดไฟล์ตอนเราสั่ง แต่ตรงนี้ไม่มีใครไป “เปิด” docs.go เลย — docs.go เป็นโค้ด Go ที่ import library ตัวนั้นแล้วประกาศ struct ที่มี field เกินมา · คนที่ฟ้องคือ compiler ตอนแปลโค้ด ไม่ใช่ library ตอนรัน

⏱️ อาการนี้จะยังไม่โผล่ตอนนี้ — มันโผล่หลังจากคุณรัน swag init (หัวข้อ 9) ไปแล้วเท่านั้น เพราะต้องมีไฟล์ docs/docs.go อยู่ก่อน ยกมาเล่าไว้ตรงนี้เพราะมันเป็นเรื่องเวอร์ชันตอนติดตั้ง ไม่ใช่เรื่องของ swag init

$ go test ./cmd/api/
# wongnok/docs
docs/docs.go:169:2: unknown field LeftDelim in struct literal of type "github.com/swaggo/swag".Spec
docs/docs.go:170:2: unknown field RightDelim in struct literal of type "github.com/swaggo/swag".Spec
FAIL	wongnok/cmd/api [build failed]

วิธีอ่าน error นี้ให้เป็น: มันชี้ไปที่ docs/docs.go ซึ่งเป็นไฟล์ที่เครื่องสร้าง ไม่ใช่ไฟล์ที่เราเขียน — พอเห็นบรรทัดแรกเป็น # wongnok/docs ให้คิดถึงเรื่องเวอร์ชันทันที อย่าไปนั่งไล่อ่าน docs.go ทีละบรรทัด (มันไม่ใช่ความผิดของไฟล์นั้น)
ทางแก้ที่ได้ผลจริงคือดึง library ให้ใหม่พอที่จะรู้จัก field ที่ CLI สร้าง:

$ go get github.com/swaggo/swag@latest
go: upgraded github.com/swaggo/swag v1.8.12 => v1.16.6

สังเกตตัวเลขให้ดี: @latest ดึง library ไปถึง v1.16.6 ซึ่งใหม่กว่า CLI ที่เป็น v1.16.4 ด้วยซ้ำ — แล้ว build ก็ผ่านปกติ
เพราะทิศที่พังมีทิศเดียว คือฝั่ง CLI สร้าง field ที่ library ยังไม่รู้จัก · เป้าหมายจึงไม่ใช่ “ทำให้เลขตรงกันเป๊ะ” แต่คืออย่าปล่อยให้ library เก่ากว่า CLI

🧠 หลักที่ต้องเก็บไป

swag CLI กับ github.com/swaggo/swag ใน go.mod ต้องเป็นรุ่นใกล้กันเสมอ
ถ้าทีมคุณมีคนหนึ่งรัน swag init แล้ว commit docs/ ขึ้นไป แล้วอีกคน build ไม่ผ่าน — เช็ค swag --version ของทั้งสองเครื่องก่อนเสมอ

🗣 ศัพท์ที่เพิ่งเรียนไป
CLI
โปรแกรมที่สั่งด้วยการพิมพ์ใน terminal · ประโยคที่ต้องแยกให้ขาดคือ “นี่เป็น CLI หรือเป็น library” เพราะสองอย่างนี้อัปเดตกันคนละคำสั่ง
ในเคสนี้ swag ถูกลงไว้ในเครื่อง (GOPATH/bin) ไม่ได้ถูก pin ไว้กับโปรเจกต์ — นี่แหละคือเหตุผลที่แต่ละเครื่องมีเวอร์ชันไม่ตรงกันได้
แต่อย่าจำเป็นกฎว่า “CLI ต้องอยู่นอกโปรเจกต์เสมอ” — Go รุ่นใหม่ pin CLI ไว้กับโปรเจกต์ได้ด้วย go get -tool (แล้วจะได้บรรทัด tool ... โผล่ใน go.mod) เหมือนที่ฝั่ง JS ใช้ devDependencies
generated code
โค้ดที่เครื่องเขียนให้ ไม่ใช่เราเขียน — docs/docs.go คืออันนี้ · ห้ามแก้ด้วยมือ เพราะ generate รอบหน้ามันจะทับทิ้ง · เวลาเจอ error ในไฟล์แบบนี้ให้พูดว่า “error อยู่ใน generated code” senior จะรู้ทันทีว่าต้องไปดูตัวที่สร้างมัน ไม่ใช่ไปแก้ไฟล์นั้น
version mismatch
“เวอร์ชันไม่เข้ากัน” · แทนที่จะบอกว่า “build ไม่ผ่านครับ” ให้พูดว่า “น่าจะเป็น version mismatch ระหว่าง swag CLI กับ library ใน go.mod” — ประโยคเดียวนี้ตัดเวลาไล่หาไปได้เป็นชั่วโมง
compile time / build ไม่ผ่าน
พังตั้งแต่ตอนแปลโค้ด ยังไม่ทันได้รันเลย · ตรงข้ามกับ runtime ที่พังตอนโปรแกรมกำลังทำงานอยู่ · เคสนี้เป็น compile time ซึ่งถือว่าโชคดี

5 · ต่อ Swagger UI เข้ากับ Gin — สองบรรทัดที่คนพลาดมากที่สุด

โค้ดที่ต้องเติมใน cmd/api/main.go สั้นมาก แต่มีสองจุดที่ผิดแล้วไม่มีใครเตือน:

package main

import (
    "github.com/gin-gonic/gin"
    swaggerFiles "github.com/swaggo/files"
    ginSwagger "github.com/swaggo/gin-swagger"

    _ "wongnok/docs"          // ← จุดที่ 1: blank import
    "wongnok/internal/user"
)

func main() {
    router := gin.Default()
    userHandler := user.NewHandler()

    v1 := router.Group("/api/v1")
    {
        v1.POST("/users", userHandler.Create)
        v1.GET("/users/:id", userHandler.FindByID)
    }

    // ← จุดที่ 2: ต้องมี *any ต่อท้าย
    router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
    router.Run(":8080")
}

นี่คือ main.go เดิมจาก บทเรียน 38 / บทเรียน 39 ที่เติมเข้าไปแค่สองบรรทัดที่มีคอมเมนต์ ← กำกับ (บวก import อีกสามบรรทัด) — ไม่ต้องสร้างไฟล์ใหม่
เมื่อทุกอย่างครบแล้ว วิธีดูผลคือรัน go run ./cmd/api แล้วเปิดเบราว์เซอร์ไปที่ http://localhost:8080/swagger/index.html

🤔 ทำไม URL เป็น /swagger/doc.json ทั้งที่ไฟล์ชื่อ docs/swagger.json?
เพราะ gin-swagger ไม่ได้อ่านไฟล์จากดิสก์เลย — มันเรียก swag.ReadDoc() ไปเอา spec ที่ถูกลงทะเบียนไว้ในหน่วยความจำ (ตัวที่ docs/docs.go ลงทะเบียนให้ตอน init() — จุดที่ 1 ข้างล่าง) แล้วเสิร์ฟออกมาที่ path ชื่อ doc.json ซึ่งเป็นค่า default ของ library
สรุป: docs/swagger.json = ไฟล์บนดิสก์ไว้ให้คนอื่นเอาไปใช้ต่อ · /swagger/doc.json = ช่องที่หน้า Swagger UI ดึง spec ไปแสดง

จุดที่ 1 — _ "wongnok/docs" ทำไมต้อง import ของที่ไม่ได้ใช้

ตอน swag init ทำงาน มันสร้าง docs/docs.go ขึ้นมา ข้างในไฟล์นั้นมี func init() ที่เอา spec ไปลงทะเบียนไว้ในตัวแปรกลาง ของ library swaggo/swag
ทีนี้ Go มีกฎว่า package ที่ไม่มีใคร import จะไม่ถูกรวมเข้าไปในไบนารีเลย — init() ก็เลยไม่เคยทำงาน
_ (blank identifier) แปลว่า “ฉันไม่ได้จะเรียกอะไรจาก package นี้ แต่ฉันต้องการผลข้างเคียงของ init()”
เป็นเทคนิคเดียวกับที่ database driver ใช้ — ถ้าเคยเห็น _ "github.com/lib/pq" ก็คืออันนี้

🍜 เทียบให้เห็นภาพ — พนักงานที่มีหน้าที่เดียวคือแปะเมนูหน้าร้าน

ร้านอาหารร้านหนึ่งมีพนักงานคนหนึ่งที่งานของเขามีอย่างเดียว: ตอนเช้าก่อนเปิดร้าน เอาแผ่นเมนูไปแปะบนบอร์ดหน้าร้าน
ทั้งวันเราไม่ได้สั่งงานเขาอีกเลย ไม่ได้เรียกใช้เขาสักครั้ง — แต่ถ้าลืมเรียกเขาเข้ากะ ร้านก็ยังเปิดได้ตามปกติ ประตูเปิด ไฟติด โต๊ะพร้อม
มีอย่างเดียวที่ผิดคือ บอร์ดหน้าร้านว่างเปล่า ลูกค้าเดินเข้ามาแล้วไม่รู้ว่าร้านนี้ขายอะไร

ตรงกับผลรันในกล่อง “ทายก่อนรัน” ข้างล่างพอดี:
· พนักงานคนนั้น = package docs · เรียกเขาเข้ากะ = _ "wongnok/docs"
· ตอนเช้าก่อนเปิดร้าน = init() ที่รันเองอัตโนมัติ
· ร้านยังเปิดได้ = /swagger/index.html ได้ 200 · บอร์ดว่าง = /swagger/doc.json ได้ 500 body ว่าง

ที่ไม่เหมือนกัน: ในร้านจริง หัวหน้าเห็นทันทีว่าวันนี้พนักงานคนนั้นไม่มา แต่ Go ไม่เตือนอะไรเลยสักคำ — คอมไพล์ผ่านสบาย ๆ เพราะไม่มีบรรทัดไหนผิดไวยากรณ์
และ _ ไม่ได้แปลว่า “ยืมของมาแล้วยังไม่ได้ใช้” — มันแปลว่า เราเรียกอะไรจาก package นี้ไม่ได้เลยด้วยซ้ำ ขอแค่ให้มันได้เข้าไปอยู่ในโปรแกรมเท่านั้นพอ

ทายก่อนรัน — ผมเขียนเทสด้วย httptest (บทเรียน 40) สามเคส ต่อ route แบบเดียวกันทั้งหมด ต่างกันแค่รายละเอียดเล็ก ๆ

// เคส A — route "/swagger/*any" + มี blank import ครบ
GET /swagger/index.html
GET /swagger/doc.json

// เคส B — route "/swagger" เฉย ๆ (ลืม *any)
GET /swagger/index.html

// เคส C — route "/swagger/*any" แต่ไฟล์ที่ตั้ง router ไม่ได้ import _ "wongnok/docs"
GET /swagger/index.html
GET /swagger/doc.json

แต่ละบรรทัดได้ status code อะไร? โดยเฉพาะเคส C สองบรรทัด — มันได้เลขเดียวกันหรือคนละเลข

// เคส A — ครบทุกอย่าง
[/swagger/*any] GET /swagger/index.html -> 200  body="\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>… <title>Swagger UI</title>…"
[/swagger/*any] GET /swagger/doc.json  -> 200  body="{\n \"schemes\": [\"http\",\"https\"],\n \"swagger\": \"2.0\",\n \"info\": {…"

// เคส B — ลืม *any
[/swagger เฉย ๆ]  GET /swagger/index.html -> 404  body="404 page not found"

// เคส C — ไม่มี blank import
ไม่มี blank import — GET /swagger/index.html -> 200  body="\n<!DOCTYPE html>… <title>Swagger UI</title>…"
ไม่มี blank import — GET /swagger/doc.json  -> 500  body=""

เคส B ตรงกับที่สไลด์บอกเป๊ะ — ไม่มี *any แล้วเปิด /swagger/index.html ได้ 404 page not found จริง

แต่เคส C คือจุดที่ผมอยากให้ดูให้ดี — สไลด์ p.55 เขียนว่า “ไม่ใส่ blank import = เปิดหน้า Swagger แล้ว 404”
ผลรันจริงบอกว่าหน้า Swagger เปิดได้ 200 ตามปกติ (เพราะไฟล์ HTML/JS ของ UI มากับ swaggo/files ซึ่ง import ไว้แล้ว) แต่ /swagger/doc.json คืน 500 พร้อม body ว่างเปล่า — ตรงกับที่โค้ดของ gin-swagger ทำ: พอ swag.ReadDoc() หา spec ที่ลงทะเบียนไว้ไม่เจอ มันสั่ง AbortWithStatus(500) ซึ่งไม่เขียน body ใด ๆ
อาการที่คุณจะเห็นบนจอจริงจึงเป็น “หน้า Swagger เปิดขึ้นมาสวย ๆ แต่ไม่มี endpoint สักอัน / ขึ้น error ตอนโหลด definition” ไม่ใช่ 404 ทั้งหน้า
หลักการของสไลด์ไม่ผิด — blank import จำเป็นจริง ๆ — แค่อาการต่างจากที่บรรยายไว้ และการรู้อาการที่ถูก คือความต่างระหว่าง “แก้ได้ใน 1 นาที” กับ “นั่งไล่ route อยู่ครึ่งชั่วโมง”

จุดที่ 2 — ทำไมต้อง /swagger/*any

จาก บทเรียน 38 เรารู้ว่า Gin มี path parameter สองแบบ: :name จับหนึ่งช่วง · *name จับทุกอย่างที่เหลือรวม /
Swagger UI ไม่ใช่หน้าเดียว มันโหลดไฟล์ย่อยเป็นกอง — index.html, doc.json, swagger-ui.css, ไฟล์ JS อีกหลายตัว
route /swagger เฉย ๆ ตรงกับ path เดียวคือ /swagger พอดิบพอดี พอ browser ขอ /swagger/index.html Gin หาไม่เจอ → 404 ตามผลรันข้างบน

⚠️ ชื่อ any ไม่ใช่คำสงวน *any คือชื่อตัวแปรที่เราตั้งเอง จะเขียน *filepath ก็ได้ ของสำคัญคือเครื่องหมาย * ไม่ใช่คำว่า any
แต่แนะนำให้ใช้ *any ตามคนอื่น เพราะเวลาไปลอกตัวอย่างจากอินเทอร์เน็ตจะได้ตรงกัน

🗣 ศัพท์ที่เพิ่งเรียนไป
blank import
การ import ด้วยขีดล่าง _ เพื่อเอาแค่ผลของ init() · เวลาถามให้พูดว่า “ตัวนี้ต้อง blank import ไหม” แทนที่จะถามว่า “ต้อง import ไหม” — คำถามแรกบอกไปในตัวว่าคุณรู้ว่ามันไม่ได้เอามาเรียกใช้
side effect
“ผลที่ไปเปลี่ยนของนอกตัวมันเอง” ไม่ใช่แค่คืนค่ากลับมา — เช่น เขียนไฟล์ ยิง DB หรือไปแก้ตัวแปรกลางของแพ็กเกจอื่น · ไม่เกี่ยวกับว่ามีคนสั่งหรือไม่สั่ง — ห้ามจำผิดจุดนี้ เพราะเดี๋ยวไปเจอคำนี้อีกทีในเรื่อง pure function
เคสนี้ตรงนิยามเป๊ะ: init() ในแพ็กเกจ docs เรียก swag.Register(...) ซึ่งไปเขียน map กลางที่อยู่ในแพ็กเกจ swaggo/swag — นั่นคือ side effect ที่เรายืมมา · ประโยคที่จะได้ยินคือ “import ตัวนี้เพื่อ side effect ของ init()”
register (ลงทะเบียน)
เอาของไปฝากไว้ในที่กลางที่คนอื่นมาหยิบต่อได้ · docs.go register spec ไว้ให้ gin-swagger มาหยิบ · ถ้าเห็น 500 ตอนโหลด doc.json ให้พูดว่า “spec น่าจะไม่ได้ถูก register”
catch-all parameter
คำที่ Gin ใช้เรียก *any เอง — route ที่จับทุก path ที่เหลือรวม / ด้วย · ส่วน :id Gin เรียกว่า named parameter · ถ้าเปิดหน้าย่อยแล้ว 404 ให้ถามว่า “route นี้เป็น catch-all หรือยัง” — ถามแบบนี้ตรงที่สุด
ระวังคำว่า wildcard: มันหลวมกว่านั้น ในโค้ดของ Gin เองนับทั้ง :name และ *name เป็น wildcard · ถามว่า “เป็น wildcard หรือยัง” เลยมีสิทธิ์ได้คำตอบคนละอย่างกับที่ต้องการ
static file
ไฟล์สำเร็จรูปที่ส่งออกไปเฉย ๆ ไม่ต้องคำนวณอะไร (HTML/CSS/JS/รูป) · หน้าตา Swagger UI ทั้งหน้าคือ static file ที่มากับ swaggo/files — เหตุผลที่หน้าเว็บเปิดได้แม้ยังไม่มี spec

6 · General API Info — ข้อมูลหัวเอกสาร เขียนครั้งเดียว

annotation ชุดนี้เขียนเหนือ func main() และมีที่เดียวในทั้งโปรเจกต์ มันอธิบาย API ทั้งตัว ไม่ใช่ endpoint ใดอันหนึ่ง

//	@title			Wongnok API
//	@version		1.0
//	@description	API สำหรับจัดการข้อมูลผู้ใช้ของ Wongnok
//	@host			localhost:8080
//	@BasePath		/api/v1
//	@schemes		http https

//	@securityDefinitions.apikey	BearerAuth
//	@in							header
//	@name						Authorization
//	@description				ใส่ค่าเป็น "Bearer {token}"
func main() { ... }

มันมีสองก้อน แยกด้วยบรรทัดว่าง — ก้อนบนคือข้อมูลหัวเอกสาร (ตารางข้างล่างอธิบายทีละบรรทัด) · ก้อนล่างสี่บรรทัด (@securityDefinitions.apikey / @in / @name / @description) คือเรื่องการล็อกอิน ข้ามไปก่อนได้ เดี๋ยวหัวข้อ 7.3 จะอธิบายว่ามันทำให้เกิดอะไรขึ้นบนหน้าจอ

annotationความหมายจำเป็น?ไปโผล่ที่ key ไหนใน swagger.json
@titleชื่อ API✓info.title
@versionเวอร์ชัน API✓info.version
@descriptionคำอธิบาย–info.description
@hosthost:port ที่ปุ่ม Try it out จะยิงไป–host
@BasePathpath นำหน้าทุก endpoint–basePath
@schemeshttp / https–schemes

🎯 @BasePath คือช่องที่คนใหม่ตั้งผิดบ่อยที่สุด
ในโค้ดเราลงทะเบียน route เป็น router.Group("/api/v1") แล้วข้างในเป็น /users
ในเอกสาร @Router จึงต้องเขียน /users เฉย ๆ เพราะ @BasePath เติม /api/v1 ให้แล้ว
ถ้าเผลอเขียน @Router /api/v1/users ปุ่ม Try it out จะยิงไป /api/v1/api/v1/users แล้วได้ 404 — โดยที่เอกสารดู “ถูก” ทุกอย่าง

7 · annotation ระดับ endpoint

ชุดนี้เขียนเหนือ handler แต่ละตัว นี่คือของจริงจาก internal/user/handler.go ที่ผมรัน swag init ผ่านแล้ว:

// FindByID godoc
//
//	@Summary		ดึงข้อมูล User รายคน
//	@Description	ค้นหา User จาก UUID แล้วคืนข้อมูลกลับมา
//	@Tags			users
//	@Produce		json
//	@Param			id	path		string	true	"User ID (UUID)"	format(uuid)
//	@Success		200	{object}	user.UserResponse
//	@Failure		400	{object}	httputil.ErrorResponse
//	@Failure		404	{object}	httputil.ErrorResponse
//	@Security		BearerAuth
//	@Router			/users/{id} [get]
func (handler *Handler) FindByID(ctx *gin.Context) { ... }
annotationหน้าที่
@Summaryหัวข้อสั้น — บรรทัดที่ขึ้นบนสุดใน UI
@Descriptionคำอธิบายยาว กางออกมาเมื่อกดเปิด endpoint
@Tagsจัดกลุ่ม endpoint ใน UI — endpoint ที่ tag เดียวกันอยู่กล่องเดียวกัน
@Accept / @Producecontent-type ที่รับเข้า / ส่งออก
@Paramพารามิเตอร์ที่รับเข้า (หัวข้อ 7.1)
@Success / @Failureรูปแบบ response แต่ละ status
@Securityendpoint นี้ต้องมี token
@Routerpath + method — ตัวเดียวที่ทำให้ endpoint “มีตัวตน” ในเอกสาร

7.1 · @Param มี 6 ช่อง คั่นด้วยช่องว่าง

@Param   <ชื่อ>   <paramType>   <dataType>   <บังคับ?>   "<คำอธิบาย>"   <attribute>
            │           │              │            │             │              │
            │           │              │            │             │              └─ Enums(), default(), format(), minimum()
            │           │              │            │             └─ ข้อความที่ขึ้นใน Swagger UI (ต้องมี " ")
            │           │              │            └─ true / false
            │           │              └─ int, string, bool หรือชื่อ struct
            │           └─ path | query | header | body | formData
            └─ ชื่อที่ client ส่งมา

ช่องที่คนสับสนคือช่องที่ 2 (paramType) กับช่องที่ 3 (dataType) — ช่องที่ 2 บอกว่า “ค่านี้เดินทางมาทางไหน” ตรงกับที่เรียนไปใน บทเรียน 35 เป๊ะ ๆ:

paramTypeใช้เมื่อไหร่ตัวอย่าง
pathระบุ resource ด้วย ID/users/{id}
queryกรอง / เรียง / แบ่งหน้า?status=active
headertoken, API key, request idAuthorization
bodyข้อมูลก้อนใหญ่ตอนสร้าง / แก้CreateUserRequest

ผมเขียน endpoint ค้นหาเพิ่มหนึ่งอันเพื่อยิงทุกช่องพร้อมกัน แล้วรัน swag init ดูของจริง:

//	@Param		q				query		string	false	"คำค้นจากชื่อ"
//	@Param		status			query		string	false	"สถานะ"			Enums(active, banned)	default(active)
//	@Param		limit			query		int		false	"จำนวนต่อหน้า"	minimum(1)	maximum(100)	default(20)
//	@Param		X-Request-ID	header		string	false	"id สำหรับ trace"
//	@Success	200				{array}		user.UserResponse
//	@Router		/users [get]

ได้ parameters ใน docs/swagger.json ออกมาแบบนี้ (ตัดมาจากไฟล์จริง):

[
  { "type": "string", "description": "คำค้นจากชื่อ", "name": "q", "in": "query" },
  { "enum": ["active", "banned"], "type": "string", "default": "active",
    "description": "สถานะ", "name": "status", "in": "query" },
  { "maximum": 100, "minimum": 1, "type": "integer", "default": 20,
    "description": "จำนวนต่อหน้า", "name": "limit", "in": "query" },
  { "type": "string", "description": "id สำหรับ trace", "name": "X-Request-ID", "in": "header" }
]

สังเกตสองอย่าง: Enums(active, banned) กลายเป็น "enum": [...] ที่ Swagger UI จะเรนเดอร์เป็นช่อง dropdown ให้เลือกไม่ใช่ช่องพิมพ์เปล่า · และ minimum(1) maximum(100) โผล่ครบทั้งคู่
attribute พวกนี้เขียนเพิ่มไม่กี่ตัวอักษร แต่ประหยัดคำถามจาก frontend ไปได้เป็นสิบข้อความ

7.2 · @Success / @Failure — บอกว่าคืนอะไรกลับไป

@Success  <httpCode>  {<รูปแบบ>}  <dataType>
รูปแบบความหมายผลใน JSON
{object}คืน object ตัวเดียว"schema": { "$ref": "#/definitions/..." }
{array}คืนเป็น array"schema": { "type": "array", "items": { "$ref": ... } }
{string}คืนค่าพื้นฐาน"schema": { "type": "string" }

⚠️ ชื่อ type ต้องเป็น package.Struct เสมอ เขียน user.UserResponse และ httputil.ErrorResponse — เขียน UserResponse เฉย ๆ swag หาไม่เจอ
เหตุผลคือ swag สแกนทั้งโปรเจกต์ ไม่ได้อยู่ใน scope ของ package ใด package หนึ่ง มันเลยไม่มีทางเดาได้ว่า UserResponse ที่คุณหมายถึงคือของ package ไหน

ทำไมต้องมี httputil.ErrorResponse แยกออกมาเป็น package กลาง?
เพราะทุก endpoint ในระบบควรคืน error รูปแบบเดียวกัน frontend จะได้เขียนโค้ดอ่าน error ครั้งเดียวใช้ได้ทั้งเว็บ — ตรงกับเรื่อง response contract ใน บทเรียน 34
โครงมันเรียบง่ายมาก:

package httputil

// ErrorResponse คือรูปแบบ error ที่ทุก endpoint ใช้ร่วมกัน
type ErrorResponse struct {
    Message string `json:"message" example:"user not found"`
}

7.3 · @Security BearerAuth — ปุ่ม Authorize

ทำงานเป็นคู่ 2 จุด ขาดจุดใดจุดหนึ่งก็ไม่เกิดผล:
1. ประกาศ @securityDefinitions.apikey BearerAuth เหนือ func main() (หัวข้อ 6) — บอกว่า “ระบบนี้มีวิธีล็อกอินชื่อ BearerAuth ซึ่งส่งมาทาง header ชื่อ Authorization”
2. ใส่ @Security BearerAuth เหนือ handler ที่ต้องล็อกอิน — บอกว่า “อันนี้แหละที่ต้องใช้”

ผลใน docs/swagger.json ที่ได้จริง แยกกันคนละที่ตามนั้น:

// ระดับเอกสาร มาจากข้อ 1
"securityDefinitions": {
  "BearerAuth": {
    "description": "ใส่ค่าเป็น \"Bearer {token}\"",
    "type": "apiKey",
    "name": "Authorization",
    "in": "header"
  }
}

// ระดับ endpoint มาจากข้อ 2 (อยู่ใต้ paths./users/{id}.get)
"security": [ { "BearerAuth": [] } ]

ผลที่ผู้ใช้เห็นบนหน้า Swagger UI: มีปุ่ม Authorize มุมขวาบน · ใส่ token ครั้งเดียวใช้ได้ทุก endpoint · endpoint ที่ล็อกอินจะมีรูปกุญแจ · กด Try it out แล้วมันแนบ header Authorization ให้เอง
ประโยชน์จริงคือ frontend ทดลอง endpoint ที่ต้องล็อกอินได้โดยไม่ต้องเปิด Postman

8 · struct tag ที่มีผลกับเอกสาร

จาก บทเรียน 13 เรารู้จัก json: tag และจาก บทเรียน 39 รู้จัก binding: tag แล้ว
ข่าวดีคือ swag อ่านทั้งสองอันที่มีอยู่แล้ว โดยเราไม่ต้องเขียนอะไรซ้ำ — มีของใหม่แค่ example: ตัวเดียว

type CreateUserRequest struct {
    Name  *string `json:"name" example:"สมชาย ใจดี"`
    Email string  `json:"email" binding:"required,email" example:"somchai@pea.co.th"`
}

type UserResponse struct {
    ID    string `json:"id" example:"3f0c1a7e-2b19-4c5e-9f3a"`
    Name  string `json:"name,omitempty" example:"สมชาย ใจดี"`
    Email string `json:"email" example:"somchai@pea.co.th"`
}

รัน swag init แล้วเปิด docs/swagger.json ได้ของจริงแบบนี้:

"user.CreateUserRequest": {
  "type": "object",
  "required": ["email"],
  "properties": {
    "email": { "type": "string", "example": "somchai@pea.co.th" },
    "name":  { "type": "string", "example": "สมชาย ใจดี" }
  }
}
🔍 สามพฤติกรรมที่ยืนยันแล้วจากไฟล์จริง

1. Name *string → เอกสารเขียนแค่ "type": "string" — swag ถอด pointer ทิ้ง เพราะ JSON ไม่มีแนวคิดเรื่อง pointer (ฝั่ง Go เราใช้ pointer เพื่อแยก “ไม่ได้ส่งมา” ออกจาก “ส่งมาเป็นค่าว่าง” — แต่ในเอกสารมันคือ string ที่ไม่บังคับ เท่านั้น)
2. json:"name,omitempty" → ชื่อ field ในเอกสารคือ name เฉย ๆ — swag ตัด ,omitempty ทิ้ง เพราะ ,omitempty เป็นเรื่องของตอน marshal (จังหวะที่ Go แปลง struct เป็น JSON แล้วตัด field ที่ค่าว่างทิ้ง — บทเรียน 13) ไม่ใช่ชื่อ field
3. binding:"required,email" → โผล่เป็น "required": ["email"] — กฎ validation กลายเป็นเอกสารเอง ไม่ต้องมานั่งจดซ้ำ และไม่มีทางไม่ตรงกับโค้ด

ข้อ 3 คือหัวใจของทั้งเรื่อง: กลับไปดูสถานการณ์ในหัวข้อ 1 อีกที — วันที่คุณเติม binding:"required" ที่ email เอกสารเปลี่ยนตามไปเองในการ generate ครั้งถัดไป ไม่มี Google Docs ให้ลืมแก้อีกแล้ว

9 · swag init — พิมพ์ตกไปนิดเดียว แล้วไม่ได้อะไรเลย

รันจากโฟลเดอร์ไหน? รันที่ root ของโปรเจกต์ เสมอ — โฟลเดอร์ที่มี go.mod อยู่ ไม่ใช่ใน cmd/api/
ดูบรรทัด search dir:./ ในผลรันข้างล่างเป็นหลักฐาน: ./ คือโฟลเดอร์ที่คุณกำลังยืนอยู่ตอนกด Enter

โครง wongnok วาง main.go ไว้ที่ cmd/api/main.go ตาม บทเรียน 27 ซึ่งเป็นเรื่องดี — แต่มันชนกับค่า default ของ swag ตรง ๆ

$ swag init
2026/08/20 22:15:46 Generate swagger docs....
2026/08/20 22:15:46 Generate general API Info, search dir:./
2026/08/20 22:15:46 warning: failed to get package name in dir: ./, error: execute go list command, exit status 1, stdout:, stderr:no Go files in <โปรเจกต์>
2026/08/20 22:15:46 cannot parse source files <โปรเจกต์>/main.go: open <โปรเจกต์>/main.go: no such file or directory

$ ls docs
ls: docs: No such file or directory

ไม่มีโฟลเดอร์ docs/ เกิดขึ้นเลยแม้แต่ไฟล์เดียว
สไลด์สรุปอาการนี้ว่า “หา @title ไม่เจอ” ซึ่งถูกในเชิงผลลัพธ์ แต่ข้อความจริงตรงกว่านั้นมาก — มันบอกตรง ๆ ว่าหาไฟล์ main.go ที่ root ไม่เจอ
เหตุผล: swag init --help เขียนไว้ว่า --generalInfo value, -g value Go file path in which 'swagger general API Info' is written (default: "main.go")
แปลว่า -g คือช่องบอกว่า “ไฟล์ไหนมี annotation ก้อนหัวเอกสารของหัวข้อ 6” และค่า default ของมันคือ main.go — มันจึงไปหา ./main.go ซึ่งโครงเราไม่มี

คำสั่งที่ถูกต้องคือชี้ไฟล์ให้มันดู:

$ swag init -g cmd/api/main.go
2026/08/20 22:15:53 Generate swagger docs....
2026/08/20 22:15:53 Generate general API Info, search dir:./
2026/08/20 22:15:53 warning: failed to get package name in dir: ./, ...
2026/08/20 22:15:53 Generating user.UserResponse
2026/08/20 22:15:53 Generating httputil.ErrorResponse
2026/08/20 22:15:53 Generating user.CreateUserRequest
2026/08/20 22:15:53 create docs.go at docs/docs.go
2026/08/20 22:15:53 create swagger.json at docs/swagger.json
2026/08/20 22:15:53 create swagger.yaml at docs/swagger.yaml

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

ไฟล์ใน docs/คืออะไรใครอ่าน
docs.goโค้ด Go ที่ฝัง spec ไว้เป็น stringตัวที่เรา blank import — โปรแกรมเรา
swagger.jsonspec มาตรฐาน เอาไปใช้ต่อได้GitLab / Postman / codegen
swagger.yamlspec เดียวกันแต่เป็น YAMLคน (อ่านง่ายกว่า)

เรื่อง --parseDependency --parseInternal — ผลจริงต่างจากสไลด์

สไลด์ p.62 บอกว่าโครง wongnok ต้องใช้ swag init -g cmd/api/main.go --parseDependency --parseInternal ไม่งั้น “definition หายหมด” เพราะโค้ดอยู่ใน internal/
คราวนี้ผมลองแยกทีละ flag แล้วเทียบไฟล์ด้วย md5 — ผลคือ definition ไม่เคยหาย และตัวการที่ทำให้ไฟล์เปลี่ยน ไม่ใช่ flag ที่สไลด์ชี้

$ swag init -g cmd/api/main.go                                   # A
$ swag init -g cmd/api/main.go --parseInternal                    # B
$ swag init -g cmd/api/main.go --parseDependency                  # C
$ swag init -g cmd/api/main.go --parseDependency --parseInternal  # D

$ md5 -q sw_A.json sw_B.json sw_C.json sw_D.json
27d4b4c32eba7c375b7cd69a3808fce9    # A
27d4b4c32eba7c375b7cd69a3808fce9    # B ← เท่ากับ A ทุกไบต์
e7c184f840b49b6bde994728d8cb1a2e    # C
e7c184f840b49b6bde994728d8cb1a2e    # D ← เท่ากับ C ทุกไบต์
รันflag ที่ใส่definition ที่ได้ขนาดไฟล์
Aไม่ใส่อะไรเลยhttputil.ErrorResponse · user.CreateUserRequest · user.UserResponse2,757 B
B--parseInternalเหมือน A ทุกตัว2,757 B
C--parseDependencyhttputil.ErrorResponse · internal_user.CreateUserRequest · internal_user.UserResponse2,793 B
Dทั้งสอง flagเหมือน C ทุกตัว2,793 B

ตารางนี้บอกสองเรื่องที่สไลด์ไม่ได้บอก:

  1. definition ไม่เคยหาย — แถว A ไม่ใส่ flag อะไรเลยก็ได้ครบ 3 ตัว แปลว่า swag เดินเข้า internal/ ของโปรเจกต์เราอยู่แล้วโดยค่าเริ่มต้น
  2. --parseInternal ไม่ได้ทำอะไรเลยในโครงนี้ — A เท่ากับ B และ C เท่ากับ D ตัวที่เปลี่ยนชื่อ definition เป็น internal_user.* คือ --parseDependency ต่างหาก
⚠ จุดที่บทนี้เคยเขียนผิดเอง

บทนี้เวอร์ชันแรกเขียนว่า “สิ่งที่ --parseInternal ทำจริงในเคสนี้คือทำให้ชื่อ definition ยาวขึ้น” — ผิด เพราะตอนนั้นผมใส่สอง flag พร้อมกันแล้วยกความดีความชอบให้ flag ผิดตัว

บทเรียนที่ได้ตรงกับที่สอนไปทั้งคอร์ส — จะสรุปว่าตัวแปรไหนเป็นเหตุ ต้องเปลี่ยนทีละตัว เปลี่ยนสองอย่างพร้อมกันแล้วเห็นผลต่าง บอกไม่ได้เลยว่าอันไหนทำ

แล้ว --parseInternal มีไว้ทำอะไรกันแน่? เปิดซอร์สของ swaggo/swag v1.16.6 ดู — ตัวแปรนี้ถูกอ่านแค่ 2 ที่:

// golist.go:51 — ในฟังก์ชัน getAllGoFileInfoFromDepsByList
ignoreInternal := pkg.Goroot && !parser.ParseInternal

// parser.go:1868 — ในฟังก์ชัน getAllGoFileInfoFromDeps
ignoreInternal := pkg.Internal && !parser.ParseInternal

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

ส่วนโฟลเดอร์ของโปรเจกต์เราถูกเดินด้วย walkWith ที่ข้ามแค่ 4 อย่าง — ไม่มีคำว่า internal อยู่ในนั้นเลย:

// parser.go:1957
if !parseVendor && f.Name() == "vendor" ||        // vendor
    f.Name() == "docs" ||                          // docs
    len(f.Name()) > 1 && f.Name()[0] == '.' {        // โฟลเดอร์ที่ขึ้นต้นด้วยจุด
    return filepath.SkipDir
}
// + path ที่ผู้ใช้สั่ง --exclude เอง

นี่คือหลักฐานตรง ๆ ว่า ./internal ไม่เคยถูกข้าม ตั้งแต่แรก

🤝 ยังไม่ต้องเชื่อผม — ตอบด้วยตัวเองใน 30 วินาที ผมทดสอบกับโครงย่อที่มี 3 struct บน swag v1.16.4 (ไลบรารี v1.16.6) โปรเจกต์จริงของคุณอาจต่างออกไป — เปลี่ยนทีละ flag แล้ว md5 เทียบเอง:
swag init -g cmd/api/main.go && cp docs/swagger.json /tmp/a.json
swag init -g cmd/api/main.go --parseInternal && md5 /tmp/a.json docs/swagger.json
ค่าเท่ากัน = flag นั้นไม่มีผลกับโปรเจกต์คุณ · ค่าต่างกัน = มีผล แล้วค่อยไปดูว่าต่างตรงไหน
ผลของเครื่องคุณคือคำตอบของโปรเจกต์คุณ

✨ เกร็ด: swag fmt — annotation ในบทนี้เว้นวรรคเป็นคอลัมน์สวย ๆ ไม่ใช่เพราะผมนั่งเคาะ space
ผมพิมพ์แบบมั่ว ๆ // @Summary สวัสดี แล้วรัน swag fmt มันจัดให้เป็น // @Summary สวัสดี (คั่นด้วย tab) พร้อมเว้นบรรทัด // ใต้บรรทัด godoc ให้เอง
ประโยชน์ที่แท้จริงไม่ใช่ความสวย — มันคือทั้งทีมจะได้ diff ไม่ชนกันเพราะ whitespace

10 · หัวใจของบท — สองกับดักที่ทำให้เอกสารกลับไปโกหกเหมือนเดิม

ถึงตรงนี้ทุกอย่างทำงานหมดแล้ว และนี่คือจุดที่คนส่วนใหญ่หยุดอ่าน
แต่สองเรื่องข้างล่างนี้ต่างหากที่ตัดสินว่าเอกสารของคุณจะเชื่อถือได้หรือไม่

กับดัก 1 — แก้ annotation แล้วลืมรัน swag init

ทายก่อนรัน — ผมรันเทสที่ยิง GET /swagger/doc.json แล้วพิมพ์ทุก path + summary ที่อยู่ในนั้นออกมา
(รันบนโปรเจกต์ตั้งต้นที่มีแค่สอง endpoint — ยังไม่ได้ใส่ endpoint ค้นหา ที่ลองเล่นในหัวข้อ 7.1 เข้าไป) ครั้งแรกได้แบบนี้:

doc.json: get /users/{id}  summary=ดึงข้อมูล User รายคน
doc.json: post /users      summary=สร้าง User ใหม่

จากนั้นผมไปแก้ annotation ในโค้ด — เปลี่ยนข้อความ @Summary และเพิ่ม endpoint ใหม่ทั้งอัน (@Router /users/{id} [delete]) แล้วสั่ง go test -count=1 ให้ build ใหม่ทั้งหมด แต่ไม่ได้รัน swag init
เทสรอบสองจะพิมพ์อะไรออกมา?

# แก้ annotation + build ใหม่ (-count=1) แต่ยังไม่ swag init
doc.json: get /users/{id}  summary=ดึงข้อมูล User รายคน
doc.json: post /users      summary=สร้าง User ใหม่
                                    ↑ เหมือนเดิมทุกตัวอักษร ไม่มี delete โผล่มา

# รัน swag init -g cmd/api/main.go แล้ว build ใหม่
doc.json: delete /users/{id}  summary=ลบ User
doc.json: get /users/{id}     summary=ดึงข้อมูล User รายคน (แก้ข้อความแล้ว)
doc.json: post /users         summary=สร้าง User ใหม่

build ใหม่ไม่ช่วยอะไรเลย เพราะ spec ถูกแช่แข็งไว้เป็น string ใน docs/docs.go ตั้งแต่ตอน generate ครั้งที่แล้ว — ตัวคอมไพเลอร์ Go ไม่เคยอ่าน @Summary ของคุณเลยสักตัว มันเห็นแค่คอมเมนต์ธรรมดา

และนี่แหละคือความน่ากลัว: ไม่มี error · ไม่มี warning · เทสก็ยังเขียว คุณ deploy ขึ้นไปได้เรียบร้อยพร้อมเอกสารเวอร์ชันเมื่อสองสัปดาห์ก่อน
เอกสารกลับไปโกหกเหมือน Google Docs ในหัวข้อ 1 เป๊ะ ๆ — เครื่องมือใหม่ ปัญหาเดิม

🗂 เทียบให้เห็นภาพ — สำเนาทะเบียนบ้านที่ถ่ายไว้เมื่อปีที่แล้ว

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

ตรงกันทุกช่อง:
· ทะเบียนบ้านตัวจริง = คอมเมนต์ @Summary / @Router ในโค้ด
· สำเนาในแฟ้ม = spec ที่ถูกแช่ไว้เป็นข้อความใน docs/docs.go
· ถ่ายสำเนาใหม่ = รัน swag init
· เปิดแฟ้มใหม่ / เปลี่ยนเครื่องถ่าย = go build ใหม่, restart server — ไม่เกี่ยวกันเลย

ที่ไม่เหมือนกัน: สำเนากระดาษยังมีวันที่ถ่ายเอกสารประทับอยู่ คนตรวจเลยพอสงสัยได้
แต่หน้า Swagger UI ไม่มีอะไรบอกเลยว่าข้อมูลข้างในเก่าแค่ไหน มันขึ้นสวยเหมือนใหม่ตลอด — นี่แหละที่ทำให้กับดักนี้อันตรายกว่า

⚠️ กฎเหล็ก (สไลด์เขียนไว้เอง) แก้ annotation แล้วต้องรัน swag init ใหม่ทุกครั้ง แล้ว restart server
ทางกันลืมที่ใช้ได้จริง: ใส่เป็น target ใน Makefile ให้มันมาคู่กับคำสั่งรัน หรือถ้าจะเอาให้แน่จริง — ใส่ใน CI ว่าให้รัน swag init แล้วเช็คว่า git diff ต้องว่าง ถ้าไม่ว่างแปลว่ามีคนลืม generate แล้ว pipeline แดงทันที

🗣 ศัพท์ที่เพิ่งเรียนไป
generate / regenerate
“สร้างไฟล์ขึ้นมาใหม่จากต้นทาง” · เวลารายงานปัญหาให้พูดว่า “เอกสารไม่ตรง น่าจะลืม regenerate” ชัดกว่า “เอกสารมันไม่อัปเดต” เพราะคำหลังฟังเหมือนเครื่องมือพัง ทั้งที่ความจริงคือมีคนลืมสั่ง
stale
“ของเก่าค้างอยู่” · ใช้กับเอกสาร cache หรือข้อมูลที่ควรอัปแล้วแต่ยังไม่อัป · ประโยคที่ใช้ได้เลย: “docs/ มัน stale แล้ว”
ฝังเป็น string constant (คนพูดสั้น ๆ ว่า embed)
spec ถูกเขียนเป็นข้อความก้อนหนึ่งลงไปในโค้ด docs/docs.go ตั้งแต่ตอน generate · เปิดไฟล์ดูจะเห็นเป็น const docTemplate = `{ ... }` ธรรมดา ๆ · พูดว่า “spec ถูกฝังไว้ตอน generate ไม่ได้อ่านจากไฟล์ตอน runtime” คือคำอธิบายทั้งกับดักนี้ในประโยคเดียว
กันสับสน: คำว่า embed ตรงนี้แปลตรงตัวว่า “ฝัง” เฉย ๆ — คนละเรื่องกับ //go:embed ของ Go (directive ที่ฝังไฟล์จริงเข้าไปตอน build) และคนละเรื่องกับ struct embedding · docs.go ที่ swag สร้าง ไม่มี //go:embed สักบรรทัด — ถ้าไปบอก senior สาย Go ว่า “embed” เขาจะไปไล่หา directive ที่ไม่มีอยู่จริง
CI (Continuous Integration)
เครื่องกลางที่รันคำสั่งตรวจงานให้อัตโนมัติทุกครั้งที่มีคนส่งโค้ดขึ้น repo · ประโยคที่เอาไปเสนอทีมได้: “ใส่ step ใน CI ให้รัน swag init แล้วเช็คว่า git diff ต้องว่าง”
drift
อาการที่ของสองอย่างซึ่งควรตรงกัน ค่อย ๆ ห่างกันไปเรื่อย ๆ โดยไม่มีใครรู้ · เอกสารกับโค้ดที่ห่างกันขึ้นทุกสัปดาห์ เรียกว่า “doc drift”

กับดัก 2 — ลืม @Router แล้ว endpoint หายเงียบ ๆ

ทายก่อนรัน — ผมเพิ่ม handler ใหม่พร้อม annotation ครบเกือบทุกช่อง แต่ไม่มีบรรทัด @Router แล้วรัน swag init

// Ping godoc
//
//	@Summary	เช็คว่าเซิร์ฟเวอร์ยังอยู่
//	@Tags		health
//	@Produce	json
//	@Success	200	{object}	user.UserResponse
func (handler *Handler) Ping(ctx *gin.Context) { ... }

swag init จะล้มเหลว หรือเตือน หรือเงียบ? และ paths ในไฟล์ที่ได้จะมีกี่อัน?

$ swag init -g cmd/api/main.go
2026/08/20 22:17:07 Generating user.UserResponse
2026/08/20 22:17:07 Generating httputil.ErrorResponse
2026/08/20 22:17:07 Generating user.CreateUserRequest
# ...create docs.go / swagger.json / swagger.yaml ครบตามปกติ
exit=0

paths: ['/users', '/users/{id}']
ops:   ['delete /users/{id}', 'get /users', 'get /users/{id}', 'post /users']

สำเร็จเรียบร้อย exit code 0 ไม่มี warning สักบรรทัด แต่ Ping ไม่โผล่ในเอกสารเลย — ไม่มี path ไหนเพิ่มขึ้นมาจากเดิม

เหตุผลตรงไปตรงมา: @Router คือสิ่งเดียวที่บอก swag ว่า “คอมเมนต์ก้อนนี้คือ endpoint ที่ path ไหน method อะไร” ไม่มีมัน swag ก็ไม่รู้จะเอาไปวางตรงไหนใน paths เลยข้ามทิ้ง
อาการที่คุณจะเจอจริงคือ “เขียน annotation ไปตั้งเยอะ ทำไม Swagger UI ไม่ขึ้น” — เช็ค @Router ก่อนเป็นอันดับแรกเสมอ

11 · ได้ swagger.json มาแล้ว เอาไปทำอะไรต่อได้บ้าง

ประเด็นที่หัวข้อ 2 ย้ำไว้กำลังจะออกดอกออกผลตรงนี้: swagger.json เป็นไฟล์มาตรฐาน ไม่ใช่ของเฉพาะ Go เครื่องมืออื่นในโลกจึงอ่านมันได้หมด

ปลายทางได้อะไรต้องทำอะไร
GitLab viewer หน้า Swagger UI ในตัว repo ไม่ต้อง deploy อะไรเพิ่ม commit docs/ เข้า repo → เปิดไฟล์ docs/swagger.json → กด Display rendered file
Postman collection ครบทุก endpoint ไม่ต้องสร้าง request ทีละอัน Import → File → swagger.json
openapi-generator โค้ด client ให้ทีม frontend (TypeScript / Dart / Java …) openapi-generator generate -i swagger.json -g typescript-axios -o ./client

📌 เงื่อนไขของ GitLab viewer ที่คนพลาดบ่อย — มันจะเรนเดอร์ให้ก็ต่อเมื่อชื่อไฟล์มีคำว่า openapi หรือ swagger และนามสกุลเป็น .json / .yaml / .yml
swagger.json ✓ · swagger.yaml ✓ · openapi_wongnok.yml ✓ · api-spec.json ✗ (ไม่มีคำว่า swagger/openapi)
ข่าวดีคือ swag init ตั้งชื่อให้ตรงเงื่อนไขอยู่แล้ว — ที่ต้องระวังคืออย่าเผลอใส่ docs/ ลงใน .gitignore ไม่งั้นไม่มีไฟล์ให้ GitLab อ่าน

🏁 สรุปทั้งบทเป็นประโยคเดียว

เขียน annotation ครั้งเดียวเหนือ handler → ได้ทั้งเอกสารบน GitLab, collection ใน Postman และโค้ด client ให้ frontend
แต่ทั้งหมดนี้จะเป็นจริงก็ต่อเมื่อคุณไม่ลืม swag init — เพราะเอกสารที่ generate แล้วลืม regenerate ก็คือเอกสารที่ไม่ตรงกับโค้ด ซึ่งอันตรายกว่าไม่มีเอกสาร เหมือนเดิม

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

1. OpenAPI กับ Swagger ต่างกันยังไง?

OpenAPI = ข้อตกลงว่าจะเขียนไฟล์อธิบาย API ยังไง (เป็นแค่ข้อความ รันไม่ได้) · Swagger = โปรแกรมที่เอาไฟล์นั้นไปทำอะไรต่อ เช่น Swagger UI, Editor, Codegen
ตัวเลือกที่ 1 สลับกัน · ตัวเลือกที่ 2 ผิดเพราะถ้าใช้แทนกันได้จริง คำว่า “Swagger UI” คงไม่มีความหมาย — และ docs/swagger.json ที่รันออกมาจริงก็เขียนว่า "swagger": "2.0" ซึ่งเท่ากับ OpenAPI 2.0 คนละอย่างกับ OpenAPI 3.x · ตัวเลือกที่ 3 ผิดเพราะ OpenAPI ออกแบบมาสำหรับ REST/HTTP โดยเฉพาะ (gRPC มี protobuf ของตัวเอง, GraphQL มี schema ของตัวเอง)

2. ลืมใส่ _ "wongnok/docs" ใน main.go แล้วเปิดเบราว์เซอร์ดู จะเจออะไร?

ผมเทสจาก package ที่ตั้งใจไม่ import docs เลย ได้ผลจริง: GET /swagger/index.html -> 200 · GET /swagger/doc.json -> 500 body=""
เพราะไฟล์ static ของ UI มากับ swaggo/files ที่ import ไว้แล้ว — หน้าเว็บจึงเปิดได้ แต่ตัว spec ถูกลงทะเบียนผ่าน init() ใน docs/docs.go ซึ่งไม่เคยถูกรัน
ตัวเลือกที่ 1 ผิดเพราะ WrapHandler รับแค่ swaggerFiles.Handler — คอมไพล์ผ่านสบาย ๆ นี่คือเหตุผลที่มันอันตราย · ตัวเลือกที่ 3 คืออาการของการลืม *any ต่างหาก (เทสได้ 404 จริงในเคสนั้น) · ตัวเลือกที่ 4 ผิด — ไม่มีใครอ่านคอมเมนต์ตอน runtime, spec เป็น string ที่ฝังไว้ตอน generate

3. แก้ @Summary ในโค้ด แล้ว go build ใหม่ แต่ไม่ได้รัน swag init — /swagger/doc.json จะเป็นยังไง?

ผมทดลองจริง: แก้ @Summary + เพิ่ม endpoint [delete] ทั้งอัน แล้ว go test -count=1 (บังคับ build ใหม่ ไม่ใช้ cache) ได้ผลเดิมเป๊ะ
พอรัน swag init -g cmd/api/main.go แล้ว build อีกที delete /users/{id} ถึงโผล่ และ summary ถึงเปลี่ยน
ตัวเลือกที่ 1 ผิดเพราะคอมไพเลอร์ Go มอง @Summary เป็นคอมเมนต์ธรรมดา ไม่มีความหมายใด ๆ · ตัวเลือกที่ 2 ผิด และนี่คือใจกลางของปัญหา — ไม่มีอะไรเตือนเลยสักอย่าง ไม่ error ไม่ warning เทสยังเขียว · ตัวเลือกที่ 4 ผิดเพราะ swag ไม่ได้ทำงานตอน build มันเป็นโปรแกรมแยกที่เรารันเอง
ทางกันลืม: ใส่ swag init ไว้ใน Makefile หรือให้ CI เช็คว่า git diff ต้องว่าง

4. เขียน annotation ครบทุกช่องยกเว้น @Router แล้วรัน swag init — เกิดอะไรขึ้น?

ผมเพิ่ม Ping ที่มี @Summary/@Tags/@Produce/@Success ครบแต่ไม่มี @Router แล้วรันจริง: exit=0 · สร้าง docs.go/swagger.json/swagger.yaml ครบ · paths: ['/users', '/users/{id}'] — เท่าเดิม ไม่มี Ping
@Router คือสิ่งเดียวที่บอกว่า “คอมเมนต์ก้อนนี้คือ endpoint ที่ path ไหน method อะไร” ไม่มีมัน swag ก็ไม่รู้จะวางไว้ตรงไหนใน paths เลยข้ามเงียบ ๆ
ตัวเลือกที่ 2 ผิด และเป็นสิ่งที่เราอยากให้มันเป็น — ถ้ามันพังจะดีกว่านี้เยอะ เพราะเรารู้ทันที · ตัวเลือกที่ 3 ผิดเพราะ swag ไม่มีทางรู้ว่าเราลงทะเบียน route ไว้ที่ path ไหนใน Gin (มันอ่านคอมเมนต์ ไม่ได้อ่าน router.GET(...)) · ตัวเลือกที่ 4 ผิดเพราะ endpoint ไม่ได้ขึ้นใน UI ตั้งแต่แรก
อาการที่จะเจอจริง: “เขียน annotation ตั้งเยอะ ทำไมไม่ขึ้นสักอัน” → เช็ค @Router ก่อนเสมอ

🛠️ ภารกิจ — ทำเอกสารให้ขึ้น แล้วทำให้มันโกหก แล้วเขียนเทสจับ

ทำต่อจากโปรเจกต์ Gin ที่คุณมีอยู่แล้วจาก บทเรียน 38 / บทเรียน 39 ได้เลย ไม่ต้องเริ่มใหม่ · เป้าหมายไม่ใช่ “ทำให้ Swagger UI ขึ้น” เฉย ๆ แต่คือพิสูจน์ว่าคุณจับได้เมื่อเอกสารเริ่มโกหก

  1. ลงเครื่องมือ + ยืนยันเวอร์ชัน: go install github.com/swaggo/swag/cmd/swag@latest แล้ว swag --version
    จากนั้น go get -u github.com/swaggo/gin-swagger และ go get -u github.com/swaggo/files
    เทียบ swag --version กับบรรทัด github.com/swaggo/swag ใน go.mod ของคุณ — ห่างกันเยอะไหม? ถ้าเยอะ go get github.com/swaggo/swag@latest ไว้ก่อนเลย
  2. เขียน General API Info เหนือ func main(): @title @version @description @host @BasePath @schemes
    ระวัง @BasePath ให้ตรงกับ router.Group() ของจริง
  3. ต่อ Swagger UI: ใส่ _ "<module>/docs" + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
    รัน swag init -g cmd/api/main.go แล้วเปิด http://localhost:8080/swagger/index.html
  4. ทำให้พังสองแบบ แล้วจดอาการไว้:
    (ก) ลบ *any ออกเหลือ /swagger → เปิดหน้าแล้วได้อะไร
    (ข) ลบ blank import ออก → หน้าเปิดขึ้นไหม, กด F12 ดู tab Network ว่า doc.json ได้ status อะไร
    สองอาการนี้ต้องแยกออกจากกันให้ได้ด้วยตาเปล่า
  5. เขียน annotation ให้ครบหนึ่ง endpoint ที่มี @Param ทั้งแบบ path และ query (ลอง Enums() กับ default() ด้วย) + @Success + @Failure + @Security BearerAuth
    แล้วเติม example: tag ใน dto (dto = struct ที่ใช้รับ-ส่งข้อมูลกับ client เช่น CreateUserRequest) — เปิด Swagger UI ดูว่า example โผล่ตรงไหน
  6. แยกให้ออกว่า flag ไหนเป็นคนทำ: รัน 4 แบบแล้วเก็บ md5 ของ docs/swagger.json ทุกแบบ — ไม่ใส่ flag / --parseInternal อย่างเดียว / --parseDependency อย่างเดียว / ใส่ทั้งคู่
    ห้ามเปลี่ยนสอง flag พร้อมกันแล้วสรุป — นั่นคือวิธีที่บทนี้เคยสรุปผิดมาแล้ว
    ตอบให้ได้ว่า โปรเจกต์ของคุณ ต้องใส่ flag ไหนบ้าง และตอบพร้อม md5 เป็นหลักฐาน
  7. ลบ @Router ออกหนึ่งอัน แล้ว swag init — exit code เท่าไหร่, มี warning ไหม, endpoint ยังอยู่ในเอกสารไหม
  8. เขียนเทสพิสูจน์ (ต่อยอด บทเรียน 40): เทสที่ใช้ httptest ยิง GET /swagger/doc.json แล้ว unmarshal JSON ออกมา (unmarshal = แปลง JSON กลับเป็น struct ของ Go) ตรวจสามอย่าง — (ก) status ต้องเป็น 200 · (ข) ทุก path ที่คุณลงทะเบียนไว้ใน router ต้องมีอยู่ใน paths ครบ · (ค) endpoint ที่ต้องล็อกอินต้องมี key security
    เทสนี้ต้อง fail ถ้าคุณแก้ annotation แล้วลืมรัน swag init — ถ้าแก้แล้วยังผ่าน แปลว่าเทสยังไม่ได้เทสอะไร

โครงที่ผมใช้ในบทนี้เอง: gin.New() → ลงทะเบียน /swagger/*any → httptest.NewRecorder() + httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil) → r.ServeHTTP(w, req)
แล้ว json.Unmarshal(w.Body.Bytes(), &spec) เข้า struct ที่มีแค่ field ที่สนใจ เช่น Paths map[string]map[string]struct{ Summary string `json:"summary"` } — ไม่ต้องแกะทั้ง spec แกะเฉพาะที่จะ assert ก็พอ

💡 จุดที่ต้องระวังตอนพิสูจน์ว่าเทส fail จริง: Go cache ผลเทสไว้ ถ้าไม่ได้แก้ไฟล์ .go มันจะขึ้น (cached) แล้วคุณจะเข้าใจผิดว่าเทสรันแล้ว
ใช้ go test -count=1 เสมอเวลาทดลองเรื่องนี้ — ผมเองก็เกือบสรุปผิดตอนทำหัวข้อ 10 เพราะบรรทัดนี้

💬 ข้อ 6 กับข้อ 8 คือสองข้อที่ผมอยากเห็นผลที่สุด — ข้อ 6 เพราะผลบนเครื่องผมขัดกับสไลด์ ผมอยากรู้ว่าโปรเจกต์ของคุณได้ diff ออกมาแบบไหน · ข้อ 8 เพราะมันคือเส้นแบ่งระหว่าง “มีเอกสาร” กับ “มีเอกสารที่เชื่อได้”
รันแล้วได้ output ไม่ตรงกับในบทนี้ตรงไหน ส่ง output มาได้เลย บอก swag --version กับบรรทัด swaggo/swag ใน go.mod มาด้วย (ทั้งบทนี้รันบน Go 1.26.5 darwin/arm64 · swag CLI v1.16.4 · swag library v1.16.6 · gin v1.12.0 · gin-swagger v1.6.1 · files v1.0.1)
บทหน้าเราจะเลิกลอกตัวอย่าง แล้วออกแบบ API ของ Wongnok เองทั้งชุด

อ้างอิง