- 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.
365 lines
12 KiB
JavaScript
365 lines
12 KiB
JavaScript
/**
|
|
* 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
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Schemas
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** 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,
|
|
})
|
|
}
|
|
|
|
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.')
|
|
|
|
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 } : {})
|
|
.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.' })
|
|
})
|
|
|
|
// ---- Read -----------------------------------------------------------------
|
|
|
|
// GET /api/menu -> the whole menu, optionally filtered and sorted
|
|
.get(
|
|
'/api/menu',
|
|
({ query }) => {
|
|
let items = [...menu]
|
|
|
|
if (query.category) items = items.filter((meal) => meal.category === query.category)
|
|
if (query.available !== undefined) {
|
|
items = items.filter((meal) => meal.available === query.available)
|
|
}
|
|
|
|
if (query.search) {
|
|
const needle = query.search.toLowerCase()
|
|
items = items.filter(
|
|
(meal) =>
|
|
meal.name.toLowerCase().includes(needle) ||
|
|
meal.description.toLowerCase().includes(needle),
|
|
)
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
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 /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`)
|