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:
| Method | Path | Purpose |
|---|---|---|
POST | /api/items | Create a new item of whichever type the request body names |
GET | /api/items | List every item, across all four types, in one response |
GET | /api/items/:id | Fetch a single item by its MongoDB _id |
PUT | /api/items/:id | Update a single item, validated against its own real type's schema |
DELETE | /api/items/:id | Remove 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:
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.
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:
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:
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:
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:
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:
Trying It End to End
With npx nodemon server.js running, all five routes are testable directly against a real
database with curl:
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
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 solutionRemove .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 solutionExplain 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 solutionChapter 3 Quick Reference
- MODEL_MAP — a plain object mapping each real
itemTypestring 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
itemTypefirst, then updates through the matching model withrunValidators: true(not the default) - DELETE /api/items/:id — the one route needing no dispatch at all, since removing by
_iddoesn't depend on schema - Next chapter: The Add-Item Form — one React form handling several genuinely different item shapes