Express app with an in-memory array as the store (no database) and three endpoints: - GET /api/movies -> the whole collection - GET /api/movies/:id -> one movie, 404 when the id is unknown - POST /api/movies -> add a movie, id assigned by the server zod schemas are the single source of truth for validation, so a missing title, genre or year comes back as a 400 with one message per field. index.html is served by the same app and drives all three endpoints with fetch(), refreshing the list after a movie is added.
205 lines
6.9 KiB
JavaScript
205 lines
6.9 KiB
JavaScript
/**
|
|
* 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<z.infer<typeof movieSchema>>} */
|
|
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`)
|
|
})
|