ข้ามไปยังเนื้อหา

การสร้าง JSON API

ต่างจาก URL และ headers ที่มีพร้อมทันที body ของ request มาเป็น stream ของ chunk คุณต้องเก็บรวบรวมด้วย event data และ end:

function readBody(req) {
return new Promise(function(resolve, reject) {
let raw = '';
req.on('data', function(chunk) { raw += chunk; });
req.on('end', function() { resolve(raw); });
req.on('error', reject);
});
}

เมื่อได้ string ดิบแล้ว ให้ parse ด้วย JSON.parse() ใน try/catch เพื่อจัดการ input ที่รูปแบบผิดพลาดอย่างสง่างาม

server ด้านล่างจัดการ list ของ item ใน memory โดยรองรับ:

  • GET /items — แสดง item ทั้งหมด
  • POST /items — เพิ่ม item (อ่าน JSON body)
  • GET /items/:id — ดึง item เดียว (คืน 404 ถ้าไม่มี)
Node.js

Needs the Node.js runtime — open in StackBlitz to run.

เปิดใน StackBlitz แล้วทดสอบ API เพิ่มเติมด้วย built-in HTTP client หรือ curl — เปลี่ยน 3000 เป็น port ที่พิมพ์ใน console:

Terminal window
curl http://localhost:3000/items
curl -X POST http://localhost:3000/items -H 'Content-Type: application/json' -d '{"name":"TypeScript"}'
curl http://localhost:3000/items/3
curl http://localhost:3000/items/99
สถานการณ์Status Code
อ่านข้อมูลสำเร็จ200 OK
สร้าง resource สำเร็จ201 Created
field หายหรือ body ผิดรูปแบบ400 Bad Request
ไม่พบ resource404 Not Found
server error500 Internal Server Error

helper readBody ด้านบนเก็บ chunk โดยไม่มี limit เลย — client สามารถ stream ข้อมูลหลาย gigabyte เข้า handler ที่แค่ concatenate ไปเรื่อย ๆ ทำให้ memory โตขึ้นจนกระบวนการล่ม body reader จริง ๆ ต้องติดตามขนาดที่รับมาสะสม แล้วหยุดทันทีเมื่อเกิน limit โดยเรียก req.destroy() เพื่อหยุดอ่าน body ที่เกินขนาดที่เหลือ และตอบ 413 Payload Too Large:

Node.js
Output (verified on Node 24)
413 413 Payload Too Large

response ถูกเขียนไป ก่อน req.destroy() — ถ้าสลับลำดับกันจะทำให้ socket ถูกทำลายก่อนที่ body ของ 413 จะไปถึง client เลย ถ้าไม่มี limit เลย handler นี้จะเป็นช่องทาง denial-of-service แบบคลาสสิก: การ upload ที่ช้าและเกินขนาดเพียงครั้งเดียวสามารถ pin memory ไว้ไม่มีกำหนดในขณะที่ไม่มีอะไรอ่านมันจบสักที

JSON.parse() throw SyntaxError กับอะไรก็ตามที่ไม่ใช่ JSON ที่ถูกต้อง demo readBody/jsonApiCode ด้านบนห่อมันด้วย try อยู่แล้ว แต่คุ้มค่าที่จะดูมันแยกเดี่ยว ๆ เพราะการลืม try/catch นี้เป็นช่องโหว่ที่พบบ่อยที่สุดอันหนึ่งในโค้ด parse body ที่ AI เขียน:

Node.js
Output (verified on Node 24)
400 { error: 'malformed JSON body' }

ถ้าไม่มี try/catch การ throw ของ SyntaxError ข้างใน request listener ที่เป็น async จะ reject Promise ที่ listener นั้นคืนค่า — Node.js ไม่แปลงมันเป็น response ให้อัตโนมัติ request เลยค้างอยู่แค่นั้น และ rejection ก็กลายเป็น unhandled rejection

output ของ JSON.stringify เป็น JavaScript string และ .length นับ UTF-16 code unit — สำหรับ JSON ที่เป็น ASCII ล้วนตัวเลขนี้เท่ากับจำนวน byte พอดี แต่ทันทีที่ value มีข้อความไทย (หรือ emoji หรือภาษาที่ไม่ใช่ Latin ส่วนใหญ่) มันจะไม่เท่ากันอีกต่อไป:

const text = 'สวัสดี'; // Thai greeting - each character is multiple UTF-8 bytes
console.log('.length (UTF-16 code units):', text.length);
console.log('Buffer.byteLength (actual UTF-8 bytes):', Buffer.byteLength(text));

res.end(data) คำนวณ Content-Length ให้คุณถูกต้องด้วยจำนวน byte จริง ดังนั้นเรื่องนี้แทบไม่กัดในโค้ดที่ปล่อยให้ res.end() ทำงานเอง มันจะกัดทันทีที่มีใครคำนวณความยาวสำหรับ header เอง, ตั้ง Content-Length ล่วงหน้าก่อนการ write() แบบ stream หรือทำ budget ขนาด body เอง — ให้ใช้ Buffer.byteLength(str) เสมอในกรณีเหล่านั้น ไม่ใช่ str.length

