Building the Express API

Personal Catalogue: React, Express & MongoDB

Chapter 3 · Building the Express API

Chapter 2 built a real Mongoose schema — one shared items collection, four discriminator subtypes, real per-type validation — but nothing has actually read or written it over HTTP yet. This chapter builds the five real routes every later feature (the add-item form, tag filtering, search, the list and detail views) will call: create, list, fetch one, update, and delete.

The Shape of the API

Five endpoints, all mounted under /api/items:

MethodPathPurpose
POST/api/itemsCreate a new item of whichever type the request body names
GET/api/itemsList every item, across all four types, in one response
GET/api/items/:idFetch a single item by its MongoDB _id
PUT/api/items/:idUpdate a single item, validated against its own real type's schema
DELETE/api/items/:idRemove a single item, regardless of its type

Create and update both need to know which discriminator model applies before they can do anything useful — read and delete, it turns out, don't. That distinction shapes every route below.

Creating an Item: Dispatching to the Right Discriminator Model

A request to create an item doesn't say "create a Book" by calling a Book-specific endpoint — it posts to the one shared /api/items path and names its own type in the body's itemType field. The route reads that field and looks up the matching model from a small map built directly from Chapter 2's own four discriminators:

// routes/items.js const express = require('express'); const router = express.Router(); const { Item } = require('../models/Item'); const Book = require('../models/Book'); const Cd = require('../models/Cd'); const Dvd = require('../models/Dvd'); const Bluray = require('../models/Bluray'); const MODEL_MAP = { Book, Cd, Dvd, Bluray };
// routes/items.js (continued) // POST /api/items — create an item of whichever type the request names router.post('/', async (req, res) => { const Model = MODEL_MAP[req.body.itemType]; if (!Model) { return res.status(400).json({ error: `Unknown itemType: ${req.body.itemType}` }); } try { const item = await Model.create(req.body); res.status(201).json(item); } catch (err) { if (err.name === 'ValidationError') { return res.status(400).json({ error: err.message }); } res.status(500).json({ error: 'Server error' }); } });

An itemType that isn't one of the four registered discriminators (a typo, a missing field, an attempt to invent a fifth type) fails fast with a real 400 before Mongoose ever sees the request — there's no generic "create a document" fallback to fall back to, since every write has to go through one specific discriminator model or the other.

Validation Still Runs Per-Type, Automatically
Book.create() and Cd.create() both run through the exact same route handler, but each one enforces its own discriminator schema — a Book missing author fails with a real Mongoose ValidationError; a Cd missing artist fails the same way. The route itself never checks for a specific required field — it doesn't need to, since Model.create() already knows which schema applies.

Reading Items Across Every Type — Resolving Chapter 2's Own Limitation

Chapter 2's own warn-box flagged a real, documented Mongoose behavior: a document fetched through the plain Item model doesn't expose discriminator-specific fields like author or artist, even though the raw stored document actually has that data — Mongoose's default strict mode only attaches paths that exist on the model doing the querying, and author isn't a path on Item's own base schema.

.lean() is the real, documented fix. A lean query skips Mongoose's usual document hydration entirely and returns a plain JavaScript object built straight from the raw stored data — no schema-based filtering applied at all:

// routes/items.js (continued) // GET /api/items — list every item, across every type, in one response router.get('/', async (req, res) => { const items = await Item.find().lean(); res.json(items); });
Confirmed Directly Against a Real Mixed-Type Result
Querying Item.find() without .lean() against the same four seeded documents from Chapter 2 returns JSON where every item shows only _id, title, releaseYear, notes, and itemType — the Book's own author and the Dvd's own director are genuinely missing from the response, exactly as Chapter 2's warn-box predicted. Adding .lean() to the identical query, with no other change, returns every field each document actually has stored — author reappears on the Book, director reappears on the Dvd — because a lean query never applies the base schema's field list to begin with.

Reading and Updating a Single Item

A single-item fetch uses the identical .lean() approach for the same reason:

// routes/items.js (continued) // GET /api/items/:id — fetch a single item by its real MongoDB _id router.get('/:id', async (req, res) => { const item = await Item.findById(req.params.id).lean(); if (!item) { return res.status(404).json({ error: 'Item not found' }); } res.json(item); });

