ุงู„ุชุทุจูŠู‚ ยท ูุตู„ ูฉ

๐Ÿงช Lab: API ุทู„ุจุงุช ุงู„ูƒุดุฑูŠ ุจุงู„ู€ Express ูˆุงู„ู€ Fastify

Express middleware ยท express.json limit ยท error formatting ยท Fastify schema ยท coercion ยท removeAdditional ยท fast-json-stringify ยท response schema
๐ŸŽฏ ุงู„ู‡ุฏู: ุชุจู†ูŠ API ุญู‚ูŠู‚ูŠ ุจูŠุฑูุถ ุฃูŠ ุทู„ุจ ุดูƒู„ู‡ ุบู„ุท ุจุฑุณุงู„ุฉ ูˆุงุถุญุฉุŒ ูˆุชุดูˆู Fastify ุจูŠุนู…ู„ ู†ูุณ ุงู„ุญุงุฌุฉ built-inุŒ ูˆูุฎ ุงู„ู€ defaults ุจุชุงุนุชู‡.

ู‡ู†ุจู†ูŠ ุฅูŠู‡ุŸ

endpoint ูˆุงุญุฏ POST /orders ู„ู…ุญู„ ูƒุดุฑูŠ. ุงู„ุทู„ุจ ู„ุงุฒู… ูŠูƒูˆู† ููŠู‡:

  • ุนู…ูŠู„: ุงุณู… ู…ู† 2 ู„ู€ 50 ุญุฑูุŒ ูˆู…ูˆุจุงูŠู„ ู…ุตุฑูŠุŒ ูˆุฅูŠู…ูŠู„ ุงุฎุชูŠุงุฑูŠ.
  • ุฃุตู†ุงู: ู…ู† 1 ู„ู€ 20 ุตู†ูุŒ ูƒู„ ุตู†ู sku ู…ู† ู‚ุงูŠู…ุฉ ู…ุนุฑูˆูุฉ ูˆqty integer ู…ู† 1 ู„ู€ 10.
  • ุทุฑูŠู‚ุฉ ุฏูุน: cash ุฃูˆ card ุฃูˆ wallet.
  • ู…ู„ุงุญุธุงุช: ุงุฎุชูŠุงุฑูŠุฉ ูˆู„ุญุฏ 200 ุญุฑู.
  • ูˆู…ููŠุด ุฃูŠ ุญู‚ู„ ุฒูŠุงุฏุฉ: ุนุดุงู† ู…ุญุฏุด ูŠุจุนุช isAdmin ุฃูˆ discount.
ajv-lab/โ”œโ”€ package.jsonโ”œโ”€ schemas/โ”‚  โ””โ”€ order.json     โ† ุงู„ุนู‚ุฏโ”œโ”€ validate.js       โ† Ajv + middleware + ุชู†ุณูŠู‚ ุงู„ู€ errorsโ””โ”€ server.js         โ† Express
terminal
mkdir ajv-lab && cd ajv-lab && pnpm initpnpm pkg set type=module scripts.start="node server.js"pnpm add ajv ajv-formats expressmkdir schemas

ุงู„ู€ schema

schemas/order.json
{  "$schema": "https://json-schema.org/draft/2020-12/schema",  "$id": "https://koshary.example/schemas/order.json",  "title": "CreateOrder",  "type": "object",  "properties": {    "customer": { "$ref": "#/$defs/customer" },    "items": {      "type": "array",      "minItems": 1,      "maxItems": 20,      "items": { "$ref": "#/$defs/item" }    },    "payment": { "enum": ["cash", "card", "wallet"] },    "notes": { "type": "string", "maxLength": 200 }  },  "required": ["customer", "items", "payment"],  "additionalProperties": false,  "$defs": {    "customer": {      "type": "object",      "properties": {        "name": { "type": "string", "minLength": 2, "maxLength": 50 },        "phone": { "type": "string", "pattern": "^01[0125][0-9]{8}$" },        "email": { "type": "string", "format": "email", "maxLength": 254 }      },      "required": ["name", "phone"],      "additionalProperties": false    },    "item": {      "type": "object",      "properties": {        "sku": { "enum": ["koshary-small", "koshary-large", "rice-pudding"] },        "qty": { "type": "integer", "minimum": 1, "maximum": 10 }      },      "required": ["sku", "qty"],      "additionalProperties": false    }  }}

ุงู„ู€ validator ูˆุงู„ู€ middleware