handler ของ JSON API เป็น pipeline ที่มีสามจุดที่พังได้ก่อนจะแตะข้อมูลของคุณเลย: network (ข้อมูลมากเกินไป, req.destroy() แก้ได้), parser (JSON.parse throw, try/catch แก้ได้) และ validation ของคุณเอง (field หายหรือ type ผิด, การเช็คแก้ได้) แต่ละขั้นตอนต้องการ guard และ status code ของตัวเอง — body limit ที่ล้มเหลวคือ 413, parse ที่ล้มเหลวคือ 400, validation ที่ล้มเหลวก็ 400 เช่นกัน (พร้อมข้อความที่เจาะจงกว่า) การยุบทุกอย่างให้เหลือแค่ 500 ทั่วไปไม่บอก client เลยว่าต้องแก้อะไร

limit ขนาด byte ที่ hardcode ไว้อย่าง demo 20 byte ด้านบนนั้นง่ายแต่ตามอำเภอใจ — ต่ำเกินไป request ที่ถูกต้องก็โดนปฏิเสธ สูงเกินไปการป้องกันก็แทบไม่ช่วยอะไร API ใน production มักจะคำนวณ limit จากความต้องการจริงของ endpoint (login form ต้องการแค่ byte ไม่ใช่ megabyte) หรือมอบให้ reverse proxy ที่อยู่หน้า Node จัดการ ซึ่งปฏิเสธ body ที่เกินขนาดได้ก่อนจะถึง process ของคุณด้วยซ้ำ — แลกมาด้วย configuration อีกชั้นที่ต้อง sync กับโค้ด application ของคุณ

  • “แค่ปฏิเสธ body ที่เกิน limit ด้วย res.end() ก็พอ ไม่ต้องแตะ req” client อาจยังอยู่กลาง upload — ถ้าไม่มี req.destroy() Node จะยังอ่าน (และ buffer) byte ที่ไม่มีวันถูกใช้ต่อไป ทำลายจุดประสงค์ของ limit ทั้งหมด
  • “JSON.parse error หายากพอที่จะข้าม try/catch ได้” client ไหนก็ตาม — integration ที่มี bug, คำสั่ง curl ที่พิมพ์มือ, request ที่ retry แล้ว body ขาด — ส่ง JSON ที่ invalid ได้ทั้งนั้น มันพบบ่อยพอ ๆ กับ input ผิดรูปแบบอื่น ๆ และไม่ถูก catch โดย default
  • “.length ใกล้เคียงพอกับจำนวน byte จริงสำหรับ header Content-Length” มันถูกต้องเป๊ะสำหรับ ASCII และผิดแบบเงียบ ๆ สำหรับทุกอย่างที่เหลือ — อาการ (response body ถูกตัดทิ้ง) จะโผล่มาให้เห็นก็ต่อเมื่อข้อความจริงที่มีตัวอักษรไม่ใช่ ASCII ไหลผ่าน code path นั้น

body reader ที่ AI เขียนมักจะ implement pattern การเก็บ data/end ได้ถูกต้อง และก็มักจะข้ามสิ่งเดียวที่ pattern นั้นต้องการเพื่อให้ปลอดภัยจาก client ที่เป็นอันตรายหรือมี bug: size limit

🐛 หา bug ในโค้ดนี้

function readBody(req) {
return new Promise(function (resolve, reject) {
let raw = '';
req.on('data', function (chunk) { raw += chunk; });
req.on('end', function () { resolve(raw); });
req.on('error', reject);
});
}
// Called directly on every incoming request, with no size check anywhere
เฉลย

readBody นี้จะ concatenate chunk ได้ไม่จำกัดจำนวน — client ที่ stream body หลาย gigabyte (โดยบังเอิญหรือตั้งใจ) จะทำให้ string raw โตขึ้นตลอดเวลา ไม่มีขอบเขตบน ค้าง memory และ event loop จนกว่า process จะ memory หมดหรือ connection ถูกฆ่าจากภายนอก ทางแก้คือ pattern ที่แสดงไว้ก่อนหน้านี้ในบทนี้: ติดตามจำนวน byte สะสมขณะที่ chunk เข้ามา แล้วเมื่อเกิน limit ให้ตอบ 413 Payload Too Large และเรียก req.destroy() เพื่อหยุดอ่านทันที แทนที่จะเชื่อว่า client จะส่ง event end มาในที่สุด

ทำไมต้องเก็บ request body ด้วย event data และ end แทนการอ่านแบบ synchronous?
HTTP status code ใดที่ POST endpoint ควรคืนเมื่อสร้าง resource ใหม่สำเร็จ?
client ส่ง JSON body ที่ invalid มา status code ตอบกลับที่ถูกต้องคืออะไร?
helper readBody เก็บ chunk เข้า string โดยไม่มี size check เลย ถูกเรียกตรง ๆ กับทุก request ที่เข้ามา ความเสี่ยงคืออะไร?
function readBody(req) {
  return new Promise(function (resolve, reject) {
    let raw = '';
    req.on('data', function (chunk) { raw += chunk; });
    req.on('end', function () { resolve(raw); });
  });
}
ทำไมต้องเรียก req.destroy() หลังจากเขียน response 413 ไปแล้ว ไม่ใช่ก่อน?
สำหรับ string ไทย 'สวัสดี' ทำไม Buffer.byteLength(text) ถึงคืนตัวเลขที่มากกว่า text.length?