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:
@@ -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,7 +23,7 @@ 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:
|
||||
|
||||
@@ -29,62 +32,156 @@ 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.
|
||||
|
||||
+971
-124
File diff suppressed because it is too large
Load Diff
@@ -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<z.infer<typeof mealSchema>>} */
|
||||
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`)
|
||||
|
||||
Reference in New Issue
Block a user