Building a RESTful API for a Task Management System

Weekend Project - Applying Express.js, REST principles, and error handling

Project Overview

For this weekend project, you'll create a comprehensive RESTful API for a task management system similar to applications like Todoist or Trello. This project will help you practice and integrate what you've learned about Express.js, RESTful API design, data handling, error handling, and validation.

What You'll Build

A backend API that allows users to:

flowchart TD A[Task Management API] --> B[Task Operations] A --> C[Project Operations] A --> D[User Operations] B --> B1[Create Tasks] B --> B2[Retrieve Tasks] B --> B3[Update Tasks] B --> B4[Delete Tasks] B --> B5[Task Filtering] B --> B6[Task Sorting] C --> C1[Create Projects] C --> C2[Retrieve Projects] C --> C3[Update Projects] C --> C4[Delete Projects] D --> D1[Register Users] D --> D2[Authenticate Users] D --> D3[User Profiles] style A fill:#f9f,stroke:#333,stroke-width:2px

Learning Objectives

George Polya's Problem-Solving Approach

We'll use George Polya's 4-step problem-solving method to approach this project:

Step 1: Understand the Problem

Let's clarify what we're building:

For this project, we need to define clear data models:

Core Data Models

// Task Model
{
  id: String,                 // Unique identifier
  title: String,              // Task title/description
  description: String,        // Detailed description (optional)
  completed: Boolean,         // Completion status
  dueDate: Date,              // When the task is due (optional)
  priority: String,           // Priority level (Low, Medium, High)
  projectId: String,          // Which project this task belongs to
  createdAt: Date,            // When the task was created
  updatedAt: Date             // When the task was last updated
}

// Project Model
{
  id: String,                 // Unique identifier
  name: String,               // Project name
  description: String,        // Project description (optional)
  createdAt: Date,            // When the project was created
  updatedAt: Date             // When the project was last updated
}

// User Model (for authentication/ownership)
{
  id: String,                 // Unique identifier
  username: String,           // Username
  email: String,              // Email address
  password: String,           // Hashed password
  createdAt: Date,            // When the user was created
  updatedAt: Date             // When the user was last updated
}

Step 2: Devise a Plan

Let's break down the implementation into manageable steps:

Implementation Plan

  1. Set up the project structure and install dependencies
  2. Create the Express application and configure middleware
  3. Implement data storage (file-based for simplicity)
  4. Create error handling middleware and custom error classes
  5. Implement validation middleware
  6. Create task routes and controllers (CRUD operations)
  7. Create project routes and controllers (CRUD operations)
  8. Implement filtering and sorting functionality
  9. Add simple authentication (optional)
  10. Create API documentation
  11. Test the API

Step 3: Execute the Plan

Now we'll execute our plan step by step.

Step 4: Review and Reflect

At the end of the project, we'll review our work, identify areas for improvement, and consider how we could extend the API in the future.

Project Setup

Let's start by setting up our project structure and installing dependencies.

Initialize the Project

// Create a new directory for your project
mkdir task_management_api
cd task_management_api

// Initialize a new Node.js project
npm init -y

// Install dependencies
npm install express cors morgan dotenv uuid express-validator

// Install development dependencies
npm install nodemon --save-dev

Dependencies Explanation

Project Structure

Let's create a well-organized folder structure:

Creating the Project Structure

// Create the folder structure
mkdir -p src/{controllers,routes,models,middlewares,utils,data}

// Create the main files
touch src/index.js
touch src/app.js
touch .env
touch .gitignore

This will create the following structure:

Project Structure task_management_api/ src/ controllers/ routes/ models/ middlewares/ utils/ data/ index.js app.js .env .gitignore

Configure the Main Files

.env File (src/.env)

# Server configuration
PORT=3000
NODE_ENV=development

.gitignore File

# Node modules
node_modules/

# Environment variables
.env