validate.js
import { readFileSync } from "node:fs";import Ajv from "ajv/dist/2020.js";import addFormats from "ajv-formats";// instance ูˆุงุญุฏ ู„ู„ุชุทุจูŠู‚ ูƒู„ู‡export const ajv = new Ajv({ allErrors: true, strict: true });addFormats(ajv);const load = (file) => JSON.parse(readFileSync(new URL(`./schemas/${file}`, import.meta.url)));ajv.addSchema(load("order.json"));// ูŠุญูˆู‘ู„ errors ุจุชุงุนุฉ Ajv ู„ุดูƒู„ ู†ุถูŠู ูŠุฑุฌุน ู„ู„ู€ clientexport function formatErrors(errors = []) {  return errors.map((e) => {    let field = e.instancePath || "/";    if (e.keyword === "required") field = `${e.instancePath}/${e.params.missingProperty}`;    if (e.keyword === "additionalProperties") field = `${e.instancePath}/${e.params.additionalProperty}`;    return { field, rule: e.keyword, message: e.message };  });}// middleware: ุจูŠุงุฎุฏ ุงู„ู€ $id ุจุชุงุน ุงู„ู€ schemaexport function validateBody(schemaId) {  const validate = ajv.getSchema(schemaId);  if (!validate) throw new Error(`schema not found: ${schemaId}`);  return (req, res, next) => {    if (validate(req.body)) return next();    res.status(400).json({ error: "ValidationError", details: formatErrors(validate.errors) });  };}
  • ุงู„ู€ getSchema ุจุฑู‡ ุงู„ู€ middleware: ุจูŠุญุตู„ ู…ุฑุฉ ูˆุงุญุฏุฉ ู„ู…ุง ุงู„ู€ route ูŠุชุณุฌู„ุŒ ู…ุด ู…ุน ูƒู„ request.
  • ุงู„ู€ throw ู„ูˆ ุงู„ู€ schema ู…ุด ู…ูˆุฌูˆุฏุฉ: ุงู„ุณูŠุฑูุฑ ูŠู‚ุน ูˆู‚ุช ุงู„ู€ startup ุฃุญุณู† ู…ุง ูŠู‚ุน ู…ุน ุฃูˆู„ request.
  • ุงู„ู€ formatErrors: ุจูŠุตู„ู‘ุญ ู…ูƒุงู† ุงู„ุญู‚ู„ ููŠ required ูˆadditionalPropertiesุŒ ูˆุจูŠุฎุจู‘ูŠ schemaPath.

ุงู„ุณูŠุฑูุฑ

server.js
import express from "express";import { validateBody } from "./validate.js";const app = express();app.use(express.json({ limit: "10kb" })); // ุญุฏ ู„ู„ุญุฌู… ู‚ุจู„ ุงู„ู€ validationapp.post("/orders", validateBody("https://koshary.example/schemas/order.json"), (req, res) => {  // ู‡ู†ุง ุงู„ู€ body ู…ุถู…ูˆู† ุดูƒู„ู‡  const count = req.body.items.reduce((sum, i) => sum + i.qty, 0);  res.status(201).json({ ok: true, customer: req.body.customer.name, count });});// errors ุงู„ู€ JSON parser ู†ูุณู‡ (ู‚ุจู„ ุงู„ู€ schema)app.use((err, req, res, next) => {  if (err.type === "entity.parse.failed") return res.status(400).json({ error: "InvalidJSON" });  if (err.type === "entity.too.large") return res.status(413).json({ error: "PayloadTooLarge" });  next(err);});const port = process.env.PORT ?? 3000;app.listen(port, () => console.log(`listening on http://localhost:${port}`));
  • ุงู„ู€ limit: "10kb": ุฃูˆู„ ุฎุท ุฏูุงุน. ุงู„ู€ body ุงู„ุถุฎู… ุจูŠุชุฑูุถ ู‚ุจู„ ู…ุง ูŠุชุนู…ู„ู‡ parse ุฃูˆ validation ุฎุงู„ุต.
  • ุงู„ู€ handler ุจู‚ู‰ ุจุณูŠุท: ู…ููŠุด ูˆู„ุง if ุนู„ู‰ ุงู„ุดูƒู„. req.body.items.reduce ุขู…ู†ุฉ ู„ุฃู† ุงู„ู€ schema ุถู…ู†ุช ุฅู† items array ููŠู‡ุง qty integer.

ุฌุฑู‘ุจ

