Avancé45 min de lecture

Building a Complete REST API

Combine everything you've learned to architect, build, and deploy a production-ready REST API from scratch.

Project: Task Manager API

Throughout this Node.js track, you have learned individual concepts in isolation — routing, middleware, databases, authentication, error handling, validation, caching, Docker, deployment. Now it is time to bring everything together into a single, cohesive, production-ready application. This is the capstone lesson where theory meets practice.

We are going to architect a Task Manager API — a real-world project that demonstrates every concept you have learned. This is not a toy example. Task management is a legitimate product domain (Todoist, Asana, Trello, Linear all started as variations of this idea), and building one from scratch forces you to make the same architectural decisions real engineering teams face.

Here is the complete feature set:

  • User Registration & Login — Secure authentication with password hashing (bcrypt) and JWT tokens. Refresh token rotation for session management.
  • Task CRUD — Create, read, update, and delete tasks. Each task has a title, description, status (todo, in-progress, done), priority (low, medium, high), due date, and an assigned user.
  • Ownership & Authorization — Users can only see and modify their own tasks. Admin users can see all tasks across the system. Role-based access control (RBAC) enforces these rules.
  • Filtering, Sorting & Pagination — The task list endpoint supports query parameters for filtering by status, priority, and date range. Results can be sorted by any field. Pagination prevents loading thousands of tasks at once.
  • Input Validation — Every request body is validated with Zod schemas before touching the database. Invalid data returns clear, structured error messages.
  • Centralized Error Handling — Custom error classes (NotFoundError, ValidationError, AuthError) with a single error middleware that formats all errors consistently.
  • Security Middleware — Helmet for security headers, CORS for cross-origin restrictions, rate limiting to prevent abuse, and request sanitization.
  • Testing — Integration tests for every endpoint using Jest and Supertest. Tests verify the complete request/response cycle including auth, validation, and error cases.

This project is your portfolio piece. When you apply for backend developer roles, a well-built REST API demonstrates that you understand not just the individual tools, but how they fit together to create a professional application.

Step 1: Project Setup & Architecture

A well-structured project is a joy to work in. A poorly structured one becomes a nightmare as it grows. Before writing a single route handler, invest time in setting up your project architecture correctly.

Initialize the project:

bash
mkdir task-manager-api && cd task-manager-api
npm init -y
npm install express mongoose jsonwebtoken bcryptjs dotenv cors helmet express-rate-limit zod
npm install -D nodemon jest supertest

Folder structure (MVC + Services pattern):

html
task-manager-api/
  src/
    config/           # Environment validation, DB connection
      db.js
      env.js
    middleware/        # Auth, error handler, rate limiter, validation
      auth.js
      errorHandler.js
      rateLimiter.js
      validate.js
    models/            # Mongoose schemas
      User.js
      Task.js
    routes/            # Express routers
      auth.routes.js
      task.routes.js
    controllers/       # Route handlers (thin — delegate to services)
      auth.controller.js
      task.controller.js
    services/          # Business logic (thick — reusable)
      auth.service.js
      task.service.js
    utils/             # Helpers, custom errors, constants
      errors.js
      asyncHandler.js
    app.js             # Express app setup (middleware, routes)
    server.js          # Entry point (starts HTTP server)
  tests/               # Integration tests
  .env                 # Environment variables (git-ignored)
  .env.example         # Template showing required vars
  Dockerfile
  docker-compose.yml
  package.json

Why separate app.js and server.js? The app.js file creates and configures the Express application (middleware, routes, error handlers) and exports it. The server.js file imports the app and starts the HTTP server. This separation lets your tests import app.js directly without starting a real server — Supertest needs the app, not a running server.

Environment validation is your first line of defense. At startup, validate that all required environment variables are present. If DATABASE_URL or JWT_SECRET is missing, crash immediately with a clear error. Do not let the app start and fail later when someone tries to log in. Use a simple validation function or Zod to parse process.env.

Database connection belongs in config/db.js. Connect to MongoDB with Mongoose, handle connection errors, and log the connection status. In production, use connection pooling and reconnection logic. Export the connection function so server.js can call it before starting the HTTP server — your app should not accept requests until the database is connected.

Step 2: User Authentication System

Authentication is the foundation of your API. Every protected route depends on it, so it must be rock-solid.

User Model — Define a Mongoose schema for users with these fields: name (required, trimmed), email (required, unique, lowercase, validated with regex), password (required, minimum 8 characters, never returned in queries), and role (enum: 'user' or 'admin', defaults to 'user'). Add timestamps for createdAt and updatedAt.