# Data files
src/data/*.json

# Logs
logs/
*.log
npm-debug.log*

# Editor directories
.idea/
.vscode/
*.sublime-*

# Operating System Files
.DS_Store
Thumbs.db

Update package.json

{
  "name": "task_management_api",
  "version": "1.0.0",
  "description": "RESTful API for a Task Management System",
  "main": "src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "task",
    "api",
    "express",
    "rest"
  ],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "cors": "^2.8.5",
    "dotenv": "^16.0.3",
    "express": "^4.18.2",
    "express-validator": "^7.0.1",
    "morgan": "^1.10.0",
    "uuid": "^9.0.0"
  },
  "devDependencies": {
    "nodemon": "^2.0.22"
  }
}

Creating the Express Application

Now let's set up our Express application and configure middleware.

app.js (src/app.js)

const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const { errorHandler, notFoundHandler } = require('./middlewares/error');

// Create Express application
const app = express();

// Configure middleware
app.use(cors()); // Enable CORS for all routes
app.use(morgan('dev')); // Log HTTP requests
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded request bodies

// Root route
app.get('/', (req, res) => {
  res.json({
    message: 'Task Management API',
    version: '1.0.0',
    endpoints: '/api/tasks, /api/projects'
  });
});

// Import routes
const taskRoutes = require('./routes/task');
const projectRoutes = require('./routes/project');

// Register routes
app.use('/api/tasks', taskRoutes);
app.use('/api/projects', projectRoutes);

// Handle 404 Not Found
app.use(notFoundHandler);

// Global error handler
app.use(errorHandler);

module.exports = app;

index.js (src/index.js)

const dotenv = require('dotenv');

// Load environment variables
dotenv.config();

const app = require('./app');

// Get port from environment variables or use default
const PORT = process.env.PORT || 3000;

// Start the server
app.listen(PORT, () => {
  console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`);
  console.log(`URL: http://localhost:${PORT}`);
});

Implementing Error Handling

Let's create our error handling middleware and custom error classes.

Custom Error Classes (src/utils/errors.js)

/**
 * Base class for API errors
 */
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true; // Mark as operational error

    Error.captureStackTrace(this, this.constructor);
  }
}

/**
 * 400 Bad Request - Invalid input
 */
class BadRequestError extends AppError {
  constructor(message = 'Bad request') {
    super(message, 400);
  }
}

/**
 * 404 Not Found - Resource not found
 */
class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404);
  }
}

/**
 * 409 Conflict - Resource already exists
 */
class ConflictError extends AppError {
  constructor(message = 'Resource already exists') {
    super(message, 409);
  }
}

/**
 * 422 Unprocessable Entity - Validation failed
 */
class ValidationError extends AppError {
  constructor(message = 'Validation failed', errors = []) {
    super(message, 422);
    this.errors = errors;
  }
}

module.exports = {
  AppError,
  BadRequestError,
  NotFoundError,
  ConflictError,
  ValidationError
};

Error Middleware (src/middlewares/error.js)

const { NotFoundError } = require('../utils/errors');

/**
 * Middleware for handling 404 Not Found errors
 */
const notFoundHandler = (req, res, next) => {
  // Create a 404 error for any route that doesn't exist
  const error = new NotFoundError(`Route ${req.originalUrl} not found`);
  next(error);
};

/**
 * Global error handling middleware
 */
const errorHandler = (err, req, res, next) => {
  // Set default values
  const statusCode = err.statusCode || 500;
  const status = err.status || 'error';
  
  // Prepare error response
  const errorResponse = {
    status,
    message: err.message || 'Internal Server Error'
  };

  // Include validation errors if available
  if (err.errors) {
    errorResponse.errors = err.errors;
  }

  // Include stack trace in development mode
  if (process.env.NODE_ENV === 'development') {
    errorResponse.stack = err.stack;
  }

  // Send error response
  res.status(statusCode).json(errorResponse);
};

module.exports = {
  notFoundHandler,
  errorHandler
};

Implementing Data Storage

For simplicity, we'll use file-based storage instead of a database. Let's create utility functions for reading and writing data files.

Data Storage Utility (src/utils/storage.js)

const fs = require('fs');
const path = require('path');
const { promisify } = require('util');

// Convert callbacks to promises
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);

// Path to data directory
const dataDir = path.join(__dirname, '../data');

/**
 * Initialize data file with empty array if it doesn't exist
 * @param {string} filename - Name of the file
 */