terminal
pnpm start# ููŠ terminal ุชุงู†ูŠH='content-type: application/json'U=localhost:3000/orders# 1) ุทู„ุจ ุณู„ูŠู…curl -s -X POST $U -H "$H" -d '{"customer":{"name":"ุณุนูŠุฏ","phone":"01012345678"},"items":[{"sku":"koshary-large","qty":2},{"sku":"rice-pudding","qty":1}],"payment":"cash"}'# 2) ูƒู„ ุญุงุฌุฉ ุบู„ุทcurl -s -X POST $U -H "$H" -d '{"customer":{"name":"ุณ","phone":"123"},"items":[{"sku":"pizza","qty":"2"}],"payment":"bitcoin","isAdmin":true}'# 3) ุญู‚ูˆู„ ู†ุงู‚ุตุฉcurl -s -X POST $U -H "$H" -d '{"customer":{"name":"ุณุนูŠุฏ"},"payment":"cash"}'# 4) JSON ุจุงูŠุธcurl -s -X POST $U -H "$H" -d '{bad json'# 5) ู…ู† ุบูŠุฑ content-typecurl -s -X POST $U -d 'hello'

ุงู„ู†ุชุงูŠุฌ ุงู„ุญู‚ูŠู‚ูŠุฉ:

output
# 1{"ok":true,"customer":"ุณุนูŠุฏ","count":3}# 2{"error":"ValidationError","details":[  {"field":"/isAdmin","rule":"additionalProperties","message":"must NOT have additional properties"},  {"field":"/customer/name","rule":"minLength","message":"must NOT have fewer than 2 characters"},  {"field":"/customer/phone","rule":"pattern","message":"must match pattern \"^01[0125][0-9]{8}$\""},  {"field":"/items/0/sku","rule":"enum","message":"must be equal to one of the allowed values"},  {"field":"/items/0/qty","rule":"type","message":"must be integer"},  {"field":"/payment","rule":"enum","message":"must be equal to one of the allowed values"}]}# 3{"error":"ValidationError","details":[  {"field":"/items","rule":"required","message":"must have required property 'items'"},  {"field":"/customer/phone","rule":"required","message":"must have required property 'phone'"}]}# 4{"error":"InvalidJSON"}# 5{"error":"ValidationError","details":[{"field":"/","rule":"type","message":"must be object"}]}
  • ููŠ 2: ูฆ ุฃุฎุทุงุก ููŠ ุฑุฏ ูˆุงุญุฏ ุจูุถู„ allErrorsุŒ ูˆูƒู„ ูˆุงุญุฏ ููŠู‡ ุงู„ู…ูƒุงู† ุจุงู„ุธุจุท. ูˆุงู„ู€ "qty": "2" ุงุชุฑูุถ ู„ุฃู†ู†ุง ู…ุดุบู‘ู„ู†ุงุด coerceTypes.
  • ููŠ 3: ุงู„ู€ field ุจู‚ู‰ /items ูˆ/customer/phone ุจุฏู„ ุงู„ู€ parentุŒ ุจูุถู„ formatErrors.
  • ููŠ 5: Express 5 ู…ุจูŠุนู…ู„ุด parse ู…ู† ุบูŠุฑ content-type: application/jsonุŒ ูุงู„ู€ req.body ุจู‚ู‰ undefinedุŒ ูˆุงู„ู€ schema ุฑูุถุชู‡ ุจู€ must be object. ู…ู† ุบูŠุฑ ุงู„ู€ schema ูƒุงู† ุงู„ู€ handler ู‡ูŠู‚ุน.
  • ูˆbody ุฃูƒุจุฑ ู…ู† 10kb: ุฑุฌุน {"error":"PayloadTooLarge"} ุจู€ status 413.

ู†ูุณ ุงู„ุญูƒุงูŠุฉ ููŠ Fastify: built-in

Fastify ุจูŠุณุชุฎุฏู… Ajv ุฌูˆู‘ุงู‡ุŒ ูู…ุด ู…ุญุชุงุฌ middleware:

fastify.js
import Fastify from "fastify";const app = Fastify();app.post("/users", {  schema: {    body: {      type: "object",      properties: {        name: { type: "string", minLength: 2 },        age: { type: "integer", minimum: 0 },      },      required: ["name"],      additionalProperties: false,    },    response: {      201: {        type: "object",        properties: { id: { type: "integer" }, name: { type: "string" } },      },    },  },}, async (req, reply) => {  reply.code(201);  return { id: 7, name: req.body.name, age: req.body.age, password: "secret123" };});await app.ready();for (const payload of [{ name: "Saeed", age: "30" }, { name: "S", isAdmin: true }, { age: 5 }]) {  const r = await app.inject({ method: "POST", url: "/users", payload });  console.log(r.statusCode, r.body);}
output
201 {"id":7,"name":"Saeed"}400 {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body/name must NOT have fewer than 2 characters"}400 {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must have required property 'name'"}
  • ุงู„ู€ schema.body: Fastify ุจูŠุนู…ู„ู‡ุง compile ูˆู‚ุช ุงู„ู€ startupุŒ ูˆุจูŠุฑุฏ 400 ู„ูˆุญุฏู‡.
  • ููŠู‡ ูƒู…ุงู†: schema.querystring ูˆschema.params ูˆschema.headers.
  • ุงู„ู€ schema.response: ุจูŠุชุนู…ู„ ุจูŠู‡ุง serializer ุณุฑูŠุน ุจู€ fast-json-stringify ุจุฏู„ JSON.stringify.
  • ู„ุงุญุธ ุงู„ู€ response: ุงู„ู€ handler ุฑุฌู‘ุน password ูˆageุŒ ุจุณ ุงู„ุฑุฏ ุทู„ุน {"id":7,"name":"Saeed"} ุจุณ. ุงู„ู€ serializer ุจูŠุทู„ู‘ุน ุงู„ุญู‚ูˆู„ ุงู„ู„ูŠ ููŠ ุงู„ู€ schema ุจุณ. ุฏู‡ ุจูŠุญู…ูŠูƒ ู…ู† ุชุณุฑูŠุจ ุญู‚ูˆู„ ุจุงู„ุบู„ุท.
  • ุงู„ู€ response schema ู…ุด validation: ู‡ูˆ ุจูŠุดูƒู‘ู„ ุงู„ู€ outputุŒ ู…ุด ุจูŠุฑูุถู‡ ู„ูˆ ุบู„ุท. ู…ุชุนุชู…ุฏุด ุนู„ูŠู‡ ูƒู€ test.
fastify-strict.js
const app = Fastify({  ajv: { customOptions: { removeAdditional: false, coerceTypes: false } },});// {"name":"Saeed","isAdmin":true} โ† 400 body must NOT have additional properties// {"name":"Saeed","age":"30"}     โ† 400 body/age must be integer

ุฎู„ู‘ุตุช ุงู„ู€ Lab ู„ู…ุง

๐Ÿง  ุงุฎุชุจุฑ ู†ูุณูƒ
ูก) ู„ูŠู‡ getSchema ุจุฑู‡ ุงู„ู€ function ุงู„ู„ูŠ ุจุชุฑุฌุน ู„ู„ู€ middlewareุŸ
ุนุดุงู† ูŠุญุตู„ ู…ุฑุฉ ูˆุงุญุฏุฉ ูˆู‚ุช ุชุณุฌูŠู„ ุงู„ู€ routeุŒ ูˆุงู„ู€ middleware ูŠุณุชุฎุฏู… ู†ูุณ ุงู„ู€ function ู…ุน ูƒู„ request.
ูข) request ู…ู† ุบูŠุฑ content-type ูˆุตู„ ุงู„ู€ handler ุจู€ req.body undefined. ุงู„ู€ schema ุนู…ู„ุช ุฅูŠู‡ุŸ
ุฑูุถุชู‡ ุจู€ must be objectุŒ ูุงู„ู€ handler ู…ุชู†ูุฐุด ุฃุตู„ู‹ุง.
ูฃ) ููŠ Fastify ุงู„ูŠูˆุฒุฑ ุจุนุช role: admin ูˆุงู„ู€ schema ููŠู‡ุง additionalProperties: falseุŒ ูˆู…ููŠุด error. ู„ูŠู‡ุŸ
ู„ุฃู† Fastify ุดุบู‘ุงู„ ุจู€ removeAdditional: trueุŒ ูุงู„ุญู‚ู„ ุงุชู…ุณุญ ุจู‡ุฏูˆุก. ู„ูˆ ุนุงูŠุฒ ุฑูุถุŒ ุฎู„ูŠู‡ false ููŠ customOptions.
ูค) ุงู„ู€ handler ุฑุฌู‘ุน passwordHash ุจุงู„ุบู„ุท. ุฅุฒุงูŠ Fastify ูŠู…ู†ุนู‡ ูŠุทู„ุนุŸ
ุจู€ schema.response ู…ู† ุบูŠุฑ ุงู„ุญู‚ู„ ุฏู‡ุŒ ู„ุฃู† ุงู„ู€ serializer ุจูŠุทู„ู‘ุน ุงู„ุญู‚ูˆู„ ุงู„ู…ุฐูƒูˆุฑุฉ ุจุณ.