Add Course Management to Your REST API

Build a multi-resource API with relational database design and Swagger docs.

Introduction

30 Second Summary

Most real-world applications don't store data in isolation. Students take courses. Customers place orders. Users join teams. Modelling these relationships correctly is one of the most important skills in backend development.

In this project, you will extend your Student API with a courses resource and an enrollment system that connects students to courses through a proper many-to-many relationship. You will design your schema with foreign key constraints, add input validation, version your API, and generate interactive documentation.

What You'll Build

By the end, you will have a multi-resource REST API where you can create courses, enroll students, and watch PostgreSQL enforce data integrity for you. You can demo every endpoint through a live Swagger UI in your browser.

By the end of this project, you'll have:

  • A Course CRUD API where you create a course and immediately see it listed alongside others, backed by database constraints that prevent duplicate course codes.
  • An Enrollment system that connects students to courses through a junction table. Try enrolling a non-existent student and watch PostgreSQL's foreign key constraint fire back an error.
  • Request validation that catches malformed input at the API boundary. Send an empty body to any endpoint and get back a structured 400 error with clear messages instead of a crash.
  • Interactive Swagger documentation auto-generated from your code. Open /api-docs in your browser and test every endpoint without leaving the page.
  • Secret Mission: Build a student's course roster endpoint with a JOIN query and enrollment status filter.

Are there any prerequisites?

This project builds directly on Project 1 (Student CRUD API). You should already have a working Bun and Express project written in TypeScript with PostgreSQL running via Docker Compose, a layered architecture (routes, controllers, services), and Git initialized.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Your existing Student API handles one resource well. This project pushes you into relational database design, where the relationships between resources are just as important as the resources themselves.

Install Packages and Version Your API

Your Student API from Project 1 works, but it has no way to evolve safely. If you change an endpoint's behavior today, any frontend or mobile app using it breaks tomorrow.

In this step, you will install three packages that give your API professional infrastructure: input validation, versioned routes, and auto-generated documentation. By the end, your existing student endpoints will live under /api/v1/ and you will have a Swagger UI ready to document every endpoint you build.

In this step, get ready to:
  • Install express-validator, swagger-jsdoc, and swagger-ui-express.
  • Create a versioned router and Swagger configuration file.
  • Update your app entry point to serve versioned routes and interactive documentation.
Install new dependencies

Your project needs three new packages. express-validator catches malformed input at the API boundary. swagger-jsdoc generates an OpenAPI spec from comments in your code. swagger-ui-express renders that spec as an interactive web page.

  • In your terminal, make sure you are inside your project folder (the one containing package.json).
  • Install all three packages at their pinned versions by running this command:
bun add express-validator@7.3.2 swagger-jsdoc@6.3.0 swagger-ui-express@5.0.1

You should see output confirming the three packages were installed successfully.

Seeing errors during install?

Make sure you are running the command inside your project directory, not in a system-level path. Run ls and confirm you can see package.json in the output.

help me fix bun add errors in my project

Create the versioned router and Swagger config

API versioning puts all routes behind a version prefix like /api/v1/. When you need breaking changes later, you create /api/v2/ without touching the old endpoints.

Why URL-prefix versioning?

Header-based versioning is another option, but URL prefixes are the most visible approach. They are easy to test with curl, obvious in logs, and make Swagger documentation cleaner since each version has distinct paths.

First, you need a src/routes/v1/ directory to hold all version-1 route files. Your existing studentRoutes.ts from Project 1 will move into this directory.

  • Create the v1 directory and move your student routes file into it by running these commands:
mkdir -p src/routes/v1
mv src/routes/studentRoutes.ts src/routes/v1/studentRoutes.ts
  • Confirm the file landed in the right place by running:
ls src/routes/v1

You should see studentRoutes.ts listed in the output.

Don't see studentRoutes.ts?

If your Project 1 used a different filename (like students.ts), adjust the mv command to match your actual filename. The file must end up at src/routes/v1/studentRoutes.ts.

help me move my student routes file into the v1 directory

Now create the version router that mounts your student routes under the /students path.

  • Create a new file at src/routes/v1/index.ts and add this code:
import { Router } from 'express';
import studentRoutes from './studentRoutes';

const router = Router();

router.use('/students', studentRoutes);

export default router;

What does this code do?

This file is the single entry point for all v1 routes. It creates an Express Router and mounts the student routes at /students.

Later, when you add course and enrollment routes, you will mount them here too. The app.ts file only needs to know about this one router.

  • Verify the router loads without errors by running:
bun -e "await import('./src/routes/v1/index.ts'); console.log('v1 router loaded')"

You should see v1 router loaded printed with no errors.

Seeing a module resolution error?

Check that studentRoutes.ts exists inside src/routes/v1/. The import './studentRoutes' line in index.ts expects it to be right next to it in the same folder.

help me fix the module resolution error in my v1 router

Next, create the Swagger configuration that tells swagger-jsdoc how to generate your OpenAPI specification.

  • Create the config directory if it does not already exist:
mkdir -p src/config
  • Create a new file at src/config/swagger.ts and add this code:
import swaggerJsdoc from 'swagger-jsdoc';

const options = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'Student Course Management API',
      version: '1.0.0',
      description: 'REST API for managing students, courses, and enrollments',
    },
    servers: [
      {
        url: 'http://localhost:3000',
        description: 'Development server',
      },
    ],
  },
  apis: ['./src/routes/v1/*.ts'],
};

const swaggerSpec = swaggerJsdoc(options);

export default swaggerSpec;

What does this code do?

The options object defines your API's identity (title, version, description) and tells swagger-jsdoc where to look for route annotations.

The apis glob pattern './src/routes/v1/*.ts' means it will scan every .ts file in your v1 routes folder for @openapi comments. You will add those comments in a later step.

  • Save src/config/swagger.ts.
Update app.ts to use versioned routes and Swagger

Your app.ts currently mounts student routes directly. You will replace its contents to mount the v1 router at /api/v1 and serve Swagger UI at /api-docs.

  • Open src/app.ts and replace its entire contents with this code:
import express, { Request, Response, NextFunction } from 'express';
import swaggerUi from 'swagger-ui-express';
import swaggerSpec from './config/swagger';
import v1Router from './routes/v1';

const app = express();

app.use(express.json());

// Swagger documentation
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));

// API v1 routes
app.use('/api/v1', v1Router);

// Error handling middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

export default app;

What does this code do?

The swaggerUi.serve and swaggerUi.setup(swaggerSpec) middleware pair serves an interactive documentation page at /api-docs.

The v1Router is mounted at /api/v1, so your student routes now live at /api/v1/students instead of /students. The error handling middleware stays at the bottom to catch anything that falls through.

  • Save src/app.ts.

Before you start the server, what do you expect to happen when you hit /api/v1/students? Think about what your student routes returned in Project 1.

  • Start your server by running:
bun run start
  • In a second terminal window, test the versioned student endpoint by running:
curl http://localhost:3000/api/v1/students