const initDataFile = async (filename) => {
  const filePath = path.join(dataDir, filename);
  
  try {
    // Check if file exists
    await readFile(filePath, 'utf8');
  } catch (error) {
    // If file doesn't exist, create it with empty array
    if (error.code === 'ENOENT') {
      await writeFile(filePath, JSON.stringify([], null, 2), 'utf8');
    } else {
      throw error;
    }
  }
};

/**
 * Read data from a JSON file
 * @param {string} filename - Name of the file
 * @returns {Array} Array of objects
 */
const readData = async (filename) => {
  // Ensure data file exists
  await initDataFile(filename);
  
  // Read and parse data
  const filePath = path.join(dataDir, filename);
  const data = await readFile(filePath, 'utf8');
  return JSON.parse(data);
};

/**
 * Write data to a JSON file
 * @param {string} filename - Name of the file
 * @param {Array} data - Array of objects to write
 */
const writeData = async (filename, data) => {
  // Ensure data directory exists
  if (!fs.existsSync(dataDir)) {
    fs.mkdirSync(dataDir, { recursive: true });
  }
  
  // Write data to file
  const filePath = path.join(dataDir, filename);
  await writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
};

module.exports = {
  readData,
  writeData
};

Creating Models

Now let's create our models, which will handle data access and business logic.

Task Model (src/models/task.js)

const { v4: uuidv4 } = require('uuid');
const { readData, writeData } = require('../utils/storage');
const { NotFoundError } = require('../utils/errors');

// File name for tasks data
const TASKS_FILE = 'tasks.json';

/**
 * Task Model - Handles operations for tasks
 */
const TaskModel = {
  /**
   * Get all tasks, with optional filtering
   * @param {Object} filters - Filter criteria
   * @returns {Array} Array of task objects
   */
  async getAll(filters = {}) {
    // Read all tasks
    const tasks = await readData(TASKS_FILE);
    
    // Apply filters if provided
    if (Object.keys(filters).length > 0) {
      return tasks.filter(task => {
        // Check each filter criteria
        return Object.entries(filters).every(([key, value]) => {
          // Handle special case for completed (convert string to boolean)
          if (key === 'completed' && typeof value === 'string') {
            value = value.toLowerCase() === 'true';
          }
          
          // Handle special case for priority (case-insensitive)
          if (key === 'priority' && typeof value === 'string') {
            return task[key]?.toLowerCase() === value.toLowerCase();
          }
          
          // Handle date ranges for dueDate
          if (key === 'dueDateFrom' && task.dueDate) {
            return new Date(task.dueDate) >= new Date(value);
          }
          
          if (key === 'dueDateTo' && task.dueDate) {
            return new Date(task.dueDate) <= new Date(value);
          }
          
          // Default equality check
          return task[key] === value;
        });
      });
    }
    
    return tasks;
  },
  
  /**
   * Get a task by ID
   * @param {string} id - Task ID
   * @returns {Object} Task object
   * @throws {NotFoundError} If task is not found
   */
  async getById(id) {
    const tasks = await readData(TASKS_FILE);
    const task = tasks.find(task => task.id === id);
    
    if (!task) {
      throw new NotFoundError('Task');
    }
    
    return task;
  },
  
  /**
   * Create a new task
   * @param {Object} taskData - Task data
   * @returns {Object} Created task object
   */
  async create(taskData) {
    const tasks = await readData(TASKS_FILE);
    
    // Create new task object
    const newTask = {
      id: uuidv4(), // Generate unique ID
      title: taskData.title,
      description: taskData.description || '',
      completed: taskData.completed || false,
      dueDate: taskData.dueDate || null,
      priority: taskData.priority || 'Medium',
      projectId: taskData.projectId || null,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    };
    
    // Add to tasks array
    tasks.push(newTask);
    
    // Save updated tasks
    await writeData(TASKS_FILE, tasks);
    
    return newTask;
  },
  
  /**
   * Update a task by ID
   * @param {string} id - Task ID
   * @param {Object} taskData - Updated task data
   * @returns {Object} Updated task object
   * @throws {NotFoundError} If task is not found
   */
  async update(id, taskData) {
    const tasks = await readData(TASKS_FILE);
    const taskIndex = tasks.findIndex(task => task.id === id);
    
    if (taskIndex === -1) {
      throw new NotFoundError('Task');
    }
    
    // Update task with new data, keeping existing values for omitted fields
    const updatedTask = {
      ...tasks[taskIndex],
      ...taskData,
      updatedAt: new Date().toISOString()
    };
    
    // Replace task in array
    tasks[taskIndex] = updatedTask;
    
    // Save updated tasks
    await writeData(TASKS_FILE, tasks);
    
    return updatedTask;
  },
  
  /**
   * Delete a task by ID
   * @param {string} id - Task ID
   * @returns {Object} Deleted task object
   * @throws {NotFoundError} If task is not found
   */
  async delete(id) {
    const tasks = await readData(TASKS_FILE);
    const taskIndex = tasks.findIndex(task => task.id === id);
    
    if (taskIndex === -1) {
      throw new NotFoundError('Task');
    }
    
    // Remove task from array
    const deletedTask = tasks.splice(taskIndex, 1)[0];
    
    // Save updated tasks
    await writeData(TASKS_FILE, tasks);
    
    return deletedTask;
  }
};

