Trim the README to running instructions, schema, endpoints and examples
This commit is contained in:
@@ -1,48 +1,24 @@
|
|||||||
# Meal API
|
# Meal API
|
||||||
|
|
||||||
A small food menu API with a single-page frontend.
|
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`.
|
||||||
|
|
||||||
A **Meal** is one item on the menu. The collection lives at `/api/menu`, and every route is
|
## Running
|
||||||
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
|
```sh
|
||||||
npm install
|
npm install
|
||||||
npm start # node server.js
|
npm start # node server.js
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open <http://localhost:3000>. The docs are at <http://localhost:3000/openapi>.
|
Open <http://localhost:3000>. API docs: <http://localhost:3000/openapi>.
|
||||||
|
|
||||||
Other ways to run the exact same file:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run dev # node --watch server.js (auto-restart on save)
|
npm run dev # node --watch server.js
|
||||||
npm run bun # bun server.js
|
npm run bun # bun server.js (also runs on Node)
|
||||||
```
|
```
|
||||||
|
|
||||||
`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
|
## 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
|
```js
|
||||||
const MEAL_CATEGORIES = ['appetizer', 'main', 'dessert', 'drink']
|
const MEAL_CATEGORIES = ['appetizer', 'main', 'dessert', 'drink']
|
||||||
|
|
||||||
@@ -51,57 +27,54 @@ const mealDraftSchema = z.object({
|
|||||||
category: z.enum(MEAL_CATEGORIES, { error: 'Pick a category: …' }),
|
category: z.enum(MEAL_CATEGORIES, { error: 'Pick a category: …' }),
|
||||||
price: z.number({ error: 'Price is required.' })
|
price: z.number({ error: 'Price is required.' })
|
||||||
.nonnegative('Price cannot be negative.')
|
.nonnegative('Price cannot be negative.')
|
||||||
.max(100000, 'That price looks too high.')
|
.refine((v) => Math.round(v * 100) / 100 === v, 'Price can have at most 2 decimal places.'),
|
||||||
.refine(hasTwoDecimals, 'Price can have at most 2 decimal places.'),
|
|
||||||
description: z.string().trim().max(240, '…').optional(),
|
description: z.string().trim().max(240, '…').optional(),
|
||||||
available: z.boolean({ error: 'Available must be true or false.' }).optional(),
|
available: z.boolean({ error: 'Available must be true or false.' }).optional(),
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
A stored meal is that draft plus the server-managed fields:
|
The same schema validates the request, types the handler and generates the OpenAPI spec.
|
||||||
|
|
||||||
| Field | Type | Notes |
|
| Field | Type | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | integer ≥ 1 | assigned by the server |
|
| `id` | integer ≥ 1 | assigned by the server |
|
||||||
| `name` | string, 1–80 chars | trimmed |
|
| `name` | string, 1–80 chars | trimmed |
|
||||||
| `category` | enum | `appetizer` · `main` · `dessert` · `drink` |
|
| `category` | enum | `appetizer` · `main` · `dessert` · `drink` |
|
||||||
| `price` | number ≥ 0 | max 2 decimal places |
|
| `price` | number ≥ 0 | at most 2 decimal places |
|
||||||
| `description` | string, ≤ 240 chars | defaults to `""` |
|
| `description` | string, ≤ 240 chars | defaults to `""` |
|
||||||
| `available` | boolean | defaults to `true` |
|
| `available` | boolean | defaults to `true` |
|
||||||
| `createdAt` | ISO datetime | set on create |
|
| `createdAt` | ISO datetime | set on create |
|
||||||
| `updatedAt` | ISO datetime | bumped on every update |
|
| `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
|
## Endpoints
|
||||||
|
|
||||||
| Method | Path | Result |
|
| Method | Path | Result |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/` or `/index.html` | the frontend |
|
| GET | `/` | the frontend |
|
||||||
| GET | `/openapi` | interactive API docs |
|
| GET | `/openapi` | interactive API docs |
|
||||||
| GET | `/openapi/json` | the OpenAPI 3.1 spec |
|
| GET | `/openapi/json` | the OpenAPI spec |
|
||||||
| GET | `/api/menu` | list meals (filter + sort) |
|
| GET | `/api/menu` | list meals |
|
||||||
| GET | `/api/menu/:id` | one meal |
|
| GET | `/api/menu/:id` | one meal |
|
||||||
| POST | `/api/menu` | create a meal → `201` + `Location` |
|
| POST | `/api/menu` | create a meal → `201` |
|
||||||
| PUT | `/api/menu/:id` | replace a meal |
|
| PUT | `/api/menu/:id` | replace a meal |
|
||||||
| PATCH | `/api/menu/:id` | update only the fields you send |
|
| PATCH | `/api/menu/:id` | update only the fields you send |
|
||||||
| DELETE | `/api/menu/:id` | remove a meal → `204` |
|
| DELETE | `/api/menu/:id` | remove a meal → `204` |
|
||||||
|
|
||||||
### Filtering and sorting
|
`GET /api/menu` also accepts `category`, `available`, `search`, `sort` (`name`, `price`,
|
||||||
|
`createdAt`) and `order` (`asc`, `desc`).
|
||||||
|
|
||||||
```sh
|
Errors come back as a message plus the offending fields:
|
||||||
curl "http://localhost:3000/api/menu?category=main&available=true&sort=price&order=desc"
|
|
||||||
curl "http://localhost:3000/api/menu?search=adobo"
|
```json
|
||||||
|
{ "error": "Name is required.", "fields": { "name": "Name is required." } }
|
||||||
```
|
```
|
||||||
|
|
||||||
`category` · `available` · `search` (matches name and description) · `sort` (`name`, `price`,
|
## Examples
|
||||||
`createdAt`) · `order` (`asc`, `desc`). Plain `/api/menu` keeps insertion order.
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl http://localhost:3000/api/menu
|
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 http://localhost:3000/api/menu/2
|
||||||
|
|
||||||
curl -X POST http://localhost:3000/api/menu \
|
curl -X POST http://localhost:3000/api/menu \
|
||||||
@@ -132,56 +105,12 @@ curl -X DELETE http://localhost:3000/api/menu/2
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Errors
|
`GET /api/menu/abc` → `400` · `GET /api/menu/99` → `404`
|
||||||
|
|
||||||
Every failure returns the same shape — `error` for the message, `fields` for the inputs the
|
|
||||||
frontend should highlight:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "error": "ID must be a whole number, like 1." }
|
||||||
"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`.
|
```json
|
||||||
|
{ "error": "No menu item with ID 99." }
|
||||||
| 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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user