You should see the same student JSON data that your old routes returned. The data has not changed, only the URL prefix.

  • Open your browser and navigate to http://localhost:3000/api-docs.

You should see the Swagger UI page with your API title ("Student Course Management API") but no endpoints listed yet. That is expected because you have not added any @openapi annotations to your route files.

Getting module resolution errors on startup?

Double-check that your import paths match your actual file locations. The app.ts file expects ./config/swagger and ./routes/v1 relative to the src/ folder.

If curl returns a connection error, make sure bun run start is still running in your first terminal and that your PostgreSQL container is up (docker compose up -d).

help me debug why my Express server won't start after adding Swagger and versioned routes

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/routes/v1/index.ts, src/config/swagger.ts, and src/app.ts.

ⓧ I'd like to double check the full code

Here are the three files you created or modified in this step. Scroll through each and confirm your code matches.

src/routes/v1/index.ts:

import { Router } from 'express';
import studentRoutes from './studentRoutes';

const router = Router();

router.use('/students', studentRoutes);

export default router;

src/config/swagger.ts:

import swaggerJsdoc from 'swagger-jsdoc';

const options = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'Student Course Management API',
      version: '1.0.0',
      description: 'REST API for managing students, courses, and enrollments',
    },
    servers: [
      {
        url: 'http://localhost:3000',
        description: 'Development server',
      },
    ],
  },
  apis: ['./src/routes/v1/*.ts'],
};

const swaggerSpec = swaggerJsdoc(options);

export default swaggerSpec;

src/app.ts:

import express, { Request, Response, NextFunction } from 'express';
import swaggerUi from 'swagger-ui-express';
import swaggerSpec from './config/swagger';
import v1Router from './routes/v1';

const app = express();

app.use(express.json());

// Swagger documentation
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));

// API v1 routes
app.use('/api/v1', v1Router);

// Error handling middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

export default app;

Your API now has versioned routes and a documentation page ready to go. Next up, you will design the database schema for courses and enrollments, thinking in ERD terms before writing any application code.

Design the Database Schema

Your API now has versioned routes, a validation library ready to go, and interactive documentation infrastructure. The next challenge is the database itself.

You need a way to store courses and connect them to students. A naive approach would add a course_id column to the students table, but that only allows one course per student. The correct design uses a junction table that sits between students and courses, letting each student enroll in many courses and each course have many students.

In this step, get ready to:
  • Create a courses table migration with proper constraints.
  • Create an enrollments junction table migration with foreign keys and indexes.
  • Run the migrations and verify the schema in PostgreSQL.
Create the courses migration

Before writing any application code, you define the database structure in SQL migration files. The courses table needs constraints that enforce data quality at the database level.

  • Create a new file at src/database/migrations/002_create_courses.sql and add the following SQL:
CREATE TABLE IF NOT EXISTS courses (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  course_code VARCHAR(20) NOT NULL UNIQUE,
  description TEXT,
  credits INTEGER NOT NULL CHECK (credits >= 1 AND credits <= 6),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

What do these constraints do?

  • NOT NULL prevents inserting a course without a name, code, or credit value.
  • UNIQUE on course_code ensures no two courses share the same code.
  • CHECK (credits >= 1 AND credits <= 6) rejects any credit value outside the valid range. The database itself enforces this, not just your application code.
  • Save 002_create_courses.sql.
Create the enrollments migration with FK constraints and indexes

The enrollments table is the junction table that creates the many-to-many relationship between students and courses. Each row represents one student enrolled in one course.

Foreign keys tell PostgreSQL to enforce referential integrity. If someone tries to enroll a student that does not exist, the database rejects the insert automatically.

  • Create a new file at src/database/migrations/003_create_enrollments.sql and add the following SQL:
CREATE TABLE IF NOT EXISTS enrollments (
  id SERIAL PRIMARY KEY,
  student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE,
  course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
  status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'completed', 'dropped')),
  enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  UNIQUE (student_id, course_id)
);

CREATE INDEX idx_enrollments_student_id ON enrollments(student_id);
CREATE INDEX idx_enrollments_course_id ON enrollments(course_id);

What does this migration set up?

  • REFERENCES students(id) ON DELETE CASCADE creates a foreign key. If a student is deleted, all their enrollments are automatically removed too.
  • UNIQUE (student_id, course_id) prevents the same student from being enrolled in the same course twice.
  • CREATE INDEX adds indexes on the foreign key columns. PostgreSQL does not automatically create indexes on foreign key referencing columns, so you add them manually for efficient lookups.

💡 Why separate indexes on foreign keys?

When you query all enrollments for a specific student, PostgreSQL needs to scan through the enrollments table to find matching rows. Without an index on student_id, it scans every single row. The index lets it jump directly to the relevant rows.

  • Save 003_create_enrollments.sql.
Run migrations and verify the schema

Both migration files are ready. Now you execute them against your PostgreSQL container to create the tables.

  • Run the courses migration by executing this command:
docker exec -i [[CONTAINER="your-postgres-container-name"]] psql -U postgres -d [[DBNAME="your-database-name"]] < src/database/migrations/002_create_courses.sql
  • You should see CREATE TABLE printed in your terminal.
  • Run the enrollments migration by executing this command:
docker exec -i [[CONTAINER="your-postgres-container-name"]] psql -U postgres -d [[DBNAME="your-database-name"]] < src/database/migrations/003_create_enrollments.sql
  • You should see CREATE TABLE followed by two CREATE INDEX confirmations.

Seeing a "relation students does not exist" error?

The enrollments table references the students table via a foreign key. If you have not yet run your Project 1 migration that creates the students table, run that first. The courses migration must also run before enrollments, since enrollments references courses.

help me fix a foreign key reference error when running my enrollments migration

Before you verify, predict what you expect to see. How many tables should appear when you list all tables in the database?

  • Connect to your PostgreSQL database by running:
docker exec -it [[CONTAINER="your-postgres-container-name"]] psql -U postgres -d [[DBNAME="your-database-name"]]
  • List all tables by running:
\dt

You should see three tables listed: students, courses, and enrollments. The output shows columns for Schema, Name, Type, and Owner.

  • Inspect the enrollments table structure by running:
\d enrollments

You should see the column definitions, followed by an Indexes section listing your two custom indexes and the unique constraint, and a Foreign-key constraints section showing the references to students(id) and courses(id).

  • Exit psql by typing:
\q

Don't see all three tables?

Make sure you ran both migration files in order (002 before 003). Check that you connected to the correct database name. If you see only students, the courses and enrollments migrations did not execute successfully.

help me debug why my migration tables are missing in PostgreSQL

Your database schema is locked in with proper constraints, foreign keys, and indexes. Next up, you will build the Course CRUD endpoints that let you create and manage courses through the API.

Build Course CRUD Endpoints

Your database schema is ready with a courses table that has constraints for valid data. Now you need application code that can talk to that table so your API consumers can create, read, update, and delete courses.

This step follows the same layered pattern (routes, controller, service) you built in Project 1 for students. You will apply it to courses, reinforcing the architecture while giving you working endpoints fast.

