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

188 lines
6.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```sh
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:
```sh
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.
```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 | `/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
```
`GET /api/menu` → `200`
```json
[
{
"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:
```json
{
"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](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.