Add movie collection API and frontend
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.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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`)
|
||||
})
|
||||
Reference in New Issue
Block a user