In this step, get ready to:
  • Create a course service with parameterized SQL queries for all CRUD operations.
  • Create a course controller and route file wired into the v1 router.
  • Test every endpoint with curl, including triggering the UNIQUE constraint.
Create course service with SQL queries

The service layer holds all your database logic. Each function maps to one SQL operation, keeping queries in one place and out of your controllers.

  • Create a new file at src/services/courseService.ts.
  • Add the pool import and the three read/create functions by pasting the following code:
import pool from '../database/pool';

export const getAll = async () => {
  const result = await pool.query(
    'SELECT * FROM courses ORDER BY created_at DESC'
  );
  return result.rows;
};

export const getById = async (id: number) => {
  const result = await pool.query('SELECT * FROM courses WHERE id = $1', [id]);
  return result.rows[0];
};

export const create = async ({ name, course_code, description, credits }: {
  name: string;
  course_code: string;
  description?: string;
  credits: number;
}) => {
  const result = await pool.query(
    'INSERT INTO courses (name, course_code, description, credits) VALUES ($1, $2, $3, $4) RETURNING *',
    [name, course_code, description, credits]
  );
  return result.rows[0];
};

What does this code do?

  • getAll fetches every course, newest first.
  • getById uses a parameterized query ($1) to prevent SQL injection.
  • create uses RETURNING * so PostgreSQL gives back the full row including the auto-generated id and created_at.
  • Add the update, remove, and exports below the create function in the same file:
export const update = async (id: number, fields: Record<string, unknown>) => {
  const entries = Object.entries(fields).filter(
    ([, value]) => value !== undefined
  );
  if (entries.length === 0) return null;

  const setClause = entries
    .map(([key], index) => `${key} = $${index + 2}`)
    .join(', ');
  const values = entries.map(([, value]) => value);

  const result = await pool.query(
    `UPDATE courses SET ${setClause} WHERE id = $1 RETURNING *`,
    [id, ...values]
  );
  return result.rows[0];
};

export const remove = async (id: number) => {
  const result = await pool.query(
    'DELETE FROM courses WHERE id = $1 RETURNING *',
    [id]
  );
  return result.rows[0];
};

Why is the update function so complex?

Without an ORM, you need to build the SET clause dynamically so a partial update (e.g. changing only credits) does not overwrite other fields with undefined. The parameterized placeholders ($1, $2, ...) still prevent SQL injection.

  • Save src/services/courseService.ts.
  • Confirm there are no syntax errors by running:
bun -e "await import('./src/services/courseService.ts')"

If no output appears and you return to the prompt, the file loaded without errors.

Seeing a syntax error?

  • Check that the backtick template literal in update uses backticks (` `) not single quotes.
  • Make sure every arrow function uses => not just =.
  • Verify that src/database/pool.ts exists from Project 1. The import '../database/pool' path depends on it.

Help me debug my courseService.ts syntax error

Create course controller and routes

The controller handles HTTP concerns (request parsing, status codes, error responses) while delegating business logic to the service. This separation keeps each file focused on one job.

  • Create a new file at src/controllers/courseController.ts.
  • Add the service import and the read handlers by pasting the following code:
import * as courseService from '../services/courseService';
import { Request, Response, NextFunction } from 'express';

export const getAll = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const courses = await courseService.getAll();
    res.json(courses);
  } catch (error) {
    next(error);
  }
};

export const getById = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const course = await courseService.getById(Number(req.params.id));
    if (!course) {
      return res.status(404).json({ error: 'Course not found' });
    }
    res.json(course);
  } catch (error) {
    next(error);
  }
};

What does this code do?

  • Each handler follows the same pattern: try the service call, respond with JSON, or pass the error to Express's error middleware via next(error).
  • getById returns a 404 when the service returns undefined (no row found).
  • Add the create and update handlers below getById in the same file:
export const create = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const course = await courseService.create(req.body);
    res.status(201).json(course);
  } catch (error: any) {
    if (error.code === '23505') {
      return res
        .status(409)
        .json({ error: 'A course with this course_code already exists' });
    }
    next(error);
  }
};

export const update = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const course = await courseService.update(Number(req.params.id), req.body);
    if (!course) {
      return res.status(404).json({ error: 'Course not found' });
    }
    res.json(course);
  } catch (error: any) {
    if (error.code === '23505') {
      return res
        .status(409)
        .json({ error: 'A course with this course_code already exists' });
    }
    next(error);
  }
};

What is error code 23505?

PostgreSQL error code 23505 means unique_violation. Your courses table has a UNIQUE constraint on course_code, so inserting or updating to a duplicate code triggers this error.

Rather than letting it bubble up as a generic 500, you catch it and return a meaningful 409 Conflict response.

  • Add the remove handler and module exports below update to finish the controller:
export const remove = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const course = await courseService.remove(Number(req.params.id));
    if (!course) {
      return res.status(404).json({ error: 'Course not found' });
    }
    res.status(204).send();
  } catch (error) {
    next(error);
  }
};

Why 204 for delete?

HTTP 204 No Content means the request succeeded but there is nothing to send back. This is the standard response for a successful DELETE. The .send() call closes the response without a body.

  • Save src/controllers/courseController.ts.

Now you need a route file to map HTTP methods and paths to these controller handlers.

  • Create a new file at src/routes/v1/courseRoutes.ts and paste the following:
import { Router } from 'express';
import * as courseController from '../../controllers/courseController';

const router = Router();

router.get('/', courseController.getAll);
router.get('/:id', courseController.getById);
router.post('/', courseController.create);
router.put('/:id', courseController.update);
router.delete('/:id', courseController.remove);

export default router;

What does this code do?

Each line maps an HTTP verb and path to a controller function. Since this router will be mounted at /api/v1/courses, a GET / here becomes GET /api/v1/courses in the full app.

  • Save src/routes/v1/courseRoutes.ts.

The last wiring step is mounting these routes in the v1 index.

  • Open src/routes/v1/index.ts. Find these lines:
import { Router } from 'express';
import studentRoutes from './studentRoutes';

const router = Router();

router.use('/students', studentRoutes);

export default router;
  • Replace the entire file contents with the following, which adds the course routes import and mount:
import { Router } from 'express';
import studentRoutes from './studentRoutes';
import courseRoutes from './courseRoutes';

const router = Router();

router.use('/students', studentRoutes);
router.use('/courses', courseRoutes);

export default router;
  • Save src/routes/v1/index.ts.
  • Restart your server by running:
bun run start
  • Create your first course by running this curl command in a second terminal:
curl -s -X POST http://localhost:3000/api/v1/courses \
  -H "Content-Type: application/json" \
  -d '{"name": "Introduction to Databases", "course_code": "CS101", "description": "Learn SQL fundamentals", "credits": 3}'

You should see a JSON response with the created course, including an auto-generated id and created_at timestamp.

Server won't start or curl returns an error?

  • If you see a module resolution error, double-check the file paths in your import statements match the folder structure exactly.
  • Make sure your Docker Compose database is still running. The pool connection will fail silently until a query is attempted.
  • If curl returns nothing, confirm the server printed its listening message (port 3000).