module.exports = TaskModel;

Project Model (src/models/project.js)

const { v4: uuidv4 } = require('uuid');
const { readData, writeData } = require('../utils/storage');
const { NotFoundError } = require('../utils/errors');

// File name for projects data
const PROJECTS_FILE = 'projects.json';
const TASKS_FILE = 'tasks.json';

/**
 * Project Model - Handles operations for projects
 */
const ProjectModel = {
  /**
   * Get all projects
   * @returns {Array} Array of project objects
   */
  async getAll() {
    return await readData(PROJECTS_FILE);
  },
  
  /**
   * Get a project by ID
   * @param {string} id - Project ID
   * @returns {Object} Project object
   * @throws {NotFoundError} If project is not found
   */
  async getById(id) {
    const projects = await readData(PROJECTS_FILE);
    const project = projects.find(project => project.id === id);
    
    if (!project) {
      throw new NotFoundError('Project');
    }
    
    return project;
  },
  
  /**
   * Create a new project
   * @param {Object} projectData - Project data
   * @returns {Object} Created project object
   */
  async create(projectData) {
    const projects = await readData(PROJECTS_FILE);
    
    // Create new project object
    const newProject = {
      id: uuidv4(), // Generate unique ID
      name: projectData.name,
      description: projectData.description || '',
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString()
    };
    
    // Add to projects array
    projects.push(newProject);
    
    // Save updated projects
    await writeData(PROJECTS_FILE, projects);
    
    return newProject;
  },
  
  /**
   * Update a project by ID
   * @param {string} id - Project ID
   * @param {Object} projectData - Updated project data
   * @returns {Object} Updated project object
   * @throws {NotFoundError} If project is not found
   */
  async update(id, projectData) {
    const projects = await readData(PROJECTS_FILE);
    const projectIndex = projects.findIndex(project => project.id === id);
    
    if (projectIndex === -1) {
      throw new NotFoundError('Project');
    }
    
    // Update project with new data, keeping existing values for omitted fields
    const updatedProject = {
      ...projects[projectIndex],
      ...projectData,
      updatedAt: new Date().toISOString()
    };
    
    // Replace project in array
    projects[projectIndex] = updatedProject;
    
    // Save updated projects
    await writeData(PROJECTS_FILE, projects);
    
    return updatedProject;
  },
  
  /**
   * Delete a project by ID
   * @param {string} id - Project ID
   * @returns {Object} Deleted project object
   * @throws {NotFoundError} If project is not found
   */
  async delete(id) {
    const projects = await readData(PROJECTS_FILE);
    const projectIndex = projects.findIndex(project => project.id === id);
    
    if (projectIndex === -1) {
      throw new NotFoundError('Project');
    }
    
    // Remove project from array
    const deletedProject = projects.splice(projectIndex, 1)[0];
    
    // Save updated projects
    await writeData(PROJECTS_FILE, projects);
    
    // Also update any tasks that reference this project
    const tasks = await readData(TASKS_FILE);
    const updatedTasks = tasks.map(task => {
      if (task.projectId === id) {
        return { ...task, projectId: null };
      }
      return task;
    });
    
    // Save updated tasks
    await writeData(TASKS_FILE, updatedTasks);
    
    return deletedProject;
  },
  
  /**
   * Get all tasks for a project
   * @param {string} projectId - Project ID
   * @returns {Array} Array of task objects
   * @throws {NotFoundError} If project is not found
   */
  async getTasks(projectId) {
    // Check if project exists
    await this.getById(projectId);
    
    // Get tasks for project
    const tasks = await readData(TASKS_FILE);
    return tasks.filter(task => task.projectId === projectId);
  }
};

