REST API Design
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”ยังไม่มีโค้ดในบทนี้ — บทนี้คือแผนที่สำหรับห้าบทถัดไป REST API ของ TaskFlow มีสี่ resource — boards, columns, cards, และ labels — และทุกตัวจะแยกเป็น module ของตัวเองใต้ taskflow/backend/api/src/ โดยมีโครงสร้างภายในเหมือนกันเป๊ะ:
taskflow/backend/api/src/├── boards/│ ├── model.rs // Board, BoardTree, ColumnWithCards (+ Column, Card — ดูบท boards)│ ├── repo.rs // SQLx query ต่อ boards + board_members│ ├── service.rs // assert_member, assert_owner, get_tree, ...│ ├── handlers.rs // axum handler + request/response DTO│ └── mod.rs // pub fn routes() -> Router<AppState>├── columns/│ ├── model.rs / repo.rs / service.rs / handlers.rs / mod.rs├── cards/│ ├── model.rs / repo.rs / service.rs / handlers.rs / mod.rs└── labels/ ├── model.rs / repo.rs / service.rs / handlers.rs / mod.rsสี่ module ห้าไฟล์ต่อ module หนึ่ง pub fn routes() -> Router<AppState> ต่อ module รวมเข้ากับ Router ตัวเดียวที่ main.rs สร้างไว้แล้วใน db-pool พอจบ module นี้ ทุก endpoint ด้านล่างจะมีจริง ถูก authorize ผ่าน board_members และมี Postgres query จริงรองรับ
REST API คือชุดคำสัญญาเรื่อง URL, verb, และ status code — กำหนด mapping นี้ให้ถูกตั้งแต่แรก แล้วทุกบทถัดไปก็แค่เติมช่องที่คาดเดาได้ทีละช่อง กำหนดผิด แล้วทุก endpoint จะกลายเป็นการตัดสินใจแยกกันไปเรื่อย ๆ: นี่ควรเป็น POST หรือ PUT? ลบ board แล้วคืน 200 หรือ 204? board ที่หายไปคืน 404 หรือ 403? ตัดสินใจคำถามพวกนี้ครั้งเดียวที่นี่ หมายความว่าห้าบทถัดไปไม่ต้องมาเถียงเรื่องนี้ซ้ำอีก
layering handlers → service → repo มีเหตุผลเดียวกับที่ AppError มีอยู่: ให้แต่ละชิ้นโค้ดมีหน้าที่เดียวเป๊ะ handlers.rs ทำแค่ HTTP เท่านั้น — ดึง request, เรียก service function ตัวเดียว, ห่อผลลัพธ์ด้วย Json service.rs ทำแค่ authorization กับ business rule เท่านั้น — user คนนี้ทำสิ่งนี้ได้ไหม แล้วถ้าได้ “ทำสิ่งนี้” หมายถึงอะไรจริง ๆ (คำนวณ position, ประกอบ tree) repo.rs ทำแค่ SQL เท่านั้น — ไม่มี AppError::Forbidden โผล่ในไฟล์ repo.rs เลย เพราะ repo.rs ไม่รู้ว่า “forbidden” คืออะไร รู้แค่ว่าแถวข้อมูลคืออะไร
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Layering แบบ resource-per-module (handlers → service → repo, ตัวที่เราใช้) เทียบกับ handlers.rs แบบแบนไฟล์เดียวต่อ resource
- ข้อดี: bug เรื่อง “user คนนี้ลบ board นี้ได้ไหม” อยู่ที่จุดเดียวเสมอ —
boards::service::assert_owner— ไม่มีวันซ้ำซ้อนระหว่าง handlerdelete_boardกับ เช่น เครื่องมือ admin ในอนาคตที่ต้องการเช็คแบบเดียวกัน function ในrepo.rsทดสอบแยกส่วนได้ง่ายมาก (ให้ pool กับ ID มา รัน SQL ถูกไหม) โดยไม่ต้องพึ่ง Axum extractor หรือ authorization logic เลย ถ้าอนาคตมีผู้ใช้ “list boards for a user” รายใหม่ (CLI tool, background job) ก็แค่เรียกboards::service::list_boardsตรง ๆ — logic ไม่เคยถูกขังอยู่ใน handler function ที่มีแต่ Axum เรียกได้ - ข้อเสีย: สี่ไฟล์แทนที่จะเป็นไฟล์เดียวแม้แต่กับ resource ที่ง่ายที่สุด (
labelsซึ่ง CRUD เล็กจริง ๆ) — เปิดไฟล์เยอะขึ้นเวลาไล่ตาม request เดียวตั้งแต่ต้นจนจบ นี่คือต้นทุนคงที่ที่จ่ายครั้งเดียวต่อ resource แลกกับทุก resource หน้าตาเหมือนกันหมด พออ่านboards/จบ ก็รู้โครงสร้างcolumns/,cards/, และlabels/ไปด้วยแล้ว
Authorization ที่ service layer (ตัวที่เราใช้) เทียบกับ authorization ใน SQL ของ repo.rs (เช่น join board_members เข้าไปทุก query เสมอ)
- ข้อดี: function ใน
service.rsอย่างassert_member/assert_ownerคืนAppResult<()>ตัวเดียวชัดเจนที่ผู้เรียกต้อง?ก่อนทำอย่างอื่น — authorization check เป็นบรรทัดโค้ดที่มองเห็นได้ ไม่ได้ซ่อนอยู่ในWHEREclause ลึกสามชั้นในสตริง query function ในrepo.rsยังคงเป็นSELECT/INSERT/UPDATE/DELETEแบบง่าย ใช้ซ้ำได้ ไม่ต้องรู้ว่า ทำไม ถึงถูกเรียก รู้แค่ ว่า ได้รับอนุญาตให้ถูกเรียก - ข้อเสีย: ไป Postgres สองรอบแทนที่จะเป็นรอบเดียว —
SELECTauthorization บนboard_membersแล้วค่อย query จริง สำหรับขนาดของ TaskFlow (สมาชิกไม่กี่คนต่อ board,board_members(user_id)มี index จาก indexes-ordering) นี่ถูกมาก ระบบที่ authorize เป็นล้าน request ต่อวินาทีอาจรวม check เข้าไปในWHEREclause ของ query หลักเพื่อประหยัด round trip แลกกับ query ที่ไม่แยก “ทำได้ไหม” ออกจาก “ได้อะไร” ให้ชัดเจนอีกต่อไป
ลงมือสร้าง
หัวข้อที่มีชื่อว่า “ลงมือสร้าง”รูปแบบ URL และ verb
หัวข้อที่มีชื่อว่า “รูปแบบ URL และ verb”| Method | Path | ความหมาย | Success status |
|---|---|---|---|
GET | /boards | list board ที่ผู้เรียกเป็นสมาชิก | 200 |
POST | /boards | สร้าง board (ผู้เรียกกลายเป็น owner) | 201 |
GET | /boards/:id | board tree เต็ม — columns พร้อม card ของแต่ละอัน | 200 |
PATCH | /boards/:id | เปลี่ยนชื่อ board | 200 |
DELETE | /boards/:id | ลบ board (owner เท่านั้น) | 204 |
POST | /boards/:id/columns | สร้าง column บน board | 201 |
PATCH | /columns/:id | เปลี่ยนชื่อ column | 200 |
DELETE | /columns/:id | ลบ column | 204 |
POST | /columns/:id/cards | สร้าง card ใน column | 201 |
GET | /cards/:id | ดึง card ตัวเดียว | 200 |
PATCH | /cards/:id | อัปเดต title/description ของ card | 200 |
DELETE | /cards/:id | ลบ card | 204 |
PATCH | /cards/:id/move | ย้าย/จัดลำดับ card ใหม่ | 200 |
POST | /boards/:id/labels | สร้าง label บน board | 201 |
GET | /boards/:id/labels | list label ของ board | 200 |
DELETE | /labels/:id | ลบ label | 204 |
POST | /cards/:id/labels/:label_id | ติด label เข้ากับ card | 204 |
DELETE | /cards/:id/labels/:label_id | ถอด label ออกจาก card | 204 |
การตัดสินใจตั้งชื่อบางอย่างที่ควรพูดถึง:
- ใช้คำนาม ไม่ใช้คำกริยา
POST /boards/:id/columnsสร้าง column — ไม่มีวันเป็นPOST /createColumnURL บอกชื่อ resource, HTTP method บอกการกระทำ - การซ้อนสะท้อนความเป็นเจ้าของ ลึกแค่ชั้นเดียว เราสร้าง column ใต้
/boards/:id/columnsเพราะ column อยู่ไม่ได้ถ้าไม่มี board — แต่พอมีidของ column แล้ว การกระทำอื่นทุกอย่าง (PATCH /columns/:id,DELETE /columns/:id,POST /columns/:id/cards) เข้าถึงตรง ๆ ไม่ต้องผ่าน/boards/:id/columns/:idprimary key แบบUuid(จาก schema) ทำให้เป็นไปได้: id เป็น unique ทั่วทั้งระบบ เลยไม่จำเป็นต้องมี parent อยู่ใน URL เพื่อแยกแยะเลย/cards/:id/labels/:label_idคือจุดเดียวใน path ที่เรามีสอง ID เพราะ attach/detach คือการกระทำต่อ ความสัมพันธ์ ระหว่างสอง resource โดยธรรมชาติ ไม่ใช่ resource ใดตัวหนึ่งเดี่ยว ๆ moveคือ path เดียวในทั้ง API ที่มีรูปร่างเหมือนคำกริยา และตั้งใจทำแบบนั้น: การย้าย card ไม่ใช่การอัปเดต field บางส่วน (นั่นคือหน้าที่ของPATCH /cards/:idธรรมดา) แต่เป็นการกระทำที่แยกต่างหาก มีรูปแบบ request ของตัวเอง (target_column_id,before_id,after_id) และมี business rule ของตัวเอง (เลขคณิต fractional-position) การจำลองเป็นPATCH /cards/:id/moveแทนที่จะยัดเข้าไปในPATCH /cards/:idทำให้ “อัปเดต title/description” กับ “จัดลำดับใหม่” ไม่ต้องแชร์ handler เดียวที่ต้องเดาว่าผู้เรียกหมายถึงอะไร
Status codes
หัวข้อที่มีชื่อว่า “Status codes”200 OK—GETหรือPATCHที่สำเร็จและคืน body201 Created—POSTที่สำเร็จและสร้าง resource ใหม่ response body คือ resource ที่สร้าง รวมidที่ server สร้างให้204 No Content—DELETEที่สำเร็จ หรือ attach/detach ที่สำเร็จแต่ไม่มีอะไรมีความหมายจะคืน (label ติดหรือไม่ติดก็แค่นั้น — ไม่มี resource ใหม่จะอธิบาย)401 Unauthorized— ไม่มี bearer token ที่ valid และไม่ถูก revoke เลย จัดการทั้งหมดโดยAuthUserextractor จาก middleware ก่อนที่ handler ใด ๆ ใน module นี้จะรันด้วยซ้ำ403 Forbidden— ผู้เรียกที่ authenticated ถูกต้องแล้ว แต่แค่ไม่ได้รับอนุญาตให้ทำสิ่งนี้ (ไม่ใช่สมาชิกของ board; เป็นสมาชิกแต่ไม่ใช่ owner พยายามลบ) นี่คือ status code ของassert_member/assert_owner404 Not Found— id ใน URL ไม่มีอยู่จริง หรือ — ตั้งใจ — มีอยู่จริงแต่เป็นของ board ที่ผู้เรียกไม่ได้เป็นสมาชิก หัวข้อด้านล่างอธิบายว่าทำไมสองกรณีนี้ใช้ status code เดียวกัน409 Conflict— สงวนไว้สำหรับ write conflict จริง ๆ; ไม่มี endpoint ใดใน module นี้เจอกรณีนี้ (ต่างจากregisterใน handlers ที่ไม่มีuniqueconstraint ให้ละเมิด) แต่AppError::Conflictยังพร้อมใช้สำหรับ module ถัดไป422 Unprocessable Entity—AppError::Validationสำหรับ request ที่ syntax ถูกแต่ผิด business rule module นี้ก็ยังไม่ใช้ด้วยเหตุผลเดียวกัน: request body ของ TaskFlow ในบทนี้ง่ายพอที่ข้อถัดไปจะรับมือกรณี “รูปแบบผิด” ให้อยู่แล้ว
การ validate เกิดขึ้นตรงไหน
หัวข้อที่มีชื่อว่า “การ validate เกิดขึ้นตรงไหน”สองชั้น และตั้งใจให้ต่างกัน:
- Shape validation ฟรี จาก
Json<T>ถ้าCreateBoardRequest { title: String }ขาดtitleหรือtitleเป็นตัวเลขแทนที่จะเป็น string,Jsonextractor ของ Axum จะปฏิเสธ request ด้วย400 Bad Requestก่อน handler body จะรันด้วยซ้ำ —Deserializederive ของserdeคือ validator ไม่ต้องเขียนเองเลย - Business-rule validation คือ
AppError::Validationสำหรับ request ที่รูปแบบถูกแต่ความหมายผิด —title: ""ที่ว่างเปล่า,colorที่ไม่ใช่ hex string ที่ valid ทั้งห้าบทใน module นี้ยังไม่เพิ่ม check นี้ (ทุก DTO ในนี้เป็นStringธรรมดา ไม่เช็คว่าว่างหรือไม่) ที่เป็นการตัดขอบเขตอย่างตั้งใจ: TaskFlow เชื่อ frontend ว่าจะส่ง title ที่สมเหตุสมผลไปก่อน เหมือนกับที่RegisterRequestของregisterเชื่อ frontend ว่าจะส่ง email จริงมาใน Module 4 variantAppError::Validationมีอยู่แล้วในerror.rsจาก error-handling เพื่อให้การเพิ่มif body.title.trim().is_empty() { return Err(AppError::Validation("title must not be empty".into())); }เข้า handler ไหนก็ได้ในอนาคต ไม่ต้องแตะerror.rsเลย
ทำไมต้อง 404 ไม่ใช่ 403 สำหรับ board ที่มีอยู่จริงแต่ไม่ใช่ของคุณ
หัวข้อที่มีชื่อว่า “ทำไมต้อง 404 ไม่ใช่ 403 สำหรับ board ที่มีอยู่จริงแต่ไม่ใช่ของคุณ”get_tree ในบท boards เรียก assert_member ก่อน ซึ่งคืน 403 สำหรับ board ที่ผู้เรียกไม่ได้เป็นสมาชิกจริง ๆ แต่ดู get_card, update_card และเพื่อน ๆ ในบท cards ให้ดี: ทั้งหมด resolve board ของ card ผ่าน column ที่ card อยู่ แล้วค่อยเรียก assert_member — และถ้า card เอง ไม่มีอยู่จริง นั่นคือ 404 ตัดสินใจก่อนจะเช็ค authorization ด้วยซ้ำ เพราะไม่มีอะไรให้ authorize การเข้าถึงเลย กรณีที่ตั้งใจอีกกรณีคือ labels::service::attach_label: ถ้า label_id มีจริงแต่เป็นของ board อื่น จาก card, handler คืน 404 ไม่ใช่ 403 — ปฏิบัติกับ “label นี้ไม่ใช่ของคุณที่จะติดได้” เหมือนกับ “label นี้ไม่มีอยู่จริง” เพื่อไม่ให้ผู้เรียกใช้ attach endpoint สำรวจว่า label id ไหนมีอยู่บน board ที่ตัวเองเข้าไม่ถึง 403 สงวนไว้สำหรับ “คุณไม่ได้เป็นสมาชิกของ board ที่ resource นี้เป็นของอย่างชัดเจนด้วยซ้ำ” — การรั่วข้อมูลที่อ่อนกว่าอย่างเห็นได้ชัด เพราะผู้เรียกรู้ board id อยู่แล้วจาก request ของตัวเอง
ทำไม list ของ board ยังไม่มี pagination
หัวข้อที่มีชื่อว่า “ทำไม list ของ board ยังไม่มี pagination”GET /boards คืนทุก board ที่ผู้เรียกเป็นสมาชิก ไม่มี pagination นี่คือการตัดขอบเขตอย่างตั้งใจ ไม่ใช่การมองข้าม: board_members(user_id) (index จาก indexes-ordering) ทำให้ query เองยังถูกอยู่ ไม่ว่า table จะใหญ่แค่ไหน และคนหนึ่งคนตามความเป็นจริงเป็นสมาชิกของ board สักไม่กี่สิบ ไม่ใช่หมื่น ๆ ที่ list ไม่มี pagination จะกลายเป็นปัญหาขนาด response จริง ๆ ถ้า TaskFlow ต้องใช้ pagination สักวัน รูปแบบที่จะใช้ก็คือรูปแบบมาตรฐานจาก Global Expert Playbook: GET /boards?limit=20&offset=0 แปลงเป็น LIMIT $n OFFSET $m ใน boards::repo::list_for_user โดยชั้น service.rs และ handlers.rs ส่งพารามิเตอร์เพิ่มสองตัวผ่านไปโดยไม่เปลี่ยนแปลง — layering จากบทนี้เองที่ทำให้เหลือเป็นการเปลี่ยนไฟล์เดียว ไม่ใช่การออกแบบใหม่
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”ไม่มีโค้ดให้รัน — ตรวจสอบความเข้าใจแทน:
DELETE /boards/:idคืน status code อะไรเมื่อผู้เรียกเป็นสมาชิกแต่ไม่ใช่ owner? (403—assert_ownerfail ก่อนที่ delete จะรันด้วยซ้ำ)GET /cards/:idคืน status code อะไรสำหรับ card id ที่ถูกลบไปแล้ว? (404—repo::find_cardคืนNoneแปลงเป็นAppError::NotFoundก่อนจะเช็ค authorization)- ทำไม
repo.rsไม่เคยสร้างAppErrorเลย? (เพราะ function ใน repo ไม่รู้ว่า ทำไม ถึงถูกเรียก — มีแค่service.rsที่รู้ context พอ เช่น “นี่คือ owner ไหม” เพื่อตัดสินใจว่า lookup ที่ fail หมายถึง403หรือ404)
ถ้าสามคำตอบนี้เข้าใจได้ แผนที่ก็ชัดเจนแล้ว — ห้าบทถัดไปคือการสร้างตามแผนที่นี้ล้วน ๆ
คุณได้เห็นตาราง URL/verb/status-code เต็มของ REST API ของ TaskFlow, layering handlers → service → repo ที่ห้าบทถัดไปจะใช้ภายใน boards/, columns/, cards/, และ labels/ และการตัดสินใจออกแบบสามอย่างที่ตั้งใจ: shape validation ฟรีจาก serde, business-rule validation ถูกเลื่อนไปหลัง AppError::Validation ที่สร้างไว้แล้ว และ resource ที่ไม่ใช่ board ของคุณคืน 404 แทน 403 เพื่อไม่รั่วการมีอยู่ ถัดไป เราจะสร้าง resource module แรกและใหญ่ที่สุด — boards — รวมถึง helper assert_member/assert_owner ที่ทุก module ในบทหลังของคอร์สนี้จะเรียกใช้