Help me debug my course endpoint setup

Test all course CRUD operations

You have a working POST. Now confirm every other operation works and that the database constraints fire correctly.

  • List all courses by running:
curl -s http://localhost:3000/api/v1/courses

You should see an array containing the course you just created.

  • Fetch the single course by its ID (use 1 if it was the first course created):
curl -s http://localhost:3000/api/v1/courses/1

You should see the single course object returned.

  • Update the course credits by running:
curl -s -X PUT http://localhost:3000/api/v1/courses/1 \
  -H "Content-Type: application/json" \
  -d '{"credits": 4}'

The response should show the course with credits updated to 4 while all other fields remain unchanged.

  • Delete the course by running:
curl -s -o /dev/null -w "%{http_code}" -X DELETE http://localhost:3000/api/v1/courses/1

You should see 204 printed, confirming a successful delete with no response body.

Now test the UNIQUE constraint. Before you run this next command, what do you think will happen if you create two courses with the same course_code?

  • Create a course, then try to create another with the same code:
curl -s -X POST http://localhost:3000/api/v1/courses \
  -H "Content-Type: application/json" \
  -d '{"name": "Web Development", "course_code": "CS201", "credits": 3}'
  • Now try the duplicate by running the same command again:
curl -s -X POST http://localhost:3000/api/v1/courses \
  -H "Content-Type: application/json" \
  -d '{"name": "Duplicate Course", "course_code": "CS201", "credits": 2}'

You should see a 409 response with the message A course with this course_code already exists. Your controller caught PostgreSQL's 23505 error and returned a meaningful message instead of crashing.

Getting a 500 instead of 409?

  • Check that your create handler in the controller checks error.code === '23505' (a string, not a number).
  • Make sure the if block is inside the catch clause, not the try block.

Help me fix my 23505 error handling

✔️ Awesome, I've got everything!

Great. Double check you have saved all three new files and the updated index.

ⓧ I'd like to double check the full code

Here are the complete files. Scroll up and confirm each section matches.

src/services/courseService.ts:

import pool from '../database/pool';

export const getAll = async () => {
  const result = await pool.query(
    'SELECT * FROM courses ORDER BY created_at DESC'
  );
  return result.rows;
};

export const getById = async (id: number) => {
  const result = await pool.query('SELECT * FROM courses WHERE id = $1', [id]);
  return result.rows[0];
};

export const create = async ({ name, course_code, description, credits }: {
  name: string; course_code: string; description?: string; credits: number;
}) => {
  const result = await pool.query(
    'INSERT INTO courses (name, course_code, description, credits) VALUES ($1, $2, $3, $4) RETURNING *',
    [name, course_code, description, credits]
  );
  return result.rows[0];
};
export const update = async (id: number, fields: Record<string, unknown>) => {
  const entries = Object.entries(fields).filter(
    ([, value]) => value !== undefined
  );
  if (entries.length === 0) return null;

  const setClause = entries
    .map(([key], index) => `${key} = $${index + 2}`)
    .join(', ');
  const values = entries.map(([, value]) => value);

  const result = await pool.query(
    `UPDATE courses SET ${setClause} WHERE id = $1 RETURNING *`,
    [id, ...values]
  );
  return result.rows[0];
};

export const remove = async (id: number) => {
  const result = await pool.query(
    'DELETE FROM courses WHERE id = $1 RETURNING *',
    [id]
  );
  return result.rows[0];
};

src/controllers/courseController.ts:

import * as courseService from '../services/courseService';
import { Request, Response, NextFunction } from 'express';

export const getAll = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const courses = await courseService.getAll();
    res.json(courses);
  } catch (error) {
    next(error);
  }
};

export const getById = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const course = await courseService.getById(Number(req.params.id));
    if (!course) {
      return res.status(404).json({ error: 'Course not found' });
    }
    res.json(course);
  } catch (error) {
    next(error);
  }
};
export const create = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const course = await courseService.create(req.body);
    res.status(201).json(course);
  } catch (error: any) {
    if (error.code === '23505') {
      return res
        .status(409)
        .json({ error: 'A course with this course_code already exists' });
    }
    next(error);
  }
};

export const update = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const course = await courseService.update(Number(req.params.id), req.body);
    if (!course) {
      return res.status(404).json({ error: 'Course not found' });
    }
    res.json(course);
  } catch (error: any) {
    if (error.code === '23505') {
      return res
        .status(409)
        .json({ error: 'A course with this course_code already exists' });
    }
    next(error);
  }
};
export const remove = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const course = await courseService.remove(Number(req.params.id));
    if (!course) {
      return res.status(404).json({ error: 'Course not found' });
    }
    res.status(204).send();
  } catch (error) {
    next(error);
  }
};

src/routes/v1/courseRoutes.ts:

import { Router } from 'express';
import * as courseController from '../../controllers/courseController';

const router = Router();

router.get('/', courseController.getAll);
router.get('/:id', courseController.getById);
router.post('/', courseController.create);
router.put('/:id', courseController.update);
router.delete('/:id', courseController.remove);

export default router;

src/routes/v1/index.ts:

import { Router } from 'express';
import studentRoutes from './studentRoutes';
import courseRoutes from './courseRoutes';

const router = Router();

router.use('/students', studentRoutes);
router.use('/courses', courseRoutes);

export default router;

You now have a fully working Course CRUD API with proper error handling for constraint violations. Next up, you will build the enrollment endpoints that connect students to courses through foreign keys.

Build Enrollment Endpoints

Your courses table is ready, but students have no way to sign up for courses yet. Enrollments live in the junction table you created in the last step, connecting rows in students to rows in courses through foreign keys.

In this step, you will build the enrollment service, controller, and routes following the same layered pattern from your course endpoints. Along the way, you will deliberately trigger a foreign key constraint error and watch PostgreSQL reject the bad data with a raw 500 error. That ugly response is the problem you will fix by the end of this step.

In this step, get ready to:
  • Create the enrollment service with SQL queries for managing enrollments.
  • Wire up enrollment routes and test a successful enrollment.
  • Handle database constraint violation errors with proper HTTP status codes.
Create the enrollment service and controller

The enrollment service encapsulates all database interactions for the enrollments table. It follows the same pattern as courseService.ts from the previous step.

  • Create a new file at src/services/enrollmentService.ts and add the core enrollment functions by pasting this code:
import pool from '../database/pool';

// Insert a new enrollment record
export const create = async ({ student_id, course_id, status = 'active' }: {
  student_id: number; course_id: number; status?: string;
}) => {
  const result = await pool.query(
    'INSERT INTO enrollments (student_id, course_id, status) VALUES ($1, $2, $3) RETURNING *',
    [student_id, course_id, status]
  );
  return result.rows[0];
};

// Fetch all enrollments for a specific student
export const getByStudentId = async (studentId: number) => {
  const result = await pool.query(
    'SELECT * FROM enrollments WHERE student_id = $1 ORDER BY enrolled_at DESC',
    [studentId]
  );
  return result.rows;
};