The critical pattern is the pre-save hook for password hashing:

javascript
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 12);
  next();
});

This hook runs before every save() call. The isModified('password') check is essential — without it, the password would be re-hashed every time you update any field on the user document. The cost factor (12) determines how computationally expensive the hash is — higher means more secure but slower. 12 is the current recommended minimum for production.

Add an instance method for password comparison:

javascript
userSchema.methods.comparePassword = async function(candidatePassword) {
  return bcrypt.compare(candidatePassword, this.password);
};

Registration Route (POST /api/auth/register):

  1. Validate the request body with Zod: name (min 2 chars), email (valid format), password (min 8 chars)
  2. Check if email already exists — return 409 Conflict if it does
  3. Create the user (pre-save hook auto-hashes the password)
  4. Generate a JWT with the user's id and role
  5. Return 201 Created with the token and user data (excluding password)

Login Route (POST /api/auth/login):

  1. Validate the request body: email and password required
  2. Find user by email — use .select('+password') because the password field should be excluded from queries by default (set select: false in the schema)
  3. Compare the submitted password with the stored hash
  4. If credentials are invalid, return 401 with a generic message: "Invalid email or password." Never reveal whether the email exists — saying "No account with that email" tells attackers which emails are registered.
  5. Generate and return a JWT

Auth Middleware:

javascript
const authMiddleware = async (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    throw new AuthenticationError('No token provided');
  }
  const token = authHeader.split(' ')[1];
  const decoded = jwt.verify(token, process.env.JWT_SECRET);
  const user = await User.findById(decoded.id);
  if (!user) throw new AuthenticationError('User no longer exists');
  req.user = user;
  next();
};

The middleware extracts the JWT from the Authorization header, verifies it, loads the user from the database (to ensure the user still exists and has not been deleted), and attaches the user to req.user for downstream route handlers to use. If any step fails, it throws an authentication error that the centralized error handler catches.

Step 3: Task CRUD with Relationships

The Task model is the core of your application. It has a relationship with the User model — each task belongs to a user, and each user can have many tasks. This is a one-to-many relationship, implemented in MongoDB/Mongoose by storing the user's ObjectId in the task document.

Task Model:

javascript
const taskSchema = new mongoose.Schema({
  title: { type: String, required: true, trim: true, maxlength: 200 },
  description: { type: String, trim: true, maxlength: 2000 },
  status: { type: String, enum: ['todo', 'in-progress', 'done'], default: 'todo' },
  priority: { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
  dueDate: { type: Date },
  user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
}, { timestamps: true });

Create Task (POST /api/tasks): The user field is automatically set from req.user.id (from auth middleware). Users cannot create tasks for other users. Validate the request body with Zod.

List Tasks (GET /api/tasks): This is the most complex endpoint. It supports:

  • Filtering: ?status=todo&priority=high — only return tasks matching these criteria
  • Sorting: ?sort=dueDate or ?sort=-createdAt (prefix - for descending)
  • Pagination: ?page=2&limit=10 — return page 2 with 10 results per page
  • Ownership: Regular users see only their tasks ({ user: req.user.id }). Admins see all tasks.
javascript
async function listTasks(req, res) {
  const filter = {};
  if (req.user.role !== 'admin') filter.user = req.user.id;
  if (req.query.status) filter.status = req.query.status;
  if (req.query.priority) filter.priority = req.query.priority;

  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 20;
  const skip = (page - 1) * limit;
  const sort = req.query.sort || '-createdAt';

  const [tasks, total] = await Promise.all([
    Task.find(filter).sort(sort).skip(skip).limit(limit),
    Task.countDocuments(filter),
  ]);

  res.json({
    success: true,
    data: tasks,
    pagination: {
      page, limit,
      total,
      pages: Math.ceil(total / limit),
    },
  });
}

Get Single Task (GET /api/tasks/:id): Find by ID AND verify ownership: Task.findOne({ _id: id, user: req.user.id }). If the task does not exist OR belongs to another user, return 404. Admins bypass the ownership check.

Update Task (PATCH /api/tasks/:id): Same ownership check. Use findOneAndUpdate with { new: true, runValidators: true }. Only allow updating specific fields (title, description, status, priority, dueDate) — never let the client change the user field.

Delete Task (DELETE /api/tasks/:id): Same ownership check. Return 204 No Content on success.

The ownership check pattern appears in get, update, and delete. Extract it into a reusable middleware or service method to keep your code DRY:

javascript
async function findUserTask(taskId, userId, isAdmin) {
  const filter = { _id: taskId };
  if (!isAdmin) filter.user = userId;
  const task = await Task.findOne(filter);
  if (!task) throw new NotFoundError('Task not found');
  return task;
}

Project File Structure & Key Code

javascript
// ── src/app.js — Express App Setup ────────────────────
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { errorHandler } = require('./middleware/errorHandler');
const authRoutes = require('./routes/auth.routes');
const taskRoutes = require('./routes/task.routes');

const app = express();

// ── Global Middleware ─────────────────────────────────
app.use(helmet());                         // Security headers
app.use(cors({ origin: process.env.CORS_ORIGIN }));
app.use(express.json({ limit: '10kb' }));  // Body parser with size limit
app.use(rateLimit({
  windowMs: 15 * 60 * 1000,               // 15 minutes
  max: 100,                                // 100 requests per window
  message: { error: 'Too many requests' }
}));

// ── Routes ────────────────────────────────────────────
app.use('/api/auth', authRoutes);
app.use('/api/tasks', taskRoutes);

// ── Health Check ──────────────────────────────────────
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// ── 404 Handler ───────────────────────────────────────
app.all('*', (req, res) => {
  res.status(404).json({ error: `Cannot ${req.method} ${req.path}` });
});

// ── Error Handler (must be last) ──────────────────────
app.use(errorHandler);

module.exports = app;


// ── src/models/Task.js ────────────────────────────────
const mongoose = require('mongoose');

const taskSchema = new mongoose.Schema({
  title:       { type: String, required: true, trim: true, maxlength: 200 },
  description: { type: String, trim: true, maxlength: 2000 },
  status:      { type: String, enum: ['todo', 'in-progress', 'done'], default: 'todo' },
  priority:    { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
  dueDate:     { type: Date },
  user:        { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
}, { timestamps: true });

taskSchema.index({ user: 1, status: 1 });  // Compound index for common queries
taskSchema.index({ dueDate: 1 });

module.exports = mongoose.model('Task', taskSchema);


// ── src/controllers/task.controller.js ────────────────
const Task = require('../models/Task');
const { NotFoundError } = require('../utils/errors');

exports.createTask = async (req, res) => {
  const task = await Task.create({ ...req.body, user: req.user.id });
  res.status(201).json({ success: true, data: task });
};

exports.getTasks = async (req, res) => {
  const filter = {};
  if (req.user.role !== 'admin') filter.user = req.user.id;
  if (req.query.status) filter.status = req.query.status;
  if (req.query.priority) filter.priority = req.query.priority;

  const page = parseInt(req.query.page) || 1;
  const limit = Math.min(parseInt(req.query.limit) || 20, 100);
  const sort = req.query.sort || '-createdAt';

  const [tasks, total] = await Promise.all([
    Task.find(filter).sort(sort).skip((page - 1) * limit).limit(limit),
    Task.countDocuments(filter),
  ]);

  res.json({
    success: true,
    data: tasks,
    pagination: { page, limit, total, pages: Math.ceil(total / limit) },
  });
};

exports.deleteTask = async (req, res) => {
  const filter = { _id: req.params.id };
  if (req.user.role !== 'admin') filter.user = req.user.id;
  const task = await Task.findOneAndDelete(filter);
  if (!task) throw new NotFoundError('Task not found');
  res.status(204).send();
};


// ── src/routes/task.routes.js ─────────────────────────
const router = require('express').Router();
const { auth } = require('../middleware/auth');
const ctrl = require('../controllers/task.controller');
const asyncHandler = require('../utils/asyncHandler');

router.use(auth);  // All task routes require authentication

router.post('/',    asyncHandler(ctrl.createTask));
router.get('/',     asyncHandler(ctrl.getTasks));
router.get('/:id',  asyncHandler(ctrl.getTask));
router.patch('/:id', asyncHandler(ctrl.updateTask));
router.delete('/:id', asyncHandler(ctrl.deleteTask));

module.exports = router;

Step 4: Testing & Deployment

Testing is not optional — it is the difference between code you trust and code you hope works. For a REST API, integration tests are the most valuable because they test the complete request-response cycle: middleware, route handlers, database operations, and error handling all working together.

Setup with Jest + Supertest:

javascript
const request = require('supertest');
const app = require('../src/app');

describe('Auth Endpoints', () => {
  it('should register a new user', async () => {
    const res = await request(app)
      .post('/api/auth/register')
      .send({ name: 'Alice', email: 'alice@test.com', password: 'password123' });
    expect(res.status).toBe(201);
    expect(res.body.token).toBeDefined();
    expect(res.body.user.email).toBe('alice@test.com');
    expect(res.body.user.password).toBeUndefined(); // Never expose password
  });

  it('should reject duplicate emails', async () => {
    // Register twice with the same email
    await request(app).post('/api/auth/register')
      .send({ name: 'Alice', email: 'dup@test.com', password: 'password123' });
    const res = await request(app).post('/api/auth/register')
      .send({ name: 'Bob', email: 'dup@test.com', password: 'password456' });
    expect(res.status).toBe(409);
  });
});

What to test:

  • Auth flow: register, login with correct credentials, login with wrong credentials, access protected route without token, access protected route with expired token
  • CRUD: create a task, list tasks (verify pagination), get a single task, update a task, delete a task
  • Ownership: user A cannot see/edit/delete user B's tasks
  • Admin access: admin can see all tasks
  • Validation: missing required fields, invalid field values, too-long strings
  • Edge cases: get a non-existent task (404), invalid ObjectId format (400), empty task list (should return empty array, not error)

End-to-end test scenario:

html
1. Register user A
2. Register user B
3. User A creates 3 tasks
4. User B creates 2 tasks
5. User A lists tasks → should see 3 (not B's tasks)
6. User A updates their task → should succeed
7. User A tries to update B's task → should get 404
8. Admin lists tasks → should see all 5
9. User A deletes their task → should succeed
10. User A lists tasks → should see 2

Deployment checklist:

  1. All tests pass (npm test)
  2. Lint passes (npm run lint)
  3. Build the Docker image (docker build -t task-manager .)
  4. Push to container registry (Docker Hub, GitHub Container Registry)
  5. Deploy to your platform of choice (Railway, Render, VPS)
  6. Set environment variables in production
  7. Run database migrations/seeds if needed
  8. Verify health check endpoint (GET /health)
  9. Test a few critical endpoints in production
  10. Set up monitoring and error tracking (Sentry, UptimeRobot)

What's Next?

Congratulations — you have built a complete, production-ready REST API from scratch. You understand the full stack of backend development: HTTP fundamentals, routing, middleware, databases, authentication, authorization, validation, error handling, caching, containerization, and deployment. This is a significant achievement.

But this is just the beginning. Backend development is a vast field, and there are many directions you can grow from here:

TypeScript — If you have not already, learn TypeScript. Type safety catches entire categories of bugs at compile time rather than runtime. Most professional Node.js codebases use TypeScript. Frameworks like NestJS are built around it.

NestJS — An opinionated, full-featured Node.js framework inspired by Angular. It provides a structured architecture with modules, controllers, services, dependency injection, and decorators out of the box. If Express feels too bare-bones for large projects, NestJS adds the structure you need.

GraphQL — An alternative to REST where the client specifies exactly what data it needs. Instead of multiple REST endpoints returning fixed data shapes, you have one endpoint that responds to flexible queries. Great for complex frontends that need different data on different pages. Learn Apollo Server for the Node.js implementation.

Microservices Architecture — Instead of one monolithic API, split your application into small, independently deployable services. The auth service, task service, notification service, and payment service each run as their own process. They communicate via HTTP APIs, message queues (RabbitMQ, AWS SQS), or event streams (Kafka). This is how large-scale systems are built at companies like Netflix, Uber, and Amazon.

System Design — Learn how to design systems that scale: load balancing, horizontal scaling, database sharding, CDNs, message queues, event-driven architecture, CQRS (Command Query Responsibility Segregation), circuit breakers, and distributed caching. This knowledge is essential for senior backend roles and is heavily tested in system design interviews.

Real-Time Features — WebSockets (which you have already learned), Server-Sent Events (SSE), and WebRTC for real-time communication. Build chat applications, live dashboards, collaborative editors, and multiplayer games.

Open Source — Contributing to open-source Node.js projects is one of the best ways to learn from experienced developers. Look at the source code of Express, Fastify, Prisma, or any library you use daily. Open issues tagged "good first issue" are welcoming starting points.

The most important thing is to build projects. Reading tutorials and courses is valuable, but real learning happens when you face real problems. Pick a project that excites you — a social media API, a real-time chat app, an e-commerce backend, a developer tool — and build it. You have all the skills you need. Now go create something.

In a task management API, which middleware should run first on a protected route?

Prêt à pratiquer ?

Crée ton compte gratuit pour accéder à l'éditeur de code interactif, lancer les défis et suivre ta progression.