ساخت API واقعی؛ درخواست نام، پاسخ JSON و کد کامل سرور
در این درس ۵ بخش
در درس قبل، ظاهر صفحه و رفتار دکمهها را ساختید؛ اما مرورگر نمیتواند بهتنهایی نقش برنامهٔ پاسخگو را بازی کند. در این درس سه فایل باقیماندهٔ اجرای اصلی را میسازیم تا درخواست واقعاً از مرورگر خارج شود، برنامه آن را بررسی کند و پاسخ برگرداند. تمام کار در پوشهٔ hello-http انجام میشود و فعلاً به دامنه یا خرید سرور نیاز نداریم. نام فایلها و مسیر پوشهٔ public را دقیق حفظ کنید. هدف پایان درس این است که بتوانید از روی کد توضیح دهید چرا یک نام درست پیام سلام میگیرد، اما یک درخواست با ساختار اشتباه پذیرفته نمیشود.
قرارداد API؛ از پیش بدانیم چه میفرستیم و چه میگیریم
API در این پروژه یک راه مشخص برای گفتوگوی برنامههاست. بهجای اینکه کاربر دکمهای در ترمینال سرور بزند، مرورگر درخواست HTTP میفرستد. هر درخواست یک روش، یک مسیر و تعدادی سرآیند دارد؛ بعضی درخواستها بدنه هم دارند. روش میگوید چه نوع کاری میخواهیم، مسیر بخش موردنظر برنامه را مشخص میکند و سرآیند، توضیحی دربارهٔ پیام است. مثلاً Content-Type میگوید بدنه چه قالبی دارد. پاسخ نیز کد وضعیت، سرآیند و بدنه دارد. متن خوشامدگوییِ دیدهشده در صفحه، از بدنهٔ همین پاسخ گرفته میشود؛ شمارهٔ وضعیت بهتنهایی متن سلام نیست.
درخواست GET /api/status وضعیت نمونهٔ برنامه را میخواند و چیزی را تغییر نمیدهد. بدنهٔ پاسخ یک #object با سه ویژگی ok، service و uptimeSeconds است. مقدار uptimeSeconds تعداد ثانیههای گذشته از آمادهشدن این نمونهٔ پاسخگوست؛ زمان روشنبودن کل سرور اوبونتو یا تضمین سلامت تمام سرویسهای آینده نیست. روش HEAD برای همین مسیر هم پذیرفته میشود، ولی فقط سرآیند میدهد و بدنهٔ JSON نمیفرستد. این تفاوت برای آزمایش مهم است: خالیبودن بدنهٔ HEAD نشانهٔ گمشدن فایل نیست. در مقابل، POST /api/greet دادهٔ نام را برای پردازش میفرستد و یک پیام تولید میکند.
قرارداد خوشامدگویی دقیق است: بدنه باید JSON معتبر و یک شیء دارای فقط ویژگی name باشد. name باید متن باشد و پس از حذف فاصلههای ابتدا و انتها، طول آن از یک تا شصت واحد رشتهٔ جاوااسکریپت باشد. طول رشته همیشه برابر تعداد شکلهای دیدهشده نیست؛ بعضی ایموجیها چند واحد دارند. برای تمرین از نام ساختگی ساده استفاده کنید. در اینجا #validation-contracts یعنی بررسی داده با قرارداد روشن، بهجای پذیرفتن خاموش عدد، آرایه یا ویژگی اضافی. قالب #json را هم با خود شیء اشتباه نگیرید: در شبکه متن JSON جابهجا میشود و برنامه باید آن را دوباره به مقدار قابل بررسی تبدیل کند.
کد وضعیت، نتیجهٔ پردازش این درخواست را خلاصه میکند. این پروژه برای پاسخ موفق ۲۰۰، مسیر ناشناخته ۴۰۴ و روش نامناسب روی مسیر شناختهشده ۴۰۵ میدهد. JSON خراب یا متن UTF-8 نامعتبر، ۴۰۰ میگیرد؛ JSON درست با نام نامعتبر، ۴۲۲. نوع محتوای پشتیبانینشده ۴۱۵ و بدنهٔ بیش از ۱۰۲۴ بایت ۴۱۳ دارد. این اعداد را حفظ نکنید؛ نمونهٔ علت را کنارشان بنویسید. مثلاً {"name":123} از نظر قالب JSON درست است، ولی قرارداد نام را نقض میکند. پاسخ ۴۲۲ در این مورد رفتار درست برنامه است، نه خرابی سرور.
سه فایل سرور را در پوشهٔ اصلی بسازید
فایل package.json شناسنامه و دفترچهٔ دستورهای پروژه است. آن را کنار پوشهٔ public، در ریشهٔ hello-http بسازید و متن کامل زیر را ذخیره کنید. type برابر module باعث میشود شیوهٔ ماژولی پروژه روشن باشد؛ در کد از import استفاده میکنیم. engines نیاز نسخهای برنامه را بیان میکند، ولی جای بررسی واقعی node --version را نمیگیرد. این پروژه بستهٔ خارجی ندارد؛ برای اجرای آن نصب Express، دانلود پوشهٔ node_modules از یک سایت یا اجرای npm install با دسترسی مدیر لازم نیست. private هم جلوی انتشار تصادفی این بسته بهعنوان بستهٔ عمومی npm را میگیرد.
{
"name": "darsavand-hello-http",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": {
"start": "node server.mjs",
"dev": "node server.mjs",
"test": "node --test",
"check": "node --check app.mjs && node --check server.mjs && node --check public/app.js && node --check app.test.mjs"
}
}
نام start فقط یک میانبُر برای node server.mjs است. test آزمونها را اجرا میکند و check نحو فایلهای تعیینشده را بررسی میکند. بررسی نحو میتواند کوتیشن بستهنشده را پیدا کند، ولی ثابت نمیکند دکمه واقعاً کار میکند یا پیام مناسب است؛ به همین دلیل درس بعد درخواست واقعی و آزمون خودکار هم دارد. دستور dev برای پیشنمایش همین پروژه همان سرور را اجرا میکند و قرار نیست یک بکاند ساختگی جدا بسازد. فایل app.test.mjs را نیز در درس بعد کامل اضافه میکنید؛ پیش از ساخت آن، اجرای check ممکن است به نبود فایل اشاره کند. نخست فایلها را کامل کنید، سپس مرحلهٔ آزمون را انجام دهید.
app.mjs؛ درخواست را در یک مسیر روشن به پاسخ برسانیم
فایل زیر را با نام app.mjs در ریشهٔ پروژه ذخیره کنید. این فایل منطق پاسخگویی را نگه میدارد و بهتنهایی پورت ثابتی را اشغال نمیکند. createHandler فایلهای سهگانهٔ صفحه را هنگام آمادهسازی میخواند و یک تابع پاسخگو برمیگرداند. مسیر فایلها نسبت به خود ماژول محاسبه شده است؛ بنابراین بازکردن ترمینال در پوشهای دیگر نباید باعث جستوجوی public در جای نامرتبط شود. فهرست فایلهای قابل ارائه نیز صریح است. برنامه قرار نیست هر نشانیِ فرستادهشده از مرورگر را به نام فایل روی دیسک تبدیل کند؛ فایل تنظیمات یا کد سرور از این راه عمومی نمیشود.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const MAX_BODY = 1024;
const SECURITY = {
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'same-origin',
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
};
class HttpError extends Error {
constructor(status, code) { super(code); this.status = status; }
}
function readJson(req) {
const type = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
if (type !== 'application/json' ||
(req.headers['content-encoding'] && req.headers['content-encoding'] !== 'identity')) {
req.resume();
return Promise.reject(new HttpError(415, 'JSON_REQUIRED'));
}
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let settled = false;
const fail = error => {
if (!settled) { settled = true; chunks.length = 0; reject(error); }
};
req.on('data', chunk => {
if (settled) return;
size += chunk.length;
if (size > MAX_BODY) fail(new HttpError(413, 'BODY_TOO_LARGE'));
else chunks.push(chunk);
});
req.on('aborted', () => fail(new HttpError(400, 'BODY_INTERRUPTED')));
req.on('error', () => fail(new HttpError(400, 'BODY_INTERRUPTED')));
req.on('end', () => {
if (settled) return;
try {
const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
const value = JSON.parse(text);
settled = true;
resolve(value);
} catch { fail(new HttpError(400, 'INVALID_JSON')); }
});
});
}
function reply(req, res, status, type, body, headers = {}) {
const data = Buffer.isBuffer(body) ? body : Buffer.from(body);
res.writeHead(status, {
...SECURITY, 'Content-Type': type, 'Content-Length': data.length, ...headers
});
res.end(req.method === 'HEAD' ? undefined : data);
}
function json(req, res, status, data, headers) {
reply(req, res, status, 'application/json; charset=utf-8', JSON.stringify(data), headers);
}
function greeting(input) {
if (!input || typeof input !== 'object' || Array.isArray(input) ||
Object.keys(input).length !== 1 || !Object.hasOwn(input, 'name')) {
throw new HttpError(422, 'INVALID_FIELDS');
}
if (typeof input.name !== 'string') throw new HttpError(422, 'INVALID_NAME');
const name = input.name.trim();
if (name.length < 1 || name.length > 60) throw new HttpError(422, 'INVALID_NAME');
return { message: `سلام، ${name}! این پاسخ از Node.js آمده است.` };
}
export async function createHandler() {
const assets = new Map();
for (const [path, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/style.css', 'style.css', 'text/css; charset=utf-8'],
['/app.js', 'app.js', 'text/javascript; charset=utf-8']
]) {
assets.set(path, { body: await readFile(new URL(`./public/${file}`, import.meta.url)), type });
}
const started = performance.now();
return async (req, res) => {
try {
let path;
try { path = new URL(req.url, 'http://127.0.0.1').pathname; }
catch { throw new HttpError(400, 'INVALID_URL'); }
const allow = assets.has(path) || path === '/api/status' ? 'GET, HEAD'
: path === '/api/greet' ? 'POST' : '';
if (!allow) throw new HttpError(404, 'NOT_FOUND');
if (!allow.split(', ').includes(req.method)) {
req.resume();
return json(req, res, 405, { error: 'METHOD_NOT_ALLOWED' }, { Allow: allow, Connection: 'close' });
}
if (assets.has(path)) {
const asset = assets.get(path);
return reply(req, res, 200, asset.type, asset.body);
}
if (path === '/api/status') {
return json(req, res, 200, {
ok: true, service: 'darsavand-hello',
uptimeSeconds: Math.floor((performance.now() - started) / 1000)
});
}
return json(req, res, 200, greeting(await readJson(req)));
} catch (error) {
if (res.destroyed || res.writableEnded) return;
const known = error instanceof HttpError;
json(req, res, known ? error.status : 500,
{ error: known ? error.message : 'INTERNAL_ERROR' }, { Connection: 'close' });
}
};
}
export async function createApp() {
return createServer(
{ requestTimeout: 15000, headersTimeout: 10000, maxHeaderSize: 8192 },
await createHandler()
);
}
export const makeServer = createApp;
از پایین فایل شروع کنید: createApp یک سرور HTTP میسازد و تابع پاسخگو را به آن میدهد. داخل پاسخگو، نخست مسیر و روش بررسی میشوند. برای فایل صفحه یا وضعیت، پاسخ بدون خواندن نام ساخته میشود؛ فقط مسیر خوشامدگویی سراغ readJson میرود. دادهٔ بدنه ممکن است در چند تکه برسد، پس readJson تکهها را تا سقف تعیینشده جمع میکند و پس از پایان، UTF-8 و JSON را بررسی میکند. #async-await کمک میکند نتیجهٔ این انتظار در مسیر کد روشن بماند. این انتظار شبکه را با محاسبهٔ موازی همهٔ کارها یکی ندانید؛ اینجا کار برنامه بسیار کوچک است.
تعداد بایت با تعداد حرف یکی نیست. ممکن است یک حرف فارسی در UTF-8 بیش از یک بایت داشته باشد؛ به همین دلیل سقف بدنه با اندازهٔ تکههای دریافتی سنجیده میشود، نه با تعداد حروف نام. ردکردن زودهنگام بدنهٔ بزرگ جلوی جمعکردن بیپایان آن در حافظه را میگیرد. در مسیر شکست، پیام کنترلشدهٔ JSON برمیگردد و پشتهٔ داخلی برنامه به مخاطب نشان داده نمیشود. سرآیندهای امنیتی هم رفتار مرورگر را محدود میکنند؛ مثلاً منابع صفحه از خود همین مبدأ بارگیری میشوند. این کنترلهای آموزشی جای محدودیت ترافیک، پایش و طراحی مناسب برای یک محصول عمومی پرترافیک را نمیگیرند.
server.mjs؛ برنامه را اجرا و درست متوقف کنیم
اکنون server.mjs را کنار دو فایل قبلی بسازید. جداکردن راهاندازی از منطق پاسخ، یک فایدهٔ عملی دارد: آزمون میتواند همان برنامه را روی یک پورت آزاد موقتی اجرا کند، بدون اینکه همیشه ۳۰۰۰ لازم باشد. فایل راهاندازی، PORT را از محیط میخواند و قبل از گوشدادن، شکل و محدودهٔ آن را میسنجد. اگر چیزی تنظیم نشده باشد از ۳۰۰۰ استفاده میکند. نشانی 127.0.0.1 فقط به همین دستگاه اشاره دارد؛ در این مرحله قرار نیست رایانهٔ دیگر مستقیماً به پورت برنامه وصل شود. در فصل اوبونتو، Nginx درگاه عمومی خواهد بود.
import { createApp } from './app.mjs';
const raw = process.env.PORT ?? '3000';
if (!/^\d{1,5}$/.test(raw) || Number(raw) < 1 || Number(raw) > 65535) {
console.error('PORT must be an integer from 1 to 65535');
process.exitCode = 1;
} else {
try {
const server = await createApp();
server.on('error', error => {
console.error(`Cannot start: ${error.code ?? 'UNKNOWN'}`);
process.exitCode = 1;
});
server.listen(Number(raw), '127.0.0.1', () => {
console.log(`Hello HTTP: http://127.0.0.1:${Number(raw)}/`);
});
let stopping = false;
const stop = () => {
if (stopping) return;
stopping = true;
server.close();
setTimeout(() => server.closeAllConnections(), 3000).unref();
};
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
} catch {
console.error('Cannot load project files; check the public folder.');
process.exitCode = 1;
}
}
بعد از اجرا، پیام Hello HTTP و نشانی صفحه چاپ میشود و ترمینال باز میماند؛ این رفتار برای سرور طبیعی است، چون منتظر درخواست است. هنگام Ctrl+C، برنامه پذیرش درخواست تازه را متوقف میکند و برای بستن اتصالهای باقیمانده مهلتی کوتاه دارد. اگر PORT نامعتبر باشد، باید پیش از ساخت شنونده با پیام مشخص متوقف شود. اگر EADDRINUSE دیدید، پورت را برنامهٔ دیگری گرفته است؛ همهٔ فرایندهای Node را نبندید. فقط اجرای متعلق به همین تمرین را در ترمینال خودش متوقف کنید، یا پس از شناخت وضعیت، تنظیم پورت را در همهٔ آزمایشهای وابسته هماهنگ تغییر دهید.
این نمونه نام را در پایگاه داده، فایل یا فهرست کاربران ثبت نمیکند؛ فقط همان درخواست را بررسی و پاسخ میسازد. بنابراین به نشست ورود یا رمز کاربران نیاز ندارد، اما الگوی آمادهٔ ثبتنام و پرداخت هم نیست. برای آموزش، نام ساختگی وارد کنید و در query string یا کد پروژه رمز قرار ندهید. اگر بعدها قابلیت نگهداری اطلاعات افزودید، مسئلهٔ مجوز دسترسی، ماندگاری داده و سیاست حذف به پروژه اضافه میشود. مرز نسخهٔ فعلی را در یادداشت خود حفظ کنید تا یک پیام سلام موفق را با آمادهبودن سامانهٔ حساب کاربری اشتباه نگیرید.
تمرین با پاسخ؛ مسیر کدام درخواست را میتوانید پیشبینی کنید؟
پیش از اجرا سه پیشبینی بنویسید: GET /api/greet چه میشود؟ POST /api/greet با نام « سارا » چه میدهد؟ و همان POST با یک ویژگی اضافی مانند admin چه نتیجهای دارد؟ پاسخ اول ۴۰۵ با Allow: POST است، چون مسیر شناختهشده ولی روش نادرست است. پاسخ دوم ۲۰۰ و سلام به سارا بدون فاصلهٔ اضافی است. پاسخ سوم ۴۲۲ میگیرد، چون قرارداد فقط name را میپذیرد. حالا در کد، خط تصمیم هر نتیجه را پیدا کنید. معیار تحویل صرفاً بازشدن صفحه نیست؛ باید بتوانید مسیر دریافت، بررسی و ساخت پاسخ این سه درخواست را توضیح دهید.
اگر قرارداد داده هنوز مبهم است، نمونهٔ کاملتر درس «مرز اعتماد در API» را مرور کنید: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=9b1d29fd-37f1-43da-a662-b777e25afbc9
برای پیوند این برنامه با HTTP و مسئولیت سرور نیز به درس «از مرورگر تا سرور» برگردید: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=12cd93b9-e8a7-44fb-b60b-928a8495a617
در درس بعد همین فایلهای ذخیرهشده را اجرا میکنیم و نتیجهٔ پیشبینیها را با درخواست واقعی مقایسه میکنیم؛ ساخت فایلهای درس دوم و این درس باید پیش از آن تمام شده باشد.
منابع رسمی انگلیسی:
Node.js HTTP: https://nodejs.org/docs/latest-v24.x/api/http.html
Node.js file system promises: https://nodejs.org/docs/latest-v24.x/api/fs.html
MDN POST: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods/POST
MDN HTTP status codes: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status
تألیف، مثالها و توضیح فارسی: تحریریه درسآوند. منابع رسمی انگلیسی برای بررسی رفتار ابزارها و مطالعهٔ بیشتر آمدهاند.
متن درسهای عمومی را بدون ورود بخوانید.