// Delete an enrollment by ID
export const remove = async (id: number) => {
  const result = await pool.query(
    'DELETE FROM enrollments WHERE id = $1 RETURNING *',
    [id]
  );
  return result.rows[0];
};

What does this code do?

  • create inserts a row into the enrollments table with a default status of 'active'.
  • getByStudentId retrieves all enrollment records for a given student, ordered by most recent first.
  • remove deletes a single enrollment and returns the deleted row so callers can confirm what was removed.
  • Add the JOIN query function below the remove function in the same file:
// Join enrollments with courses for a student's full roster
export const getStudentCourses = async (studentId: number, status?: string) => {
  let query = `
    SELECT c.id, c.name, c.course_code, c.credits, e.status, e.enrolled_at
    FROM enrollments e
    JOIN courses c ON e.course_id = c.id
    WHERE e.student_id = $1
  `;
  const params: (number | string)[] = [studentId];

  if (status) {
    query += ' AND e.status = $2';
    params.push(status);
  }

  query += ' ORDER BY e.enrolled_at DESC';

  const result = await pool.query(query, params);
  return result.rows;
};

What does getStudentCourses do?

This function performs a JOIN across the enrollments and courses tables, returning course details alongside enrollment metadata like status and enrollment date.

The optional status parameter lets callers filter results to only active, completed, or dropped enrollments. You will wire this to a route in the Secret Mission.

  • Save src/services/enrollmentService.ts.
  • Verify the module loads without syntax errors by running:
bun -e "await import('./src/services/enrollmentService.ts'); console.log('Service loaded')"

You should see Service loaded printed with no errors.

