diff --git a/README.md b/README.md index db90d62..be432de 100644 --- a/README.md +++ b/README.md @@ -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 . +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 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 ` -
-

Food Menu

+ -
- - - -
+
+
+

Meals

+

Loading…

+
- + - - - - - - - - - - +
+ + + + + +
+ + + + + +
- diff --git a/server.js b/server.js index a305ec1..c6e4cb8 100644 --- a/server.js +++ b/server.js @@ -1,62 +1,364 @@ +/** + * Meal API — the whole backend in one file. + * + * - Domain: a **Meal** is one item on the menu. The collection lives at /api/menu. + * - Validation: zod v4 schemas are the single source of truth. Elysia consumes them + * directly (Standard Schema), so the same schema validates the request, types the + * handler and documents the endpoint in the OpenAPI spec at /openapi. + * - Runs on Node (`node server.js`) and Bun (`bun server.js`) from this same file. + */ import { Elysia, status } from 'elysia' +import { node } from '@elysiajs/node' +import { openapi } from '@elysiajs/openapi' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' +import { z } from 'zod' const PORT = process.env.PORT || 3000 -// Resolve index.html next to this file, so it works no matter where you run it from. -const INDEX_HTML = readFileSync(fileURLToPath(new URL('./index.html', import.meta.url)), 'utf8') +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- -const HTML_HEADERS = { - 'content-type': 'text/html; charset=utf-8', - 'content-length': String(Buffer.byteLength(INDEX_HTML)), +/** The categories a meal can belong to. */ +const MEAL_CATEGORIES = ['appetizer', 'main', 'dessert', 'drink'] + +const ID_MESSAGE = 'ID must be a whole number, like 1.' + +/** `:id` path param. Coerces "2" to 2 and rejects "abc", "0", "-1" and "1.5". */ +const mealIdSchema = z.coerce.number({ error: ID_MESSAGE }).int(ID_MESSAGE).min(1, ID_MESSAGE) + +/** What a client sends to create (or fully replace) a meal. */ +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: ${MEAL_CATEGORIES.join(', ')}.`, + }), + // Strict number on purpose: an empty form field arrives as "" and must be + // rejected as missing, never quietly coerced into a ₱0 meal. + price: z + .number({ error: 'Price is required.' }) + .nonnegative('Price cannot be negative.') + .max(100000, 'That price looks too high.') + .refine( + (value) => Math.round(value * 100) / 100 === value, + 'Price can have at most 2 decimal places.', + ), + description: z.string().trim().max(240, 'Keep the description under 240 characters.').optional(), + available: z.boolean({ error: 'Available must be true or false.' }).optional(), +}) + +/** What the API stores and returns: the draft, plus the server-managed fields. */ +const mealSchema = mealDraftSchema.required({ description: true, available: true }).extend({ + id: z.number().int().min(1), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +/** PATCH body — every field optional, but an empty patch is rejected. */ +const mealPatchSchema = mealDraftSchema + .partial() + .refine((patch) => Object.keys(patch).length > 0, 'Provide at least one field to update.') + +/** Filtering / sorting for the list endpoint. */ +const mealQuerySchema = z.object({ + category: z.enum(MEAL_CATEGORIES, { + error: `Unknown category. Pick one of: ${MEAL_CATEGORIES.join(', ')}.`, + }).optional(), + available: z.stringbool({ error: 'available must be true or false.' }).optional(), + search: z.string().trim().min(1, 'Search cannot be empty.').optional(), + sort: z.enum(['name', 'price', 'createdAt'], { error: 'Sort by name, price or createdAt.' }).optional(), + order: z.enum(['asc', 'desc'], { error: 'Order must be asc or desc.' }).optional(), +}) + +/** Every error the API returns: a message, plus per-field messages when relevant. */ +const errorSchema = z.object({ + error: z.string(), + fields: z.record(z.string(), z.string()).optional(), +}) + +// --------------------------------------------------------------------------- +// Store — in-memory, seeded with the original three meals +// --------------------------------------------------------------------------- + +let nextId = 1 + +/** @type {Array>} */ +const menu = [] + +function seed(name, category, price, description) { + const at = new Date().toISOString() + menu.push({ + id: nextId++, + name, + category, + price, + description, + available: true, + createdAt: at, + updatedAt: at, + }) } -// The food menu data. -const menu = [ - { id: 1, food: 'Adobo', price: 75 }, - { id: 2, food: 'Fried Chicken', price: 50 }, - { id: 3, food: 'Yum Burger', price: 70 }, -] +seed('Adobo', 'main', 75, 'Pork belly simmered in soy sauce, vinegar and bay leaves.') +seed('Fried Chicken', 'main', 50, 'Buttermilk-brined thigh, dredged and fried to order.') +seed('Yum Burger', 'main', 70, 'Beef patty, cheese and house sauce on a toasted bun.') -// Elysia serves through Bun.serve on Bun, but it needs the Node adapter on Node. -// This one file therefore runs on either runtime: `bun server.js` or `node server.js`. +const findMeal = (id) => menu.find((meal) => meal.id === id) + +/** The message every "no such meal" response uses. */ +const missingMeal = (id) => ({ error: `No menu item with ID ${id}.` }) + +// --------------------------------------------------------------------------- +// The frontend +// --------------------------------------------------------------------------- + +// Read per request, so editing index.html shows up without restarting the server. +const INDEX_HTML_PATH = fileURLToPath(new URL('./index.html', import.meta.url)) + +function indexResponse() { + const html = readFileSync(INDEX_HTML_PATH, 'utf8') + + return new Response(html, { + headers: { + 'content-type': 'text/html; charset=utf-8', + 'content-length': String(Buffer.byteLength(html)), + }, + }) +} + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +// Elysia serves through Bun.serve on Bun, but needs the Node adapter on Node. const adapter = typeof Bun === 'undefined' ? (await import('@elysiajs/node')).node() : undefined new Elysia(adapter ? { adapter } : {}) - .onRequest(({ request }) => { - if (request.method !== 'GET') { - return status(405, { error: 'Method not allowed' }) + .use( + openapi({ + path: '/openapi', + // zod -> JSON Schema, so the docs describe the real schemas. + mapJsonSchema: { zod: z.toJSONSchema }, + documentation: { + info: { + title: 'Meal API', + version: '1.0.0', + description: + 'A small food menu API. A meal is one item on the menu; /api/menu is the collection.', + }, + tags: [ + { name: 'Menu', description: 'Browse, create, update and delete meals.' }, + ], + }, + }), + ) + + // Turn every zod validation failure into one consistent error shape. + // Elysia defaults to 422 here; we answer 400 with the first message plus + // a field-by-field breakdown the frontend can render next to each input. + .onError(({ code, error, status }) => { + if (code === 'VALIDATION') { + const issues = (error.all ?? []).map((issue) => ({ + key: typeof issue.path === 'string' ? issue.path : issue.path?.[0], + message: issue.message, + })) + + const fields = {} + for (const { key, message } of issues) { + if (typeof key === 'string' && !(key in fields)) fields[key] = message + } + + return status(400, { error: issues[0]?.message ?? 'Invalid request.', fields }) } + + if (code === 'NOT_FOUND') return status(404, 'Not found') + + console.error('Unhandled error:', error) + return status(500, { error: 'Internal server error.' }) }) - // GET /api/menu -> the whole menu - .get('/api/menu', () => menu) + // ---- Read ----------------------------------------------------------------- - // GET /api/menu/:id -> one item - .get('/api/menu/:id', ({ params: { id }, status }) => { - const num = Number(id) + // GET /api/menu -> the whole menu, optionally filtered and sorted + .get( + '/api/menu', + ({ query }) => { + let items = [...menu] - if (!Number.isInteger(num) || num < 1) { - return status(400, { error: 'ID must be a whole number, like 1.' }) - } + if (query.category) items = items.filter((meal) => meal.category === query.category) + if (query.available !== undefined) { + items = items.filter((meal) => meal.available === query.available) + } - const item = menu.find((food) => food.id === num) - if (!item) { - return status(404, { error: `No menu item with ID ${num}.` }) - } + if (query.search) { + const needle = query.search.toLowerCase() + items = items.filter( + (meal) => + meal.name.toLowerCase().includes(needle) || + meal.description.toLowerCase().includes(needle), + ) + } - return item - }) + if (query.sort) { + const direction = query.order === 'desc' ? -1 : 1 + items.sort((a, b) => { + const left = a[query.sort] + const right = b[query.sort] + if (left === right) return 0 + return (left > right ? 1 : -1) * direction + }) + } - // GET / -> the frontend - .get('/', () => new Response(INDEX_HTML, { headers: HTML_HEADERS })) - .get('/index.html', () => new Response(INDEX_HTML, { headers: HTML_HEADERS })) + return items + }, + { + query: mealQuerySchema, + response: { 200: z.array(mealSchema) }, + detail: { + summary: 'List meals', + description: 'Returns every meal. Supports category, availability, search, sort and order.', + tags: ['Menu'], + }, + }, + ) - .get('*', () => status(404, 'Not found')) + // GET /api/menu/:id -> one meal + .get( + '/api/menu/:id', + ({ params: { id }, status }) => { + const meal = findMeal(id) + if (!meal) return status(404, missingMeal(id)) + return meal + }, + { + params: z.object({ id: mealIdSchema }), + response: { 200: mealSchema, 404: errorSchema }, + detail: { summary: 'Get one meal', tags: ['Menu'] }, + }, + ) + + // ---- Create --------------------------------------------------------------- + + // POST /api/menu -> add a meal + .post( + '/api/menu', + ({ body, set, status }) => { + const at = new Date().toISOString() + + const meal = { + id: nextId++, + name: body.name, + category: body.category, + price: body.price, + description: body.description ?? '', + available: body.available ?? true, + createdAt: at, + updatedAt: at, + } + + menu.push(meal) + set.headers.location = `/api/menu/${meal.id}` + + return status(201, meal) + }, + { + body: mealDraftSchema, + response: { 201: mealSchema, 400: errorSchema }, + detail: { summary: 'Create a meal', tags: ['Menu'] }, + }, + ) + + // ---- Update --------------------------------------------------------------- + + // PUT /api/menu/:id -> replace a meal (omitted optional fields reset) + .put( + '/api/menu/:id', + ({ params: { id }, body, status }) => { + const index = menu.findIndex((meal) => meal.id === id) + if (index === -1) return status(404, missingMeal(id)) + + const replaced = { + id, + name: body.name, + category: body.category, + price: body.price, + description: body.description ?? '', + available: body.available ?? true, + createdAt: menu[index].createdAt, + updatedAt: new Date().toISOString(), + } + + menu[index] = replaced + return replaced + }, + { + params: z.object({ id: mealIdSchema }), + body: mealDraftSchema, + response: { 200: mealSchema, 404: errorSchema }, + detail: { summary: 'Replace a meal', tags: ['Menu'] }, + }, + ) + + // PATCH /api/menu/:id -> update only the fields you send + .patch( + '/api/menu/:id', + ({ params: { id }, body, status }) => { + const index = menu.findIndex((meal) => meal.id === id) + if (index === -1) return status(404, missingMeal(id)) + + const updated = { + ...menu[index], + ...body, + id, + createdAt: menu[index].createdAt, + updatedAt: new Date().toISOString(), + } + + menu[index] = updated + return updated + }, + { + params: z.object({ id: mealIdSchema }), + body: mealPatchSchema, + response: { 200: mealSchema, 404: errorSchema }, + detail: { summary: 'Update a meal', tags: ['Menu'] }, + }, + ) + + // ---- Delete --------------------------------------------------------------- + + // DELETE /api/menu/:id -> remove a meal + .delete( + '/api/menu/:id', + ({ params: { id }, status }) => { + const index = menu.findIndex((meal) => meal.id === id) + if (index === -1) return status(404, missingMeal(id)) + + menu.splice(index, 1) + return status(204) + }, + { + params: z.object({ id: mealIdSchema }), + response: { 404: errorSchema }, + detail: { summary: 'Delete a meal', tags: ['Menu'] }, + }, + ) + + // ---- Frontend ------------------------------------------------------------- + + .get('/', indexResponse, { detail: { hide: true } }) + .get('/index.html', indexResponse, { detail: { hide: true } }) + + .get('*', () => status(404, 'Not found'), { detail: { hide: true } }) .listen(PORT) const runtime = typeof Bun === 'undefined' ? 'Node.js' : 'Bun' console.log(`Meal API running at http://localhost:${PORT} (${runtime})`) +console.log(`API docs at http://localhost:${PORT}/openapi`)