Complete REST API Project
Time to put the whole track together. This project is the LearnHub API in one runnable file: users register and log in, instructors manage their own courses, students enrol, admins moderate — validated, secured and consistent.
On this page · 12 min · Advanced
On this page
- Learning objectives
- Explanation
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 12 min
- Difficulty
- Advanced
- Updated
- Oct 28, 2026
Related tutorials
Interview questions
Learning objectives#
- Combine routing, validation, errors, auth and authorization in one API
- Enforce roles and ownership on real endpoints
- Return paginated lists and Problem Details errors
- Verify the whole flow end to end
Explanation#
Structure (one file here, split into folders in a real project): config → errors → data → schemas → middleware → routers → createApp → end-to-end run.
Auth: register (students only — instructors are created by admins), login with bcrypt and a dummy hash, 15-minute Bearer tokens, rate-limited login.
Courses: public list with pagination and level filter; create for instructors/admins; update for the owner or an admin; delete for admins. router.param loads the course once.
Enrollments: students enrol once (second time → 409). Course DTOs include the instructor name and enrolment count — never raw rows.
Every error is application/problem+json; validation problems add an errors array. Swap the in-memory data for the mysql2 repositories from earlier lessons to go to production.
Code example: LearnHub API: the complete project#
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { rateLimit } from 'express-rate-limit';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
// ── config.js ──
const config = { jwtSecret: process.env.JWT_SECRET ?? 'dev-only-secret-change-me-32-chars-long', corsOrigins: ['http://localhost:5173'], bcryptCost: 10 };
// ── errors.js ──
class ApiError extends Error {
constructor(status, title, detail, errors) { super(detail); Object.assign(this, { status, title, detail, errors }); }
}
const notFound = what => new ApiError(404, 'Not Found', `${what} was not found`);
const forbidden = detail => new ApiError(403, 'Forbidden', detail);
// ── data (swap for mysql2 repositories) ──
const db = { users: [], courses: [], enrollments: [] };
const DUMMY_HASH = await bcrypt.hash('not-a-real-password', config.bcryptCost);
db.users.push(
{ id: 1, email: 'admin@learnhub.example', name: 'Admin', role: 'admin', passwordHash: await bcrypt.hash('admin-pass-2026!', config.bcryptCost) },
{ id: 2, email: 'ravi@learnhub.example', name: 'Ravi', role: 'instructor', passwordHash: await bcrypt.hash('ravi-teaches-node', config.bcryptCost) },
);
// ── schemas.js ──
const Email = z.string().trim().toLowerCase().email();
const Registration = z.strictObject({ email: Email, password: z.string().min(12).max(72), name: z.string().trim().min(2).max(60) });
const Login = z.object({ email: Email, password: z.string().min(1) });
const Level = z.enum(['beginner', 'intermediate', 'advanced']);
const CourseInput = z.strictObject({ title: z.string().trim().min(3).max(120), level: Level, price: z.number().int().min(0) });
const ListQuery = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(50).default(10), level: Level.optional() });
function parse(schema, value) {
const result = schema.safeParse(value ?? {});
if (result.success) return result.data;
throw new ApiError(400, 'Validation failed', 'One or more fields are invalid',
result.error.issues.map(i => ({ field: i.path.join('.') || null, message: i.message })));
}
// ── middleware ──
function authenticate(req, res, next) {
const [scheme, token] = (req.get('authorization') ?? '').split(' ');
try {
if (scheme !== 'Bearer') throw new Error('no token');
req.user = jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
req.user.id = Number(req.user.sub);
next();
} catch {
next(new ApiError(401, 'Unauthorized', 'Authentication required'));
}
}
const requireRole = (...roles) => (req, res, next) =>
roles.includes(req.user.role) ? next() : next(forbidden(`Requires role: ${roles.join(' or ')}`));
// ── routes/auth.js ──
const auth = express.Router();
auth.post('/register', async (req, res) => {
const { email, password, name } = parse(Registration, req.body);
if (db.users.some(u => u.email === email)) throw new ApiError(409, 'Conflict', 'Email already registered');
const user = { id: db.users.length + 1, email, name, role: 'student', passwordHash: await bcrypt.hash(password, config.bcryptCost) };
db.users.push(user);
res.status(201).json({ id: user.id, email, name, role: user.role });
});
auth.post('/login', rateLimit({ windowMs: 15 * 60_000, limit: 10, standardHeaders: 'draft-7', legacyHeaders: false }), async (req, res) => {
const { email, password } = parse(Login, req.body);
const user = db.users.find(u => u.email === email);
const ok = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH);
if (!user || !ok) throw new ApiError(401, 'Unauthorized', 'Invalid email or password');
const accessToken = jwt.sign({ sub: String(user.id), role: user.role }, config.jwtSecret, { expiresIn: '15m' });
res.json({ accessToken, tokenType: 'Bearer', expiresIn: 900 });
});
// ── routes/courses.js ──
const toCourse = c => ({
id: c.id, title: c.title, level: c.level, price: c.price,
instructor: db.users.find(u => u.id === c.instructorId).name,
enrolled: db.enrollments.filter(e => e.courseId === c.id).length,
});
const courses = express.Router();
courses.param('id', (req, res, next, id) => {
req.course = db.courses.find(c => c.id === Number(id));
req.course ? next() : next(notFound(`Course ${id}`));
});
courses.get('/', (req, res) => {
const { page, pageSize, level } = parse(ListQuery, req.query);
const all = db.courses.filter(c => !level || c.level === level);
res.json({ data: all.slice((page - 1) * pageSize, page * pageSize).map(toCourse), meta: { page, pageSize, total: all.length } });
});
courses.get('/:id', (req, res) => res.json(toCourse(req.course)));
courses.post('/', authenticate, requireRole('instructor', 'admin'), (req, res) => {
const course = { id: db.courses.length + 1, ...parse(CourseInput, req.body), instructorId: req.user.id };
db.courses.push(course);
res.status(201).location(`/api/v1/courses/${course.id}`).json(toCourse(course));
});
courses.patch('/:id', authenticate, (req, res) => {
if (req.course.instructorId !== req.user.id && req.user.role !== 'admin') throw forbidden('You can only edit your own courses');
Object.assign(req.course, parse(CourseInput.partial(), req.body));
res.json(toCourse(req.course));
});
courses.delete('/:id', authenticate, requireRole('admin'), (req, res) => {
db.courses.splice(db.courses.indexOf(req.course), 1);
res.sendStatus(204);
});
courses.post('/:id/enrollments', authenticate, requireRole('student'), (req, res) => {
if (db.enrollments.some(e => e.courseId === req.course.id && e.userId === req.user.id)) throw new ApiError(409, 'Conflict', 'Already enrolled in this course');
db.enrollments.push({ courseId: req.course.id, userId: req.user.id });
res.status(201).json({ courseId: req.course.id, userId: req.user.id });
});
// ── app.js ──
function createApp() {
const app = express();
app.use(helmet(), cors({ origin: config.corsOrigins, credentials: true }), express.json({ limit: '10kb' }));
app.get('/health', (req, res) => res.json({ status: 'ok' }));
const v1 = express.Router();
v1.use('/auth', auth);
v1.use('/courses', courses);
app.use('/api/v1', v1);
app.use((req, res, next) => next(notFound(`Route ${req.method} ${req.path}`)));
app.use((err, req, res, next) => {
const status = err instanceof ApiError ? err.status : err.type === 'entity.parse.failed' ? 400 : 500;
if (status === 500) console.error(err);
res.status(status).type('application/problem+json').json({
type: 'about:blank',
title: err.title ?? (status === 400 ? 'Bad Request' : 'Internal Server Error'),
status,
detail: status === 500 ? 'Something went wrong' : err.detail ?? err.message,
...(err.errors && { errors: err.errors }),
instance: req.originalUrl,
});
});
return app;
}
// ── End-to-end run ──
const server = createApp().listen(3119, async () => {
const base = 'http://localhost:3119/api/v1';
async function step(label, method, path, { token, body } = {}) {
const r = await fetch(base + path, {
method,
headers: { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) },
body: body && JSON.stringify(body),
});
const data = r.status === 204 ? null : await r.json();
let summary = data?.accessToken ? 'Bearer token issued' : data?.errors ? `${data.detail}: ${data.errors.map(e => e.field).join(', ')}` : data?.detail ?? JSON.stringify(data);
if (r.status === 204) summary = '(no body)';
console.log(`${label.padEnd(30)} ${r.status} ${summary}`);
return data;
}
await step('Asha registers', 'POST', '/auth/register', { body: { email: 'asha@example.com', password: 'asha-learns-2026', name: 'Asha' } });
const asha = (await step('Asha logs in', 'POST', '/auth/login', { body: { email: 'ASHA@example.com', password: 'asha-learns-2026' } })).accessToken;
const ravi = (await step('Ravi (instructor) logs in', 'POST', '/auth/login', { body: { email: 'ravi@learnhub.example', password: 'ravi-teaches-node' } })).accessToken;
const admin = (await step('Admin logs in', 'POST', '/auth/login', { body: { email: 'admin@learnhub.example', password: 'admin-pass-2026!' } })).accessToken;
await step('Ravi creates a course', 'POST', '/courses', { token: ravi, body: { title: 'Node.js REST APIs', level: 'beginner', price: 499 } });
await step('Ravi sends invalid data', 'POST', '/courses', { token: ravi, body: { title: 'JS', level: 'expert', price: -1 } });
await step('Asha tries to create', 'POST', '/courses', { token: asha, body: { title: 'My course', level: 'beginner', price: 0 } });
await step('Create without a token', 'POST', '/courses', { body: { title: 'Anonymous', level: 'beginner', price: 0 } });
await step('Asha enrols', 'POST', '/courses/1/enrollments', { token: asha });
await step('Asha enrols again', 'POST', '/courses/1/enrollments', { token: asha });
await step('Asha edits Ravi’s course', 'PATCH', '/courses/1', { token: asha, body: { price: 1 } });
await step('Ravi lowers the price', 'PATCH', '/courses/1', { token: ravi, body: { price: 399 } });
await step('List beginner courses', 'GET', '/courses?level=beginner&pageSize=5');
await step('Get a missing course', 'GET', '/courses/42');
await step('Ravi tries to delete', 'DELETE', '/courses/1', { token: ravi });
await step('Admin deletes it', 'DELETE', '/courses/1', { token: admin });
server.close();
});
Asha registers 201 {"id":3,"email":"asha@example.com","name":"Asha","role":"student"}
Asha logs in 200 Bearer token issued
Ravi (instructor) logs in 200 Bearer token issued
Admin logs in 200 Bearer token issued
Ravi creates a course 201 {"id":1,"title":"Node.js REST APIs","level":"beginner","price":499,"instructor":"Ravi","enrolled":0}
Ravi sends invalid data 400 One or more fields are invalid: title, level, price
Asha tries to create 403 Requires role: instructor or admin
Create without a token 401 Authentication required
Asha enrols 201 {"courseId":1,"userId":3}
Asha enrols again 409 Already enrolled in this course
Asha edits Ravi’s course 403 You can only edit your own courses
Ravi lowers the price 200 {"id":1,"title":"Node.js REST APIs","level":"beginner","price":399,"instructor":"Ravi","enrolled":1}
List beginner courses 200 {"data":[{"id":1,"title":"Node.js REST APIs","level":"beginner","price":399,"instructor":"Ravi","enrolled":1}],"meta":{"page":1,"pageSize":5,"total":1}}
Get a missing course 404 Course 42 was not found
Ravi tries to delete 403 Requires role: admin
Admin deletes it 204 (no body)This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- Seeded admin and instructor accounts exist; public registration always creates students.
- Login normalises the email (ASHA@example.com works) and issues a Bearer token.
- Validation, roles (403), missing tokens (401) and duplicates (409) all come back as Problem Details.
- Ownership: Asha cannot edit Ravi’s course; Ravi can.
- Lists are paginated DTOs with the instructor name and enrolment count; only an admin may delete.
Real-world use case#
This is the back end for the React integration lesson and the Angular CRUD project. Replace db with the MySQL repositories, move each section into its folder, add the logging middleware and the Dockerfile, and it is ready to deploy.
Common mistakes#
{ "role": "admin" } in a sign-up body is a classic privilege escalation. strictObject rejects it here; roles are assigned by admins.
Ownership checks need the course; router.param loads it first, then the handler compares owners.
The fallback JWT secret is for local runs only. Production must fail to start without JWT_SECRET (see the config lessons).
Best practices#
- Layers: config, errors, data, schemas, middleware, routes
- Roles + ownership on every write
- Problem Details everywhere
- An end-to-end script that exercises the whole flow
Remember this#
authenticate → requireRole → ownership check → validate → act.
401 no token · 403 not allowed · 409 duplicate · 404 missing.
DTOs out, schemas in.
Practice exercise#
Your task: Add GET /api/v1/me/enrollments that returns the signed-in student’s courses as DTOs.
Need a hint?
Show solution
// const me = express.Router();
// me.get('/enrollments', authenticate, (req, res) => {
// const courseIds = db.enrollments.filter(e => e.userId === req.user.id).map(e => e.courseId);
// res.json(db.courses.filter(c => courseIds.includes(c.id)).map(toCourse));
// });
// v1.use('/me', me);
console.log('GET /api/v1/me/enrollments → 200 [ { id, title, level, price, instructor, enrolled } ]');
Mini quiz#
Why does registration ignore (reject) a role field?
Want more? Practise all Node.js REST API MCQs · Node.js REST API interview questions
My notes#
Saved in this browser.