Seeing a syntax error?

  • Check that the template literal in getStudentCourses uses backticks (`) not single quotes.
  • Confirm the export keyword is on each function and there are no leftover module.exports lines.

help me debug a syntax error in my enrollmentService.ts file

✔️ Awesome, I've got everything!

Great. Confirm you saved the file and saw the Service loaded message.

ⓧ I'd like to double check the full code

Your src/services/enrollmentService.ts should contain both chunks above in order. Scroll up and confirm:

  • The import pool line, then create, getByStudentId, and remove functions (first chunk).
  • The getStudentCourses function (second chunk).

Now you need a controller to translate HTTP requests into calls to this service.

  • Create a new file at src/controllers/enrollmentController.ts and add the first three handlers by pasting this code:
import * as enrollmentService from '../services/enrollmentService';
import { Request, Response, NextFunction } from 'express';

// Create a new enrollment (no constraint handling yet)
export const create = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const enrollment = await enrollmentService.create(req.body);
    res.status(201).json(enrollment);
  } catch (error) {
    next(error);
  }
};

// Get all enrollments for a student
export const getByStudentId = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const enrollments = await enrollmentService.getByStudentId(
      Number(req.params.studentId)
    );
    res.json(enrollments);
  } catch (error) {
    next(error);
  }
};

// Delete an enrollment
export const remove = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const enrollment = await enrollmentService.remove(Number(req.params.id));
    if (!enrollment) {
      return res.status(404).json({ error: 'Enrollment not found' });
    }
    res.status(204).send();
  } catch (error) {
    next(error);
  }
};

What does this code do?

Each handler follows the same try/catch pattern you used in courseController.ts. Errors pass to next(error) so Express's error-handling middleware catches them.

Notice the create handler has no special error handling yet. If PostgreSQL throws a constraint error, it falls straight through to the global error handler and returns a generic 500.

  • Add the getStudentCourses handler below the remove function in the same file:
import pool from '../database/pool';

// Get courses a student is enrolled in (with optional status filter)
export const getStudentCourses = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const studentCheck = await pool.query(
      'SELECT id FROM students WHERE id = $1',
      [req.params.id]
    );
    if (studentCheck.rows.length === 0) {
      return res.status(404).json({ error: 'Student not found' });
    }

    const status = req.query.status as string | undefined;
    const courses = await enrollmentService.getStudentCourses(
      Number(req.params.id),
      status
    );
    res.json(courses);
  } catch (error) {
    next(error);
  }
};

Why check if the student exists first?

If someone requests courses for a non-existent student, returning an empty array would be misleading. By querying the students table first, you can return a clear 404 instead.

  • Save src/controllers/enrollmentController.ts.
  • Verify the controller loads correctly by running:
bun -e "await import('./src/controllers/enrollmentController.ts'); console.log('Controller loaded')"

You should see Controller loaded with no errors.

Seeing a module resolution error?

  • Check the import path at the top of the file. It should be ../services/enrollmentService (one directory up from controllers, then into services.
  • Confirm src/services/enrollmentService.ts exists and was saved from the previous chunk.

help me fix a module resolution error in my enrollmentController.ts

Create enrollment routes and attempt the FK violation

With the service and controller in place, you need routes to expose them over HTTP.

  • Create a new file at src/routes/v1/enrollmentRoutes.ts and add the route definitions by pasting this code:
import { Router } from 'express';
import * as enrollmentController from '../../controllers/enrollmentController';

const router = Router();

// POST /api/v1/enrollments - create an enrollment
router.post('/', enrollmentController.create);

// GET /api/v1/enrollments/student/:studentId - list enrollments
router.get('/student/:studentId', enrollmentController.getByStudentId);

// DELETE /api/v1/enrollments/:id - remove an enrollment
router.delete('/:id', enrollmentController.remove);

export default router;

What does this code do?

  • POST / creates an enrollment when the v1 router mounts this at /api/v1/enrollments.
  • GET /student/:studentId lists all enrollments for a specific student.
  • DELETE /:id removes a single enrollment by its primary key.
  • Save src/routes/v1/enrollmentRoutes.ts.

Now you need to mount these routes in the v1 router so Express knows about them.

  • In src/routes/v1/index.ts, find the existing import statements at the top:
import { Router } from 'express';
import studentRoutes from './studentRoutes';
import courseRoutes from './courseRoutes';
  • Add the enrollment routes import on the next line, so the imports look like this:
import { Router } from 'express';
import studentRoutes from './studentRoutes';
import courseRoutes from './courseRoutes';
import enrollmentRoutes from './enrollmentRoutes';
  • Find the router.use('/courses', courseRoutes); line and add the enrollment mount below it:
router.use('/courses', courseRoutes);
router.use('/enrollments', enrollmentRoutes);
  • Save src/routes/v1/index.ts.
  • Restart your server by running:
bun run start

The server should start without errors on port 3000.

Before you test enrollments, make sure you have at least one student and one course in your database. If you do not have any from previous testing, create them now.

  • Create a student (skip if you already have one) by running:
curl -X POST http://localhost:3000/api/v1/students \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice Johnson", "email": "alice@example.com"}'
  • Create a course (skip if you already have one) by running:
curl -X POST http://localhost:3000/api/v1/courses \
  -H "Content-Type: application/json" \
  -d '{"name": "Intro to Databases", "course_code": "CS101", "credits": 3}'

Note the id values in the responses. You will use them in the next test.

What do you think will happen when you POST valid student and course IDs to the enrollment endpoint?

  • Enroll the student in the course by running (use the IDs from your responses above):
curl -X POST http://localhost:3000/api/v1/enrollments \
  -H "Content-Type: application/json" \
  -d '{"student_id": 1, "course_id": 1}'

You should see a 201 response with the new enrollment record including student_id, course_id, status active, and an enrolled_at timestamp.

Now for the interesting part. What do you think will happen if you try to enroll a student that does not exist in the database?

  • Attempt to enroll a non-existent student by running:
curl -X POST http://localhost:3000/api/v1/enrollments \
  -H "Content-Type: application/json" \
  -d '{"student_id": 99999, "course_id": 1}'

You get back a generic 500 Internal Server Error response. In your terminal logs, you can see PostgreSQL's raw error output mentioning a foreign key violation.

Why did this happen?

PostgreSQL enforced the foreign key constraint on student_id. It rejected the insert because no student with ID 99999 exists in the students table.

The database did its job perfectly. The problem is your controller does not know what to do with that error. It passes it to the global error handler, which returns a useless 500 to the client with no explanation of what went wrong.

Handle constraint violation errors gracefully

That 500 response is the gap you need to close. PostgreSQL attaches a specific error code to each constraint violation, and your controller can use those codes to return meaningful HTTP responses.

  • In src/controllers/enrollmentController.ts, find the create function's catch block:
  } catch (error) {
    next(error);
  }
  • Replace the entire create function with this version that detects constraint error codes:
// Create a new enrollment (with constraint error handling)
export const create = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const enrollment = await enrollmentService.create(req.body);
    res.status(201).json(enrollment);
  } catch (error: any) {
    if (error.code === '23503') {
      return res.status(409).json({
        error: 'Referenced student or course does not exist',
        detail: error.detail,
      });
    }
    if (error.code === '23505') {
      return res.status(409).json({
        error: 'Student is already enrolled in this course',
      });
    }
    next(error);
  }
};

What do these error codes mean?

  • 23503 is PostgreSQL's code for foreign_key_violation. It fires when you reference a row that does not exist in the parent table.
  • 23505 is unique_violation. It fires when you insert a duplicate of a UNIQUE-constrained value, like enrolling the same student in the same course twice.
  • Both return 409 Conflict because the request conflicts with the current state of the database.
  • Save src/controllers/enrollmentController.ts and restart your server.

Do you think the same curl command will still return a 500, or will your new error handling catch the constraint violation?

  • Test the FK violation again by running:
curl -X POST http://localhost:3000/api/v1/enrollments \
  -H "Content-Type: application/json" \
  -d '{"student_id": 99999, "course_id": 1}'

This time you should see a 409 Conflict response with the message Referenced student or course does not exist and a detail field showing which key was missing.

  • Test the duplicate enrollment constraint by re-running your original successful enrollment:
curl -X POST http://localhost:3000/api/v1/enrollments \
  -H "Content-Type: application/json" \
  -d '{"student_id": 1, "course_id": 1}'

You should see a 409 response with Student is already enrolled in this course. The UNIQUE constraint on (student_id, course_id) prevented the duplicate.

Still seeing a 500 error?

  • Make sure you saved the controller file after editing and restarted the server.
  • Check that the error code checks use string comparison ('23503' with quotes). PostgreSQL error codes arrive as strings, not numbers.
  • Confirm the if (error.code === '23503') block is inside the catch, before next(error).

help me debug why my enrollment controller still returns 500 instead of 409

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files before moving on.

ⓧ I'd like to double check the full code

Here is src/routes/v1/enrollmentRoutes.ts:

import { Router } from 'express';
import * as enrollmentController from '../../controllers/enrollmentController';

const router = Router();

router.post('/', enrollmentController.create);
router.get('/student/:studentId', enrollmentController.getByStudentId);
router.delete('/:id', enrollmentController.remove);

export default router;

Here is src/routes/v1/index.ts:

import { Router } from 'express';
import studentRoutes from './studentRoutes';
import courseRoutes from './courseRoutes';
import enrollmentRoutes from './enrollmentRoutes';

const router = Router();

router.use('/students', studentRoutes);
router.use('/courses', courseRoutes);
router.use('/enrollments', enrollmentRoutes);

export default router;

For the longer files, scroll up and confirm each chunk is present in order:

src/services/enrollmentService.ts should contain:

  • The import pool line at the top.
  • create, getByStudentId, and remove functions (first chunk).
  • getStudentCourses function (second chunk).

src/controllers/enrollmentController.ts should contain:

  • The import lines at the top.
  • The FIXED create function with 23503 and 23505 handling (not the original naive version).
  • getByStudentId and remove handlers.
  • getStudentCourses handler.

Your enrollment endpoints now handle both success cases and constraint failures with clear, structured responses. The database guards your data, and the controller translates database errors into HTTP responses your API consumers can understand.

Next up, you will add input validation so bad data gets rejected before it ever reaches the database, and generate interactive Swagger documentation for all your endpoints.

Add Input Validation and Swagger Documentation

Your FK constraint caught bad data at the database layer in the last step. That means every invalid request still travels through your entire application before PostgreSQL finally rejects it.

Validation at the API boundary catches malformed input the moment it arrives, with clear error messages. In this step, you will add request validation with express-validator and generate interactive Swagger documentation so consumers know what valid input looks like without reading your source code.

In this step, get ready to:
  • Create a reusable validation middleware and define rules for courses and enrollments.
  • Wire validation into your routes so bad input returns a 400 before hitting the database.
  • Add OpenAPI annotations and verify interactive documentation at /api-docs.
Create validation middleware and course rules

The validation pattern works in two parts: a generic middleware that runs validation chains and returns errors, and separate rule files that define what valid input looks like for each resource.

  • Create a new file at src/middleware/validate.ts with the following code:
import { Request, Response, NextFunction } from 'express';
import { ValidationChain, validationResult } from 'express-validator';

const validate = (validations: ValidationChain[]) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    for (const validation of validations) {
      const result = await validation.run(req);
      if (!result.isEmpty()) {
        return res.status(400).json({ errors: result.array() });
      }
    }
    next();
  };
};

export default validate;

What does this code do?

This middleware accepts an array of validation chains and runs them sequentially against the incoming request.

  • validation.run(req) executes each chain against the request body, params, or query.
  • If any chain fails, the middleware immediately returns a 400 response with the error details. The request never reaches your controller.
  • If all chains pass, next() forwards the request to the controller as normal.
  • Create a new folder src/validators/ and a file inside it called courseValidator.ts with the course creation rules:
import { body } from 'express-validator';

export const createCourseRules = [
  body('name')
    .notEmpty()
    .withMessage('Course name is required')
    .isLength({ max: 255 })
    .withMessage('Course name must be 255 characters or fewer'),
  body('course_code')
    .notEmpty()
    .withMessage('Course code is required')
    .isLength({ max: 20 })
    .withMessage('Course code must be 20 characters or fewer'),
  body('description')
    .optional()
    .isString()
    .withMessage('Description must be a string'),
  body('credits')
    .notEmpty()
    .withMessage('Credits are required')
    .isInt({ min: 1, max: 6 })
    .withMessage('Credits must be an integer between 1 and 6'),
];

What does this code do?

Each entry in the array validates one field from the request body. The .notEmpty() check ensures the field is present. The .isInt({ min: 1, max: 6 }) check mirrors the CHECK constraint in your database, catching invalid credits before they hit PostgreSQL.

The .optional() modifier means the field can be absent from the request entirely. If it IS present, the remaining checks apply.

  • Confirm both files were created by running:
ls src/middleware src/validators

You should see validate.ts under middleware and courseValidator.ts under validators.

  • Now add the update rules below createCourseRules in the same courseValidator.ts file:
export const updateCourseRules = [
  body('name')
    .optional()
    .notEmpty()
    .withMessage('Course name cannot be empty')
    .isLength({ max: 255 })
    .withMessage('Course name must be 255 characters or fewer'),
  body('course_code')
    .optional()
    .notEmpty()
    .withMessage('Course code cannot be empty')
    .isLength({ max: 20 })
    .withMessage('Course code must be 20 characters or fewer'),
  body('description')
    .optional()
    .isString()
    .withMessage('Description must be a string'),
  body('credits')
    .optional()
    .isInt({ min: 1, max: 6 })
    .withMessage('Credits must be an integer between 1 and 6'),
];

Why are the update rules different?

For updates, every field is .optional() because a PUT might only change one field. But if a field IS provided, it still cannot be empty. That is what .optional().notEmpty() achieves: "you don't have to send this field, but if you do, it cannot be blank."

  • Save courseValidator.ts.
  • Now wire validation into your course routes. In src/routes/v1/courseRoutes.ts, find these lines at the top:
import { Router } from 'express';
import * as courseController from '../../controllers/courseController';
  • Replace them with these imports that add the validate middleware and course rules:
import { Router } from 'express';
import * as courseController from '../../controllers/courseController';
import validate from '../../middleware/validate';
import { createCourseRules, updateCourseRules } from '../../validators/courseValidator';
  • Next, find these two route lines:
router.post('/', courseController.create);
router.put('/:id', courseController.update);
  • Replace them with versions that run validation first:
router.post('/', validate(createCourseRules), courseController.create);
router.put('/:id', validate(updateCourseRules), courseController.update);
  • Save courseRoutes.ts and restart your server.
  • Test validation by sending an empty body to the courses endpoint:
curl -s -X POST http://localhost:3000/api/v1/courses \
  -H "Content-Type: application/json" \
  -d '{}'

You should see a 400 response with an errors array listing the missing required fields (name, course_code, credits).

Still getting a 201 or 500?

  • Check that courseRoutes.ts imports both validate and the rules. A missing import means the middleware never runs.
  • Make sure validate(createCourseRules) appears between the route path and courseController.create. Order matters.
  • Confirm you restarted the server after saving.

Help me debug why validation isn't catching my empty request body.

Add enrollment validation and apply to routes

Enrollments need their own rules to ensure student_id and course_id are positive integers before the request ever reaches the database.

  • Create src/validators/enrollmentValidator.ts with the following rules:
import { body } from 'express-validator';

export const createEnrollmentRules = [
  body('student_id')
    .notEmpty()
    .withMessage('Student ID is required')
    .isInt({ min: 1 })
    .withMessage('Student ID must be a positive integer'),
  body('course_id')
    .notEmpty()
    .withMessage('Course ID is required')
    .isInt({ min: 1 })
    .withMessage('Course ID must be a positive integer'),
  body('status')
    .optional()
    .isIn(['active', 'completed', 'dropped'])
    .withMessage('Status must be one of: active, completed, dropped'),
];

What does this code do?

The .isInt({ min: 1 }) check rejects zero, negative numbers, and non-numeric strings. Without this, a request like {"student_id": "abc"} would pass straight to PostgreSQL and produce an ugly database error.

The optional status field uses .isIn() to restrict values to the same three options your database CHECK constraint allows.

  • Save enrollmentValidator.ts.
  • Now wire it into src/routes/v1/enrollmentRoutes.ts. Find these lines at the top:
import { Router } from 'express';
import * as enrollmentController from '../../controllers/enrollmentController';
  • Replace them with these imports:
import { Router } from 'express';
import * as enrollmentController from '../../controllers/enrollmentController';
import validate from '../../middleware/validate';
import { createEnrollmentRules } from '../../validators/enrollmentValidator';
  • Find the POST route line:
router.post('/', enrollmentController.create);
  • Replace it with the validated version:
router.post('/', validate(createEnrollmentRules), enrollmentController.create);
  • Save enrollmentRoutes.ts and restart your server.
  • Test enrollment validation by sending an empty body:
curl -s -X POST http://localhost:3000/api/v1/enrollments \
  -H "Content-Type: application/json" \
  -d '{}'

You should see a 400 response listing Student ID is required as the first validation error.

Getting a 409 instead of 400?

A 409 means the request bypassed validation and hit the database constraint handler. Check that validate(createEnrollmentRules) appears between the route path and enrollmentController.create in your route definition.

Help me figure out why enrollment validation isn't firing.

Add Swagger JSDoc annotations and verify documentation

Your Swagger config from Step 1 is already scanning ./src/routes/v1/*.ts for @openapi annotations. You just need to add structured comments above each route definition and swagger-jsdoc will auto-generate the documentation.

  • In src/routes/v1/courseRoutes.ts, add this annotation above router.get('/', ...):
/**
 * @openapi
 * /api/v1/courses:
 *   get:
 *     summary: Get all courses
 *     tags: [Courses]
 *     responses:
 *       200:
 *         description: List of all courses
 */

What is an @openapi annotation?

The @openapi tag tells swagger-jsdoc to parse the YAML inside this comment as an OpenAPI path definition. The tags field groups endpoints in the UI. The responses field documents what each status code means.

Every annotation you add will automatically appear in the Swagger UI at /api-docs after a server restart.

  • Add this annotation above router.get('/:id', ...):
/**
 * @openapi
 * /api/v1/courses/{id}:
 *   get:
 *     summary: Get a course by ID
 *     tags: [Courses]
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: integer
 *     responses:
 *       200:
 *         description: Course details
 *       404:
 *         description: Course not found
 */
  • Add this annotation above router.post('/', ...):
/**
 * @openapi
 * /api/v1/courses:
 *   post:
 *     summary: Create a new course
 *     tags: [Courses]
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             required: [name, course_code, credits]
 *             properties:
 *               name:
 *                 type: string
 *               course_code:
 *                 type: string
 *               description:
 *                 type: string
 *               credits:
 *                 type: integer
 *                 minimum: 1
 *                 maximum: 6
 *     responses:
 *       201:
 *         description: Course created
 *       400:
 *         description: Validation error
 *       409:
 *         description: Course code already exists
 */
  • Add this annotation above router.put('/:id', ...):
/**
 * @openapi
 * /api/v1/courses/{id}:
 *   put:
 *     summary: Update a course
 *     tags: [Courses]
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: integer
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             properties:
 *               name:
 *                 type: string
 *               course_code:
 *                 type: string
 *               description:
 *                 type: string
 *               credits:
 *                 type: integer
 *                 minimum: 1
 *                 maximum: 6
 *     responses:
 *       200:
 *         description: Course updated
 *       404:
 *         description: Course not found
 *       409:
 *         description: Course code already exists
 */
  • Add this annotation above router.delete('/:id', ...):
/**
 * @openapi
 * /api/v1/courses/{id}:
 *   delete:
 *     summary: Delete a course
 *     tags: [Courses]
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: integer
 *     responses:
 *       204:
 *         description: Course deleted
 *       404:
 *         description: Course not found
 */
  • Save courseRoutes.ts.
  • Now do the same for src/routes/v1/enrollmentRoutes.ts. Add this annotation above router.post('/', ...):
/**
 * @openapi
 * /api/v1/enrollments:
 *   post:
 *     summary: Enroll a student in a course
 *     tags: [Enrollments]
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             required: [student_id, course_id]
 *             properties:
 *               student_id:
 *                 type: integer
 *               course_id:
 *                 type: integer
 *               status:
 *                 type: string
 *                 enum: [active, completed, dropped]
 *     responses:
 *       201:
 *         description: Enrollment created
 *       400:
 *         description: Validation error
 *       409:
 *         description: Constraint violation (invalid reference or duplicate)
 */
  • Add this annotation above router.get('/student/:studentId', ...):
/**
 * @openapi
 * /api/v1/enrollments/student/{studentId}:
 *   get:
 *     summary: Get all enrollments for a student
 *     tags: [Enrollments]
 *     parameters:
 *       - in: path
 *         name: studentId
 *         required: true
 *         schema:
 *           type: integer
 *     responses:
 *       200:
 *         description: List of enrollments
 */
  • Add this annotation above router.delete('/:id', ...):
/**
 * @openapi
 * /api/v1/enrollments/{id}:
 *   delete:
 *     summary: Delete an enrollment
 *     tags: [Enrollments]
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: integer
 *     responses:
 *       204:
 *         description: Enrollment deleted
 *       404:
 *         description: Enrollment not found
 */
  • Save enrollmentRoutes.ts and restart your server.

Before you check the documentation, what do you think you will see at /api-docs? How many endpoint groups do you expect?

  • Open http://localhost:3000/api-docs in your browser.

You should see two tag groups: Courses with five endpoints and Enrollments with three endpoints. Click any endpoint to expand it and see the parameters, request body schema, and response codes.

  • Click the Try it out button on POST /api/v1/courses, leave the body empty, and click Execute.

Swagger returns the same 400 validation error you saw in the terminal. Your API is now self-documenting and interactive.

Swagger UI shows no endpoints?

  • Make sure each annotation starts with exactly @openapi (not @swagger). Both work, but double-check for typos.
  • Verify your swagger.ts config has apis: ['./src/routes/v1/*.ts']. If the glob pattern doesn't match your files, no annotations are found.
  • Confirm you restarted the server. Swagger reads annotations at startup, not on the fly.

Help me debug why my Swagger UI is empty.

✔️ Awesome, I've got everything!

Great. Double check you have saved both courseRoutes.ts and enrollmentRoutes.ts with all annotations in place.

ⓧ I'd like to double check the full code

Your annotated route files are long. Scroll up and confirm each of these annotation blocks is present above its corresponding route, in order:

  • courseRoutes.ts: GET /, GET /:id, POST /, PUT /:id, DELETE /:id (5 annotations total).
  • enrollmentRoutes.ts: POST /, GET /student/:studentId, DELETE /:id (3 annotations total).

Also confirm these imports are at the top of each file:

// courseRoutes.ts imports
import { Router } from 'express';
import * as courseController from '../../controllers/courseController';
import validate from '../../middleware/validate';
import { createCourseRules, updateCourseRules } from '../../validators/courseValidator';
// enrollmentRoutes.ts imports
import { Router } from 'express';
import * as enrollmentController from '../../controllers/enrollmentController';
import validate from '../../middleware/validate';
import { createEnrollmentRules } from '../../validators/enrollmentValidator';

If your Swagger UI shows all 8 endpoints and validation returns 400 on empty bodies, everything is correct.

Your API now validates input at the boundary and documents itself automatically. Next up, you will take on the Secret Mission and build a JOIN-based endpoint that combines data across your related tables.

Secret mission

Build a Student Course Roster with JOINs

Your API can enroll students in courses, but there's no way to ask 'what courses is this student taking?' Real APIs need endpoints that combine data from multiple tables. Can you build a JOIN-based roster endpoint with query parameter filtering?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally via Docker Compose, so there are no ongoing costs.

Resources you used:

  • PostgreSQL database with students, courses, and enrollments tables (running in Docker Compose).
  • Project dependencies in node_modules (express-validator, swagger-jsdoc, swagger-ui-express).

Keep everything running

No action needed. Choose this if you are still actively building on this project or plan to start the next project in the series soon.

  • Your Docker Compose services remain available. Run docker compose up any time to start the database and API.
  • All data in your courses and enrollments tables persists in the Docker volume.
  • Your installed packages remain in node_modules ready for the next session.

Pause - I'll come back to this later

Shut down running containers to free up system resources while preserving all your data for later.

  • Stop the Docker Compose services by running:
docker compose stop
  • This preserves your database volume (all student, course, and enrollment data stays intact).
  • When you are ready to resume, run docker compose up from your project folder to start everything again.

Delete - I don't want to use this again

Remove all Docker containers and volumes. This deletes all data in your database permanently.

  • Remove containers and volumes by running:
docker compose down -v
  • This removes the PostgreSQL container and its data volume. All rows in your students, courses, and enrollments tables are gone.
  • Remove the installed packages by running:
rm -rf node_modules
  • If you want to delete the entire project folder, navigate to your Desktop and run:
cd ~/Desktop
rm -rf student-api
  • On Windows, use this command instead:
cd ~\Desktop
rmdir /s /q student-api

Nice Work!

Nice Work!

You just built a multi-resource REST API with proper relational database design, enforced referential integrity, validated inputs, and self-documenting endpoints.

You learned how to:

  • Design a normalized relational schema with a junction table, foreign key constraints, and indexes that PostgreSQL enforces automatically.
  • Build Course CRUD and Enrollment endpoints following a layered architecture, handling database constraint violations with proper HTTP status codes instead of 500 crashes.
  • Add request validation with express-validator to catch bad input at the API boundary, and generate interactive Swagger documentation from JSDoc annotations in your route files.
  • Secret Mission: Built a JOIN-based endpoint with query parameter filtering across the many-to-many relationship.

Ready to quiz yourself?