Appearance
Week 4: REST APIs & Layered Architecture β
Lecture notes: Week 04: REST APIs (Sept 29) Β· In-class code: Week 4 NYT (REST layers)
Where my Week 4 code stands
- Built through every layer:
GET /products,POST /products, andGET /products/:id(Now You Try #1). - Tests:
products.test.tsruns against MongoMemoryServer. 4 pass, 1 fails. The failing one is the "incomplete product" test from the extra NYT, and it's failing correctly: it caught a real bug inPOST(explained below). - Not done in class: Now You Try #2 (tests for
GET /products/:id) and theDELETEstretch goals.
The Now You Try guide walks through all of these step by step.
Overview β
You can already build routes that work. This week asks what makes an API well designed: easy for another developer to understand and use without reading your code, and easy for you to maintain as it grows. It gives two answers:
- REST, a widely adopted set of principles for designing web APIs around resources and standard HTTP methods.
- A layered architecture. The Week 3 app splits into six folders (
db,models,services,controllers,routes,middleware), each with one job and each knowing only about the layer below it.
The second half of the lecture finishes what Week 3 started: automated tests with Supertest against a throwaway MongoMemoryServer database.
Why it matters: this folder structure and endpoint style is what most Express codebases (and HW2 onward) look like. Once you know it, you can open an unfamiliar API project and find things.
Key concepts β
APIs and REST β
An API (Application Programming Interface) is the set of requests a program accepts and the responses it promises to give back: a contract between your server and its clients. A web API is one whose requests arrive over HTTP.
REST (REpresentational State Transfer) is a style of designing web APIs, described by Roy Fielding in his 2000 dissertation. An API that follows its principles is called RESTful. The lecture lists six principles:
| Principle | Plain-language meaning |
|---|---|
| Uniform interface | every resource is used the same, predictable way: standard HTTP methods on consistently named URLs |
| Clientβserver | client and server are separate and can change independently (the Week 1 model) |
| Stateless | each request carries everything needed to handle it. The server doesn't remember earlier requests from the same client |
| Cacheable | responses say whether they can be reused (inconsistently done in practice) |
| Layered system | the system is built in layers that each only talk to their neighbor. This week's folder structure is one way to do that |
| Code on demand (optional) | the server may send code for the client to run. Rarely used |
REST won out over older approaches (SOAP, CORBA) because it matches how HTTP already works (stateless, clientβserver) and because the alternatives were large and painful.
Resources and representations β
A resource is any "thing" your API manages: a product, a customer, an order. Each has a URI (its address, like /products/6abbβ¦).
The key idea in REST's name: the server never hands over the resource itself. It sends a representation of it, a snapshot in some format (JSON for us). The server keeps the actual thing. That's why the representation can differ from how the data is stored. The database has _id: ObjectId(β¦), and the API sends "id": "β¦".
π₯ Real life: the real-estate listing
When you browse a house online, you get photos, a floor plan, and a description: a representation. The house stays where it is, and the listing doesn't have to match the builder's blueprints either (no one lists the plumbing diagram). Your API's JSON is the listing, and MongoDB holds the house.
Designing endpoints in three steps β
An endpoint is one method + path combination your API supports, like GET /products. The lecture's design process, applied to a shop with products, customers, and orders:
- Entities and relationships. What are the things, and how do they relate? A customer has many orders, and an order contains many products.
- Representations. What JSON does each entity look like when it goes over the wire? The product is
{ id, modelName, modelNumber, manufacturer, color, price, quantity }, andidis a string. - Lay out endpoints. Use plural nouns for collections, and let the HTTP method be the verb:
| Endpoint | GET | POST | PATCH | DELETE |
|---|---|---|---|---|
/products | list all | create one | ||
/products/:id | get one | update given fields | delete it | |
/customers | list all | create one | ||
/customers/:id | get one | update given fields | delete it | |
/customers/:id/orders | that customer's orders | create an order for them |
Two rules of thumb from the notes: nouns, not verbs (POST /products, never /createProduct), and don't just mirror your database structure. Design around what clients need.
HTTP methods, precisely β
| Method | Meaning | Typical success code |
|---|---|---|
| GET | read; never changes anything | 200 |
| POST | create a new item in a collection | 201 Created |
| PUT | replace an item completely with the body sent | 200 |
| PATCH | change only the fields sent | 200 |
| DELETE | remove an item | 200 with a body, or 204 No Content |
Idempotent (a term from the Microsoft reading) means that sending the same request twice has the same effect as sending it once. GET, PUT, and DELETE are idempotent: deleting product 5 twice still leaves product 5 deleted. POST is not, because posting twice creates two products.
π₯ Real life: the light switch vs. the doorbell
PUT/DELETE are like setting a light switch to OFF: flip it to OFF five times and the light is still just off (idempotent). POST is a doorbell: press it five times and it rings five times (five new products).
A layered architecture β
A layer is a group of code with one responsibility that talks only to the layer directly below it. This is separation of concerns: each piece of code cares about one thing. The standard layout for an Express API:
| Layer (folder) | Job | Knows about |
|---|---|---|
index.ts / app.ts | build the app and start the server | everything, to wire it together |
middleware/ | general processing for many routes (error handling, auth, logging) | Express |
routes/ | map each endpoint to a controller function, and nothing more | controllers |
controllers/ | handle the HTTP side: read req, call a service, choose the status code, send res | services, Express Request/Response |
services/ | application logic: what "get all products" or "add a product" actually means | db, models |
models/ | define what a resource is (types) and convert raw data into it | nothing (a pure definition) |
db/ | talk to the database: connect, find, insert | MongoDB |
The payoff: each layer can be understood, changed, and tested on its own. The lecture builds from the bottom up (db β model β service β controller β route β app), which works precisely because no layer needs anything above it.
π₯ Real life: the restaurant
The host at the door (routes) only decides which table (controller) you go to. The waiter (controller) takes your order, talks to the kitchen, and brings back the plate. They're the only one who speaks "customer" (HTTP). The kitchen (service) knows how to make each dish but never talks to customers. The recipe cards (models) define what each dish is. The pantry (db) stores the ingredients. The chef doesn't care which table ordered, and the waiter doesn't need to know where the flour is kept.
A useful test of the boundaries, from the Now You Try: when GET /products/:id doesn't find anything, who decides to send 404? 404 is an HTTP idea, so it's the controller. The service just returns "a product or nothing" (Product | null).
Model functions and ?? β
The model layer has two converters:
productFromDocument(doc)turns a MongoDB document into aProduct, renaming_id(anObjectId) toid(a string). Nothing above the model ever sees_id.productFromFields(fields)turns a partial set of fields (from a request) into a completeProduct, filling in defaults.
productFromFields uses the nullish coalescing operator ??: a ?? b gives a, unless a is null or undefined, in which case it gives b. So fields.price ?? 0.0 means "the price they sent, or 0 if they didn't send one".
Extra context: ?? vs ||
|| also supplies defaults, but it replaces every falsy value (0, '', false). fields.quantity || 10 would turn a deliberate quantity: 0 into 10, while fields.quantity ?? 10 keeps the 0. For defaults, ?? is almost always what you mean.
Testing with MongoMemoryServer β
This picks up where Week 3 stopped. Postman vs. automated tests:
| Postman | Vitest + Supertest + MongoMemoryServer | |
|---|---|---|
| The server | a real one on port 6790 (npm run dev) | none: requests go straight to the app object |
| The database | your real week4 database | a throwaway database that exists only during the run |
| The data | whatever you imported, still there tomorrow | whatever the test just put there |
| Who checks the answer | you, reading JSON | expect |
| When it runs | when you click Send | every npm test |
Two consequences the notes point out: tests don't need mongod or npm run dev running, and index.ts is never tested, since Supertest imports app directly. A missing await db.init() there would break the real server while every test still passed.
Pointing the db layer at the test database. db.init gets default parameters: values used when the caller passes nothing. The app calls db.init() and gets the real database, and a test calls db.init(mongo.getUri(), 'test'):
ts
const init = async (uri: string = MONGO_URI, dbName: string = DB_NAME): Promise<void> => { β¦ };Two test-only functions (hence the _ prefix convention) complete it: disconnect() and _clearCollection(name).
Seed through the db layer, not the route under test. To test GET /products, the test inserts two coffee makers with db.addToCollection in beforeEach. If it used POST /products to set up, a bug in POST would make the GET test fail and send you looking in the wrong place.
Arrange, act, assert. Every test has the same shape: arrange the data (here done by beforeEach), act (send the request), assert (state what should be true).
Two more matchers join the Week 3 list: toMatch(/regex/) to check the shape of a value you can't predict (a generated id), and toBeUndefined() to check that something is absent (product._id). The notes add one caution: negative assertions like .not.toContain('REVOTRA') pass for many wrong reasons, including an empty array, so pair them with a positive one.
π₯ Real life: resetting the stage between scenes
beforeAll is building the theater set once before the play: slow, done once. beforeEach is the stagehands putting every prop back on its mark between scenes: quick, done every time. You'd never rebuild the whole set between scenes, and you'd never skip resetting the props.
Code walkthrough β
Full source: db/db.ts Β· models/products.ts Β· services/product-service.ts Β· controllers/product-controllers.ts Β· routes/product-routes.ts Β· middleware/error-handler.ts Β· app.ts Β· index.ts Β· products.test.ts
Following one request, POST /products, top to bottom through the layers:
Route: map the endpoint β
ts
// routes/product-routes.ts
export const productRouter = express.Router();
productRouter.get('/', productControllers.getProducts);
productRouter.post('/', productControllers.addProduct); // that's the whole jobController: speak HTTP β
ts
// controllers/product-controllers.ts
const addProduct = async (req: Request, res: Response): Promise<void> => {
const postData = req.body; // read the request
const { id } = await productService.add(postData); // ask the service
res.status(201).json({ id }); // choose status + send
};It responds with only the id because that's the one thing the client didn't already know: Mongo generated it.
Service: application logic β
ts
// services/product-service.ts
const add = async (productInfo: ProductFields): Promise<Product> => {
const { insertedId } = await db.addToCollection(db.PRODUCTS, productInfo);
return productFromFields({ ...productInfo, id: insertedId.toString() }); // spread + override
};The service knows about db and the model, but nothing about Request, Response, or status codes.
Model: what a product is β
ts
// models/products.ts
export type Product = { id: string; modelName: string; /* β¦ */ price: number; quantity: number };
export const productFromDocument = (productDocument: Document): Product => ({
id: productDocument._id.toString(), // ObjectId β string; _id never leaves this layer
modelName: productDocument.modelName,
/* β¦ */
});
export type ProductFields = Partial<Product>; // "some of the fields"
export const productFromFields = (fields: ProductFields): Product => ({
id: fields.id ?? String(Date.now()),
modelName: fields.modelName ?? '',
/* β¦ */
price: fields.price ?? 0.0,
quantity: fields.quantity ?? 0,
});The notes chose type over interface here "for cleaner syntax", and ProductFields has to be a type because Partial<Product> is computed (Page 0 decision guide).
DB: talk to MongoDB β
ts
// db/db.ts
let mongoClient: MongoClient | null = null; // null = "not connected yet"
let theDb: Db;
const addToCollection = async (collectionName: string, docData: Document): Promise<InsertOneResult> => {
if (!mongoClient) { // guard: connect on first use
await init();
}
return await theDb.collection(collectionName).insertOne(docData);
};
export const db = { init, getAllInCollection, addToCollection, PRODUCTS };- Exporting one
dbobject (db.init,db.PRODUCTS) groups the layer's public surface, so everything not in it stays private to the file. PRODUCTS = 'products'is a named constant, so a typo like'prodcuts'can't silently create a new collection.- The notes point out that returning
InsertOneResult"leaks some Mongo knowledge up to the service layer". A stricter design would return just the new id.
A second trip down the stack: GET /products/:id β
Now You Try #1, as it ended up in my code. Each layer adds one small piece:
ts
// routes/product-routes.ts: pass the controller itself; Express calls it with (req, res)
productRouter.get('/:id', productControllers.getProductById);
// controllers/product-controllers.ts: the only layer that knows about 404
const getProductById = async (req: Request, res: Response): Promise<void> => {
const product = await productService.getProductById(String(req.params.id));
if (!product) {
res.status(404).send({ error: `Not found: no product with id of ${req.params.id}` });
return;
}
res.json(product);
};
// services/product-service.ts: "a product or nothing", no HTTP
const getProductById = async (id: string): Promise<Product | null> => {
const productDoc = await db.getThroughID(id, db.PRODUCTS);
if (!productDoc) return null;
return productFromDocument(productDoc); // _id β id
};
// db/db.ts: MongoDB only
const getThroughID = async (id: string, collectionName: string): Promise<Document | null> => {
if (!mongoClient) await init();
if (!ObjectId.isValid(id)) return null; // "abc" would make new ObjectId() throw
return await theDb.collection(collectionName).findOne({ _id: new ObjectId(id) });
};The model layer needed no change: productFromDocument already does the conversion.
The test file β
src/__tests__/products.test.ts follows the lecture's setup: MongoMemoryServer in beforeAll, clear and seed two coffee makers in beforeEach, and disconnect and stop in afterAll. It needed two test-only helpers in db.ts:
ts
const disconnect = async (): Promise<void> => {
if (mongoClient) { await mongoClient.close(); mongoClient = null; }
};
const _clearCollection = async (collectionName: string): Promise<void> => {
await theDb.collection(collectionName).deleteMany({});
};
export const db = { init, getAllInCollection, addToCollection, getThroughID, disconnect, _clearCollection, PRODUCTS };Current results:
text
β lists the products that are in the database
β lists the products that are in the database β same test twice (copy-paste)
β POST /products βΊ adds a product we can then read back
Γ POST /products βΊ should accept a post with missing fields β real bug, see below
β GET /products/:id βΊ finds a product through an id β empty body: passes without testing anythingApp and server β
ts
// app.ts
app.use(express.json());
app.use('/products', productRouter);
app.use(errorHandler); // LAST, after every route (Week 2)
// index.ts
await db.init(); // connect, thenβ¦
app.listen(port, () => { β¦ }); // β¦open the portReadings summary β
Microsoft: Web API design best practices β
A long reference guide for designing RESTful HTTP APIs. The core points:
- Organize around resources, named with nouns.
/orders, not/create-order, because the HTTP method already supplies the verb. Use plural names for collections, and a hierarchy for items (/customers/5). - Relationships can appear in the URI (
/customers/5/orders), but avoid anything deeper than collection/item/collection. - Don't mirror the database. Clients shouldn't see your internal table (or collection) structure.
- Avoid "chatty" APIs made of many tiny resources that force clients into lots of requests.
- Use the methods as HTTP defines them, with a table of what GET, POST, PUT, and DELETE mean on a collection vs. an item.
- PUT must be idempotent, while POST and PATCH needn't be.
- Status codes per method. GET gives 200 or 404. A POST that creates gives 201, ideally with the new resource's location. 400 means bad client data, and 405 means that method isn't supported on that URI.
- Beyond the basics: pagination and filtering through query strings, versioning strategies, and linking related resources in responses (HATEOAS).
Connection to lecture: the lecture's three-step design (entities β representations β endpoints) and its endpoint table follow these rules directly, as does the model layer's _id β id renaming ("don't mirror the database").
Stack Overflow Blog: Best practices for REST API design β
A shorter, Express-flavored checklist (John Au-Yeung & Ryan Donovan, 2020). The goal: APIs that are easy to understand, future-proof, secure, and fast. The rules:
- Accept and respond with JSON. Parse request bodies as JSON (
bodyParser.json(), today'sexpress.json()), send withres.json(), which setsContent-Type: application/json. XML and form data are more work for ordinary text and numbers. - Nouns, not verbs, in paths. The HTTP method already is the verb:
GET /articles,POST /articles,PUT /articles/:id,DELETE /articles/:id. Verbs in paths add length and vary by developer ("get" vs "retrieve"). - Plural collection names:
/articles, not/article. - Nest logically, but not too deep. Child resources go after their parent:
/articles/:articleId/comments. Past two or three levels, return a link instead ("author": "/users/:userId") rather than/articles/:id/comments/:id/author. Nesting should follow how clients think, not mirror the database, which also avoids handing attackers information. - Standard error codes, with a message. 400 (bad input), 401 (not logged in), 403 (logged in but not allowed), 404, 500 (generic server error; shouldn't be thrown on purpose), 502, 503. Messages should help the client fix the problem without revealing internals an attacker could use.
- Filtering, sorting, pagination through the query string (
/employees?lastName=Smith&age=30,?page=2,?sort=+author,-datepublished) so you never send the whole database at once. - Security: always SSL/TLS, and the principle of least privilege: users can see only what they need (no other users' data, no admin data), managed with roles.
- Caching (e.g.
apicachemiddleware) for speed, at the cost of sometimes-stale data. SendCache-Controlheaders if you cache. - Versioning (
/v1/employees,/v2/employees) so breaking changes don't break existing clients.
Connection to lecture: the same noun/plural/method rules as the Microsoft reading, in Express code. One difference: the article uses PUT for updates, while the lecture's API uses PATCH, because a partial update is what PATCH means. The query-string filtering pattern shows up in Week 5 as GET /products?lowStock=true, and the 401/403 split is next week's topic (authentication vs. authorization).
Corey Cleary: Project structure for an Express REST API β
The source for this week's layered layout. Cleary's main point is that Node has no standard project structure, so instead of asking "what's the best folder layout?", ask "where does each type of logic go?" Put logic in the right place and a structure falls out naturally, whether you group by layer (controllers/, services/, β¦) or by feature.
He splits API code into two kinds of logic:
| Kind | Layers | What goes there |
|---|---|---|
| HTTP logic | routes | map requests to controller functions. No logic |
| controllers | "orchestrators": pull what's needed out of req, call services, choose the status code, send the response | |
| Business logic | services | the actual work: business rules, calling the data layer, calling external APIs (e.g. posting to Twitter when a blog post is created) |
data access (db/) | talking to whatever stores data (a database, Redis, Elasticsearch) |
Other points:
- Express stops at the controller. Don't pass
reqinto services. Destructure the pieces you need and pass those. Otherwise the whole app depends on Express, is harder to test, and is harder to move to another framework. - Avoid "fat controllers". Even a service that only calls the database is worth having. When requirements grow, that logic would otherwise pile up in the controller and become hard to test.
utils/holds general helpers that aren't about your domain at all (e.g. converting milliseconds to minutes).app.jswires middleware and routes together.- Tests: he keeps them in a root
tests/folder mirroring the app, and skips route tests, since routes contain no logic. Separate unit and integration folders only if you have many integration tests. - In the article's controller, errors are handled with
try/catchin each controller and a500.
Connection to lecture: this is the routes β controllers β services β db layout, and the "a layer only knows the layer below it" rule. Where the course differs: tests sit beside the code they test or in src/__tests__/, routes are tested end-to-end with Supertest, and in Week 5 errors flow to one error-handling middleware instead of a try/catch in each controller.
Revised in Week 5
Week 5 moves the _id β id conversion out of the models and into a repository in the db layer, and models become types only. See What changed since Week 4.
Gotchas & common mistakes β
- Putting HTTP in the service. A service that calls
res.status(404)or importsRequestbreaks the layering. Returnnull(or throw) and let the controller choose the status. - Letting
_idescape. Returning raw documents from the service leaks MongoDB details to clients. Always pass them throughproductFromDocument. - POST stores whatever the client sends.
productService.add(req.body)inserts the body as-is, including unexpected fields. The notes say the model converter "is also where error checking and validation would go", and none has been added yet. - Defaults applied after saving never reach the database.
productFromFieldsruns after the insert inservice.add, so missing fields stay missing in MongoDB. This is exactly what the incomplete-product test catches. Apply defaults before inserting. res.jsonsilently dropsundefinedfields. A missingcolordoesn't show up as"color": null; it's just absent from the JSON, which is why the test sawundefined.- Swapped arguments compile fine.
getThroughID(db.PRODUCTS, id)vs(id, db.PRODUCTS): both are strings, so TypeScript can't tell them apart. The result is a quiet "always 404". - A test with no
expectalways passes. An emptyit(...)block shows up green and checks nothing. - Copy-pasted tests. Two identical tests add nothing and can only fail together.
insertOnemodifies its argument. The driver adds an_idto the object you pass in, so afteraddToCollection(db.PRODUCTS, productInfo),productInfohas an_idtoo. It's harmless here becauseproductFromFieldscopies only known fields, but surprising.- Every field is empty or 0 after a POST. Postman's body type is still Text instead of JSON, or
app.use(express.json())is missing. - "Nothing for 30 seconds, then an error".
mongodisn't running. Cannot POST /products. The method is wrong, or the route isn't registered.ECONNREFUSED.npm run devisn't running, or it crashed.toBeon a whole product object fails even with identical data. UsetoEqual.- Forgetting
afterAllteardown. MongoMemoryServer keeps running until the process exits. Stop what you start. - File names. The notes use
models/product.ts(singular), but the repo hasmodels/products.ts. Imports must match the real file name.
Now You Try guide β
Step-by-step help for this week's three in-class exercises. Each part says what the exercise asks, walks through it layer by layer or test by test, and ends with a checkpoint so you know when you're done. Solutions are hidden, so try each step first.
The one rule for every step
Each layer only talks to the layer directly below it. If a step makes you want to import Request into a service, or ObjectId into a controller, stop: that code belongs in a different layer.
NYT #1: GET /products/:id β
The ask: return a single product by its id, 200 with one product object if it exists, 404 if it doesn't. β Done in my code (see the walkthrough above).
The notes' table of what each layer adds:
| Layer | Add | Returns |
|---|---|---|
db | find one document by id | Document | null |
models | nothing: productFromDocument already converts | |
services | look it up, convert it | Product | null |
controllers | read req.params.id, call the service, choose 200 or 404 | nothing (it sends res) |
routes | map GET /:id to the controller |
Build it bottom-up, and run npm run typecheck after each layer. A red squiggle is much easier to fix one layer at a time.
Mistakes I actually hit while building it (useful for next time):
| Symptom | Cause | Fix |
|---|---|---|
Expected 2 arguments, but got 1 in the controller | the controller called itself instead of the service | call productService.getProductById(...) |
| request hangs forever | the controller never sent a response | res.json(product), or 404 |
| always 404, even for a real id | getThroughID(db.PRODUCTS, id): arguments swapped | match the signature: getThroughID(id, db.PRODUCTS) |
| nothing visible, but the "connect if needed" guard is broken | await init; without () refers to the function but never calls it. It only worked because index.ts had already connected | await init(); |
/products/abc gives 500 | new ObjectId('abc') throws | check ObjectId.isValid(id) first |
| route sends a Promise, or "headers already sent" | res.json(productControllers.getProductById(req.params.id)) in the route | pass the controller itself: productRouter.get('/:id', productControllers.getProductById) |
A design choice to be aware of
My getThroughID returns null for a malformed id like abc, so the API answers 404. Another defensible answer is 400 Bad Request: "that isn't even an id". The Week 3 stretch goal leaned toward 400. Either is fine if you pick one and test it. See Practice problem 3.
Checkpoint (in Postman, with npm run dev and mongod running):
GET /products/<an id copied from GET /products>β 200, and the body is one object, not an array.GET /products/aaaaaaaaaaaaaaaaaaaaaaaaβ 404 (well-formed, but not in the database).- The product has
id, a string, and no_id.
NYT #1 stretch: DELETE /products/:id β
The ask: same five layers and same 404 question, plus a decision: what does a successful delete return? The notes accept 200 with a body (like { status: 'success' }) or 204 No Content.
Hint: what each layer returns
- db:
deleteOne(...)gives{ deletedCount }. Return just the number, so no Mongo type leaks upward. - service: turn that into a
boolean, "was something deleted?". - controller:
trueβ 204 (or 200),falseβ 404. - You can't name a function
delete, because it's a reserved word in JavaScript. UsedeleteProductById,remove, etc.
Solution, matching my naming style
ts
// db/db.ts
const deleteThroughID = async (id: string, collectionName: string): Promise<number> => {
if (!mongoClient) await init();
if (!ObjectId.isValid(id)) return 0;
const result = await theDb.collection(collectionName).deleteOne({ _id: new ObjectId(id) });
return result.deletedCount;
};
export const db = { /* β¦existingβ¦ */ deleteThroughID };
// services/product-service.ts
const deleteProductById = async (id: string): Promise<boolean> => {
const deletedCount = await db.deleteThroughID(id, db.PRODUCTS);
return deletedCount === 1;
};
export const productService = { getAll, add, getProductById, deleteProductById };
// controllers/product-controllers.ts
const deleteProductById = async (req: Request, res: Response): Promise<void> => {
const deleted = await productService.deleteProductById(String(req.params.id));
if (!deleted) {
res.status(404).send({ error: `Not found: no product with id of ${req.params.id}` });
return;
}
res.sendStatus(204);
};
export const productControllers = { getProducts, addProduct, getProductById, deleteProductById };
// routes/product-routes.ts
productRouter.delete('/:id', productControllers.deleteProductById);Checkpoint: delete one product, and GET /products should come back one shorter. Refresh Compass and it's gone there too. Deleting the same id again should give 404.
NYT #2: Test GET /products/:id β
The ask: turn the NYT #1 checkpoint into automated tests. Add a describe('GET /products/:id') block with (1) the product is there β 200 and it's the right one, and (2) the product isn't there β 404. (Class didn't get to this, and my repo has the describe block with an empty test in it.)
Step 1: fill in the empty test first. it('finds a product through an id', async () => {}) currently passes, because a test with no expect can't fail. A green test that checks nothing is worse than no test, since it tells you something works when nothing was checked.
Step 2: where does a test get a real id? MongoMemoryServer invents new ids every run, so you can't hard-code one. The notes' hint: ask a route you already trust. GET /products returns both seeded coffee makers, each with its id.
Step 3: compare the whole object. It's an object, so use toEqual, not toBe.
Hint: the absent-id test
You need an id that's well-formed but absent: 'a'.repeat(24) is 24 hex characters and certainly not in a fresh database.
Solution
ts
describe('GET /products/:id', () => {
it('finds a product through an id', async () => {
const all = await request(app).get('/products');
const braun: Product = all.body.find((p: Product) => p.manufacturer === 'Braun');
const res = await request(app).get(`/products/${braun.id}`);
expect(res.status).toBe(200);
expect(res.body).toEqual(braun); // same product, every field
expect(res.body._id).toBeUndefined(); // the model layer did its job
});
it('returns 404 for a well-formed id that is not there', async () => {
const res = await request(app).get(`/products/${'a'.repeat(24)}`);
expect(res.status).toBe(404);
});
it('returns 404 for a malformed id', async () => { // documents my design choice
const res = await request(app).get('/products/abc');
expect(res.status).toBe(404);
});
});Finding the Braun by manufacturer instead of taking all.body[0] means the test doesn't depend on the order of results.
Also tidy up while you're in the file: the "lists the products that are in the database" test appears twice. Delete one copy, because two identical tests can only ever fail together and add nothing.
Checkpoint: npm test shows the new tests passing. Then break the route on purpose: temporarily make the controller always send 404, and watch the first test go red. That's how you know a test really checks something.
NYT #2 stretch: Test DELETE /products/:id β
The ask: deleting a product leaves one behind, and deleting an absent id is a 404.
The notes point out what's new here: the interesting assertion is about a different request than the one being tested. You DELETE, then you ask GET /products whether it worked.
Solution (needs the DELETE route from the NYT #1 stretch)
ts
describe('DELETE /products/:id', () => {
it('deletes a product, leaving the other one', async () => {
const before = await request(app).get('/products');
const target: Product = before.body[0];
const del = await request(app).delete(`/products/${target.id}`);
expect(del.status).toBe(204);
const after = await request(app).get('/products'); // check with a second request
expect(after.body).toHaveLength(1);
expect(after.body.map((p: Product) => p.id)).not.toContain(target.id);
});
it('returns 404 for an id that is not there', async () => {
const res = await request(app).delete(`/products/${'a'.repeat(24)}`);
expect(res.status).toBe(404);
});
});The .not.toContain sits next to a positive toHaveLength(1), following the notes' advice that negative assertions shouldn't stand alone.
NYT: Incomplete product test β
The ask: write a test for POSTing a product that's missing some fields. The fields you send should be stored, and the ones you leave out should get the defaults from productFromFields ('' for strings, 0 for numbers).
My test is committed and fails:
text
Γ POST /products βΊ should accept a post with missing fields
AssertionError: expected undefined to be ''
β― expect(colors[2]).toBe("");The test is right, and the app is wrong. Tracing the request through the layers shows why:
| Step | Layer | What happens |
|---|---|---|
| 1 | controller | passes req.body ({ modelName, modelNumber, manufacturer }) to productService.add |
| 2 | service | db.addToCollection(db.PRODUCTS, productInfo) stores the body as-is: no color, price, or quantity in MongoDB |
| 3 | service | then calls productFromFields(...). The defaults exist only in the return value, and the controller only uses its id |
| 4 | GET /products | reads the stored document with productFromDocument, which has no defaults, so color is undefined |
| 5 | res.json | drops undefined properties from the JSON, so the test receives undefined, not '' |
π₯ Real life: the order form that was checked too late
A deli clerk takes your sandwich order and files the ticket in the kitchen exactly as you wrote it. Afterwards, they fill in "white bread, no cheese" on a photocopy that they hand back to you. The kitchen never sees the defaults, so your sandwich comes out with no bread. The defaults have to go on the ticket before it's filed.
Hint: where should the defaults be applied?
In service.add, before the insert, not after. Build the complete product with productFromFields, then store that. Watch out for one thing: productFromFields also invents an id (String(Date.now())), and you don't want to store that, since MongoDB makes the real one.
Solution: fix the service
ts
// services/product-service.ts
const add = async (productInfo: ProductFields): Promise<Product> => {
// 1. fill in defaults FIRST; drop the placeholder id (Mongo generates the real one)
const { id: _placeholderId, ...fields } = productFromFields(productInfo);
// 2. store the complete product
const { insertedId } = await db.addToCollection(db.PRODUCTS, fields);
// 3. hand back the complete product with its real id
return { ...fields, id: insertedId.toString() };
};Two side benefits: a client can no longer store its own id field, and unexpected extra keys in the body ({ "hacked": true }) are no longer saved, because productFromFields only copies the known fields.
Alternative: add ?? defaults in productFromDocument instead. The test would pass, but MongoDB would still hold incomplete documents, and anything else reading the database directly (Compass, a future report) would see missing fields. Fixing it at write time keeps the stored data correct.
Solution: a sturdier version of the test
My version picks the new product with colors[2], which depends on it being third in the list. GET /products/:id exists now, so the test can look it up by the id POST returned, and check both halves of the requirement in one assertion:
ts
it('fills in defaults for fields a POST leaves out', async () => {
const created = await request(app).post('/products').send({
modelName: 'Whipped Cream',
modelNumber: 'FR3-80085-900',
manufacturer: 'halloworlf',
});
expect(created.status).toBe(201);
expect(created.body.id).toMatch(/^[0-9a-f]{24}$/);
const res = await request(app).get(`/products/${created.body.id}`);
expect(res.body).toEqual({
id: created.body.id,
modelName: 'Whipped Cream', // provided β kept
modelNumber: 'FR3-80085-900',
manufacturer: 'halloworlf',
color: '', // not provided β default
price: 0,
quantity: 0,
});
});0.0 and 0 are the same number in JavaScript, so price: 0 is fine.
Checkpoint: with the service fix in place, npm test β all tests pass. Then temporarily undo the fix: exactly this test should go red again. A test that fails for the bug and passes for the fix is doing its job.
Red is information
The notes end Week 4 with "green is the least informative thing a test suite ever tells you." My failing test is a good example: it's the only one of the five that found something wrong.
Practice problems β
Extra practice beyond the Now You Try exercises.
Problem 1: PATCH /products/:id through all five layers β
Add a PATCH route that updates only the fields sent, returns 200 with the updated product, or 404 if there's no such id. Don't let the client change id.
Hint
db: updateOne({ _id }, { $set: changes }) β return matchedCount. service: strip id from the changes, update, then re-read with getProductById so you can return the updated product. controller: null β 404.
Solution
ts
// db/db.ts
const updateThroughID = async (id: string, collectionName: string, changes: Document): Promise<number> => {
if (!mongoClient) await init();
if (!ObjectId.isValid(id)) return 0;
const result = await theDb.collection(collectionName).updateOne({ _id: new ObjectId(id) }, { $set: changes });
return result.matchedCount; // matched, not modified: same values still means "found"
};
// services/product-service.ts
const updateProductById = async (id: string, changes: ProductFields): Promise<Product | null> => {
const { id: _ignored, ...allowed } = changes; // never let the client change id
const matched = await db.updateThroughID(id, db.PRODUCTS, allowed);
if (matched === 0) return null;
return await getProductById(id); // read back the updated product
};
// controllers/product-controllers.ts
const updateProductById = async (req: Request, res: Response): Promise<void> => {
const product = await productService.updateProductById(String(req.params.id), req.body);
if (!product) {
res.status(404).send({ error: `Not found: no product with id of ${req.params.id}` });
return;
}
res.json(product);
};
// routes/product-routes.ts
productRouter.patch('/:id', productControllers.updateProductById);Problem 2: Test PATCH β
Test that PATCHing { price: 79.99 } on a seeded product changes the price and nothing else.
Solution
ts
it('changes only the fields sent', async () => {
const all = await request(app).get('/products');
const original: Product = all.body[0];
const res = await request(app).patch(`/products/${original.id}`).send({ price: 79.99 });
expect(res.status).toBe(200);
expect(res.body).toEqual({ ...original, price: 79.99 }); // spread: everything else identical
});{ ...original, price: 79.99 } builds the exact expected object from the original, so one toEqual checks both "price changed" and "nothing else did".
Problem 3: 400 or 404 for a malformed id? β
Change the API so /products/abc returns 400 Bad Request instead of 404, for GET, PATCH, and DELETE, without editing each controller. Where should that check live?
Solution
"Is this even a valid id?" is a question about the request, and it's shared by several routes, so it fits middleware (Week 2):
ts
// middleware/validate-id.ts
import type { NextFunction, Request, Response } from 'express';
export const validateId = (req: Request, res: Response, next: NextFunction): void => {
if (!/^[0-9a-f]{24}$/.test(String(req.params.id))) {
res.status(400).send({ error: `Invalid id: ${req.params.id}` });
return;
}
next();
};
// routes/product-routes.ts
productRouter.get('/:id', validateId, productControllers.getProductById);
productRouter.patch('/:id', validateId, productControllers.updateProductById);
productRouter.delete('/:id', validateId, productControllers.deleteProductById);The ObjectId.isValid check in db.ts can stay as a safety net. Update the "malformed id" test from NYT #2 to expect 400.
Problem 4: Design the orders endpoints β
A client needs to: (a) see all of customer 7's orders, (b) place a new order for customer 7, (c) mark order 42 as shipped, (d) cancel (remove) order 42. Write the method + path for each and the success status code. Avoid verbs in paths.
Solution
| Need | Endpoint | Success |
|---|---|---|
| (a) | GET /customers/7/orders | 200 |
| (b) | POST /customers/7/orders | 201 + the new order's id |
| (c) | PATCH /orders/42 with { "status": "shipped" } | 200 |
| (d) | DELETE /orders/42 | 204 (or 200 with a body) |
Not POST /orders/42/ship or /cancelOrder: "ship" is just a change to the order's status field, which PATCH already expresses. (c) and (d) use /orders/42 rather than /customers/7/orders/42 to keep URIs no deeper than collection/item/collection, as the Microsoft reading advises. The lecture leaves open whether orders are their own collection. This answer assumes they are.
Problem 5: Spot the layer violation β
What's wrong with this service function, and how would you fix it?
ts
const getById = async (req: Request, res: Response) => {
const doc = await db.findById(db.PRODUCTS, String(req.params.id));
if (!doc) return res.sendStatus(404);
res.json(doc);
};Solution
It does the controller's job (reading req, choosing 404, sending res) inside the service, and it returns a raw MongoDB document with _id, skipping the model. The service should take a plain id: string, return Product | null via productFromDocument, and leave every HTTP decision to the controller (exactly the split in NYT #1). As written, it can't be reused outside an HTTP request or tested without faking Express objects.
Review questions β
1. What does "representational" in REST mean, and where does the Week 4 code show it?
Answer
The server never sends the resource itself, only a representation of it (here, JSON). It keeps the real thing, and the representation can differ from how it's stored. productFromDocument shows this: MongoDB stores _id: ObjectId(β¦), and the API's representation has id: "β¦" instead.
2. Why is POST /products better than POST /createProduct? What about GET /getAllProducts?
Answer
REST endpoints are nouns naming resources, and the HTTP method is the verb. POST /products already says "create in the products collection", so createProduct repeats it in a non-standard way. Likewise, GET /products already means "get all products". Consistent noun-based URIs are what make an API predictable (the "uniform interface").
3. In the layered design, which layer decides to return 404 for a missing product, and why that one?
Answer
The controller. 404 is an HTTP concept, and the controller is the only layer that deals with HTTP (Request/Response). The service just reports "a product or nothing" (Product | null), which keeps it reusable and testable without Express.
4. What's the difference between fields.quantity ?? 0 and fields.quantity || 0?
Answer
?? substitutes 0 only when quantity is null or undefined. || substitutes it for any falsy value, including a real 0, '', or false. The results match here only by coincidence (both give 0). With a non-zero default like ?? 10, || would wrongly turn a deliberate 0 into 10.
5. Why does the GET test seed data with db.addToCollection instead of calling POST /products?
Answer
The thing under test is the GET route. If setup went through POST, a bug in POST would make the GET test fail, pointing you at the wrong code. Seeding through the db layer keeps each test's failure pointing at the code it's actually testing.
6. Supertest tests all pass, but the real server fails to start. How is that possible?
Answer
Tests import app from app.ts and never run index.ts, the file that calls db.init() and app.listen(). A bug there (a missing await db.init(), a wrong port) isn't covered by the suite. That's why the notes suggest checking the real server with Postman at least once.