module.exports = ProjectModel;

Implementing Validation

Let's create validation middleware using express-validator.

Validation Middleware (src/middlewares/validation.js)

const { validationResult } = require('express-validator');
const { ValidationError } = require('../utils/errors');

/**
 * Middleware to validate request data
 */
const validateRequest = (req, res, next) => {
  // Check for validation errors
  const errors = validationResult(req);
  
  if (!errors.isEmpty()) {
    // Format errors for consistent response
    const formattedErrors = errors.array().map(error => ({
      field: error.path,
      message: error.msg,
      value: error.value
    }));
    
    // Throw validation error with formatted errors
    throw new ValidationError('Validation failed', formattedErrors);
  }
  
  // No errors, continue
  next();
};

module.exports = {
  validateRequest
};

Task Validation Rules (src/middlewares/taskValidation.js)

const { body, param, query } = require('express-validator');
const { validateRequest } = require('./validation');

/**
 * Validate task creation request
 */
const validateCreateTask = [
  // Title is required and must be a string
  body('title')
    .notEmpty().withMessage('Title is required')
    .isString().withMessage('Title must be a string')
    .isLength({ min: 1, max: 100 }).withMessage('Title must be between 1 and 100 characters'),
  
  // Description is optional but must be a string if provided
  body('description')
    .optional()
    .isString().withMessage('Description must be a string'),
  
  // Completed is optional but must be a boolean if provided
  body('completed')
    .optional()
    .isBoolean().withMessage('Completed must be a boolean'),
  
  // Due date is optional but must be a valid date if provided
  body('dueDate')
    .optional()
    .isISO8601().withMessage('Due date must be a valid date'),
  
  // Priority is optional but must be one of the allowed values if provided
  body('priority')
    .optional()
    .isIn(['Low', 'Medium', 'High']).withMessage('Priority must be Low, Medium, or High'),
  
  // Project ID is optional but must be a string if provided
  body('projectId')
    .optional()
    .isString().withMessage('Project ID must be a string'),
  
  // Run validation
  validateRequest
];

/**
 * Validate task update request
 */
const validateUpdateTask = [
  // Title is optional but must be a string if provided
  body('title')
    .optional()
    .isString().withMessage('Title must be a string')
    .isLength({ min: 1, max: 100 }).withMessage('Title must be between 1 and 100 characters'),
  
  // Description is optional but must be a string if provided
  body('description')
    .optional()
    .isString().withMessage('Description must be a string'),
  
  // Completed is optional but must be a boolean if provided
  body('completed')
    .optional()
    .isBoolean().withMessage('Completed must be a boolean'),
  
  // Due date is optional but must be a valid date if provided
  body('dueDate')
    .optional()
    .isISO8601().withMessage('Due date must be a valid date'),
  
  // Priority is optional but must be one of the allowed values if provided
  body('priority')
    .optional()
    .isIn(['Low', 'Medium', 'High']).withMessage('Priority must be Low, Medium, or High'),
  
  // Project ID is optional but must be a string if provided
  body('projectId')
    .optional()
    .isString().withMessage('Project ID must be a string'),
  
  // Run validation
  validateRequest
];

/**
 * Validate task ID parameter
 */
const validateTaskId = [
  // Task ID must be a string
  param('id')
    .notEmpty().withMessage('Task ID is required')
    .isString().withMessage('Task ID must be a string'),
  
  // Run validation
  validateRequest
];

/**
 * Validate task filter query parameters
 */