Updating is genuinely different, because a write has the same per-type validation problem creation does. The route reads the item's own stored itemType first — that field lives on the base schema, so a plain (non-lean) Item.findById() already returns it correctly — then routes the actual update through the matching discriminator model, exactly the way POST does:

// routes/items.js (continued) // PUT /api/items/:id — update a single item, validated against its own real type router.put('/:id', async (req, res) => { const existing = await Item.findById(req.params.id); if (!existing) { return res.status(404).json({ error: 'Item not found' }); } const Model = MODEL_MAP[existing.itemType]; try { const updated = await Model.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true, }).lean(); res.json(updated); } catch (err) { if (err.name === 'ValidationError') { return res.status(400).json({ error: err.message }); } res.status(500).json({ error: 'Server error' }); } });
runValidators Defaults to False
Mongoose's findByIdAndUpdate() does not run schema validators by default — an update that violates a required field or an enum constraint (like Bluray's own resolution field) would silently succeed without runValidators: true explicitly set. This is a real, documented, easy-to-miss default — not a Chapter 3-specific quirk — and it's the reason the option appears explicitly above rather than being assumed.

Updating through Model.findByIdAndUpdate() rather than the base Item.findByIdAndUpdate() matters for the same reason create does: if the update ran through Item directly, a request trying to change a Book's own author field would have that field silently stripped, since author isn't part of the base schema Item's own update logic checks against.

Deleting an Item: The One Route That Doesn't Need Dispatch

Deletion is the odd one out, and deliberately simpler for it. Removing a document by its _id doesn't touch any field at all — it doesn't matter whether the document is a Book, a Cd, a Dvd, or a Bluray, since the operation is "delete whatever's at this _id," not "delete according to this schema." The base Item model handles it directly:

// routes/items.js (continued) // DELETE /api/items/:id — remove a single item, regardless of its type router.delete('/:id', async (req, res) => { const deleted = await Item.findByIdAndDelete(req.params.id); if (!deleted) { return res.status(404).json({ error: 'Item not found' }); } res.status(204).send(); }); module.exports = router;

Wiring the Router Into server.js

Chapter 1's server.js confirmed a MongoDB connection with no routes at all. Mounting this file is a two-line addition, placed after the connection is established:

// server.js (additions) const itemsRouter = require('./routes/items'); app.use('/api/items', itemsRouter);

Trying It End to End

With npx nodemon server.js running, all five routes are testable directly against a real database with curl:

# Create a book curl -X POST http://localhost:4000/api/items \ -H "Content-Type: application/json" \ -d '{"itemType":"Book","title":"Clean Code","author":"Robert C. Martin","tags":["Programming"]}' # List everything — author should be visible thanks to .lean() curl http://localhost:4000/api/items # Update just the notes field (use the real _id from the list above) curl -X PUT http://localhost:4000/api/items/<id> \ -H "Content-Type: application/json" \ -d '{"notes":"Recommended by a colleague"}' # Delete it curl -X DELETE http://localhost:4000/api/items/<id>

A follow-up GET /api/items after the delete should return one fewer item than before — the real, end-to-end confirmation that all five routes are talking to the same live collection consistently.

Hands-On Exercises

Exercise 1

Build all five routes (POST, GET list, GET one, PUT, DELETE) into a real routes/items.js file, mount it in server.js, and confirm all five work against a running MongoDB instance using curl or a REST client.

📄 View solution
Exercise 2

Remove .lean() from the GET /api/items route, create a Book item, and confirm via a real request that its author field is missing from the response. Restore .lean() and confirm author reappears. Explain why.

📄 View solution
Exercise 3

Explain why the PUT route has to look up which discriminator model applies before calling findByIdAndUpdate, while the DELETE route works correctly using only the base Item model with no such lookup at all.

📄 View solution

Chapter 3 Quick Reference

  • MODEL_MAP — a plain object mapping each real itemType string to its own discriminator model, used by both create and update
  • POST /api/items — dispatches to the right model via MODEL_MAP[req.body.itemType]; a 400 if the type isn't recognized
  • .lean() on reads — resolves Chapter 2's own base-model field-visibility limitation by skipping schema-based filtering entirely
  • PUT /api/items/:id — reads the existing document's own itemType first, then updates through the matching model with runValidators: true (not the default)
  • DELETE /api/items/:id — the one route needing no dispatch at all, since removing by _id doesn't depend on schema
  • Next chapter: The Add-Item Form — one React form handling several genuinely different item shapes