Assembling the Complete API
Objectives
By the end of this lesson, you should be able to:
- Organize a full authenticated API across separate, focused files
- Wire hashing, JWTs, roles, ownership, validation, rate limiting, and logging into one Express app
- Use a factory function to build fresh, isolated app instances instead of one shared module-level app
๐ก Why this matters: Every module in this course built one piece of this API in isolation. This lesson puts them all in the same project, a small Notes API, registration, login, role-gated admin access, and ownership-checked notes, exactly the shape of a real, small authenticated service.
โ ๏ธ A note on verification: every file below was actually run, and every route in it is covered by the test suite in the next lesson.
The Project
A small Notes API: users register and log in, each user can create, read, and delete their own notes, an admin role can view any note and list all users. Split across focused files, exactly the separation of concerns this course has used throughout.
capstone/
โโโ schemas.js Zod schemas (Module 5)
โโโ validate.js validation middleware (Module 5)
โโโ errors.js AppError class (Module 6, Module 9)
โโโ auth.js JWTs, requireAuth, requireRole (Module 3, Module 4)
โโโ app.js the Express app itself, assembled from the above
โโโ app.test.js integration tests (Module 8)
โโโ schemas.test.js unit tests (Module 7)
Schemas and Validation
// schemas.js
const { z } = require('zod');
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8, 'Password must be at least 8 characters')
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1, 'Password is required')
});
const noteSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters').max(100),
body: z.string().min(1, 'Body is required')
});
module.exports = { registerSchema, loginSchema, noteSchema };
// validate.js
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const error = new Error('Invalid request data');
error.status = 400;
error.isOperational = true;
error.details = result.error.issues.map(i => ({ field: i.path.join('.'), message: i.message }));
return next(error);
}
req.validatedBody = result.data;
next();
};
}
module.exports = { validate };
validate now passes its error to next(error) instead of responding directly, this is what lets the shared error handler (below) log every kind of failure consistently, validation included, rather than validation errors bypassing the logging this course built in Module 9.
A Shared Error Type
// errors.js
class AppError extends Error {
constructor(message, status) {
super(message);
this.status = status;
this.isOperational = true;
}
}
module.exports = { AppError };
Authentication and Authorization
// auth.js
const jwt = require('jsonwebtoken');
const { AppError } = require('./errors');
const JWT_SECRET = process.env.JWT_SECRET || 'test-secret-for-this-course';
function issueToken(user) {
return jwt.sign({ sub: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '1h' });
}
function requireAuth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return next(new AppError('Missing or invalid Authorization header', 401));
}
try {
req.user = jwt.verify(header.slice(7), JWT_SECRET);
next();
} catch (err) {
return next(new AppError('Invalid or expired token', 401));
}
}
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return next(new AppError(`Requires ${role} role`, 403));
}
next();
};
}
module.exports = { issueToken, requireAuth, requireRole, JWT_SECRET };
Notice JWT_SECRET is read from process.env, falling back to a fixed value only for this course’s own testing, in a real deployment the environment variable (Module 6) would always be set, never the fallback.
The App Itself, as a Factory
// app.js
const express = require('express');
const bcrypt = require('bcryptjs');
const pino = require('pino');
const pinoHttp = require('pino-http');
const rateLimit = require('express-rate-limit');
const { registerSchema, loginSchema, noteSchema } = require('./schemas');
const { validate } = require('./validate');
const { AppError } = require('./errors');
const { issueToken, requireAuth, requireRole } = require('./auth');
function createApp({ authLimiterMax = 5 } = {}) {
const logger = pino({ level: process.env.LOG_LEVEL || 'silent' });
const app = express();
app.use(express.json());
app.use(pinoHttp({ logger }));
// In-memory "database", scoped to this app instance, not shared module state.
let users = [];
let notes = [];
let nextUserId = 1;
let nextNoteId = 1;
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: authLimiterMax,
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
req.log.warn({ status: 429 }, 'Rate limit exceeded on auth route');
res.status(429).json({ error: 'TooManyRequests', message: 'Too many attempts, try again later' });
}
});
app.post('/api/v1/auth/register', authLimiter, validate(registerSchema), async (req, res, next) => {
const { email, password } = req.validatedBody;
if (users.find(u => u.email === email)) {
return next(new AppError('Email already registered', 409));
}
const passwordHash = await bcrypt.hash(password, 10);
const user = { id: nextUserId++, email, passwordHash, role: 'member' };
users.push(user);
req.log.info({ userId: user.id }, 'User registered');
res.status(201).json({ id: user.id, email: user.email, role: user.role });
});
app.post('/api/v1/auth/login', authLimiter, validate(loginSchema), async (req, res, next) => {
const { email, password } = req.validatedBody;
const user = users.find(u => u.email === email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return next(new AppError('Incorrect email or password', 401));
}
req.log.info({ userId: user.id }, 'User logged in');
res.json({ token: issueToken(user) });
});
app.post('/api/v1/notes', requireAuth, validate(noteSchema), (req, res) => {
const note = { id: nextNoteId++, authorId: req.user.sub, ...req.validatedBody };
notes.push(note);
res.status(201).json(note);
});
app.get('/api/v1/notes/:id', requireAuth, (req, res, next) => {
const note = notes.find(n => n.id === Number(req.params.id));
if (!note) return next(new AppError('Note not found', 404));
const isOwner = note.authorId === req.user.sub;
const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin) {
return next(new AppError('You do not own this note', 403));
}
res.json(note);
});
app.delete('/api/v1/notes/:id', requireAuth, (req, res, next) => {
const note = notes.find(n => n.id === Number(req.params.id));
if (!note) return next(new AppError('Note not found', 404));
const isOwner = note.authorId === req.user.sub;
const isAdmin = req.user.role === 'admin';
if (!isOwner && !isAdmin) {
return next(new AppError('You do not own this note', 403));
}
notes = notes.filter(n => n.id !== note.id);
res.status(204).send();
});
app.get('/api/v1/admin/users', requireAuth, requireRole('admin'), (req, res) => {
res.json(users.map(u => ({ id: u.id, email: u.email, role: u.role })));
});
app.use((err, req, res, next) => {
const status = err.status || 500;
if (err.isOperational) {
req.log.warn({ status, err: err.message }, 'Operational error handled');
} else {
req.log.error({ status, err }, 'Unexpected error, not marked operational');
}
const body = { error: err.isOperational ? err.message : 'Internal server error' };
if (err.details) body.details = err.details;
res.status(status).json(body);
});
// Testing-only hook, not an HTTP route, never reachable from outside the process.
app.locals.testHelpers = {
promoteToAdmin(email) {
const user = users.find(u => u.email === email);
if (user) user.role = 'admin';
}
};
return app;
}
module.exports = { createApp };
Why a Factory Function, Not a Single Exported App
Every earlier module in this course exported one app, a module-level constant. Here, createApp(options) builds a brand-new app, with its own in-memory data and its own rate limiter, on every call. This matters specifically for testing, the next lesson creates a fresh app in every single test, so no test’s registered users, notes, or consumed rate-limit quota ever leaks into another test, without needing a reset endpoint or any other test-only route on the real application surface. authLimiterMax being configurable is the same idea applied to the rate limiter specifically, tests that need to trigger a 429 can ask for a strict limit, without that strict limit affecting every other test in the suite.
Try It
- Build all five files above, and confirm
createApp()returns a working Express app by sending it a single request manually. - Register a user, log in, create a note, and fetch it back, by hand, confirming the full flow works end to end.
- Explain, in your own words, why
app.locals.testHelpersis a reasonable place for a testing hook, compared to adding a real HTTP route like/__test__/resetthat would ship in production. - Identify, for each route in
app.js, which earlier module in this course it came from.
Recap
- This capstone combines hashing (Module 1), JWTs (Module 3), role and ownership checks (Module 4), Zod validation (Module 5), rate limiting (Module 6), and structured logging (Module 9) into one small, real API.
createApp(options)is a factory, not a single shared app, each call produces an isolated instance, essential for clean, independent tests.- A testing-only hook lives on
app.locals, directly accessible to tests that already have theappobject, not as a route that would ship in production.
Next lesson: the complete test suite, unit tests for the schemas and integration tests for every route, covering the whole API end to end.