const validateTaskFilters = [
  // Completed is optional but must be a boolean string if provided
  query('completed')
    .optional()
    .isIn(['true', 'false']).withMessage('Completed must be true or false'),
  
  // Priority is optional but must be one of the allowed values if provided
  query('priority')
    .optional()
    .isIn(['Low', 'Medium', 'High']).withMessage('Priority must be Low, Medium, or High'),
  
  // Project ID is optional but must be a string if provided
  query('projectId')
    .optional()
    .isString().withMessage('Project ID must be a string'),
  
  // Due date range is optional but must be valid dates if provided
  query('dueDateFrom')
    .optional()
    .isISO8601().withMessage('Due date from must be a valid date'),
  
  query('dueDateTo')
    .optional()
    .isISO8601().withMessage('Due date to must be a valid date'),
  
  // Run validation
  validateRequest
];

module.exports = {
  validateCreateTask,
  validateUpdateTask,
  validateTaskId,
  validateTaskFilters
};

Project Validation Rules (src/middlewares/projectValidation.js)

const { body, param } = require('express-validator');
const { validateRequest } = require('./validation');

/**
 * Validate project creation request
 */
const validateCreateProject = [
  // Name is required and must be a string
  body('name')
    .notEmpty().withMessage('Name is required')
    .isString().withMessage('Name must be a string')
    .isLength({ min: 1, max: 100 }).withMessage('Name must be between 1 and 100 characters'),
  
  // Description is optional but must be a string if provided
  body('description')
    .optional()
    .isString().withMessage('Description must be a string'),
  
  // Run validation
  validateRequest
];

/**
 * Validate project update request
 */
const validateUpdateProject = [
  // Name is optional but must be a string if provided
  body('name')
    .optional()
    .isString().withMessage('Name must be a string')
    .isLength({ min: 1, max: 100 }).withMessage('Name must be between 1 and 100 characters'),
  
  // Description is optional but must be a string if provided
  body('description')
    .optional()
    .isString().withMessage('Description must be a string'),
  
  // Run validation
  validateRequest
];

/**
 * Validate project ID parameter
 */
const validateProjectId = [
  // Project ID must be a string
  param('id')
    .notEmpty().withMessage('Project ID is required')
    .isString().withMessage('Project ID must be a string'),
  
  // Run validation
  validateRequest
];

module.exports = {
  validateCreateProject,
  validateUpdateProject,
  validateProjectId
};

Creating Controllers

Now let's create our controllers, which will handle the business logic for our API endpoints.

Task Controller (src/controllers/task.js)

const TaskModel = require('../models/task');
const { BadRequestError } = require('../utils/errors');

/**
 * Task Controller - Handles HTTP requests for task operations
 */
const TaskController = {
  /**
   * Get all tasks with optional filtering
   * @param {Object} req - Express request object
   * @param {Object} res - Express response object
   * @param {Function} next - Express next middleware function
   */
  async getAllTasks(req, res, next) {
    try {
      // Extract query parameters for filtering
      const filters = { ...req.query };
      
      // Get tasks with filters
      const tasks = await TaskModel.getAll(filters);
      
      // Send response
      res.json({
        status: 'success',
        results: tasks.length,
        data: {
          tasks
        }
      });
    } catch (error) {
      next(error);
    }
  },
  
  /**
   * Get a task by ID
   * @param {Object} req - Express request object
   * @param {Object} res - Express response object
   * @param {Function} next - Express next middleware function
   */
  async getTaskById(req, res, next) {
    try {
      const task = await TaskModel.getById(req.params.id);
      
      res.json({
        status: 'success',
        data: {
          task
        }
      });
    } catch (error) {
      next(error);
    }
  },
  
  /**
   * Create a new task
   * @param {Object} req - Express request object
   * @param {Object} res - Express response object
   * @param {Function} next - Express next middleware function
   */
  async createTask(req, res, next) {
    try {
      const task = await TaskModel.create(req.body);
      
      res.status(201).json({
        status: 'success',
        data: {
          task
        }
      });
    } catch (error) {
      next(error);
    }
  },
  
  /**
   * Update a task by ID
   * @param {Object} req - Express request object
   * @param {Object} res - Express response object
   * @param {Function} next - Express next middleware function
   */
  async updateTask(req, res, next) {
    try {
      // Ensure at least one field is provided
      if (Object.keys(req.body).length === 0) {
        throw new BadRequestError('No fields provided for update');
      }
      
      const task = await TaskModel.update(req.params.id, req.body);
      
      res.json({
        status: 'success',
        data: {
          task
        }
      });
    } catch (error) {
      next(error);
    }
  },
  
  /**
   * Delete a task by ID
   * @param {Object} req - Express request object
   * @param {Object} res - Express response object
   * @param {Function} next - Express next middleware function
   */
  async deleteTask(req, res, next) {
    try {
      await TaskModel.delete(req.params.id);
      
      res.status(204).send();
    } catch (error) {
      next(error);
    }
  }
};

