# 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 . The docs are at . 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 `