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:
- Create, read, update, and delete tasks
- Organize tasks into projects
- Set priorities and due dates
- Mark tasks as complete
- Filter and sort tasks by various criteria
Learning Objectives
- Implement a complete RESTful API using Express.js
- Design and structure routes according to REST principles
- Implement proper error handling and validation
- Create a well-organized project structure
- Practice working with JSON data
- Apply input validation techniques
- Implement filtering and sorting functionality
- Create API documentation
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:
- What are we creating? A RESTful API for managing tasks
- Who will use it? Users who need to track and organize tasks
- What functionality is needed? CRUD operations for tasks and projects, plus filtering and sorting
- What data will we work with? Tasks, projects, and users
- What constraints do we have? Building a backend API only (no frontend)
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
- Set up the project structure and install dependencies
- Create the Express application and configure middleware
- Implement data storage (file-based for simplicity)
- Create error handling middleware and custom error classes
- Implement validation middleware
- Create task routes and controllers (CRUD operations)
- Create project routes and controllers (CRUD operations)
- Implement filtering and sorting functionality
- Add simple authentication (optional)
- Create API documentation
- 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
- express: Web framework for building the API
- cors: Middleware to enable Cross-Origin Resource Sharing
- morgan: HTTP request logger middleware
- dotenv: Load environment variables from a .env file
- uuid: Generate unique IDs for tasks and projects
- express-validator: Validate and sanitize input data
- nodemon: Automatically restart the server during development
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:
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
- GET /api/tasks - Get all tasks (with optional filtering)
- GET /api/tasks/:id - Get a specific task by ID
- POST /api/tasks - Create a new task
- PUT /api/tasks/:id - Update a task
- DELETE /api/tasks/:id - Delete a task
Project Endpoints
- GET /api/projects - Get all projects
- GET /api/projects/:id - Get a specific project by ID
- GET /api/projects/:id/tasks - Get all tasks for a project
- POST /api/projects - Create a new project
- PUT /api/projects/:id - Update a project
- DELETE /api/projects/:id - Delete a project
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
- Created a RESTful API with proper resource endpoints
- Implemented CRUD operations for tasks and projects
- Added filtering and sorting functionality
- Implemented error handling and validation
- Used a well-organized project structure
- Created a file-based data storage system
Potential Extensions
Here are some ways you could extend the project:
- Authentication and Authorization
- Add user authentication with JWT
- Implement user registration and login
- Restrict access to resources based on ownership or permissions
- Database Integration
- Replace file-based storage with a database (MongoDB, PostgreSQL, etc.)
- Implement proper data relationships
- Add pagination for large datasets
- Advanced Features
- Add task labels/tags
- Implement subtasks
- Add recurring tasks
- Support task dependencies
- Add file attachment support
- Implement comments and notes
- API Documentation
- Generate API documentation using Swagger/OpenAPI
- Create a Postman collection
- Automated Testing
- Add unit tests for models and controllers
- Implement integration tests for API endpoints
- 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:
- RESTful API design principles
- Proper project structure and organization
- Error handling and validation
- Data access layer with models
- Controller-based business logic
- Route configuration and HTTP methods
- Query parameter filtering
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
- RESTful APIs follow a standard structure for predictable interactions
- Proper error handling significantly improves API usability
- Validation is essential for data integrity and security
- A well-organized project structure makes maintenance easier
- Separation of concerns (models, controllers, routes) creates maintainable code
- File-based storage can be a simple alternative to databases for prototyping
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.