module.exports = TaskController;

Project Controller (src/controllers/project.js)

const ProjectModel = require('../models/project');
const { BadRequestError } = require('../utils/errors');

/**
 * Project Controller - Handles HTTP requests for project operations
 */
const ProjectController = {
  /**
   * Get all projects
   * @param {Object} req - Express request object
   * @param {Object} res - Express response object
   * @param {Function} next - Express next middleware function
   */
  async getAllProjects(req, res, next) {
    try {
      const projects = await ProjectModel.getAll();
      
      res.json({
        status: 'success',
        results: projects.length,
        data: {
          projects
        }
      });
    } catch (error) {
      next(error);
    }
  },
  
  /**
   * Get a project by ID
   * @param {Object} req - Express request object
   * @param {Object} res - Express response object
   * @param {Function} next - Express next middleware function
   */
  async getProjectById(req, res, next) {
    try {
      const project = await ProjectModel.getById(req.params.id);
      
      res.json({
        status: 'success',
        data: {
          project
        }
      });
    } catch (error) {
      next(error);
    }
  },
  
  /**
   * Create a new project
   * @param {Object} req - Express request object
   * @param {Object} res - Express response object
   * @param {Function} next - Express next middleware function
   */
  async createProject(req, res, next) {
    try {
      const project = await ProjectModel.create(req.body);
      
      res.status(201).json({
        status: 'success',
        data: {
          project
        }
      });
    } catch (error) {
      next(error);
    }
  },
  
  /**
   * Update a project by ID
   * @param {Object} req - Express request object
   * @param {Object} res - Express response object
   * @param {Function} next - Express next middleware function
   */
  async updateProject(req, res, next) {
    try {
      // Ensure at least one field is provided
      if (Object.keys(req.body).length === 0) {
        throw new BadRequestError('No fields provided for update');
      }
      
      const project = await ProjectModel.update(req.params.id, req.body);
      
      res.json({
        status: 'success',
        data: {
          project
        }
      });
    } catch (error) {
      next(error);
    }
  },
  
  /**
   * Delete a project by ID
   * @param {Object} req - Express request object
   * @param {Object} res - Express response object
   * @param {Function} next - Express next middleware function
   */
  async deleteProject(req, res, next) {
    try {
      await ProjectModel.delete(req.params.id);
      
      res.status(204).send();
    } catch (error) {
      next(error);
    }
  },
  
  /**
   * Get all tasks for a project
   * @param {Object} req - Express request object
   * @param {Object} res - Express response object
   * @param {Function} next - Express next middleware function
   */
  async getProjectTasks(req, res, next) {
    try {
      const tasks = await ProjectModel.getTasks(req.params.id);
      
      res.json({
        status: 'success',
        results: tasks.length,
        data: {
          tasks
        }
      });
    } catch (error) {
      next(error);
    }
  }
};

module.exports = ProjectController;

Creating Routes

Finally, let's create our route files to connect our controllers to HTTP endpoints.

Task Routes (src/routes/task.js)

const express = require('express');
const router = express.Router();
const TaskController = require('../controllers/task');
const {
  validateCreateTask,
  validateUpdateTask,
  validateTaskId,
  validateTaskFilters
} = require('../middlewares/taskValidation');

// GET /api/tasks - Get all tasks with optional filtering
router.get('/', validateTaskFilters, TaskController.getAllTasks);

// GET /api/tasks/:id - Get a task by ID
router.get('/:id', validateTaskId, TaskController.getTaskById);

// POST /api/tasks - Create a new task
router.post('/', validateCreateTask, TaskController.createTask);

// PUT /api/tasks/:id - Update a task
router.put('/:id', validateTaskId, validateUpdateTask, TaskController.updateTask);

// DELETE /api/tasks/:id - Delete a task
router.delete('/:id', validateTaskId, TaskController.deleteTask);

