กลับไปที่บทความ
API Backend Architecture REST

การออกแบบ RESTful API Endpoints: Best Practices สำหรับ HTTP Methods และโครงสร้าง URL

พลากร วรมงคล
28 เมษายน 2568 18 นาที

“คู่มือครบวงจรสำหรับการออกแบบ RESTful API — ครอบคลุม HTTP Methods (GET, POST, PUT, PATCH, DELETE) โครงสร้าง URL, Status Codes, รูปแบบ Pagination, Error Handling ด้วย RFC 7807 และการ Implement จริงใน TypeScript, Java, Python และ C#”

การออกแบบ 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 และคาดการณ์ได้:

  1. Client-Server — ความกังวลของ UI และการจัดเก็บข้อมูลถูกแยกออกจากกัน; พวกมันพัฒนาอย่างเป็นอิสระ
  2. Statelessness — ทุก request มีข้อมูลทั้งหมดที่ server ต้องการ; ไม่มีการเก็บ session state ฝั่ง server
  3. Cacheability — responses ต้องกำหนดตัวเองว่า cacheable หรือไม่ เพื่อป้องกันไม่ให้ client ใช้ข้อมูลที่หมดอายุ
  4. Uniform Interface — วิธีที่สอดคล้องกันในการโต้ตอบกับทุก resource สร้างบน sub-constraint สี่ข้อ: identification ของ resources, การจัดการผ่าน representations, self-descriptive messages และ HATEOAS
  5. Layered System — client ไม่สามารถบอกได้ว่าเชื่อมต่อกับ end server หรือตัวกลาง (load balancer, cache, gateway)
  6. 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 resourceGET /users
Singleton resourceGET /users/42
Sub-resource collectionGET /users/42/orders
Sub-resource singletonGET /users/42/orders/7
Action on resourcePOST /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)
MethodSafeIdempotentRequest BodyResponse ปกติ
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 ยัง return 204; 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 /getUserGET /users/42
POST /createOrderPOST /orders
POST /deleteAccountDELETE /accounts/42
POST /updateUserProfilePATCH /users/42/profile
POST /searchProductsGET /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)
HeaderAccept: application/vnd.api+json;version=1URL สะอาดกว่า, 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-BasedCursor-Based
Random page accessใช่ไม่
Stable ภายใต้ inserts/deletesไม่ใช่
Total count ได้ใช่ไม่
ประสิทธิภาพที่ scaleลดลงคงที่
ความซับซ้อนในการ implementต่ำปานกลาง

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
200OKGET, PUT, PATCH สำเร็จ; หรือ POST เมื่อไม่ได้สร้าง
201CreatedPOST/PUT สำเร็จที่สร้าง resource ใหม่
202AcceptedRequest ยอมรับสำหรับ async processing
204No ContentDELETE สำเร็จ; PUT/PATCH ที่ไม่ return body
207Multi-StatusBulk operations ที่มีผลลัพธ์ผสม

4xx Client Errors

Codeชื่อUse Case
400Bad RequestMalformed request syntax, invalid JSON
401UnauthorizedMissing หรือ invalid authentication credentials
403ForbiddenAuthenticated แต่ไม่ได้รับอนุญาตสำหรับ resource นี้
404Not FoundResource ไม่มีอยู่
405Method Not AllowedHTTP method ไม่รองรับสำหรับ endpoint นี้
409ConflictState conflict (duplicate, version mismatch)
410GoneResource ถูกลบอย่างถาวร (เคยอยู่ที่นี่ ตอนนี้หายไปแล้ว)
415Unsupported Media TypeContent-Type header ผิด
422Unprocessable EntityRequest ที่ถูกต้องทาง syntax แต่ semantic validation ล้มเหลว
429Too Many Requestsเกิน rate limit

401 vs 403: 401 หมายถึง “คุณคือใคร?” (พิสูจน์ตัวตน) 403 หมายถึง “ฉันรู้ว่าคุณเป็นใคร แต่คุณทำสิ่งนี้ไม่ได้” อย่า return 404 เพื่อซ่อนการมีอยู่ของ resource ที่ client ไม่มีสิทธิ์เข้าถึง — นั่นคือ 403

5xx Server Errors

Codeชื่อUse Case
500Internal Server ErrorUnhandled exception; อย่า leak stack traces
502Bad GatewayUpstream service return invalid response
503Service UnavailableServer โหลดเกินหรืออยู่ในโหมด maintenance ชั่วคราว
504Gateway TimeoutUpstream 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 token
  • Basic <base64(user:pass)> — เฉพาะผ่าน HTTPS เท่านั้น เฉพาะสำหรับ machine-to-machine
  • ApiKey <key> — simple API key (มักอยู่ใน X-API-Key header แทน)

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 StructurePlural nouns, 2-level nesting, path versioning
Status Codes2xx สำหรับ success, 4xx สำหรับ client error, 5xx สำหรับ server error
Error FormatRFC 7807 Problem Details ทุกที่
PaginationCursor-based สำหรับ datasets ขนาดใหญ่, offset สำหรับขนาดเล็ก
PUT vs PATCHPUT = full replacement, PATCH = partial update (RFC 7396)
SecurityValidate input, rate limit, ใช้ Authorization header, restrict CORS
DocumentationOpenAPI spec — นี่คือ contract ของคุณ

ผลประโยชน์ในทางปฏิบัติที่ใหญ่ที่สุดมาจากความสอดคล้อง: เลือก convention สำหรับการตัดสินใจแต่ละอย่างข้างต้น, บันทึกไว้ใน team API style guide และ enforce ผ่าน linting (Spectral), code review และ contract tests APIs ที่ทำงานได้อย่างสอดคล้องกันทุก endpoints นั้นง่ายกว่ามากในการ consume, debug และ evolve

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

เขียนโดย พลากร วรมงคล

Software Engineer Specialist ประสบการณ์กว่า 20 ปี เขียนเกี่ยวกับ Architecture, Performance และการสร้างระบบ Production

เพิ่มเติมเกี่ยวกับผม

บทความที่เกี่ยวข้อง