Files
bonpin-adrian-meal-api/README.md
T
adrianbonpin a7e7ad238d Add zod-validated CRUD, OpenAPI docs and a styled CRUD frontend
- Define the Meal once as a zod v4 schema and derive everything from it:
  request validation, response validation, types and the OpenAPI spec.
- Full CRUD on /api/menu: list (filter/search/sort), get one, create, replace,
  update and delete, while keeping the original GET error messages intact.
- Normalize Elysia's 422 validation failures into 400 with a per-field
  `fields` map the frontend renders under each input.
- Add the @elysiajs/openapi plugin: Scalar UI at /openapi, spec at
  /openapi/json, with zod -> JSON Schema via mapJsonSchema.
- Rebuild index.html on the it3206-minisys design language (dark, white/10
  hairlines) with create/edit/delete, filters and a two-step delete confirm.
- Read index.html per request so frontend edits need no restart.
2026-09-16 20:26:04 +08:00

6.6 KiB
Raw Blame History

Meal API

A small food menu API with a single-page frontend.

A Meal is one item on the menu. The collection lives at /api/menu, and every route is validated with zod schemas that also generate the OpenAPI docs at /openapi.

Files

File Purpose
index.html The frontend. Markup, styles and script are all inline.
server.js The whole backend: zod schemas, the meals, the CRUD routes, the docs.
package.json So that elysia, @elysiajs/node, zod and @elysiajs/openapi can be installed.

There is no public/ folder and no separate menu.js, app.js or style.css — the app is index.html + server.js.

Run it

npm install
npm start          # node server.js

Then open http://localhost:3000. The docs are at http://localhost:3000/openapi.

Other ways to run the exact same file:

npm run dev        # node --watch server.js  (auto-restart on save)
npm run bun        # bun  server.js

server.js runs on both Node and Bun. Elysia serves through Bun.serve on Bun, and the file loads the @elysiajs/node adapter automatically whenever Bun isn't present, so neither runtime needs its own copy of the server.

index.html is read from disk per request, so editing it shows up on refresh without a restart.

Schema

server.js defines the meal once as a zod schema, and everything else follows from it: the request validation, the TypeScript types, the response validation and the OpenAPI spec.

const MEAL_CATEGORIES = ['appetizer', 'main', 'dessert', 'drink']

const mealDraftSchema = z.object({
  name: z.string().trim().min(1, 'Name is required.').max(80, 'Keep the name under 80 characters.'),
  category: z.enum(MEAL_CATEGORIES, { error: 'Pick a category: …' }),
  price: z.number({ error: 'Price is required.' })
    .nonnegative('Price cannot be negative.')
    .max(100000, 'That price looks too high.')
    .refine(hasTwoDecimals, 'Price can have at most 2 decimal places.'),
  description: z.string().trim().max(240, '…').optional(),
  available: z.boolean({ error: 'Available must be true or false.' }).optional(),
})

A stored meal is that draft plus the server-managed fields:

Field Type Notes
id integer ≥ 1 assigned by the server
name string, 1–80 chars trimmed
category enum appetizer · main · dessert · drink
price number ≥ 0 max 2 decimal places
description string, ≤ 240 chars defaults to ""
available boolean defaults to true
createdAt ISO datetime set on create
updatedAt ISO datetime bumped on every update

price is a real JSON number on purpose. An empty form field arrives as "" and is rejected with "Price is required." rather than being silently coerced into a ₱0 meal.

Endpoints

Method Path Result
GET / or /index.html the frontend
GET /openapi interactive API docs
GET /openapi/json the OpenAPI 3.1 spec
GET /api/menu list meals (filter + sort)
GET /api/menu/:id one meal
POST /api/menu create a meal → 201 + Location
PUT /api/menu/:id replace a meal
PATCH /api/menu/:id update only the fields you send
DELETE /api/menu/:id remove a meal → 204

Filtering and sorting

curl "http://localhost:3000/api/menu?category=main&available=true&sort=price&order=desc"
curl "http://localhost:3000/api/menu?search=adobo"

category · available · search (matches name and description) · sort (name, price, createdAt) · order (asc, desc). Plain /api/menu keeps insertion order.

Examples

curl http://localhost:3000/api/menu
curl http://localhost:3000/api/menu/2

curl -X POST http://localhost:3000/api/menu \
  -H 'content-type: application/json' \
  -d '{"name":"Halo-Halo","category":"dessert","price":65.5}'

curl -X PATCH http://localhost:3000/api/menu/2 \
  -H 'content-type: application/json' \
  -d '{"price":55}'

curl -X DELETE http://localhost:3000/api/menu/2

GET /api/menu → 200

[
  {
    "id": 1,
    "name": "Adobo",
    "category": "main",
    "price": 75,
    "description": "Pork belly simmered in soy sauce, vinegar and bay leaves.",
    "available": true,
    "createdAt": "2026-09-16T12:00:00.000Z",
    "updatedAt": "2026-09-16T12:00:00.000Z"
  }
]

Errors

Every failure returns the same shape — error for the message, fields for the inputs the frontend should highlight:

{
  "error": "Name is required.",
  "fields": {
    "name": "Name is required.",
    "category": "Pick a category: appetizer, main, dessert, drink.",
    "price": "Price is required."
  }
}

Elysia answers validation failures with 422 by default; onError normalizes them to 400.

Case Status Body
invalid body/query/param 400 { "error": "…", "fields": { … } }
id isn't a whole number ≥ 1 (/api/menu/abc) 400 { "error": "ID must be a whole number, like 1." }
id doesn't exist (/api/menu/99) 404 { "error": "No menu item with ID 99." }
unknown path (/nope) 404 Not found
unexpected server error 500 { "error": "Internal server error." }

Frontend

index.html is one page that talks to the API with fetch():

  • lists meals in a table, with the description under each name
  • searches and filters by category and availability (the search box is debounced)
  • creates meals with POST, edits them with PATCH (sending only the changed fields) and deletes them behind a two-step confirm
  • renders the API's fields messages under the matching inputs, so validation lives on the server and is displayed, not duplicated

Rows are built with createElement/textContent rather than innerHTML, so a meal name can never inject markup into the page.

The styling follows the dark design language used in the it3206-minisys project — neutral-900 background, white/10 hairlines, white/5 fills, rounded panels and a blurred navbar. It is hand-written CSS in the <style> tag, so the page needs no build step, no Tailwind and no CDN.

Notes

  • Meals live in memory, so restarting the server restores the original three. Swap the menu array for a database when the data needs to survive a restart.
  • The meal's name field is name. If something still expects the older food key, add food: z.string().optional() to the schema and map it in the handlers.
  • The /openapi page is the Scalar UI, which loads its assets from a CDN, so it needs internet access to render. /openapi/json is served entirely locally.