module.exports = router;

Project Routes (src/routes/project.js)

const express = require('express');
const router = express.Router();
const ProjectController = require('../controllers/project');
const {
  validateCreateProject,
  validateUpdateProject,
  validateProjectId
} = require('../middlewares/projectValidation');

// GET /api/projects - Get all projects
router.get('/', ProjectController.getAllProjects);

// GET /api/projects/:id - Get a project by ID
router.get('/:id', validateProjectId, ProjectController.getProjectById);

// GET /api/projects/:id/tasks - Get all tasks for a project
router.get('/:id/tasks', validateProjectId, ProjectController.getProjectTasks);

// POST /api/projects - Create a new project
router.post('/', validateCreateProject, ProjectController.createProject);

// PUT /api/projects/:id - Update a project
router.put('/:id', validateProjectId, validateUpdateProject, ProjectController.updateProject);

// DELETE /api/projects/:id - Delete a project
router.delete('/:id', validateProjectId, ProjectController.deleteProject);

module.exports = router;

Testing the API

Now that we've built our Task Management API, it's time to run and test it.

Running the Server

// Start the development server
npm run dev

API Documentation

Here's a summary of the available endpoints:

Task Endpoints

Project Endpoints

Testing with Postman or cURL

You can test the API using tools like Postman or cURL. Here are some example requests:

Example cURL Commands

# Create a project
curl -X POST http://localhost:3000/api/projects \
  -H "Content-Type: application/json" \
  -d '{"name": "Work Tasks", "description": "Tasks related to work"}'

# Get all projects
curl http://localhost:3000/api/projects

# Create a task
curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Complete API Project", "description": "Finish the task management API", "priority": "High", "dueDate": "2023-08-01T00:00:00.000Z", "projectId": "PROJECT_ID_HERE"}'

# Get tasks with filtering
curl http://localhost:3000/api/tasks?priority=High&completed=false

# Update a task (mark as completed)
curl -X PUT http://localhost:3000/api/tasks/TASK_ID_HERE \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

# Delete a task
curl -X DELETE http://localhost:3000/api/tasks/TASK_ID_HERE

Step 4: Reviewing and Extending the Project

Now that we've built our Task Management API, let's review our implementation and consider some extensions.

What We've Accomplished

Potential Extensions

Here are some ways you could extend the project:

flowchart TD A[Task Management API Extensions] --> B[User Authentication] A --> C[Database Integration] A --> D[Subtasks Support] A --> E[File Attachments] A --> F[Comments and Notes] A --> G[API Documentation] A --> H[Frontend Client] B --> B1[JWT Authentication] B --> B2[User Permissions] C --> C1[MongoDB Integration] C --> C2[PostgreSQL Integration] D --> D1[Nested Subtasks] D --> D2[Task Dependencies] E --> E1[File Upload Support] E --> E2[Cloud Storage] F --> F1[Task Comments] F --> F2[Collaboration Features] G --> G1[Swagger/OpenAPI] G --> G2[Postman Collection] H --> H1[React Frontend] H --> H2[Mobile App]
  1. Authentication and Authorization
    • Add user authentication with JWT
    • Implement user registration and login
    • Restrict access to resources based on ownership or permissions
  2. Database Integration
    • Replace file-based storage with a database (MongoDB, PostgreSQL, etc.)
    • Implement proper data relationships
    • Add pagination for large datasets
  3. Advanced Features
    • Add task labels/tags
    • Implement subtasks
    • Add recurring tasks
    • Support task dependencies
    • Add file attachment support
    • Implement comments and notes
  4. API Documentation
    • Generate API documentation using Swagger/OpenAPI
    • Create a Postman collection
  5. Automated Testing
    • Add unit tests for models and controllers
    • Implement integration tests for API endpoints
  6. Frontend Client
    • Create a web frontend using React, Vue, or Angular
    • Develop a mobile app using React Native or Flutter

Conclusion

In this weekend project, we built a complete RESTful API for a task management system using Express.js. We covered important concepts including:

This project provides a solid foundation for a task management application and can be extended in various ways to create a more feature-rich application.

Key Takeaways

By completing this project, you've gained hands-on experience with many core aspects of backend web development that are applicable to a wide range of applications.