/** * 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>} */ 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`)