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.
This commit is contained in:
2026-09-16 20:26:04 +08:00
parent 3899025fa3
commit a7e7ad238d
3 changed files with 1447 additions and 201 deletions
+134 -37
View File
@@ -2,16 +2,19 @@
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 (page, styles and script are all inline) |
| `server.js` | The whole backend: the menu data + the routes |
| `package.json` | Only needed so `elysia` can be installed |
| `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. |
`index.html` and `server.js` are the whole application — no `public/` folder,
no separate `menu.js`, `app.js` or `style.css`.
There is no `public/` folder and no separate `menu.js`, `app.js` or `style.css` — the app is
`index.html` + `server.js`.
## Run it
@@ -20,71 +23,165 @@ npm install
npm start # node server.js
```
Then open <http://localhost:3000>.
Then open <http://localhost:3000>. The docs are at <http://localhost:3000/openapi>.
Other ways to run the exact same file:
```sh
npm run dev # node --watch server.js (auto-restart on save)
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 `server.js` loads the `@elysiajs/node` adapter automatically whenever
Bun isn't present, so neither runtime needs its own version of the file.
`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.
```js
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 | `/api/menu` | the whole menu |
| GET | `/api/menu/:id` | one menu item |
| 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
```sh
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
```sh
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
```
### Responses
`GET /api/menu` -> `200`
`GET /api/menu` → `200`
```json
[
{ "id": 1, "food": "Adobo", "price": 75 },
{ "id": 2, "food": "Fried Chicken", "price": 50 },
{ "id": 3, "food": "Yum Burger", "price": 70 }
{
"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"
}
]
```
`GET /api/menu/2` -> `200`
### Errors
Every failure returns the same shape — `error` for the message, `fields` for the inputs the
frontend should highlight:
```json
{ "id": 2, "food": "Fried Chicken", "price": 50 }
{
"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." }` |
| id isn't a whole number (`/api/menu/abc`, `/api/menu/0`, `/api/menu/1.5`) | `400` | `{ "error": "ID must be a whole number, like 1." }` |
| any non-GET method | `405` | `{ "error": "Method not allowed" }` |
| unknown path (`/nope`) | `404` | `Not found` |
## Menu data
Defined in `server.js`:
```js
const menu = [
{ id: 1, food: 'Adobo', price: 75 },
{ id: 2, food: 'Fried Chicken', price: 50 },
{ id: 3, food: 'Yum Burger', price: 70 },
]
```
| unexpected server error | `500` | `{ "error": "Internal server error." }` |
## Frontend
`index.html` lets you either load the whole menu or look up a single item by ID.
It fetches the API with `fetch()`, renders the results into a table, and shows a
message instead of the table whenever the API returns an error.
`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](https://git.ranio.xyz/adrianbonpin/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.