OpenAPI สำหรับงานจริง: ออกแบบ API แบบ contract-first ให้ทีมคุยกันรู้เรื่อง
หลายทีมเริ่มทำ API จากการเขียน route ก่อน แล้วค่อยตามมาเขียน documentation ทีหลัง หรือบางครั้งก็ไม่มี documentation ที่อัปเดตตามโค้ดจริงเลย ผลลัพธ์ที่เกิดขึ้นคือ frontend เรียก field ไม่ตรง backend, QA ไม่แน่ใจว่าค่าไหนบังคับหรือไม่บังคับ, ฝั่ง integration อ่านจาก wiki เก่าแล้วใช้งานผิด และเมื่อมีการเปลี่ยน shape ของ response ก็เริ่มกระทบหลายจุดพร้อมกันโดยไม่มีใครเห็นภาพรวม
ปัญหาแบบนี้ไม่ได้เกิดจากทีมเขียนโค้ดไม่เก่ง แต่เกิดจากระบบยังไม่มี contract กลาง ที่ทุกฝ่ายใช้ยึดร่วมกันอย่างจริงจัง
OpenAPI เข้ามาแก้ปัญหานี้โดยทำให้ API ไม่ใช่แค่ route ที่รันได้ แต่เป็น ข้อตกลงที่ตรวจสอบได้ ว่า endpoint นี้รับอะไร ส่งอะไร, field ไหนจำเป็น, status code ไหนควรเกิด, auth แบบไหนถูกต้อง, และเมื่อมีการเปลี่ยนแปลงจะกระทบใครบ้าง
บทความนี้จะพาไปดู OpenAPI ในมุมของงานจริง เน้นวิธีคิดแบบ contract-first และตัวอย่างโค้ดที่ทีมเอาไปปรับใช้ต่อได้ทันที
OpenAPI คืออะไร
OpenAPI คือมาตรฐานสำหรับอธิบาย API ในรูปแบบ machine-readable โดยทั่วไปจะเขียนเป็น YAML หรือ JSON จุดสำคัญไม่ใช่แค่ “มีเอกสาร” แต่คือเอกสารนั้นสามารถถูกนำไปใช้ต่อได้หลายทาง เช่น
- render หน้า docs ให้ทีมอ่าน
- generate client SDK
- ใช้ตรวจ schema ของ request/response
- ใช้เทียบ breaking change ระหว่าง version
- ใช้เป็น input สำหรับ mock server หรือ contract testing
พูดอีกแบบคือ OpenAPI ทำให้ API spec กลายเป็น artifact ที่ใช้งานได้จริง ไม่ใช่แค่ข้อความอธิบาย
ทำไมงานจริงควรคิดแบบ contract-first
แนวคิด contract-first คือเขียนหรือกำหนด spec ก่อน แล้วให้ implementation วิ่งตาม spec แทนที่จะปล่อยให้โค้ดเป็นต้นฉบับเพียงอย่างเดียว
ข้อดีในงานจริงมีชัดมาก
1) frontend กับ backend คุยกันได้เร็วขึ้น
ก่อน backend ทำเสร็จ frontend ก็อ่าน spec แล้วเริ่มทำ integration layer หรือ mock data ได้เลย
2) ลดข้อถกเถียงเรื่อง field
เวลาไม่มี spec กลาง การคุยจะกลายเป็น “นึกว่า endpoint นี้ส่งมาแบบนั้น” แต่ถ้ามี schema ชัด ทุกคนดูเอกสารชุดเดียวกัน
3) ลด regression ตอนแก้ response
ถ้าระบบมี validation และ contract diff checking จะเห็นทันทีว่าเปลี่ยน field แบบ breaking หรือไม่
4) QA และ integrator ทำงานง่ายขึ้น
ทีมทดสอบเห็นได้ว่าควรลองค่าอะไรบ้าง, required field คืออะไร, status code ที่ควรคาดหวังคืออะไร
โครงสร้างสำคัญของ OpenAPI
ตัวอย่างไฟล์ OpenAPI แบบย่อ:
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
servers:
- url: https://api.example.com
paths:
/orders:
post:
summary: Create an order
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequest'
responses:
'201':
description: Order created
content:
application/json:
schema:
$ref: '#/components/schemas/OrderResponse'
components:
schemas:
CreateOrderRequest:
type: object
required: [customerId, items]
properties:
customerId:
type: string
items:
type: array
minItems: 1
items:
type: object
required: [sku, quantity]
properties:
sku:
type: string
quantity:
type: integer
minimum: 1
OrderResponse:
type: object
required: [id, status]
properties:
id:
type: string
status:
type: string
enum: [pending, confirmed, cancelled]
ส่วนที่ควรรู้ให้แม่นคือ
infoอธิบายชื่อ API และ versionserversระบุ base URLpathsระบุ endpoint และ methodrequestBodyอธิบาย payload ที่รับresponsesอธิบายผลลัพธ์ที่ส่งกลับcomponents.schemasรวม schema ที่ reuse ได้
หลักคิดก่อนลงมือเขียน spec
หลายทีมพลาดตรงที่รีบเขียน YAML ก่อนตอบคำถามเชิงออกแบบให้ชัด ทำให้ spec สวยแต่ใช้งานจริงลำบาก
ก่อนเขียน ควรถามให้ชัดว่า
- resource หลักของระบบคืออะไร
- endpoint นี้แทน action หรือ resource state
- response ต้องเสถียรแค่ไหน
- field ไหนเป็น internal field ที่ไม่ควร expose
- validation rule ไหนควรอยู่ใน contract และ rule ไหนอยู่ใน business logic
- breaking change แบบไหนยอมรับได้หรือไม่ได้
ตัวอย่างเช่น ถ้ากำลังทำระบบ booking อย่าเพิ่งเริ่มจาก route ยาว ๆ แบบ
POST /create-booking-for-customer
แต่ควรถอยมาคิดก่อนว่า resource หลักคือ bookings และ operation หลักคือ create, read, update status, cancel หรือไม่
แนวนี้จะทำให้ API โตต่อได้ง่ายกว่า เช่น
POST /bookings
GET /bookings/{bookingId}
POST /bookings/{bookingId}/cancel
ตั้งชื่อ schema และ field ให้คงเส้นคงวา
OpenAPI ช่วยไม่ได้ถ้าทีมตั้งชื่อไม่เป็นระบบ
ตัวอย่างที่ควรระวัง:
- endpoint หนึ่งใช้
customerIdแต่อีก endpoint ใช้customer_id - response หนึ่งใช้
createdAtอีกอันใช้created_at - บางที่ส่ง
true/falseแต่บางที่ใช้YES/NO
ในงานจริงควรกำหนด style กลาง เช่น
- JSON field ใช้
camelCase - path parameter ใช้ชื่อชัด เช่น
bookingId,orderId - วันเวลาใช้ ISO 8601
- enum ต้องกำหนดค่าตายตัวและอธิบายความหมาย
ตัวอย่าง schema ที่ชัด:
components:
schemas:
BookingStatus:
type: string
enum:
- pending_payment
- confirmed
- assigned
- in_progress
- completed
- cancelled
การแยก enum ออกมาเป็น schema กลางช่วยให้หลาย endpoint ใช้สถานะชุดเดียวกันได้
แยกให้ชัดระหว่าง validation ใน contract กับ business rule
นี่เป็นจุดที่สำคัญมาก
OpenAPI เหมาะกับการบอกว่า payload ต้องมีรูปแบบอะไร เช่น
- เป็น string หรือ integer
- ต้องมี field ไหนบ้าง
- min/max length เท่าไร
- enum อะไรบ้าง
- format เป็น email หรือ date-time หรือไม่
แต่กฎอย่างเช่น
- ลูกค้าคนนี้ถูก blacklist หรือไม่
- worker คนนี้รับงานซ้อนเวลาได้หรือไม่
- booking นี้ยกเลิกได้หลังชำระเงินหรือไม่
สิ่งเหล่านี้เป็น business rule ไม่ใช่ schema validation ธรรมดา อย่าพยายามยัดทุกอย่างลง OpenAPI เพราะสุดท้าย spec จะรกและคนอ่านจะสับสน
ตัวอย่าง OpenAPI สำหรับ endpoint สมัคร inquiry
openapi: 3.1.0
info:
title: Inquiry API
version: 1.0.0
paths:
/api/v1/inquiries:
post:
summary: Create a new inquiry
tags: [Inquiries]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateInquiryRequest'
responses:
'201':
description: Inquiry created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/CreateInquiryResponse'
'400':
description: Validation failed
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
schemas:
CreateInquiryRequest:
type: object
required:
- name
- businessType
- contact
- websiteType
- goal
properties:
name:
type: string
minLength: 2
maxLength: 120
businessType:
type: string
minLength: 2
maxLength: 120
contact:
type: string
minLength: 3
maxLength: 255
websiteType:
type: string
minLength: 2
maxLength: 120
goal:
type: string
minLength: 2
maxLength: 500
pageEstimate:
type: string
maxLength: 120
features:
type: string
maxLength: 1000
existingWebsite:
type: string
maxLength: 255
budget:
type: string
maxLength: 120
timeline:
type: string
maxLength: 120
details:
type: string
maxLength: 3000
CreateInquiryResponse:
type: object
required: [success, inquiry]
properties:
success:
type: boolean
inquiry:
type: object
required: [id, createdAt]
properties:
id:
type: string
createdAt:
type: string
format: date-time
ErrorResponse:
type: object
required: [success, error]
properties:
success:
type: boolean
example: false
error:
type: object
required: [code, message]
properties:
code:
type: string
message:
type: string
จุดสำคัญคือ request/response ถูกนิยามชัด ไม่ต้องเดาว่าฟิลด์ไหนอาจจะมีหรือไม่มี
ใช้ OpenAPI กับ Express/Node.js อย่างไร
ในโลก Node.js มีได้หลายแนว แต่แนวที่ใช้ได้จริงบ่อยคือ
- เขียนไฟล์ spec แยกไว้เอง
- ใช้ middleware validate request จาก spec
- ใช้ Swagger UI แสดง docs
- ทำ test ให้เช็คว่า implementation ยังตรงกับ contract
ตัวอย่างโครงสร้างไฟล์
src/
app.ts
routes/
inquiries.ts
openapi/
openapi.yaml
ติดตั้ง package
npm install express swagger-ui-express yamljs express-openapi-validator
app.ts
import express from "express";
import swaggerUi from "swagger-ui-express";
import YAML from "yamljs";
import * as OpenApiValidator from "express-openapi-validator";
import { inquiryRouter } from "./routes/inquiries";
const app = express();
const apiSpec = YAML.load("./openapi/openapi.yaml");
app.use(express.json());
app.use(
OpenApiValidator.middleware({
apiSpec: "./openapi/openapi.yaml",
validateRequests: true,
validateResponses: false,
})
);
app.use("/docs", swaggerUi.serve, swaggerUi.setup(apiSpec));
app.use("/api/v1/inquiries", inquiryRouter);
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(err.status || 500).json({
success: false,
error: {
code: err.code || "INTERNAL_ERROR",
message: err.message || "Unexpected error",
details: err.errors || undefined,
},
});
});
export { app };
routes/inquiries.ts
import { Router } from "express";
const inquiryRouter = Router();
inquiryRouter.post("/", async (req, res, next) => {
try {
const payload = req.body;
const inquiry = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
...payload,
};
return res.status(201).json({
success: true,
inquiry: {
id: inquiry.id,
createdAt: inquiry.createdAt,
},
});
} catch (error) {
return next(error);
}
});
export { inquiryRouter };
เมื่อมี request ที่ไม่ตรง schema เช่น name สั้นเกินไป หรือไม่มี goal middleware จะ reject ก่อนเข้า business logic ทำให้ validation กลายเป็นระบบกลางแทนการเขียนเช็คกระจัดกระจายทุก route
ควร validate response ด้วยไหม
ในระบบที่เริ่มมีหลายคนแก้ backend พร้อมกัน การเปิด response validation ในบาง environment มีประโยชน์มาก เพราะช่วยจับได้ว่า implementation เผลอส่ง field ผิด type หรือส่ง structure ไม่ตรง contract
แต่ใน production ต้องพิจารณาเรื่อง overhead ด้วย บางทีมจะเปิดเฉพาะ test/staging เพื่อจับปัญหาก่อน deploy
แนวทางที่ใช้งานได้จริงคือ
- validate request ใน runtime เสมอ
- validate response ใน test หรือ staging
- ใช้ schema-based tests กัน regression
การแยก spec ให้อ่านง่ายเมื่อระบบเริ่มโต
พอ API ใหญ่ขึ้น ไม่ควรยัดทุกอย่างไว้ไฟล์เดียวจนอ่านยาก ควรแยกเป็นหลายไฟล์ เช่น
openapi/
openapi.yaml
paths/
inquiries.yaml
bookings.yaml
payments.yaml
schemas/
inquiry.yaml
booking.yaml
error.yaml
ตัวอย่าง openapi.yaml แบบรวม reference:
openapi: 3.1.0
info:
title: Internal Platform API
version: 1.0.0
paths:
/api/v1/inquiries:
$ref: './paths/inquiries.yaml#/inquiriesCollection'
components:
schemas:
CreateInquiryRequest:
$ref: './schemas/inquiry.yaml#/CreateInquiryRequest'
CreateInquiryResponse:
$ref: './schemas/inquiry.yaml#/CreateInquiryResponse'
ErrorResponse:
$ref: './schemas/error.yaml#/ErrorResponse'
การแยกแบบนี้ช่วยให้แต่ละโดเมนดูแลง่ายขึ้นและ review ง่ายกว่ามาก
เวอร์ชันของ API ควรจัดการอย่างไร
OpenAPI ไม่ได้แก้เรื่อง versioning ให้อัตโนมัติ แต่ช่วยทำให้การเปลี่ยนแปลงถูกมองเห็นชัดขึ้น
หลักคิดที่ควรใช้คือ
- เพิ่ม optional field มักเป็น non-breaking
- ลบ field, เปลี่ยน type, เปลี่ยน enum เป็น breaking
- เปลี่ยนความหมายของ field เดิมแม้ชื่อไม่เปลี่ยน ก็ถือว่าอันตราย
ตัวอย่าง breaking change ที่เจอบ่อย:
{
"status": "paid"
}
แล้วเปลี่ยนเป็น
{
"paymentStatus": "paid"
}
frontend เดิมพังทันทีถ้ายังอ่าน status
เพราะฉะนั้นควรมีวินัยว่าเมื่อมี breaking change จริง ให้
- แตก version เช่น
/api/v2/... - หรือทำ deprecation period ก่อนถอด field เก่า
- บันทึก changelog ให้ integrator ตามได้
ตัวอย่าง error response ที่ควรมาตรฐาน
หลายระบบเอกสารสวย แต่ error response ไม่สม่ำเสมอ จน frontend ต้องเขียนเงื่อนไขพิเศษเต็มไปหมด
ควรกำหนด shape กลาง เช่น
ErrorResponse:
type: object
required: [success, error]
properties:
success:
type: boolean
example: false
error:
type: object
required: [code, message]
properties:
code:
type: string
example: VALIDATION_FAILED
message:
type: string
example: Request validation failed
details:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
เมื่อทุก endpoint ส่ง error ใกล้เคียงกัน ระบบฝั่ง client จะจัดการได้ง่ายขึ้นมาก
OpenAPI ไม่ใช่แค่ docs แต่เป็นฐานของ automation
เมื่อ spec เริ่มนิ่ง คุณจะเริ่มต่อยอดได้อีกหลายอย่าง เช่น
1) Generate typed client
frontend ไม่ต้องเขียน type ซ้ำเองทุก endpoint
2) Mock server
ใช้ทดสอบ flow ก่อน backend เสร็จจริง
3) Contract testing
เช็คว่า implementation ยังตรง spec หลังมีการแก้ endpoint
4) Change review
เวลาจะ merge PR ที่แก้ spec ทีมเห็นได้ทันทีว่า field ไหนถูกเพิ่ม ลบ หรือเปลี่ยน
ตัวอย่าง test เช็ค contract แบบง่าย
ถ้าอยากเช็คอย่างน้อยว่า route สำคัญยังส่ง shape ถูก สามารถเขียน integration test ได้ เช่น
import request from "supertest";
import { app } from "../src/app";
describe("POST /api/v1/inquiries", () => {
it("should create inquiry successfully", async () => {
const response = await request(app)
.post("/api/v1/inquiries")
.send({
name: "Apichet Janya",
businessType: "Insurance Technology",
contact: "apichet@example.com",
websiteType: "Corporate Website",
goal: "Generate leads and build trust",
});
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(typeof response.body.inquiry.id).toBe("string");
expect(typeof response.body.inquiry.createdAt).toBe("string");
});
it("should reject invalid payload", async () => {
const response = await request(app)
.post("/api/v1/inquiries")
.send({
name: "A",
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
แม้ test ชุดนี้จะไม่ validate spec ตรง ๆ แบบเต็มระบบ แต่ช่วยบังคับให้ implementation ไม่ drift จาก contract ง่ายเกินไป
ข้อผิดพลาดที่ทีมเจอบ่อยเมื่อเริ่มใช้ OpenAPI
เขียน spec หลังโค้ดเสร็จตลอด
สุดท้าย spec จะตามไม่ทันของจริง และกลายเป็นเอกสารเก่า
เขียน schema หยาบเกินไป
เช่นใช้ type: object กว้าง ๆ โดยไม่กำหนด required field หรือ enum เลย ทำให้เอกสารแทบไม่มีพลัง
ยัด business rule ทุกอย่างลง spec
ทำให้ spec อ่านยากและยังบังคับใช้ไม่ได้จริงทั้งหมด
ไม่กำหนด error shape กลาง
client integration จะยุ่งมากเมื่อแต่ละ endpoint ล้มเหลวคนละรูปแบบ
ไม่มีระบบ review การเปลี่ยนแปลง contract
เปลี่ยน response แล้ว merge เลย โดยไม่รู้ว่ามี consumer พังหรือไม่
แนวทางใช้งานในทีมแบบค่อย ๆ โต
ถ้าทีมยังไม่ได้ใช้ OpenAPI อย่างจริงจัง ไม่จำเป็นต้องพลิกทั้งระบบในวันเดียว ลำดับที่ทำได้จริงคือ
- เริ่มจาก endpoint สำคัญที่มีคนใช้ร่วมหลายฝ่าย
- กำหนด request/response schema ให้ชัด
- เปิด docs ให้ทีมใช้ spec เดียวกัน
- เพิ่ม request validation
- ค่อยเพิ่ม response validation และ contract testing
- สร้างกติกาว่า PR ที่เปลี่ยน contract ต้องแก้ spec เสมอ
แนวทางนี้ทำให้ adoption เกิดได้จริงกว่าการประกาศว่าทุก endpoint ต้องสมบูรณ์ 100% ตั้งแต่วันแรก
สรุป
OpenAPI มีคุณค่าไม่ใช่เพราะมันทำให้เอกสาร API ดูสวย แต่เพราะมันทำให้ทีมมี สัญญากลาง ที่ชัดเจนและตรวจสอบได้
เมื่อใช้แบบจริงจัง มันจะช่วยให้
- frontend, backend, QA, integrator คุยกันง่ายขึ้น
- validation มีมาตรฐาน
- breaking change ถูกมองเห็นเร็วขึ้น
- เอกสารไม่หลุดจาก implementation ง่าย
- การขยายระบบในระยะยาวมีวินัยมากขึ้น
ถ้าระบบของคุณเริ่มโต มีหลาย service หรือมีหลายคนแตะ API พร้อมกัน การทำ OpenAPI แบบ contract-first ไม่ใช่งานเอกสารเกินจำเป็น แต่เป็นเครื่องมือควบคุมความเสี่ยงของระบบโดยตรง