117 lines
3.3 KiB
Markdown
117 lines
3.3 KiB
Markdown
# Meal API
|
||
|
||
A food menu API with a single-page frontend. A **Meal** is one item on the menu; the
|
||
collection is `/api/menu`, validated with zod and documented at `/openapi`.
|
||
|
||
## Running
|
||
|
||
```sh
|
||
npm install
|
||
npm start # node server.js
|
||
```
|
||
|
||
Open <http://localhost:3000>. API docs: <http://localhost:3000/openapi>.
|
||
|
||
```sh
|
||
npm run dev # node --watch server.js
|
||
npm run bun # bun server.js (also runs on Node)
|
||
```
|
||
|
||
## Schema
|
||
|
||
```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.')
|
||
.refine((v) => Math.round(v * 100) / 100 === v, '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(),
|
||
})
|
||
```
|
||
|
||
The same schema validates the request, types the handler and generates the OpenAPI spec.
|
||
|
||
| 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 | at most 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 |
|
||
|
||
## Endpoints
|
||
|
||
| Method | Path | Result |
|
||
| --- | --- | --- |
|
||
| GET | `/` | the frontend |
|
||
| GET | `/openapi` | interactive API docs |
|
||
| GET | `/openapi/json` | the OpenAPI spec |
|
||
| GET | `/api/menu` | list meals |
|
||
| GET | `/api/menu/:id` | one meal |
|
||
| POST | `/api/menu` | create a meal → `201` |
|
||
| 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` |
|
||
|
||
`GET /api/menu` also accepts `category`, `available`, `search`, `sort` (`name`, `price`,
|
||
`createdAt`) and `order` (`asc`, `desc`).
|
||
|
||
Errors come back as a message plus the offending fields:
|
||
|
||
```json
|
||
{ "error": "Name is required.", "fields": { "name": "Name is required." } }
|
||
```
|
||
|
||
## Examples
|
||
|
||
```sh
|
||
curl http://localhost:3000/api/menu
|
||
curl "http://localhost:3000/api/menu?category=main&sort=price&order=desc"
|
||
curl "http://localhost:3000/api/menu?search=adobo"
|
||
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"
|
||
}
|
||
]
|
||
```
|
||
|
||
`GET /api/menu/abc` → `400` · `GET /api/menu/99` → `404`
|
||
|
||
```json
|
||
{ "error": "ID must be a whole number, like 1." }
|
||
```
|
||
|
||
```json
|
||
{ "error": "No menu item with ID 99." }
|
||
```
|