การออกแบบ RESTful API Endpoints: Best Practices สำหรับ HTTP Methods และโครงสร้าง URL
API ที่ออกแบบมาอย่างดีคือสัญญา มันกำหนดวิธีที่ client และ server สื่อสารกัน และการตัดสินใจที่ผิดพลาดในช่วงต้นจะสะสมกลายเป็นหนี้ทางเทคนิคเป็นปีๆ ไม่ว่าคุณจะสร้าง public API, internal microservice หรือ mobile backend หลักการเดียวกันล้วนใช้ได้: ใช้ HTTP อย่างถูกต้อง, จำลอง resource อย่างชัดเจน, และสื่อสารอย่างชัดเจนผ่าน status codes และ response bodies
คู่มือนี้ครอบคลุมทุกชั้นของการออกแบบ REST API endpoint — ตั้งแต่ข้อจำกัดพื้นฐานผ่าน HTTP method semantics, URL conventions, pagination, error handling, security, และ documentation — พร้อมการ implement ที่ใช้งานได้จริงใน TypeScript, Java, Python และ C#
ส่วนที่ 1: พื้นฐาน REST
REST คืออะไรจริงๆ
REST (Representational State Transfer) คือรูปแบบสถาปัตยกรรม ไม่ใช่โปรโตคอล Roy Fielding กำหนดข้อจำกัดหกข้อในวิทยานิพนธ์ปี 2000 ของเขาซึ่งร่วมกันสร้าง API ที่ปรับขนาดได้, stateless และคาดการณ์ได้:
- Client-Server — ความกังวลของ UI และการจัดเก็บข้อมูลถูกแยกออกจากกัน; พวกมันพัฒนาอย่างเป็นอิสระ
- Statelessness — ทุก request มีข้อมูลทั้งหมดที่ server ต้องการ; ไม่มีการเก็บ session state ฝั่ง server
- Cacheability — responses ต้องกำหนดตัวเองว่า cacheable หรือไม่ เพื่อป้องกันไม่ให้ client ใช้ข้อมูลที่หมดอายุ
- Uniform Interface — วิธีที่สอดคล้องกันในการโต้ตอบกับทุก resource สร้างบน sub-constraint สี่ข้อ: identification ของ resources, การจัดการผ่าน representations, self-descriptive messages และ HATEOAS
- Layered System — client ไม่สามารถบอกได้ว่าเชื่อมต่อกับ end server หรือตัวกลาง (load balancer, cache, gateway)
- Code on Demand (ตัวเลือก) — server สามารถขยายฟังก์ชันการทำงานของ client โดยการส่งโค้ดที่ executable
Statelessness คือข้อจำกัดที่เข้าใจผิดมากที่สุด มันหมายความว่า server ไม่เก็บบริบทของ request ระหว่างการเรียก — ไม่ได้หมายความว่าคุณจะมีฐานข้อมูลไม่ได้ Authentication tokens, pagination cursors และ filter parameters ต้องส่งกับทุก request
Resources และ URIs
ใน REST ทุกอย่างคือ resource — การ mapping เชิงแนวคิดกับ entity หรือ collection Resources ถูกระบุด้วย URI และจัดการผ่าน representations (โดยทั่วไปคือ JSON หรือ XML)
| แนวคิด | ตัวอย่าง |
|---|---|
| Collection resource | GET /users |
| Singleton resource | GET /users/42 |
| Sub-resource collection | GET /users/42/orders |
| Sub-resource singleton | GET /users/42/orders/7 |
| Action on resource | POST /users/42/deactivate |
ข้อคิดสำคัญ: URI ระบุ อะไร, HTTP methods อธิบาย จะทำอะไรกับมัน
Richardson Maturity Model
ทีมส่วนใหญ่พัฒนาผ่านสามระดับก่อนถึง REST ที่แท้จริง:
- Level 0 — URI เดียว, operations ทั้งหมดผ่าน POST (แบบ SOAP, XML-RPC)
- Level 1 — URI แยกต่างหากต่อ resource แต่ทั้งหมดผ่าน POST
- Level 2 — HTTP verbs ที่ถูกต้องต่อ operation (ที่ production APIs ส่วนใหญ่อยู่)
- Level 3 — HATEOAS: responses มี links ไปยัง possible next actions
API ใหม่ส่วนใหญ่ควรมุ่งเป้าที่ Level 2 และเพิ่ม HATEOAS อย่างเลือกสรรเมื่อมันช่วย client ค้นหา state transitions จริงๆ
ส่วนที่ 2: HTTP Methods ในเชิงลึก
แต่ละ HTTP method มี semantics ที่แม่นยำ สองคุณสมบัติสำคัญที่สุด:
- Safe — ไม่มี side effects ที่สังเกตได้ (GET, HEAD, OPTIONS)
- Idempotent — การทำซ้ำ N ครั้งให้ผลลัพธ์เหมือนกับทำครั้งเดียว (GET, PUT, DELETE, HEAD, OPTIONS)
| Method | Safe | Idempotent | Request Body | Response ปกติ |
|---|---|---|---|---|
| GET | ใช่ | ใช่ | ไม่ | 200 OK |
| POST | ไม่ | ไม่ | ใช่ | 201 Created |
| PUT | ไม่ | ใช่ | ใช่ | 200 OK / 204 No Content |
| PATCH | ไม่ | ไม่* | ใช่ | 200 OK / 204 No Content |
| DELETE | ไม่ | ใช่ | ตัวเลือก | 204 No Content |
| HEAD | ใช่ | ใช่ | ไม่ | 200 OK (ไม่มี body) |
| OPTIONS | ใช่ | ใช่ | ไม่ | 200 OK |
*PATCH ไม่รับประกัน idempotent แต่ JSON Merge Patch (RFC 7396) operations โดยทั่วไปแล้วก็เป็น
GET — Safe, Idempotent, Cacheable
GET ดึง resource representation มันต้องไม่เปลี่ยนแปลง state เลย ใช้ query parameters สำหรับ filtering, sorting และ pagination Responses ควรมี Cache-Control headers
// Express / Fastify
import { Router, Request, Response } from 'express';
const router = Router();
router.get('/users', async (req: Request, res: Response) => {
const { page = '1', limit = '20', sort = 'createdAt', order = 'desc', role } = req.query;
const filters = { ...(role && { role: String(role) }) };
const pagination = { page: Number(page), limit: Math.min(Number(limit), 100) };
const sorting = { field: String(sort), direction: String(order) as 'asc' | 'desc' };
const result = await userService.listUsers(filters, pagination, sorting);
res
.set('Cache-Control', 'private, max-age=60')
.set('X-Total-Count', String(result.total))
.status(200)
.json({
data: result.users,
pagination: {
page: pagination.page,
limit: pagination.limit,
total: result.total,
pages: Math.ceil(result.total / pagination.limit),
},
});
});
router.get('/users/:id', async (req: Request, res: Response) => {
const user = await userService.getUserById(req.params.id);
if (!user) {
return res.status(404).json({
type: 'https://api.example.com/errors/not-found',
title: 'User Not Found',
status: 404,
detail: `User ${req.params.id} does not exist`,
instance: req.path,
});
}
res.set('Cache-Control', 'private, max-age=300').status(200).json(user);
});// Spring Boot
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping
public ResponseEntity<PagedResponse<User>> listUsers(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int limit,
@RequestParam(defaultValue = "createdAt") String sort,
@RequestParam(defaultValue = "desc") String order,
@RequestParam(required = false) String role) {
int safeLimit = Math.min(limit, 100);
UserFilters filters = UserFilters.builder().role(role).build();
PageRequest pageable = PageRequest.of(page - 1, safeLimit,
Sort.by(Sort.Direction.fromString(order), sort));
Page<User> result = userService.listUsers(filters, pageable);
PagedResponse<User> body = PagedResponse.<User>builder()
.data(result.getContent())
.pagination(PaginationMeta.of(page, safeLimit, result.getTotalElements()))
.build();
return ResponseEntity.ok()
.header("Cache-Control", "private, max-age=60")
.header("X-Total-Count", String.valueOf(result.getTotalElements()))
.body(body);
}
@GetMapping("/{id}")
public ResponseEntity<?> getUser(@PathVariable String id) {
return userService.getUserById(id)
.map(user -> ResponseEntity.ok()
.header("Cache-Control", "private, max-age=300")
.body((Object) user))
.orElse(ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "User Not Found",
"status", 404,
"detail", "User " + id + " does not exist")));
}
}# FastAPI
from fastapi import APIRouter, Query, HTTPException
from typing import Optional
from models import User, PagedResponse, PaginationMeta
router = APIRouter(prefix="/users")
@router.get("", response_model=PagedResponse[User])
async def list_users(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
sort: str = Query("created_at"),
order: str = Query("desc", pattern="^(asc|desc)$"),
role: Optional[str] = None,
):
filters = {"role": role} if role else {}
result = await user_service.list_users(filters, page, limit, sort, order)
return PagedResponse(
data=result.users,
pagination=PaginationMeta(
page=page,
limit=limit,
total=result.total,
pages=-(-result.total // limit),
),
)
@router.get("/{user_id}", response_model=User)
async def get_user(user_id: str):
user = await user_service.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=404,
detail={
"type": "https://api.example.com/errors/not-found",
"title": "User Not Found",
"status": 404,
"detail": f"User {user_id} does not exist",
},
)
return user// ASP.NET Core
[ApiController]
[Route("users")]
public class UsersController : ControllerBase
{
[HttpGet]
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Client)]
public async Task<IActionResult> ListUsers(
[FromQuery] int page = 1,
[FromQuery] int limit = 20,
[FromQuery] string sort = "createdAt",
[FromQuery] string order = "desc",
[FromQuery] string? role = null)
{
var safeLimit = Math.Min(limit, 100);
var filters = new UserFilters { Role = role };
var result = await _userService.ListUsersAsync(filters, page, safeLimit, sort, order);
Response.Headers["X-Total-Count"] = result.Total.ToString();
return Ok(new
{
data = result.Users,
pagination = new { page, limit = safeLimit, total = result.Total, pages = (int)Math.Ceiling((double)result.Total / safeLimit) }
});
}
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(string id)
{
var user = await _userService.GetUserByIdAsync(id);
if (user == null)
return NotFound(new { type = "https://api.example.com/errors/not-found", title = "User Not Found", status = 404, detail = $"User {id} does not exist" });
Response.Headers["Cache-Control"] = "private, max-age=300";
return Ok(user);
}
}POST — สร้าง Resources
POST สร้าง resource ใหม่ภายใต้ collection URI มัน ไม่ idempotent — การส่ง POST เดิมสองครั้งสร้างสอง resource เสมอ return 201 Created พร้อม Location header ที่ชี้ไปยัง resource ใหม่
router.post('/users', async (req: Request, res: Response) => {
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({
type: 'https://api.example.com/errors/unprocessable-entity',
title: 'Validation Failed',
status: 422,
detail: 'One or more fields failed validation',
errors: parsed.error.flatten().fieldErrors,
instance: req.path,
});
}
try {
const user = await userService.createUser(parsed.data);
res
.status(201)
.set('Location', `/users/${user.id}`)
.json(user);
} catch (err) {
if (err instanceof DuplicateEmailError) {
return res.status(409).json({
type: 'https://api.example.com/errors/conflict',
title: 'Email Already Registered',
status: 409,
detail: `The email ${parsed.data.email} is already in use`,
instance: req.path,
});
}
throw err;
}
});@PostMapping
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest body,
BindingResult binding, HttpServletRequest request) {
if (binding.hasErrors()) {
Map<String, List<String>> fieldErrors = binding.getFieldErrors().stream()
.collect(Collectors.groupingBy(FieldError::getField,
Collectors.mapping(FieldError::getDefaultMessage, Collectors.toList())));
return ResponseEntity.status(422).body(Map.of(
"type", "https://api.example.com/errors/unprocessable-entity",
"title", "Validation Failed",
"status", 422,
"errors", fieldErrors));
}
try {
User user = userService.createUser(body);
URI location = URI.create("/users/" + user.getId());
return ResponseEntity.created(location).body(user);
} catch (DuplicateEmailException e) {
return ResponseEntity.status(409).body(Map.of(
"type", "https://api.example.com/errors/conflict",
"title", "Email Already Registered",
"status", 409,
"detail", e.getMessage()));
}
}from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, EmailStr
class CreateUserRequest(BaseModel):
name: str
email: EmailStr
role: str = "user"
@router.post("", status_code=201)
async def create_user(body: CreateUserRequest, request: Request):
try:
user = await user_service.create_user(body.dict())
return JSONResponse(
status_code=201,
content=user.dict(),
headers={"Location": f"/users/{user.id}"},
)
except DuplicateEmailError:
return JSONResponse(status_code=409, content={
"type": "https://api.example.com/errors/conflict",
"title": "Email Already Registered",
"status": 409,
"detail": f"The email {body.email} is already in use",
})[HttpPost]
public async Task<IActionResult> CreateUser([FromBody] CreateUserRequest body)
{
if (!ModelState.IsValid)
{
var errors = ModelState.ToDictionary(
k => k.Key,
v => v.Value!.Errors.Select(e => e.ErrorMessage).ToList());
return UnprocessableEntity(new { type = "https://api.example.com/errors/unprocessable-entity", title = "Validation Failed", status = 422, errors });
}
try
{
var user = await _userService.CreateUserAsync(body);
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
}
catch (DuplicateEmailException ex)
{
return Conflict(new { type = "https://api.example.com/errors/conflict", title = "Email Already Registered", status = 409, detail = ex.Message });
}
}PUT vs PATCH — ความแตกต่างที่สำคัญ
นี่คือจุดที่ทีมส่วนใหญ่ทำผิด ความแตกต่างนั้นชัดเจน:
- PUT แทนที่การแสดง ทั้งหมด ของ resource ทุก field ต้องได้รับ; field ที่ละเว้นจะถูกลบหรือรีเซ็ตเป็นค่าเริ่มต้น
- PATCH ใช้ partial update เฉพาะ field ที่ให้มาเท่านั้นที่จะถูกแก้ไข
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
Note over C,DB: PUT — Full Replacement
C->>S: PUT /users/42<br/>{ name, email, role, address, phone }
S->>DB: SELECT * FROM users WHERE id = 42
DB-->>S: existing user record
S->>S: Validate ALL fields present<br/>Replace entire document
S->>DB: UPDATE users SET name=?, email=?,<br/>role=?, address=?, phone=? WHERE id=42
DB-->>S: updated record
S-->>C: 200 OK — full user object
Note over C,DB: PATCH — Partial Update
C->>S: PATCH /users/42<br/>{ address: "123 New St" }
S->>DB: SELECT * FROM users WHERE id = 42
DB-->>S: existing user record
S->>S: Merge patch: only address changes<br/>name, email, role, phone unchanged
S->>DB: UPDATE users SET address=? WHERE id=42
DB-->>S: updated record
S-->>C: 200 OK — full user object
รูปแบบ PATCH: สองแนวทางมาตรฐานคือ:
- JSON Merge Patch (RFC 7396) — ส่ง partial JSON object; ค่า
nullลบ field นั้น ง่าย, ใช้กันอย่างแพร่หลาย - JSON Patch (RFC 6902) — ส่ง array ของ operation objects (
add,remove,replace,move,copy,test) มีประสิทธิภาพ, atomic, แต่ verbose
// JSON Merge Patch (RFC 7396) — Content-Type: application/merge-patch+json
{ "address": "123 New St", "phone": null }
// JSON Patch (RFC 6902) — Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/address", "value": "123 New St" },
{ "op": "remove", "path": "/phone" }
]
// PUT — full replacement
router.put('/users/:id', async (req: Request, res: Response) => {
const parsed = replaceUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({ type: 'https://api.example.com/errors/unprocessable-entity', title: 'Validation Failed', status: 422, errors: parsed.error.flatten().fieldErrors });
}
const updated = await userService.replaceUser(req.params.id, parsed.data);
if (!updated) return res.status(404).json({ type: 'https://api.example.com/errors/not-found', title: 'User Not Found', status: 404 });
res.status(200).json(updated);
});
// PATCH — partial update (JSON Merge Patch)
router.patch('/users/:id', async (req: Request, res: Response) => {
const parsed = patchUserSchema.safeParse(req.body); // all fields optional
if (!parsed.success) {
return res.status(422).json({ type: 'https://api.example.com/errors/unprocessable-entity', title: 'Validation Failed', status: 422, errors: parsed.error.flatten().fieldErrors });
}
const updated = await userService.patchUser(req.params.id, parsed.data);
if (!updated) return res.status(404).json({ type: 'https://api.example.com/errors/not-found', title: 'User Not Found', status: 404 });
res.status(200).json(updated);
});// PUT
@PutMapping("/{id}")
public ResponseEntity<?> replaceUser(@PathVariable String id,
@Valid @RequestBody ReplaceUserRequest body) {
return userService.replaceUser(id, body)
.map(u -> ResponseEntity.ok((Object) u))
.orElse(ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "User Not Found", "status", 404)));
}
// PATCH
@PatchMapping(value = "/{id}", consumes = "application/merge-patch+json")
public ResponseEntity<?> patchUser(@PathVariable String id,
@RequestBody Map<String, Object> patch) {
return userService.patchUser(id, patch)
.map(u -> ResponseEntity.ok((Object) u))
.orElse(ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "User Not Found", "status", 404)));
}from typing import Optional
from pydantic import BaseModel
class ReplaceUserRequest(BaseModel):
name: str
email: EmailStr
role: str
address: str
phone: str
class PatchUserRequest(BaseModel):
name: Optional[str] = None
email: Optional[EmailStr] = None
role: Optional[str] = None
address: Optional[str] = None
phone: Optional[str] = None
@router.put("/{user_id}")
async def replace_user(user_id: str, body: ReplaceUserRequest):
updated = await user_service.replace_user(user_id, body.dict())
if not updated:
raise HTTPException(status_code=404, detail={"title": "User Not Found", "status": 404})
return updated
@router.patch("/{user_id}")
async def patch_user(user_id: str, body: PatchUserRequest):
patch = body.dict(exclude_unset=True)
updated = await user_service.patch_user(user_id, patch)
if not updated:
raise HTTPException(status_code=404, detail={"title": "User Not Found", "status": 404})
return updated// PUT
[HttpPut("{id}")]
public async Task<IActionResult> ReplaceUser(string id, [FromBody] ReplaceUserRequest body)
{
if (!ModelState.IsValid) return UnprocessableEntity(ModelState);
var updated = await _userService.ReplaceUserAsync(id, body);
return updated != null ? Ok(updated) : NotFound(new { title = "User Not Found", status = 404 });
}
// PATCH
[HttpPatch("{id}")]
public async Task<IActionResult> PatchUser(string id, [FromBody] JsonElement patch)
{
var updated = await _userService.PatchUserAsync(id, patch);
return updated != null ? Ok(updated) : NotFound(new { title = "User Not Found", status = 404 });
}DELETE — Idempotent Removal
DELETE ลบ resource มัน idempotent: การลบ resource ที่ถูกลบไปแล้วควร return 204 No Content ไม่ใช่ 404 (แม้ว่าบางทีมจะ return 404 ในการลบครั้งที่สอง — เลือกอย่างใดอย่างหนึ่งและบันทึกไว้)
Soft delete vs hard delete:
- Hard delete — ลบ record ออกจากฐานข้อมูลอย่างถาวร Returns
204 No Content - Soft delete — ตั้งค่า timestamp
deletedAt, ซ่อนจาก queries แต่เก็บ record ไว้ API ยัง return204; resource เพียงแค่ไม่สามารถเข้าถึงได้ผ่าน GET อีกต่อไป
router.delete('/users/:id', async (req: Request, res: Response) => {
const exists = await userService.userExists(req.params.id);
if (!exists) {
return res.status(404).json({
type: 'https://api.example.com/errors/not-found',
title: 'User Not Found',
status: 404,
detail: `User ${req.params.id} does not exist`,
});
}
await userService.softDeleteUser(req.params.id);
res.status(204).send();
});@DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable String id) {
if (!userService.userExists(id)) {
return ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "User Not Found", "status", 404,
"detail", "User " + id + " does not exist"));
}
userService.softDeleteUser(id);
return ResponseEntity.noContent().build();
}@router.delete("/{user_id}", status_code=204)
async def delete_user(user_id: str):
exists = await user_service.user_exists(user_id)
if not exists:
raise HTTPException(status_code=404, detail={
"type": "https://api.example.com/errors/not-found",
"title": "User Not Found",
"status": 404,
})
await user_service.soft_delete_user(user_id)[HttpDelete("{id}")]
public async Task<IActionResult> DeleteUser(string id)
{
if (!await _userService.UserExistsAsync(id))
return NotFound(new { type = "https://api.example.com/errors/not-found", title = "User Not Found", status = 404, detail = $"User {id} does not exist" });
await _userService.SoftDeleteUserAsync(id);
return NoContent();
}HEAD และ OPTIONS
HEAD returns headers เดิมกับ GET แต่ไม่มี response body ใช้เพื่อตรวจสอบการมีอยู่ของ resource, รับ Content-Length หรือ validate caching โดยไม่ต้อง download ข้อมูล
OPTIONS อธิบาย communication options สำหรับ resource browser ส่งมันโดยอัตโนมัติก่อน cross-origin requests (CORS preflight) implement มันอย่างถูกต้องเสมอ
OPTIONS /users/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: Content-Type, Authorization
HTTP/1.1 204 No Content
Allow: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
ส่วนที่ 3: URL Structure Best Practices
Nouns ไม่ใช่ Verbs
HTTP method แสดง action แล้ว URI ควรอธิบาย resource ไม่ใช่ operation
| ผิด (verb ใน URL) | ถูกต้อง (noun + HTTP method) |
|---|---|
POST /getUser | GET /users/42 |
POST /createOrder | POST /orders |
POST /deleteAccount | DELETE /accounts/42 |
POST /updateUserProfile | PATCH /users/42/profile |
POST /searchProducts | GET /products?q=shirt&color=blue |
ข้อยกเว้น: Resource actions ที่ไม่ map กับ CRUD operations อย่างชัดเจน ใช้ sub-resource noun:
POST /users/42/deactivate # trigger deactivation (ไม่ใช่ "deactivateUser")
POST /orders/7/cancel # trigger cancellation
POST /payments/9/refund # trigger refund
Plural Resource Names
ใช้คำนามพหูพจน์สำหรับ collections เสมอ เอกพจน์สำหรับ singletons (account settings, global config)
/users ✓
/user ✗
/orders ✓
/order ✗
/users/42/profile ✓ (singleton sub-resource — หนึ่ง profile ต่อ user)
Nested Resources
ใช้ nesting เพื่อแสดงความสัมพันธ์ ownership/containment แต่จำกัดที่ สองระดับความลึก การซ้อนที่ลึกกว่านั้นจัดการยาก
/users/:userId/orders # orders ที่เป็นของ user
/users/:userId/orders/:orderId # specific order ที่เป็นของ user
/orders/:orderId/items # line items ภายใน order
เมื่อ resource มี parent ธรรมชาติหลายตัว ให้ prefer flat resource ที่มี query parameters:
# Problematic: /users/:userId/organizations/:orgId/projects/:projectId
# Better:
GET /projects?userId=42&orgId=7
Versioning Strategies
การ version API ตั้งแต่วันแรกช่วยแก้ปัญหาได้มากในภายหลัง สามแนวทางทั่วไป:
| Strategy | ตัวอย่าง | ข้อดี | ข้อเสีย |
|---|---|---|---|
| URI path | /v1/users | มองเห็นได้ง่าย, routing ง่าย, cacheable | ทำลาย REST purity (version ไม่ใช่ resource) |
| Header | Accept: application/vnd.api+json;version=1 | URL สะอาดกว่า, REST compliant | ทดสอบในเบราว์เซอร์ยากกว่า, routing ซับซ้อน |
| Query param | /users?version=1 | ทดสอบง่าย | ทำให้ query params รก, มักถูกละเว้นโดยไม่ตั้งใจ |
URI path versioning ถูกใช้กันมากที่สุดในทางปฏิบัติ:
https://api.example.com/v1/users
https://api.example.com/v2/users
รักษา version เก่าไว้ตามระยะเวลา deprecation ที่บันทึกไว้ เพิ่ม Deprecation และ Sunset header เมื่อ version กำลังจะหมดอายุ:
Deprecation: Mon, 01 Jan 2026 00:00:00 GMT
Sunset: Mon, 01 Jul 2026 00:00:00 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"
Query Parameters
ใช้ query parameters สำหรับ filtering, sorting และ pagination — ไม่ใช่สำหรับการระบุ resources
GET /products?category=electronics&minPrice=100&maxPrice=500
GET /orders?status=pending&createdAfter=2025-01-01
GET /users?sort=lastName&order=asc&page=2&limit=25
GET /users?q=alice # full-text search
GET /users?fields=id,name,email # sparse fieldsets
ส่วนที่ 4: Request และ Response Patterns
Response Envelope vs Flat Response
สองแบบแผนทั่วไปสำหรับการจัดโครงสร้าง JSON responses:
// Flat — ง่ายกว่า, mapping ตรงกับ resource
{
"id": "42",
"name": "Alice",
"email": "alice@example.com"
}
// Envelope — เพิ่ม metadata, พัฒนาได้ง่ายกว่า
{
"data": { "id": "42", "name": "Alice", "email": "alice@example.com" },
"meta": { "requestId": "req-abc123", "version": "1.0" }
}
สำหรับ list endpoints ให้ใช้ envelope เสมอเพื่อพา pagination metadata ไปพร้อมกับ array
RFC 7807 Problem Details
อย่า return bare error strings ใช้รูปแบบ RFC 7807 Problem Details สำหรับ error responses ทั้งหมด มันให้มาตรฐานที่ machine-readable ที่ clients สามารถ parse ได้อย่างสอดคล้อง
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient Funds",
"status": 402,
"detail": "Account balance $12.00 is below the required $50.00",
"instance": "/payments/abc123",
"balance": 12.00,
"required": 50.00
}
Fields:
type— URI ที่ระบุประเภทปัญหา (ควร resolve ไปยัง documentation)title— สรุปย่อที่อ่านได้โดยมนุษย์ (ควรไม่เปลี่ยนระหว่าง occurrences)status— HTTP status code (สะท้อน response status)detail— คำอธิบายที่อ่านได้โดยมนุษย์สำหรับ occurrence นี้โดยเฉพาะinstance— URI ที่ระบุ occurrence นี้โดยเฉพาะ- สมาชิกเพิ่มเติมได้รับอนุญาตและส่งเสริม
sequenceDiagram
participant C as Client
participant S as API Server
participant V as Validator
participant DB as Database
C->>S: POST /payments<br/>{ amount: -50, currency: "XYZ" }
S->>V: Validate request body
V-->>S: amount must be positive<br/>currency XYZ not supported
S-->>C: 422 Unprocessable Entity<br/>{ type: .../validation-error,<br/> title: Validation Failed,<br/> errors: { amount: [...], currency: [...] } }
Note over C: Client fixes request
C->>S: POST /payments<br/>{ amount: 50, currency: "USD" }
S->>V: Validate request body
V-->>S: Valid
S->>DB: Check account balance
DB-->>S: balance = $12.00
S-->>C: 402 Payment Required<br/>{ type: .../insufficient-funds,<br/> title: Insufficient Funds,<br/> detail: balance $12 < required $50 }
Note over C: User tops up account
C->>S: POST /payments<br/>{ amount: 50, currency: "USD" }
S->>DB: Check balance → $100
DB-->>S: Sufficient funds
S->>DB: Process payment
DB-->>S: payment created
S-->>C: 201 Created<br/>Location: /payments/pay_xyz789
Pagination: Cursor-Based vs Offset-Based
Offset-based pagination ง่ายแต่มีปัญหากับ datasets ขนาดใหญ่และการแก้ไขพร้อมกัน:
GET /users?page=3&limit=25
{
"data": [...],
"pagination": {
"page": 3,
"limit": 25,
"total": 1240,
"pages": 50,
"prev": "/users?page=2&limit=25",
"next": "/users?page=4&limit=25"
}
}
Cursor-based pagination ใช้ opaque pointer ไปยัง item สุดท้ายที่เห็น จัดการการ insert/delete ได้อย่างถูกต้องและทำงานได้ดีกว่าใน datasets ขนาดใหญ่:
GET /users?limit=25&after=cursor_dXNlcl8xMDA
{
"data": [...],
"pagination": {
"limit": 25,
"hasMore": true,
"cursors": {
"before": "cursor_dXNlcl85MA",
"after": "cursor_dXNlcl8xMDA"
},
"next": "/users?limit=25&after=cursor_dXNlcl8xMDA"
}
}
sequenceDiagram
participant C as Client
participant S as API Server
participant DB as Database
C->>S: GET /users?limit=25
S->>DB: SELECT * FROM users ORDER BY id ASC LIMIT 26
DB-->>S: 26 rows (one extra to detect hasMore)
S->>S: hasMore = true (got 26, return first 25)<br/>cursor = base64(id of 25th row)
S-->>C: 200 OK<br/>{ data: [25 users], pagination: { hasMore: true, cursors: { after: "cursor_abc" } } }
C->>S: GET /users?limit=25&after=cursor_abc
S->>S: Decode cursor → id = 100
S->>DB: SELECT * FROM users WHERE id > 100 ORDER BY id ASC LIMIT 26
DB-->>S: 18 rows (less than 26, no more pages)
S->>S: hasMore = false
S-->>C: 200 OK<br/>{ data: [18 users], pagination: { hasMore: false } }
เมื่อใดควรใช้อะไร:
| Offset-Based | Cursor-Based | |
|---|---|---|
| Random page access | ใช่ | ไม่ |
| Stable ภายใต้ inserts/deletes | ไม่ | ใช่ |
| Total count ได้ | ใช่ | ไม่ |
| ประสิทธิภาพที่ scale | ลดลง | คงที่ |
| ความซับซ้อนในการ implement | ต่ำ | ปานกลาง |
HATEOAS Links
HATEOAS (Hypermedia As The Engine Of Application State) ฝัง links ไปยัง actions ที่เกี่ยวข้องใน responses ทำให้ API อธิบายตัวเองได้ implement อย่างเลือกสรร — full HATEOAS นั้นแทบไม่คุ้มค่าความซับซ้อน:
{
"id": "42",
"name": "Alice",
"status": "active",
"_links": {
"self": { "href": "/users/42" },
"orders": { "href": "/users/42/orders" },
"deactivate": { "href": "/users/42/deactivate", "method": "POST" }
}
}
Bulk Operations
สำหรับการ operate บน resources หลายตัวพร้อมกัน ใช้ dedicated bulk endpoint:
POST /users/bulk
Content-Type: application/json
{
"operations": [
{ "op": "create", "data": { "name": "Bob", "email": "bob@example.com" } },
{ "op": "update", "id": "42", "data": { "role": "admin" } },
{ "op": "delete", "id": "99" }
]
}
Return 207 Multi-Status พร้อม per-operation results เมื่อ operations อาจ fail บางส่วน:
{
"results": [
{ "op": "create", "status": 201, "data": { "id": "101", "name": "Bob" } },
{ "op": "update", "id": "42", "status": 200, "data": { "id": "42", "role": "admin" } },
{ "op": "delete", "id": "99", "status": 404, "error": { "title": "User Not Found" } }
]
}
ส่วนที่ 5: HTTP Status Codes
2xx Success
| Code | ชื่อ | Use Case |
|---|---|---|
| 200 | OK | GET, PUT, PATCH สำเร็จ; หรือ POST เมื่อไม่ได้สร้าง |
| 201 | Created | POST/PUT สำเร็จที่สร้าง resource ใหม่ |
| 202 | Accepted | Request ยอมรับสำหรับ async processing |
| 204 | No Content | DELETE สำเร็จ; PUT/PATCH ที่ไม่ return body |
| 207 | Multi-Status | Bulk operations ที่มีผลลัพธ์ผสม |
4xx Client Errors
| Code | ชื่อ | Use Case |
|---|---|---|
| 400 | Bad Request | Malformed request syntax, invalid JSON |
| 401 | Unauthorized | Missing หรือ invalid authentication credentials |
| 403 | Forbidden | Authenticated แต่ไม่ได้รับอนุญาตสำหรับ resource นี้ |
| 404 | Not Found | Resource ไม่มีอยู่ |
| 405 | Method Not Allowed | HTTP method ไม่รองรับสำหรับ endpoint นี้ |
| 409 | Conflict | State conflict (duplicate, version mismatch) |
| 410 | Gone | Resource ถูกลบอย่างถาวร (เคยอยู่ที่นี่ ตอนนี้หายไปแล้ว) |
| 415 | Unsupported Media Type | Content-Type header ผิด |
| 422 | Unprocessable Entity | Request ที่ถูกต้องทาง syntax แต่ semantic validation ล้มเหลว |
| 429 | Too Many Requests | เกิน rate limit |
401 vs 403: 401 หมายถึง “คุณคือใคร?” (พิสูจน์ตัวตน) 403 หมายถึง “ฉันรู้ว่าคุณเป็นใคร แต่คุณทำสิ่งนี้ไม่ได้” อย่า return 404 เพื่อซ่อนการมีอยู่ของ resource ที่ client ไม่มีสิทธิ์เข้าถึง — นั่นคือ 403
5xx Server Errors
| Code | ชื่อ | Use Case |
|---|---|---|
| 500 | Internal Server Error | Unhandled exception; อย่า leak stack traces |
| 502 | Bad Gateway | Upstream service return invalid response |
| 503 | Service Unavailable | Server โหลดเกินหรืออยู่ในโหมด maintenance ชั่วคราว |
| 504 | Gateway Timeout | Upstream service ไม่ตอบสนองในเวลา |
ส่วนที่ 6: Complete CRUD Implementation
นี่คือ Users API ที่มีฟีเจอร์ครบถ้วนที่รวม patterns ทั้งหมดข้างต้น — รวมถึง validation, pagination, error handling และ soft delete
// TypeScript — Express with Zod validation
import express, { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
const router = Router();
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['user', 'admin']).default('user'),
});
const patchUserSchema = createUserSchema.partial();
// GET /users — list with filtering, sorting, cursor pagination
router.get('/', async (req: Request, res: Response) => {
const { limit = '25', after, role, q } = req.query;
const safeLimit = Math.min(Number(limit), 100);
const cursor = after ? Buffer.from(String(after), 'base64').toString() : undefined;
const filters = {
...(role && { role: String(role) }),
...(q && { search: String(q) }),
...(cursor && { afterId: cursor }),
};
const users = await userService.listUsers(filters, safeLimit + 1);
const hasMore = users.length > safeLimit;
const page = hasMore ? users.slice(0, safeLimit) : users;
const nextCursor = hasMore ? Buffer.from(page[page.length - 1].id).toString('base64') : null;
res.status(200).json({
data: page,
pagination: {
limit: safeLimit,
hasMore,
cursors: { after: nextCursor },
...(nextCursor && { next: `/users?limit=${safeLimit}&after=${nextCursor}` }),
},
});
});
// POST /users
router.post('/', async (req: Request, res: Response) => {
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({ type: 'https://api.example.com/errors/unprocessable-entity', title: 'Validation Failed', status: 422, errors: parsed.error.flatten().fieldErrors, instance: req.path });
}
const user = await userService.createUser(parsed.data);
res.status(201).set('Location', `/users/${user.id}`).json(user);
});
// PATCH /users/:id
router.patch('/:id', async (req: Request, res: Response) => {
const parsed = patchUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({ type: 'https://api.example.com/errors/unprocessable-entity', title: 'Validation Failed', status: 422, errors: parsed.error.flatten().fieldErrors });
}
const updated = await userService.patchUser(req.params.id, parsed.data);
if (!updated) return res.status(404).json({ type: 'https://api.example.com/errors/not-found', title: 'User Not Found', status: 404 });
res.status(200).json(updated);
});
// DELETE /users/:id
router.delete('/:id', async (req: Request, res: Response) => {
const deleted = await userService.softDeleteUser(req.params.id);
if (!deleted) return res.status(404).json({ type: 'https://api.example.com/errors/not-found', title: 'User Not Found', status: 404 });
res.status(204).send();
});
// Global error handler
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
console.error(err);
res.status(500).json({ type: 'https://api.example.com/errors/internal', title: 'Internal Server Error', status: 500 });
});// Java — Spring Boot with Bean Validation
@RestController
@RequestMapping("/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Map<String, Object>> listUsers(
@RequestParam(defaultValue = "25") int limit,
@RequestParam(required = false) String after,
@RequestParam(required = false) String role,
@RequestParam(required = false) String q) {
int safeLimit = Math.min(limit, 100);
String cursor = after != null
? new String(Base64.getDecoder().decode(after))
: null;
UserFilters filters = UserFilters.builder()
.role(role).search(q).afterId(cursor).build();
List<User> users = userService.listUsers(filters, safeLimit + 1);
boolean hasMore = users.size() > safeLimit;
List<User> page = hasMore ? users.subList(0, safeLimit) : users;
String nextCursor = hasMore
? Base64.getEncoder().encodeToString(page.get(page.size() - 1).getId().getBytes())
: null;
Map<String, Object> pagination = new LinkedHashMap<>();
pagination.put("limit", safeLimit);
pagination.put("hasMore", hasMore);
if (nextCursor != null) {
pagination.put("cursors", Map.of("after", nextCursor));
pagination.put("next", "/v1/users?limit=" + safeLimit + "&after=" + nextCursor);
}
return ResponseEntity.ok(Map.of("data", page, "pagination", pagination));
}
@PostMapping
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest body) {
User user = userService.createUser(body);
return ResponseEntity.status(201)
.header("Location", "/v1/users/" + user.getId())
.body(user);
}
@PatchMapping(value = "/{id}", consumes = "application/merge-patch+json")
public ResponseEntity<?> patchUser(@PathVariable String id,
@RequestBody Map<String, Object> patch) {
return userService.patchUser(id, patch)
.map(u -> ResponseEntity.ok((Object) u))
.orElse(ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "User Not Found", "status", 404)));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable String id) {
if (!userService.softDeleteUser(id))
return ResponseEntity.status(404).body(Map.of(
"type", "https://api.example.com/errors/not-found",
"title", "User Not Found", "status", 404));
return ResponseEntity.noContent().build();
}
}# Python — FastAPI with Pydantic v2
from fastapi import APIRouter, Query, HTTPException, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, EmailStr
from typing import Optional
import base64
router = APIRouter(prefix="/v1/users", tags=["users"])
class CreateUserRequest(BaseModel):
name: str
email: EmailStr
role: str = "user"
class PatchUserRequest(BaseModel):
name: Optional[str] = None
email: Optional[EmailStr] = None
role: Optional[str] = None
def encode_cursor(value: str) -> str:
return base64.b64encode(value.encode()).decode()
def decode_cursor(cursor: str) -> str:
return base64.b64decode(cursor.encode()).decode()
@router.get("")
async def list_users(
limit: int = Query(25, ge=1, le=100),
after: Optional[str] = None,
role: Optional[str] = None,
q: Optional[str] = None,
):
cursor_id = decode_cursor(after) if after else None
filters = {"role": role, "search": q, "after_id": cursor_id}
users = await user_service.list_users(filters, limit + 1)
has_more = len(users) > limit
page = users[:limit]
next_cursor = encode_cursor(page[-1].id) if has_more else None
return {
"data": page,
"pagination": {
"limit": limit,
"hasMore": has_more,
"cursors": {"after": next_cursor} if next_cursor else {},
**({"next": f"/v1/users?limit={limit}&after={next_cursor}"} if next_cursor else {}),
},
}
@router.post("", status_code=201)
async def create_user(body: CreateUserRequest, response: Response):
user = await user_service.create_user(body.model_dump())
response.headers["Location"] = f"/v1/users/{user.id}"
return user
@router.patch("/{user_id}")
async def patch_user(user_id: str, body: PatchUserRequest):
patch = body.model_dump(exclude_unset=True)
updated = await user_service.patch_user(user_id, patch)
if not updated:
raise HTTPException(status_code=404, detail={"type": "https://api.example.com/errors/not-found", "title": "User Not Found", "status": 404})
return updated
@router.delete("/{user_id}", status_code=204)
async def delete_user(user_id: str):
deleted = await user_service.soft_delete_user(user_id)
if not deleted:
raise HTTPException(status_code=404, detail={"type": "https://api.example.com/errors/not-found", "title": "User Not Found", "status": 404})// C# — ASP.NET Core with FluentValidation
[ApiController]
[Route("v1/users")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
public UsersController(IUserService userService) => _userService = userService;
[HttpGet]
public async Task<IActionResult> ListUsers(
[FromQuery] int limit = 25,
[FromQuery] string? after = null,
[FromQuery] string? role = null,
[FromQuery] string? q = null)
{
var safeLimit = Math.Min(limit, 100);
var cursor = after != null
? System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(after))
: null;
var filters = new UserFilters { Role = role, Search = q, AfterId = cursor };
var users = await _userService.ListUsersAsync(filters, safeLimit + 1);
var hasMore = users.Count > safeLimit;
var page = hasMore ? users.Take(safeLimit).ToList() : users;
var nextCursor = hasMore
? Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(page.Last().Id))
: null;
return Ok(new
{
data = page,
pagination = new
{
limit = safeLimit,
hasMore,
cursors = nextCursor != null ? new { after = nextCursor } : null,
next = nextCursor != null ? $"/v1/users?limit={safeLimit}&after={nextCursor}" : null
}
});
}
[HttpPost]
public async Task<IActionResult> CreateUser([FromBody] CreateUserRequest body)
{
if (!ModelState.IsValid) return UnprocessableEntity(ModelState);
var user = await _userService.CreateUserAsync(body);
return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
}
[HttpPatch("{id}")]
public async Task<IActionResult> PatchUser(string id, [FromBody] JsonElement patch)
{
var updated = await _userService.PatchUserAsync(id, patch);
return updated != null ? Ok(updated) : NotFound(new { type = "https://api.example.com/errors/not-found", title = "User Not Found", status = 404 });
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteUser(string id)
{
var deleted = await _userService.SoftDeleteUserAsync(id);
return deleted ? NoContent() : NotFound(new { type = "https://api.example.com/errors/not-found", title = "User Not Found", status = 404 });
}
}ส่วนที่ 7: Security Best Practices
Input Validation และ Sanitization
อย่าเชื่อถือข้อมูลที่ client ให้มา Validate ที่ boundary ก่อนที่มันจะสัมผัส business logic หรือฐานข้อมูลใดๆ
- Schema validation — ใช้ Zod (TS), Bean Validation (Java), Pydantic (Python) หรือ FluentValidation (C#) เพื่อ enforce types, ranges และรูปแบบ
- Parameterized queries — อย่า interpolate user input ลงใน SQL เสมอ; ใช้ prepared statements หรือ ORM เสมอ
- Size limits — cap request body size (เช่น 1MB) และความยาว array เพื่อป้องกัน DoS
- Sanitize output — strip หรือ encode HTML ใน text fields เพื่อป้องกัน XSS ใน clients ที่ render raw API output
# Nginx: enforce request body size
client_max_body_size 1m;
Rate Limiting
ปกป้องทุก endpoint จากการใช้งานในทางที่ผิด Return 429 Too Many Requests พร้อม Retry-After header:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714300800
{
"type": "https://api.example.com/errors/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "You have exceeded 100 requests per minute. Try again after 60 seconds."
}
ใช้ token bucket หรือ sliding window algorithm ใช้ limits ที่แตกต่างกันต่อ tier (anonymous, authenticated, premium) implement rate limiting ที่ API gateway layer (Kong, AWS API Gateway, nginx) แทนที่ application code เมื่อเป็นไปได้
Authentication Headers
อย่าส่ง credentials ใน URL path หรือ query string — พวกมันปรากฏใน logs และ browser history
# Correct — credentials ใน Authorization header
GET /users HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
Common schemes:
Bearer <token>— JWT หรือ opaque OAuth 2.0 tokenBasic <base64(user:pass)>— เฉพาะผ่าน HTTPS เท่านั้น เฉพาะสำหรับ machine-to-machineApiKey <key>— simple API key (มักอยู่ในX-API-Keyheader แทน)
Validate token ในทุก request อย่า log tokens ตั้ง expiry times สั้นบน access tokens (15 นาที) และใช้ refresh tokens สำหรับ sessions ที่ใช้งานนาน
CORS Configuration
ระบุอย่างชัดเจน อย่าตั้งค่า Access-Control-Allow-Origin: * สำหรับ credentialed requests
# Restrictive CORS — อนุญาตเฉพาะ origins ที่รู้จัก
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
รักษา allowlist ของ trusted origins และ set header แบบ dynamic จาก list นั้น พร้อม log unauthorized origins
Additional Hardening
# Security headers ทุก API ควร return
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Content-Security-Policy: default-src 'none'
Cache-Control: no-store # สำหรับ sensitive endpoints
ส่วนที่ 8: API Documentation ด้วย OpenAPI
OpenAPI (เดิมชื่อ Swagger) คือมาตรฐานอุตสาหกรรมสำหรับการอธิบาย REST APIs กำหนด API ใน YAML หรือ JSON จากนั้น generate interactive documentation, client SDKs และ server stubs โดยอัตโนมัติ
openapi: 3.1.0
info:
title: Users API
version: 1.0.0
description: Manage users with full CRUD support
paths:
/v1/users:
get:
summary: List users
operationId: listUsers
parameters:
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
- name: after
in: query
schema: { type: string, description: Cursor for pagination }
- name: role
in: query
schema: { type: string, enum: [user, admin] }
responses:
'200':
description: Paginated list of users
content:
application/json:
schema: { $ref: '#/components/schemas/UserListResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { $ref: '#/components/responses/RateLimitExceeded' }
post:
summary: Create a user
operationId: createUser
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateUserRequest' }
responses:
'201':
description: User created
headers:
Location:
schema: { type: string }
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
'409': { $ref: '#/components/responses/Conflict' }
'422': { $ref: '#/components/responses/ValidationError' }
components:
schemas:
User:
type: object
properties:
id: { type: string }
name: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [user, admin] }
createdAt: { type: string, format: date-time }
CreateUserRequest:
type: object
required: [name, email]
properties:
name: { type: string, minLength: 1, maxLength: 100 }
email: { type: string, format: email }
role: { type: string, enum: [user, admin], default: user }
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []
Tooling:
- Swagger UI / Redoc — interactive browser-based documentation จาก OpenAPI spec ของคุณ
- Spectral — lint OpenAPI spec ของคุณกับ best-practice rules
- OpenAPI Generator — generate client SDKs ใน 50+ ภาษา
- Prism — mock server จาก spec ของคุณสำหรับ frontend development ก่อน backend พร้อม
สรุป
REST API ที่ออกแบบมาอย่างดีมีความสอดคล้อง คาดการณ์ได้ และยากที่จะใช้ผิด หลักการที่ครอบคลุมในคู่มือนี้รวมกัน:
| พื้นที่ | การตัดสินใจหลัก |
|---|---|
| HTTP Methods | ใช้ GET/POST/PUT/PATCH/DELETE ด้วย semantics ที่ถูกต้อง |
| URL Structure | Plural nouns, 2-level nesting, path versioning |
| Status Codes | 2xx สำหรับ success, 4xx สำหรับ client error, 5xx สำหรับ server error |
| Error Format | RFC 7807 Problem Details ทุกที่ |
| Pagination | Cursor-based สำหรับ datasets ขนาดใหญ่, offset สำหรับขนาดเล็ก |
| PUT vs PATCH | PUT = full replacement, PATCH = partial update (RFC 7396) |
| Security | Validate input, rate limit, ใช้ Authorization header, restrict CORS |
| Documentation | OpenAPI spec — นี่คือ contract ของคุณ |
ผลประโยชน์ในทางปฏิบัติที่ใหญ่ที่สุดมาจากความสอดคล้อง: เลือก convention สำหรับการตัดสินใจแต่ละอย่างข้างต้น, บันทึกไว้ใน team API style guide และ enforce ผ่าน linting (Spectral), code review และ contract tests APIs ที่ทำงานได้อย่างสอดคล้องกันทุก endpoints นั้นง่ายกว่ามากในการ consume, debug และ evolve