Files
bonpin-adrian-movie-api/server.js
T

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