Appearance
Cheat Sheet β
A quick reference for writing course code. Each snippet is a pattern from the in-class code, and each section links to the page that explains it. Use the On this page menu (or / to search) to jump around.
Project setup β
Explained in Week 1 and Week 2.
bash
mkdir my-api && cd my-api
npm init -y
npm pkg set type="module" # lets you use import/export
npm install express mongodb # runtime dependencies
npm install -D typescript tsx vitest supertest mongodb-memory-server \
@types/node@^22 @types/express @types/supertestpackage.json scripts
json
"scripts": {
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit",
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest"
}| Command | Does |
|---|---|
npm run dev | runs the server, restarts on save (doesn't check types) |
npm run typecheck | checks types only |
npm test | runs the tests once |
tsconfig.json (Week 3/4 version)
json
{
"compilerOptions": {
"module": "nodenext", "target": "esnext", "lib": ["esnext"], "types": ["node"],
"rootDir": "./src", "outDir": "./dist", "sourceMap": true,
"strict": true, "verbatimModuleSyntax": true, "isolatedModules": true,
"moduleDetection": "force", "skipLibCheck": true
},
"include": ["src"]
}.gitignore: node_modules and dist.
Folder layout (Week 5):
src/
βββ db/ db.ts (connect/getDb) + <resource>-repository.ts (Mongo β Product)
βββ models/ types only (Product, NewProduct, ProductFields)
βββ services/ business logic: Products in, Products out (+ *.unit.test.ts)
βββ controllers/ read req, call service, send res
βββ routes/ endpoint β controller
βββ middleware/ error handler, auth, loggingβ¦
βββ __tests__/ *.e2e.test.ts + seed data
βββ errors.ts custom error classes (ValidationError)
βββ app.ts builds + exports the app (no listen)
βββ index.ts connects db, then listensServer skeleton β
Explained in Week 2: E2E testing.
ts
// src/app.ts: build the app, export it, never listen here
import express from 'express';
import { productRouter } from './routes/product-routes.js'; // note: .js, not .ts
import { errorHandler } from './middleware/error-handler.js';
const app = express();
app.use(express.json()); // parse JSON bodies β req.body
app.use('/products', productRouter); // mount routers
app.use(errorHandler); // LAST
export { app };ts
// src/index.ts: the only file that opens a port
import { app } from './app.js';
import { db } from './db/db.js';
const port = 6790;
await db.init(); // connect BEFORE listening
app.listen(port, () => console.log(`Server running on port ${port}`));Routes β
Explained in Week 1: routes and Week 2: routers.
ts
import express from 'express';
import type { Request, Response } from 'express';
export const booksRouter = express.Router(); // mounted at /books in app.ts
booksRouter.get('/', (req: Request, res: Response) => { β¦ }); // GET /books
booksRouter.get('/:id', (req: Request, res: Response) => { β¦ }); // GET /books/42
booksRouter.post('/', (req: Request, res: Response) => { β¦ }); // POST /books
booksRouter.patch('/:id', (req: Request, res: Response) => { β¦ }); // PATCH /books/42
booksRouter.delete('/:id', (req: Request, res: Response) => { β¦ }); // DELETE /books/42- Inside a router, paths are relative to the mount point:
'/', not'/books'. - Order matters. Put specific paths (
/badroute) above/:id, or/:idwill capture them. - Handlers that
awaitmust beasync:async (req: Request, res: Response) => { β¦ }.
Which method?
| Want to⦠| Method | Path | Success code |
|---|---|---|---|
| list all | GET | /products | 200 |
| get one | GET | /products/:id | 200 (404 if missing) |
| create | POST | /products | 201 |
| change some fields | PATCH | /products/:id | 200 (404 if missing) |
| replace entirely | PUT | /products/:id | 200 |
| remove | DELETE | /products/:id | 200 or 204 (404 if missing) |
| sub-collection | GET / POST | /customers/:id/orders | 200 / 201 |
Use nouns, not verbs: POST /products, never /createProduct (Week 4).
Reading the request (req) β
Explained in Week 1: three ways data arrives.
| Data | URL / request | Read it with | Arrives as |
|---|---|---|---|
| Route param | /products/6abb⦠for route /:id | req.params.id | string (typed string | string[]) |
| Query string | /books?author=Tolkien&year=1937 | req.query.author | string, array, or undefined |
| Body | JSON sent with POST/PATCH | req.body.title | any (needs express.json()) |
| Header | Authorization: Bearer SI679 | req.headers.authorization | string or undefined (keys lowercase) |
| Method / path | req.method, req.path | string |
ts
const { id } = req.params; // destructuring
const { title, author, year } = req.body;
const { keywords } = req.query;
const id = String(req.params.id); // force string (Week 3)
const year = Number(req.query.year); // query values are strings β convert
const category = typeof req.query.category === 'string' ? req.query.category : undefined;Sending the response (res) β
Explained in Week 1: status codes and Week 2.
ts
res.send('plain text or HTML'); // Content-Type: text/html
res.json({ id, title }); // Content-Type: application/json
res.status(201).json({ id }); // set status, then send
res.status(400).send('title is required');
res.status(404).json({ error: 'Not found' });
res.sendStatus(204); // status + default text, no body to writeExactly one response per request. After an early response, return:
ts
if (!product) {
res.sendStatus(404);
return; // otherwise the next line sends again β crash
}
res.json(product);Status codes
| Code | Meaning | When |
|---|---|---|
| 200 | OK | success |
| 201 | Created | POST made something |
| 204 | No Content | success, nothing to send (e.g. DELETE) |
| 400 | Bad Request | invalid input, malformed JSON, bad id format |
| 401 | Unauthorized | missing or invalid credentials (unauthenticated) |
| 403 | Forbidden | authenticated, but not allowed |
| 404 | Not Found | no such route, or no item with that id |
| 500 | Internal Server Error | your code broke (the error handler's default) |
Validating input and ids β
Explained in Week 1: status codes, HW1 walkthrough, and Page 0: unknown.
ts
// numeric id (Weeks 1β2)
if (isNaN(Number(req.params.id))) {
res.status(400).send('id must be a number');
return;
}
// MongoDB id: 24 hex characters (Week 3). new ObjectId() THROWS on anything else
if (!/^[0-9a-f]{24}$/.test(String(req.params.id))) {
res.status(400).json({ error: 'invalid id' });
return;
}
// required non-empty string
if (typeof title !== 'string' || title.trim() === '') { /* 400 */ }
// whole number β₯ 1
if (!Number.isInteger(quantity) || quantity < 1) { /* 400 */ }
// collect ALL problems, then respond once (HW1 style)
const problems: string[] = [];
if (!title) problems.push('title is required');
if (!author) problems.push('author is required');
if (problems.length > 0) {
res.status(400).json({ errors: problems });
return;
}A well-formed id that surely doesn't exist (for 404 tests): 'a'.repeat(24).
Middleware β
Explained in Week 2: middleware.
ts
import type { Request, Response, NextFunction } from 'express';
// Logging: always passes on
const logger = (req: Request, res: Response, next: NextFunction): void => {
console.log(req.method, req.path);
next();
};
// Auth: "Authorization: Bearer <token>"
const checkAuth = (req: Request, res: Response, next: NextFunction): void => {
const token = req.headers.authorization?.split(' ')[1];
if (token === 'SI679') {
next();
} else {
res.status(401).json({ error: 'missing or invalid token' });
}
};
// Validation
const validateBook = (req: Request, res: Response, next: NextFunction): void => {
const { title, author } = req.body;
if (title && author) { next(); return; }
res.status(400).send('title and author are required');
};Attaching it
ts
app.use(express.json()); // whole app
booksRouter.use(logger); // whole router
booksRouter.delete('/:id', checkAuth, handler); // one route
booksRouter.post('/', [checkAuth, validateBook], handler); // several, in orderRules: call next() or send a response (never neither). Register it before the routes it should affect.
Catch-all 404 + error handler (end of app.ts, in this order)
ts
// no route matched
app.use((req: Request, res: Response) => {
res.status(404).json({ error: 'Not found' });
});
// something threw: FOUR params, registered LAST
app.use((err: Error, req: Request, res: Response, next: NextFunction): void => {
if (err instanceof SyntaxError && 'body' in err) {
res.status(400).json({ error: 'malformed JSON' }); // client's fault
return;
}
console.error(err);
res.status(500).json({ error: 'Something went wrong on our end' });
});TypeScript for Express β
Explained in Page 0.
ts
import express from 'express'; // value β normal import
import type { Request, Response, NextFunction } from 'express'; // types β import type
import { BookStatus, type Book } from './types.js'; // mixed
// handler signatures
(req: Request, res: Response): void // route handler
async (req: Request, res: Response): Promise<void> // async route handler
(req: Request, res: Response, next: NextFunction): void // middleware
(err: Error, req: Request, res: Response, next: NextFunction): void // error handlerTyping your data
ts
export interface NewOrder { customerName: string; items: OrderItem[]; } // request body
export interface Order extends NewOrder { id: string; status: OrderStatus; createdAt: string; }
export type OrderStatus = 'pending' | 'in-progress' | 'completed'; // union β type
export type Product = { id: string; name: string; price: number; quantity: number };
export type NewProduct = Omit<Product, 'id'>; // POST body
export type ProductUpdate = Partial<NewProduct>; // PATCH body| Need | Use |
|---|---|
| plain object shape | interface or type (be consistent) |
| union / literal set / tuple | type |
derived (Partial, Omit, Pick, keyof) | type |
| shape built on another shape | interface β¦ extends |
| unvalidated input | unknown, not any |
MongoDB β
Explained in Week 3.
Connect
ts
import { MongoClient, ObjectId } from 'mongodb';
import type { Db, Document } from 'mongodb';
let mongoClient: MongoClient | null = null;
let theDb: Db;
const init = async (uri = 'mongodb://127.0.0.1:27017', dbName = 'week4'): Promise<void> => {
mongoClient = new MongoClient(uri);
await mongoClient.connect();
theDb = mongoClient.db(dbName);
};
const disconnect = async (): Promise<void> => {
if (mongoClient) { await mongoClient.close(); mongoClient = null; }
};
const products = () => theDb.collection('products');CRUD
| Do | Code | Returns |
|---|---|---|
| Create one | await products().insertOne(doc) | { insertedId } |
| Create many | await products().insertMany([a, b]) | { insertedCount, insertedIds } |
| Read all | await products().find().toArray() | Document[] |
| Read matching | await products().find({ price: { $lt: 5 } }).toArray() | Document[] |
| Read one | await products().findOne({ _id: new ObjectId(id) }) | Document | null |
| Update one | await products().updateOne({ _id: new ObjectId(id) }, { $set: changes }) | { matchedCount, modifiedCount } |
| Delete one | await products().deleteOne({ _id: new ObjectId(id) }) | { deletedCount } |
| Delete all | await products().deleteMany({}) | { deletedCount } (tests only!) |
Query operators
| Operator | Means | Example |
|---|---|---|
| (none) | equals | { name: 'Duct Tape' } |
$lt / $lte | < / β€ | { price: { $lt: 5 } } |
$gt / $gte | > / β₯ | { price: { $gte: 5 } } |
| combined | range | { price: { $gte: 2, $lte: 10 } } |
{} | everything | find({}) |
Update operators
ts
{ $set: { price: 4.29 } } // set/replace these fields only
{ $inc: { quantity: 10 } } // add to a numberIds
ts
new ObjectId(id) // string β ObjectId (throws if not 24 hex chars)
doc._id.toString() // ObjectId β string404 checks
ts
const doc = await products().findOne({ _id: new ObjectId(id) });
if (!doc) β 404 // findOne gives null
const { matchedCount } = await products().updateOne(β¦);
if (matchedCount === 0) β 404 // matched, not modified
const { deletedCount } = await products().deleteOne(β¦);
if (deletedCount === 0) β 404Model converter (keeps _id inside the model layer)
ts
export const productFromDocument = (doc: Document): Product => ({
id: doc._id.toString(),
name: doc.name,
price: doc.price,
quantity: doc.quantity,
});Start the database: mongod --dbpath ~/data/mdata (or check the connection in Compass). Shell: mongosh β use week3db β db.products.find().
One resource through all the layers β
Explained in Week 4: layers. A template for GET /products/:id:
ts
// db/db.ts: MongoDB only
const findById = async (collection: string, id: string): Promise<Document | null> => {
if (!mongoClient) await init();
return await theDb.collection(collection).findOne({ _id: new ObjectId(id) });
};
export const db = { init, findById, PRODUCTS: 'products' };
// models/products.ts: types + converters
export type Product = { id: string; /* β¦ */ };
export const productFromDocument = (doc: Document): Product => ({ id: doc._id.toString(), /* β¦ */ });
// services/product-service.ts: logic; no req/res, no status codes
const getById = async (id: string): Promise<Product | null> => {
const doc = await db.findById(db.PRODUCTS, id);
return doc ? productFromDocument(doc) : null;
};
export const productService = { getById };
// controllers/product-controllers.ts: HTTP only
const getProduct = async (req: Request, res: Response): Promise<void> => {
const product = await productService.getById(String(req.params.id));
if (!product) { res.sendStatus(404); return; }
res.json(product);
};
export const productControllers = { getProduct };
// routes/product-routes.ts: mapping only
productRouter.get('/:id', productControllers.getProduct);Repository + business logic + custom errors (Week 5) β
Explained in Week 5. The Week 5 version of the layers: the repository converts, the service enforces rules, errors become status codes in one place.
ts
// db/products-repository.ts: the ONLY place that knows about Mongo
export const updateProduct = async (id: string, changes: ProductFields): Promise<Product | null> => {
const doc = await getDb().collection(PRODUCTS)
.findOneAndUpdate({ _id: new ObjectId(id) }, { $set: changes }, { returnDocument: 'after' });
return doc ? toProduct(doc) : null;
};
// services/products-service.ts: business rules, checked BEFORE writing
const existing = await productsRepository.findProduct(id);
if (!existing) return null; // controller β 404
if ((changes.price ?? existing.price) < 0) throw new ValidationError('price cannot be negative');
return productsRepository.updateProduct(id, changes);
// errors.ts
export class ValidationError extends Error {}
// middleware/error-handler.ts (registered last in app.ts)
if (err instanceof ValidationError) { res.status(400).json({ error: err.message }); return; }
res.status(500).json({ error: 'Something went wrong' });Naming: controller = HTTP verbs (patchProduct) Β· service = CRUD (updateProduct) Β· repository = Mongo (findProduct, insertProduct).
Testing (Vitest + Supertest + MongoMemoryServer) β
Explained in Week 2, Week 3, and Week 4.
ts
// src/__tests__/products.test.ts
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import request from 'supertest';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { app } from '../app.js';
import { db } from '../db/db.js';
let mongo: MongoMemoryServer;
beforeAll(async () => { // once: start throwaway db
mongo = await MongoMemoryServer.create();
await db.init(mongo.getUri(), 'test');
});
beforeEach(async () => { // every test: known state
await db._clearCollection(db.PRODUCTS);
await db.addToCollection(db.PRODUCTS, { name: 'Duct Tape', price: 5.99, quantity: 120 });
});
afterAll(async () => { // once: clean up
await db.disconnect();
await mongo.stop();
});
describe('GET /products', () => {
it('lists products', async () => {
const res = await request(app).get('/products'); // act
expect(res.status).toBe(200); // assert
expect(res.body).toHaveLength(1);
});
});Supertest requests
ts
await request(app).get('/books?author=Tolkien');
await request(app).post('/books').set('Authorization', 'Bearer SI679').send({ title: 'Dune' });
await request(app).patch(`/products/${id}`).send({ price: 4.99 });
await request(app).delete(`/products/${'a'.repeat(24)}`);
// read back: res.status Β· res.body (JSON) Β· res.text (send() responses)Matchers
| Matcher | Use for |
|---|---|
toBe(200) | primitives (numbers, strings, booleans) |
toEqual({ β¦ }) / toEqual([β¦]) | objects and arrays (compares contents) |
toHaveLength(2) | arrays, strings |
toContain('Braun') | array includes a primitive |
toContainEqual({ β¦ }) | array includes an object |
toMatch(/^[0-9a-f]{24}$/) | string matches a pattern |
toBeUndefined() / toBeNull() / toBeDefined() | absence / presence |
.not.β¦ | inverts any matcher (pair it with a positive one) |
await expect(p).rejects.toThrow() | an async function throws |
Unit tests with mocks and spies (Week 5). File sits beside the code: products-service.unit.test.ts.
ts
import { beforeEach, expect, it, vi } from 'vitest';
import * as productsService from './products-service.js'; // what we TEST
import * as productsRepository from '../db/products-repository.js'; // what we MOCK
beforeEach(() => { vi.restoreAllMocks(); }); // real functions back
it('writes nothing when quantity would go negative', async () => {
vi.spyOn(productsRepository, 'findProduct').mockResolvedValue(makeProduct({ quantity: 10 })); // arrange
const update = vi.spyOn(productsRepository, 'updateProduct'); // watch only
await expect(productsService.updateProduct('abc', { quantity: -1 })) // act
.rejects.toThrow(ValidationError); // assert
expect(update).not.toHaveBeenCalled();
});| Tool | Does |
|---|---|
vi.fn() | new fake; records calls, returns undefined |
.mockReturnValue(x) / .mockReturnValueOnce(x) | answer x always / next call only |
.mockResolvedValue(x) / .mockRejectedValue(err) | async: promise succeeds with x / fails with err |
.mockImplementation((a) => β¦) | real behavior for the fake |
vi.spyOn(obj, 'f') | record calls to a real function (it still runs) |
vi.spyOn(obj, 'f').mockResolvedValue(x) | replace a real function |
vi.restoreAllMocks() | undo every spy (put in beforeEach) |
toHaveBeenCalled() / toHaveBeenCalledTimes(n) / toHaveBeenCalledWith(a, b) / .not.toHaveBeenCalled() | check the record |
Fake res for a controller test: const res = { status: vi.fn(), json: vi.fn() }; res.status.mockReturnValue(res);
Run subsets: npm run test:unit (vitest run unit.test) Β· npm run test:e2e (vitest run e2e.test) Β· it.todo('β¦') = planned test.
Without a database (Week 2): reset in-memory state with beforeEach(() => { _resetCatalog(); });.
vitest.config.ts (needed for MongoMemoryServer):
ts
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { hookTimeout: 120_000, testTimeout: 20_000 } });Postman quick steps β
- Method dropdown β GET / POST / PATCH / DELETE, then the URL
http://localhost:6790/products. - Body β raw β type JSON (not Text), then paste the JSON.
- Headers β
Authorization=Bearer <token>. - Send, then check the status and body. Copy ids from responses into later requests.
When it breaks β
| You see | Likely cause |
|---|---|
Cannot GET /x / Cannot POST /x | no route for that method + path, or the router is mounted at a different prefix |
req.body is undefined / fields empty | express.json() missing or registered after the route, or Postman body not set to JSON |
| request hangs forever | middleware didn't call next() or respond, or mongod isn't running (~30 s timeout) |
Cannot set headers after they are sent | sent two responses: add return after early res.β¦ |
Cannot use import statement outside a module | "type": "module" missing in package.json |
Cannot find module './types' | import needs the .js extension: './types.js' |
'Request' is a type and must be imported using a type-only import | use import type { Request } |
Cannot find name 'process' | "types": ["node"] missing in tsconfig.json |
input must be a 24 character hex stringβ¦ β 500 | new ObjectId() got a malformed id: validate first |
findOne by id returns null for a real id | searched with a string, not new ObjectId(id) |
response shows _id instead of id | the service skipped productFromDocument |
| test passes once, fails on rerun | leftover data: use MongoMemoryServer + beforeEach clear |
Hook timed out in 10000ms | vitest.config.ts missing (MongoMemoryServer needs longer) |
toBe fails on identical objects | use toEqual |
No test suite found in file | a test file with no it(...) yet: add a test or an it.todo |
unit test: Cannot read properties of undefined (reading 'collection') | the code under test called a repository function you didn't mock |
| unit test passes no matter what | you mocked the function under test, not the layer below it |
expect(...).rejects test passes when it shouldn't | missing await before expect |
| error handler never runs | registered before the routes, or has only 3 parameters |
| code with type errors runs anyway | tsx doesn't type-check: run npm run typecheck |