/** * Movie Collection API — the whole backend in one file. * * - Domain: a **Movie** is one entry in the collection. The collection lives at /api/movies. * - Storage: a plain JavaScript array. There is no database, so anything you add * disappears when the server restarts. * - Validation: zod v4 schemas are the single source of truth. They validate the * request and produce the per-field error messages the frontend renders. * - Runs on Node (`node server.js`), which is all this assignment needs. */ import express from 'express' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { z } from 'zod' const PORT = process.env.PORT || 3000 // --------------------------------------------------------------------------- // Schemas // --------------------------------------------------------------------------- const ID_MESSAGE = 'ID must be a whole number, like 1.' /** `:id` path param. Accepts "2", rejects "abc", "0", "-1" and "1.5". */ const movieIdSchema = z.coerce.number({ error: ID_MESSAGE }).int(ID_MESSAGE).min(1, ID_MESSAGE) /** "2010" (from a form or a query string) and 2010 are the same year. */ const yearSchema = z.preprocess( (value) => { if (value === null || value === undefined) return undefined if (typeof value !== 'string') return value const trimmed = value.trim() if (trimmed === '') return undefined return /^-?[0-9]+(\.[0-9]+)?$/.test(trimmed) ? Number(trimmed) : value }, z .number({ error: (issue) => issue.input === undefined ? 'Year is required.' : 'Year must be a number, like 2010.', }) .int('Year must be a whole year, like 2010.') .min(1888, 'Year must be 1888 or later.') .max(2100, 'Year must be 2100 or earlier.'), ) /** The fields a client sends. All three are required. */ const movieFields = { title: z .string({ error: 'Title is required.' }) .trim() .min(1, 'Title is required.') .max(120, 'Keep the title under 120 characters.'), genre: z .string({ error: 'Genre is required.' }) .trim() .min(1, 'Genre is required.') .max(60, 'Keep the genre under 60 characters.'), year: yearSchema, } /** What a client sends to add a movie. */ const movieDraftSchema = z.object(movieFields) /** What the API stores and returns: the draft, plus the server-assigned id. */ const movieSchema = z.object({ id: z.number().int().min(1), ...movieFields }) /** Every response that returns more than one movie. */ const movieListSchema = z.array(movieSchema) /** 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 three movies the frontend starts with // --------------------------------------------------------------------------- let nextId = 1 /** @type {Array>} */ const movies = [] function seed(title, genre, year) { movies.push({ id: nextId++, title, genre, year }) } seed('Interstellar', 'Science Fiction', 2014) seed('Avengers: Endgame', 'Action', 2019) seed('Coco', 'Animation', 2017) const findMovie = (id) => movies.find((movie) => movie.id === id) /** The message every "no such movie" response uses. */ const missingMovie = (id) => ({ error: `No movie with ID ${id}.` }) // --------------------------------------------------------------------------- // Validation helpers // --------------------------------------------------------------------------- /** * Turns a zod failure into the API's one error shape: a summary message plus a * `fields` map the frontend can render next to each input. */ function validationError(error) { const fields = {} for (const issue of error.issues) { const key = issue.path[0] if (typeof key === 'string' && !(key in fields)) fields[key] = issue.message } return { error: error.issues[0]?.message ?? 'Invalid request.', fields } } /** Validates a body/params object; returns either `{ data }` or `{ error }`. */ function parse(schema, value) { const result = schema.safeParse(value) return result.success ? { data: result.data } : { error: validationError(result.error) } } // --------------------------------------------------------------------------- // The frontend // --------------------------------------------------------------------------- const INDEX_HTML_PATH = fileURLToPath(new URL('./index.html', import.meta.url)) // Read per request, so editing index.html shows up without restarting the server. async function indexResponse(req, res) { const html = await readFile(INDEX_HTML_PATH, 'utf8') res.type('html').send(html) } // --------------------------------------------------------------------------- // App // --------------------------------------------------------------------------- const app = express() app.disable('x-powered-by') app.use(express.json()) // ---- Read ------------------------------------------------------------------- // GET /api/movies -> the whole collection app.get('/api/movies', (req, res) => { res.json(movieListSchema.parse(movies)) }) // GET /api/movies/:id -> one movie app.get('/api/movies/:id', (req, res) => { const params = parse(z.object({ id: movieIdSchema }), req.params) if (params.error) return res.status(400).json(params.error) const movie = findMovie(params.data.id) if (!movie) return res.status(404).json(missingMovie(params.data.id)) res.json(movieSchema.parse(movie)) }) // ---- Create ----------------------------------------------------------------- // POST /api/movies -> add a movie, with the id assigned by the server app.post('/api/movies', (req, res) => { const body = parse(movieDraftSchema, req.body) if (body.error) return res.status(400).json(body.error) const movie = { id: nextId++, ...body.data } movies.push(movie) res.status(201).location(`/api/movies/${movie.id}`).json(movieSchema.parse(movie)) }) // ---- Frontend --------------------------------------------------------------- app.get('/', indexResponse) app.get('/index.html', indexResponse) // ---- Fallbacks -------------------------------------------------------------- app.use('/api', (req, res) => { res.status(404).json({ error: `No route for ${req.method} ${req.originalUrl}.` }) }) // Anything the API did not answer is a dead link, not a JSON error. app.use((req, res) => { res.status(404).type('txt').send('Not found') }) // Malformed JSON and anything unexpected land here. app.use((error, req, res, next) => { if (error?.type === 'entity.parse.failed') { return res.status(400).json({ error: 'Request body must be valid JSON.' }) } console.error('Unhandled error:', error) res.status(500).json({ error: 'Internal server error.' }) }) app.listen(PORT, () => { console.log(`Movie API running at http://localhost:${PORT}`) console.log(`${movies.length} movies in the in-memory collection`) })